|
| 1 | +/*--------------------------------------------------------------------------------------------- |
| 2 | + * Copyright (c) Microsoft Corporation. All rights reserved. |
| 3 | + * Licensed under the MIT License. See License.txt in the project root for license information. |
| 4 | + *--------------------------------------------------------------------------------------------*/ |
| 5 | + |
| 6 | +import { ILogService } from '../../log/common/log.js'; |
| 7 | + |
| 8 | +const LOG_PREFIX = '[SSHRemoteAgentHost]'; |
| 9 | + |
| 10 | +/** |
| 11 | + * Validate that a quality string is safe for bare interpolation in shell commands. |
| 12 | + * Quality comes from `productService.quality` (not user input) but we validate |
| 13 | + * as defense-in-depth since these values end up in unquoted shell paths (the `~` |
| 14 | + * prefix requires shell expansion, so we cannot single-quote the entire path). |
| 15 | + */ |
| 16 | +export function validateShellToken(value: string, label: string): string { |
| 17 | + if (!/^[a-zA-Z0-9._-]+$/.test(value)) { |
| 18 | + throw new Error(`Unsafe ${label} value for shell interpolation: ${JSON.stringify(value)}`); |
| 19 | + } |
| 20 | + return value; |
| 21 | +} |
| 22 | + |
| 23 | +/** Install location for the VS Code CLI on the remote machine. */ |
| 24 | +export function getRemoteCLIDir(quality: string): string { |
| 25 | + const q = validateShellToken(quality, 'quality'); |
| 26 | + return q === 'stable' ? '~/.vscode-cli' : `~/.vscode-cli-${q}`; |
| 27 | +} |
| 28 | + |
| 29 | +export function getRemoteCLIBin(quality: string): string { |
| 30 | + const q = validateShellToken(quality, 'quality'); |
| 31 | + const binaryName = q === 'stable' ? 'code' : 'code-insiders'; |
| 32 | + return `${getRemoteCLIDir(q)}/${binaryName}`; |
| 33 | +} |
| 34 | + |
| 35 | +/** Escape a string for use as a single shell argument (single-quote wrapping). */ |
| 36 | +export function shellEscape(s: string): string { |
| 37 | + // Wrap in single quotes; escape embedded single quotes as: '\'' |
| 38 | + const escaped = s.replace(/'/g, '\'\\\'\''); |
| 39 | + return `'${escaped}'`; |
| 40 | +} |
| 41 | + |
| 42 | +export function resolveRemotePlatform(unameS: string, unameM: string): { os: string; arch: string } | undefined { |
| 43 | + const os = unameS.trim().toLowerCase(); |
| 44 | + const machine = unameM.trim().toLowerCase(); |
| 45 | + |
| 46 | + let platformOs: string; |
| 47 | + if (os === 'linux') { |
| 48 | + platformOs = 'linux'; |
| 49 | + } else if (os === 'darwin') { |
| 50 | + platformOs = 'darwin'; |
| 51 | + } else { |
| 52 | + return undefined; |
| 53 | + } |
| 54 | + |
| 55 | + let arch: string; |
| 56 | + if (machine === 'x86_64' || machine === 'amd64') { |
| 57 | + arch = 'x64'; |
| 58 | + } else if (machine === 'aarch64' || machine === 'arm64') { |
| 59 | + arch = 'arm64'; |
| 60 | + } else if (machine === 'armv7l') { |
| 61 | + arch = 'armhf'; |
| 62 | + } else { |
| 63 | + return undefined; |
| 64 | + } |
| 65 | + |
| 66 | + return { os: platformOs, arch }; |
| 67 | +} |
| 68 | + |
| 69 | +export function buildCLIDownloadUrl(os: string, arch: string, quality: string): string { |
| 70 | + return `https://update.code.visualstudio.com/latest/cli-${os}-${arch}/${quality}`; |
| 71 | +} |
| 72 | + |
| 73 | +/** Redact connection tokens from log output. */ |
| 74 | +export function redactToken(text: string): string { |
| 75 | + return text.replace(/\?tkn=[^\s&]+/g, '?tkn=***'); |
| 76 | +} |
| 77 | + |
| 78 | +/** Path to our state file on the remote, recording the agent host's PID/port/token. */ |
| 79 | +export function getAgentHostStateFile(quality: string): string { |
| 80 | + return `${getRemoteCLIDir(quality)}/.agent-host-state`; |
| 81 | +} |
| 82 | + |
| 83 | +export interface AgentHostState { |
| 84 | + readonly pid: number; |
| 85 | + readonly port: number; |
| 86 | + readonly connectionToken: string | null; |
| 87 | +} |
| 88 | + |
| 89 | +/** |
| 90 | + * Validate that a parsed object conforms to the AgentHostState shape. |
| 91 | + * Returns the validated state or undefined if the shape is invalid. |
| 92 | + */ |
| 93 | +function parseAgentHostState(raw: unknown): AgentHostState | undefined { |
| 94 | + if (typeof raw !== 'object' || raw === null) { |
| 95 | + return undefined; |
| 96 | + } |
| 97 | + const obj = raw as Record<string, unknown>; |
| 98 | + if (typeof obj.pid !== 'number' || !Number.isSafeInteger(obj.pid) || obj.pid <= 0) { |
| 99 | + return undefined; |
| 100 | + } |
| 101 | + if (typeof obj.port !== 'number' || !Number.isSafeInteger(obj.port) || obj.port <= 0 || obj.port > 65535) { |
| 102 | + return undefined; |
| 103 | + } |
| 104 | + if (obj.connectionToken !== null && typeof obj.connectionToken !== 'string') { |
| 105 | + return undefined; |
| 106 | + } |
| 107 | + return { pid: obj.pid, port: obj.port, connectionToken: obj.connectionToken as string | null }; |
| 108 | +} |
| 109 | + |
| 110 | +/** |
| 111 | + * Abstraction over SSH command execution to enable testing without a real SSH connection. |
| 112 | + */ |
| 113 | +export interface ISshExec { |
| 114 | + (command: string, opts?: { ignoreExitCode?: boolean }): Promise<{ stdout: string; stderr: string; code: number }>; |
| 115 | +} |
| 116 | + |
| 117 | +/** |
| 118 | + * Try to find a running agent host on the remote by reading our state file and |
| 119 | + * verifying the recorded PID is still alive. |
| 120 | + */ |
| 121 | +export async function findRunningAgentHost( |
| 122 | + exec: ISshExec, |
| 123 | + logService: ILogService, |
| 124 | + quality: string, |
| 125 | +): Promise<{ port: number; connectionToken: string | undefined } | undefined> { |
| 126 | + const stateFile = getAgentHostStateFile(quality); |
| 127 | + const { stdout, code } = await exec(`cat ${stateFile} 2>/dev/null`, { ignoreExitCode: true }); |
| 128 | + if (code !== 0 || !stdout.trim()) { |
| 129 | + return undefined; |
| 130 | + } |
| 131 | + |
| 132 | + let state: AgentHostState | undefined; |
| 133 | + try { |
| 134 | + state = parseAgentHostState(JSON.parse(stdout.trim())); |
| 135 | + } catch { |
| 136 | + // fall through |
| 137 | + } |
| 138 | + if (!state) { |
| 139 | + logService.info(`${LOG_PREFIX} Invalid agent host state file ${stateFile}, removing`); |
| 140 | + await exec(`rm -f ${stateFile}`, { ignoreExitCode: true }); |
| 141 | + return undefined; |
| 142 | + } |
| 143 | + |
| 144 | + // Verify the PID is still alive |
| 145 | + const { code: killCode } = await exec(`kill -0 ${state.pid} 2>/dev/null`, { ignoreExitCode: true }); |
| 146 | + if (killCode !== 0) { |
| 147 | + logService.info(`${LOG_PREFIX} Stale agent host state in ${stateFile} (PID ${state.pid} not running), cleaning up`); |
| 148 | + await exec(`rm -f ${stateFile}`, { ignoreExitCode: true }); |
| 149 | + return undefined; |
| 150 | + } |
| 151 | + |
| 152 | + logService.info(`${LOG_PREFIX} Found running agent host via ${stateFile}: PID ${state.pid}, port ${state.port}`); |
| 153 | + return { port: state.port, connectionToken: state.connectionToken ?? undefined }; |
| 154 | +} |
| 155 | + |
| 156 | +/** |
| 157 | + * After starting an agent host, record its PID/port/token in a state file on |
| 158 | + * the remote so that future connections can reuse the process. |
| 159 | + */ |
| 160 | +export async function writeAgentHostState( |
| 161 | + exec: ISshExec, |
| 162 | + logService: ILogService, |
| 163 | + quality: string, |
| 164 | + pid: number | undefined, |
| 165 | + port: number, |
| 166 | + connectionToken: string | undefined, |
| 167 | +): Promise<void> { |
| 168 | + if (!pid) { |
| 169 | + logService.info(`${LOG_PREFIX} Agent host PID unknown, state file not written`); |
| 170 | + return; |
| 171 | + } |
| 172 | + |
| 173 | + const stateFile = getAgentHostStateFile(quality); |
| 174 | + const state: AgentHostState = { pid, port, connectionToken: connectionToken ?? null }; |
| 175 | + const json = JSON.stringify(state); |
| 176 | + // Remove any existing file first so `>` creates a fresh inode with the |
| 177 | + // new umask (overwriting an existing file preserves its old permissions). |
| 178 | + // Use a subshell with restrictive umask (077) so the file is created with |
| 179 | + // owner-only permissions (0600), protecting the connection token. |
| 180 | + // The CLI itself stores its token file with the same permissions. |
| 181 | + await exec(`rm -f ${stateFile} && (umask 077 && echo ${shellEscape(json)} > ${stateFile})`, { ignoreExitCode: true }); |
| 182 | + logService.info(`${LOG_PREFIX} Wrote agent host state to ${stateFile}: PID ${pid}, port ${port}`); |
| 183 | +} |
| 184 | + |
| 185 | +/** |
| 186 | + * Kill a remote agent host tracked by our state file and remove the state file. |
| 187 | + */ |
| 188 | +export async function cleanupRemoteAgentHost( |
| 189 | + exec: ISshExec, |
| 190 | + logService: ILogService, |
| 191 | + quality: string, |
| 192 | +): Promise<void> { |
| 193 | + const stateFile = getAgentHostStateFile(quality); |
| 194 | + const { stdout, code } = await exec(`cat ${stateFile} 2>/dev/null`, { ignoreExitCode: true }); |
| 195 | + if (code === 0 && stdout.trim()) { |
| 196 | + let state: AgentHostState | undefined; |
| 197 | + try { |
| 198 | + state = parseAgentHostState(JSON.parse(stdout.trim())); |
| 199 | + } catch { /* ignore parse errors */ } |
| 200 | + if (state) { |
| 201 | + logService.info(`${LOG_PREFIX} Killing remote agent host PID ${state.pid} (from ${stateFile})`); |
| 202 | + await exec(`kill ${state.pid} 2>/dev/null`, { ignoreExitCode: true }); |
| 203 | + } |
| 204 | + } |
| 205 | + await exec(`rm -f ${stateFile}`, { ignoreExitCode: true }); |
| 206 | +} |
0 commit comments