diff --git a/src/vs/platform/agentHost/node/sshRemoteAgentHostHelpers.ts b/src/vs/platform/agentHost/node/sshRemoteAgentHostHelpers.ts index 51d13fd964720..f91cc0f6421ad 100644 --- a/src/vs/platform/agentHost/node/sshRemoteAgentHostHelpers.ts +++ b/src/vs/platform/agentHost/node/sshRemoteAgentHostHelpers.ts @@ -21,16 +21,52 @@ export function validateShellToken(value: string, label: string): string { return value; } -/** Install location for the VS Code CLI on the remote machine. */ -export function getRemoteCLIDir(quality: string): string { +/** + * Name of the CLI binary as it appears inside the downloaded archive, + * derived from product quality. Matches the names used by Remote-SSH's + * exec-server installer so that CLI binaries can be shared between the + * two features. + */ +export function getRemoteCLIArchiveName(quality: string): string { const q = validateShellToken(quality, 'quality'); - return q === 'stable' ? '~/.vscode-cli' : `~/.vscode-cli-${q}`; + switch (q) { + case 'stable': return 'code'; + case 'exploration': return 'code-exploration'; + default: return 'code-insiders'; + } } -export function getRemoteCLIBin(quality: string): string { - const q = validateShellToken(quality, 'quality'); - const binaryName = q === 'stable' ? 'code' : 'code-insiders'; - return `${getRemoteCLIDir(q)}/${binaryName}`; +/** + * Install root for the VS Code CLI on the remote machine. Shared with + * Remote-SSH's exec-server installer so the two features can reuse each + * other's installations. Also the parent of the agent host lockfile dir. + */ +export function getRemoteCLIInstallRoot(serverDataFolderName: string): string { + const d = validateShellToken(serverDataFolderName, 'server data folder name'); + return `~/${d}`; +} + +/** + * Full path to the installed CLI binary on the remote. + * + * When `commit` is provided, the path is keyed on commit (e.g. + * `~/.vscode-server/code-insiders-<40hex>`) so we can install the CLI + * matching the current desktop without disturbing other installs. This + * mirrors Remote-SSH's exec-server layout. + * + * When `commit` is undefined (dev/OSS builds with no commit in product + * metadata), the path is just `/` — a single, non-keyed + * filename. Caller code should keep the loose `--version`-based reuse + * check in that case. + */ +export function getRemoteCLIBin(serverDataFolderName: string, quality: string, commit?: string): string { + const archive = getRemoteCLIArchiveName(quality); + const root = getRemoteCLIInstallRoot(serverDataFolderName); + if (commit) { + const c = validateShellToken(commit, 'commit'); + return `${root}/${archive}-${c}`; + } + return `${root}/${archive}`; } /** Escape a string for use as a single shell argument (single-quote wrapping). */ @@ -67,8 +103,107 @@ export function resolveRemotePlatform(unameS: string, unameM: string): { os: str return { os: platformOs, arch }; } -export function buildCLIDownloadUrl(os: string, arch: string, quality: string): string { - return `https://update.code.visualstudio.com/latest/cli-${os}-${arch}/${quality}`; +/** + * URL of the CLI download artifact. + * + * When `commit` is provided, uses the commit-pinned URL form so we get + * the exact CLI matching the current desktop build (mirrors Remote-SSH). + * When `commit` is undefined (dev/OSS builds), falls back to `latest`. + */ +export function buildCLIDownloadUrl(os: string, arch: string, quality: string, commit?: string): string { + const base = 'https://update.code.visualstudio.com'; + const artifact = `cli-${os}-${arch}`; + if (commit) { + // Note: commit safety is enforced by the caller / by getRemoteCLIBin + // which runs the same validation. Repeat it here as defense-in-depth + // because the URL is built independently in some call paths. + const c = validateShellToken(commit, 'commit'); + return `${base}/commit:${c}/${artifact}/${quality}`; + } + return `${base}/latest/${artifact}/${quality}`; +} + +/** + * Shell snippet that prunes older commit-keyed CLI binaries from the + * install root, keeping the 5 most recently modified. Mirrors the + * retention policy in Remote-SSH's exec-server installer. + * + * The glob is tightened to exactly 40 hex chars (`[0-9a-f]`-only) so we + * never accidentally delete (or hand to `xargs`) any filename that + * happens to start with `-` but isn't actually one of our + * commit-keyed binaries — both for correctness and to avoid passing + * attacker-controlled filenames through `xargs rm` with option/whitespace + * splitting hazards. We also use `rm -f --` and `xargs -I{}` (which + * skips the command entirely on empty input on both GNU and BSD `xargs`). + */ +export function buildCleanupOldCLIsCommand(serverDataFolderName: string, quality: string): string { + const root = getRemoteCLIInstallRoot(serverDataFolderName); + const archive = getRemoteCLIArchiveName(quality); + const commitGlob = '[0-9a-f]'.repeat(40); + // `ls -1t` sorts by mtime newest-first on both Linux (coreutils) and + // macOS (BSD). `awk 'NR>5'` drops the 5 most recent entries we want to + // keep. `xargs -I{} rm -f -- {}` is one-rm-per-line — slow but safe + // against whitespace splitting and option injection, and a no-op when + // input is empty on both BSDs and GNU. + return `ls -1t -- ${root}/${archive}-${commitGlob} 2>/dev/null | awk 'NR>5' | xargs -I{} rm -f -- {} 2>/dev/null; true`; +} + +/** + * Shell snippet that prints candidate CLI binary paths that could be + * used as a fallback when the commit-pinned download fails. Order: any + * commit-keyed binaries in the shared install root (newest mtime first), + * then the legacy single-binary paths from the previous installer + * (`~/.vscode-cli{,-}/`). + * + * Each line is a single path. The glob for commit-keyed candidates is + * restricted to exactly 40 hex chars so the output can only contain + * filenames we recognise (callers should still re-validate with + * {@link isValidFallbackCLIPath}). The legacy paths are fixed strings + * derived from validated tokens, so they cannot contain shell + * metacharacters either. + */ +export function buildFindFallbackCLICommand(serverDataFolderName: string, quality: string): string { + const root = getRemoteCLIInstallRoot(serverDataFolderName); + const archive = getRemoteCLIArchiveName(quality); + const commitGlob = '[0-9a-f]'.repeat(40); + const q = validateShellToken(quality, 'quality'); + const legacyDir = q === 'stable' ? '~/.vscode-cli' : `~/.vscode-cli-${q}`; + const legacyBin = `${legacyDir}/${archive}`; + return [ + `ls -1t -- ${root}/${archive}-${commitGlob} 2>/dev/null`, + `ls -1 -- ${legacyBin} 2>/dev/null`, + 'true', + ].join('; '); +} + +/** + * Validate that a candidate path string returned by the remote shell + * matches one of the two shapes we expect from + * {@link buildFindFallbackCLICommand}: + * + * - `/-<40 hex chars>` — commit-keyed install + * - `/` — legacy single-binary install + * + * Anything else is rejected. This guards against the candidate being + * interpolated into a follow-up shell command (` --version`, + * agent host spawn) with attacker-controlled metacharacters in the + * event that something unexpected ends up in the install root. + */ +export function isValidFallbackCLIPath(candidate: string, serverDataFolderName: string, quality: string): boolean { + const root = getRemoteCLIInstallRoot(serverDataFolderName); + const archive = getRemoteCLIArchiveName(quality); + const q = validateShellToken(quality, 'quality'); + const legacyDir = q === 'stable' ? '~/.vscode-cli' : `~/.vscode-cli-${q}`; + const legacyBin = `${legacyDir}/${archive}`; + if (candidate === legacyBin) { + return true; + } + const pinnedPrefix = `${root}/${archive}-`; + if (candidate.startsWith(pinnedPrefix)) { + const suffix = candidate.slice(pinnedPrefix.length); + return /^[0-9a-f]{40}$/.test(suffix); + } + return false; } /** Redact connection tokens from log output. */ diff --git a/src/vs/platform/agentHost/node/sshRemoteAgentHostService.ts b/src/vs/platform/agentHost/node/sshRemoteAgentHostService.ts index 226df449795c1..f415e43264288 100644 --- a/src/vs/platform/agentHost/node/sshRemoteAgentHostService.ts +++ b/src/vs/platform/agentHost/node/sshRemoteAgentHostService.ts @@ -31,10 +31,13 @@ import { } from '../common/sshRemoteAgentHost.js'; import { buildCLIDownloadUrl, + buildCleanupOldCLIsCommand, + buildFindFallbackCLICommand, cleanupRemoteAgentHost, findRunningAgentHost, getRemoteCLIBin, - getRemoteCLIDir, + getRemoteCLIInstallRoot, + isValidFallbackCLIPath, redactToken, resolveRemotePlatform, shellEscape, @@ -307,11 +310,15 @@ function bindSshExec(client: SSHClient): (command: string, opts?: { ignoreExitCo function startRemoteAgentHost( client: SSHClient, logService: ILogService, - quality: string, + cliBin: string | undefined, commandOverride?: string, ): Promise<{ port: number; connectionToken: string | undefined; pid: number | undefined; stream: SSHChannel }> { return new Promise((resolve, reject) => { - const baseCmd = commandOverride ?? `${getRemoteCLIBin(quality)} agent host --port 0`; + if (!commandOverride && !cliBin) { + reject(new Error(`${LOG_PREFIX} startRemoteAgentHost requires either a cliBin path or a commandOverride`)); + return; + } + const baseCmd = commandOverride ?? `${cliBin} agent host --port 0`; // Wrap in a login shell so the agent host process inherits the // user's PATH and environment from ~/.bash_profile / ~/.bashrc // (ssh2 exec runs a non-interactive non-login shell by default). @@ -699,28 +706,37 @@ export class SSHRemoteAgentHostMainService extends Disposable implements ISSHRem reportProgress(localize('sshProgressConnecting', "Establishing SSH connection...")); sshClient = await this._connectSSH(config, connectionKey); - if (config.remoteAgentHostCommand) { - // Dev override: skip platform detection and CLI install, - // use the provided command directly. - this._logService.info(`${LOG_PREFIX} Using custom agent host command: ${config.remoteAgentHostCommand}`); - } else { - // 2. Detect remote platform - const { stdout: unameS } = await sshExec(sshClient, 'uname -s'); - const { stdout: unameM } = await sshExec(sshClient, 'uname -m'); + let cliBin: string | undefined; + let cliResolved = false; + // Resolve the remote CLI lazily: platform detection and CLI + // install/refresh only run when we're actually about to spawn + // an agent host. Reconnects that reuse a live AH via the + // lockfile skip this work entirely, since the running AH was + // spawned from whatever CLI was current at the time. + const ensureCliResolved = async (): Promise => { + if (cliResolved) { + return; + } + cliResolved = true; + if (config.remoteAgentHostCommand) { + this._logService.info(`${LOG_PREFIX} Using custom agent host command: ${config.remoteAgentHostCommand}`); + return; + } + const { stdout: unameS } = await sshExec(sshClient!, 'uname -s'); + const { stdout: unameM } = await sshExec(sshClient!, 'uname -m'); const platform = resolveRemotePlatform(unameS, unameM); if (!platform) { throw new Error(`${LOG_PREFIX} Unsupported remote platform: ${unameS.trim()} ${unameM.trim()}`); } this._logService.info(`${LOG_PREFIX} Remote platform: ${platform.os}-${platform.arch}`); - - // 3. Install CLI if needed reportProgress(localize('sshProgressInstallingCLI', "Checking remote CLI installation...")); - await this._ensureCLIInstalled(sshClient, platform, reportProgress); - } + cliBin = await this._ensureCLIInstalled(sshClient!, platform, reportProgress); + }; - // 4. Check for an already-running agent host on the remote. + // 2. Check for an already-running agent host on the remote first. // This prevents accumulating orphaned processes when the SSH - // connection drops and we reconnect. + // connection drops and we reconnect — and avoids paying for + // platform detection + CLI install on every reconnect. let remoteHost: string = '127.0.0.1'; let remotePort: number | undefined; let connectionToken: string | undefined; @@ -736,9 +752,12 @@ export class SSHRemoteAgentHostMainService extends Disposable implements ISSHRem } if (remotePort === undefined) { - // 5. Start agent-host and capture port/token + // 3. Need to spawn fresh: resolve the CLI now. + await ensureCliResolved(); + + // 4. Start agent-host and capture port/token reportProgress(localize('sshProgressStartingAgent', "Starting remote agent host...")); - const result = await this._startRemoteAgentHost(sshClient, this._quality, config.remoteAgentHostCommand); + const result = await this._startRemoteAgentHost(sshClient, cliBin, config.remoteAgentHostCommand); remotePort = result.port; connectionToken = result.connectionToken; agentStream = result.stream; @@ -762,13 +781,15 @@ export class SSHRemoteAgentHostMainService extends Disposable implements ISSHRem if (existingAH.kind !== 'compatible') { throw relayErr; } - // The reused agent host is not connectable — kill it and start fresh + // The reused agent host is not connectable — kill it and start fresh. + // Resolve the CLI now (we skipped it on the reuse path). const relayErrorMessage = relayErr instanceof Error ? relayErr.message : String(relayErr); this._logService.warn(`${LOG_PREFIX} Failed to connect to reused agent host on ${remoteHost}:${remotePort}: ${relayErrorMessage}. Starting fresh`); await cleanupRemoteAgentHost(exec, this._logService, this._serverDataFolderName, this._quality); + await ensureCliResolved(); reportProgress(localize('sshProgressStartingAgent', "Starting remote agent host...")); - const result = await this._startRemoteAgentHost(sshClient, this._quality, config.remoteAgentHostCommand); + const result = await this._startRemoteAgentHost(sshClient, cliBin, config.remoteAgentHostCommand); remoteHost = '127.0.0.1'; remotePort = result.port; connectionToken = result.connectionToken; @@ -1336,10 +1357,14 @@ export class SSHRemoteAgentHostMainService extends Disposable implements ISSHRem return this._productService.serverDataFolderName ?? '.vscode-server-oss'; } + private get _commit(): string | undefined { + return this._productService.commit; + } + protected _startRemoteAgentHost( - client: SSHClient, quality: string, commandOverride?: string, + client: SSHClient, cliBin: string | undefined, commandOverride?: string, ): Promise<{ port: number; connectionToken: string | undefined; pid: number | undefined; stream: SSHChannel }> { - return startRemoteAgentHost(client, this._logService, quality, commandOverride); + return startRemoteAgentHost(client, this._logService, cliBin, commandOverride); } protected async _createWebSocketRelay( @@ -1350,25 +1375,165 @@ export class SSHRemoteAgentHostMainService extends Disposable implements ISSHRem return createWebSocketRelay(nativeRequire, client, dstHost, dstPort, connectionToken, this._logService, onMessage, onClose); } - private async _ensureCLIInstalled(client: SSHClient, platform: { os: string; arch: string }, reportProgress: (message: string) => void): Promise { - const cliDir = getRemoteCLIDir(this._quality); - const cliBin = getRemoteCLIBin(this._quality); + /** + * Resolve which CLI binary to run on the remote. + * + * When the desktop has a `productService.commit` (release builds), we + * pin to that commit: install at `~//-` + * (sharing the install root with Remote-SSH), reuse on file existence, + * download from the commit-pinned URL on miss, and clean up older + * commit-keyed CLIs (keep last 5). The agent host CLI does not + * self-update on this path, so the desktop pushes freshness on every + * fresh start — but tolerantly: if the download fails and any other + * usable CLI is present (other commit-keyed or the legacy + * `~/.vscode-cli{,-}/`), we fall back to the newest + * one rather than refusing to connect. + * + * In dev/OSS builds with no commit, we keep the loose, non-pinned + * behavior: install `~//` from the + * `latest` URL, with a `--version`-based reuse check. + * + * Returns the resolved CLI binary path to run. + */ + private async _ensureCLIInstalled(client: SSHClient, platform: { os: string; arch: string }, reportProgress: (message: string) => void): Promise { + const commit = this._commit; + if (!commit) { + return this._ensureCLIInstalledLoose(client, platform, reportProgress); + } + return this._ensureCLIInstalledPinned(client, platform, reportProgress, commit); + } + + /** + * Commit-pinned install path. See {@link _ensureCLIInstalled}. + */ + private async _ensureCLIInstalledPinned(client: SSHClient, platform: { os: string; arch: string }, reportProgress: (message: string) => void, commit: string): Promise { + const cliBin = getRemoteCLIBin(this._serverDataFolderName, this._quality, commit); + const installRoot = getRemoteCLIInstallRoot(this._serverDataFolderName); + + // Primary reuse check: pure file existence on the commit-keyed path. + // No `--version` parsing — we know the file is ours and matches the + // desktop commit. + const { code: existsCode } = await sshExec(client, `test -x ${cliBin}`, { ignoreExitCode: true }); + if (existsCode === 0) { + this._logService.info(`${LOG_PREFIX} Reusing remote CLI at ${cliBin}`); + // Bump mtime so the retention pass below doesn't prune the + // binary we just decided to reuse. Without this, a user + // rotating between several desktop builds could see their + // currently-used CLI fall out of the 5-newest window and + // get deleted just before the next reconnect. + await sshExec(client, `touch -- ${cliBin}`, { ignoreExitCode: true }); + // Now that the in-use binary is the newest by mtime, prune + // older commit-keyed installs. Best-effort. + await sshExec(client, buildCleanupOldCLIsCommand(this._serverDataFolderName, this._quality), { ignoreExitCode: true }); + return cliBin; + } + + reportProgress(localize('sshProgressDownloadingCLI', "Installing VS Code CLI on remote...")); + const url = buildCLIDownloadUrl(platform.os, platform.arch, this._quality, commit); + + // Extract into a temp dir inside the install root so the final `mv` + // is a same-filesystem atomic rename. Concurrent SSH sessions racing + // here both end up with a valid binary for the same commit; the + // trailing `rm -rf` of the tmp dir is idempotent. + const installCmd = [ + `mkdir -p ${installRoot}`, + `tmpdir=$(mktemp -d ${installRoot}/.cli-install-XXXXXX)`, + `(cd "$tmpdir" && curl -fsSL ${shellEscape(url)} | tar xz)`, + // The archive contains exactly one file: the CLI binary, named per quality. + `mv "$tmpdir"/* ${cliBin}`, + `chmod +x ${cliBin}`, + `rm -rf "$tmpdir"`, + ].join(' && '); + + try { + await sshExec(client, installCmd); + // Validate the installed binary actually runs. If the archive was + // for the wrong platform / corrupted, this surfaces immediately. + const { code: versionCode } = await sshExec(client, `${cliBin} --version`, { ignoreExitCode: true }); + if (versionCode !== 0) { + throw new Error(`CLI at ${cliBin} failed --version check after install (exit code ${versionCode})`); + } + this._logService.info(`${LOG_PREFIX} Installed remote CLI at ${cliBin}`); + // Prune older commit-keyed installs now that the new binary is + // in place and is the newest by mtime. + await sshExec(client, buildCleanupOldCLIsCommand(this._serverDataFolderName, this._quality), { ignoreExitCode: true }); + return cliBin; + } catch (installErr) { + // Soft fallback (key difference from Remote-SSH): if the + // commit-pinned download fails (offline, 404, etc.) but another + // usable CLI is already on the box, use that instead of refusing + // to connect. The agent host has no strict commit-lock with the + // desktop — the protocol handshake will catch genuine + // incompatibilities. + const installErrorMessage = installErr instanceof Error ? installErr.message : String(installErr); + this._logService.warn(`${LOG_PREFIX} Could not install matching CLI for commit ${commit}: ${installErrorMessage}. Looking for a fallback CLI on the remote...`); + const fallback = await this._findFallbackCLI(client); + if (fallback) { + this._logService.warn(`${LOG_PREFIX} Using fallback CLI at ${fallback} (does not match desktop commit ${commit}).`); + return fallback; + } + throw installErr; + } + } + + /** + * Loose dev-build install: no commit pin. See {@link _ensureCLIInstalled}. + */ + private async _ensureCLIInstalledLoose(client: SSHClient, platform: { os: string; arch: string }, reportProgress: (message: string) => void): Promise { + const cliBin = getRemoteCLIBin(this._serverDataFolderName, this._quality); + const installRoot = getRemoteCLIInstallRoot(this._serverDataFolderName); + this._logService.warn(`${LOG_PREFIX} Desktop has no product commit; falling back to non-pinned CLI install at ${cliBin}.`); + const { code } = await sshExec(client, `${cliBin} --version`, { ignoreExitCode: true }); if (code === 0) { - this._logService.info(`${LOG_PREFIX} VS Code CLI already installed on remote`); - return; + this._logService.info(`${LOG_PREFIX} Reusing remote CLI at ${cliBin} (dev build, --version check passed)`); + return cliBin; } reportProgress(localize('sshProgressDownloadingCLI', "Installing VS Code CLI on remote...")); const url = buildCLIDownloadUrl(platform.os, platform.arch, this._quality); const installCmd = [ - `mkdir -p ${cliDir}`, - `curl -fsSL ${shellEscape(url)} | tar xz -C ${cliDir}`, + `mkdir -p ${installRoot}`, + `curl -fsSL ${shellEscape(url)} | tar xz -C ${installRoot}`, `chmod +x ${cliBin}`, ].join(' && '); await sshExec(client, installCmd); - this._logService.info(`${LOG_PREFIX} VS Code CLI installed successfully`); + this._logService.info(`${LOG_PREFIX} Installed remote CLI at ${cliBin}`); + return cliBin; + } + + /** + * List remote CLI candidates that could be used as a fallback when the + * commit-pinned download fails, and return the newest one that passes + * a `--version` check. Returns `undefined` if no candidate works. + */ + private async _findFallbackCLI(client: SSHClient): Promise { + const { stdout } = await sshExec(client, buildFindFallbackCLICommand(this._serverDataFolderName, this._quality), { ignoreExitCode: true }); + const rawCandidates = stdout.split('\n').map(s => s.trim()).filter(s => s.length > 0); + // Defensive validation: the finder shell snippet emits paths we + // trust by construction, but the output is still data coming back + // over SSH that we then interpolate into a follow-up command + // (` --version`). Filter to the exact shapes we expect + // — `/-<40 hex>` or `/` — so a + // malicious or junk file in the install root can never become a + // shell argument. + const candidates: string[] = []; + for (const candidate of rawCandidates) { + if (isValidFallbackCLIPath(candidate, this._serverDataFolderName, this._quality)) { + candidates.push(candidate); + } else { + this._logService.info(`${LOG_PREFIX} Ignoring fallback CLI candidate with unexpected path shape: ${candidate}`); + } + } + for (const candidate of candidates) { + const { code } = await sshExec(client, `${candidate} --version`, { ignoreExitCode: true }); + if (code === 0) { + return candidate; + } + this._logService.info(`${LOG_PREFIX} Fallback CLI candidate ${candidate} failed --version check (exit ${code}); trying next.`); + } + return undefined; } } diff --git a/src/vs/platform/agentHost/test/node/sshRemoteAgentHostHelpers.test.ts b/src/vs/platform/agentHost/test/node/sshRemoteAgentHostHelpers.test.ts index a0b3eb2229a4c..ff7bfc4ab8bb8 100644 --- a/src/vs/platform/agentHost/test/node/sshRemoteAgentHostHelpers.test.ts +++ b/src/vs/platform/agentHost/test/node/sshRemoteAgentHostHelpers.test.ts @@ -10,11 +10,15 @@ import { createRemoteAgentHostState } from '../../common/remoteAgentHostMetadata import { PROTOCOL_VERSION } from '../../common/state/protocol/version/registry.js'; import { buildCLIDownloadUrl, + buildCleanupOldCLIsCommand, + buildFindFallbackCLICommand, cleanupRemoteAgentHost, findRunningAgentHost, getAgentHostLockfile, + getRemoteCLIArchiveName, getRemoteCLIBin, - getRemoteCLIDir, + getRemoteCLIInstallRoot, + isValidFallbackCLIPath, redactToken, resolveRemotePlatform, shellEscape, @@ -67,27 +71,84 @@ suite('SSH Remote Agent Host Helpers', () => { }); }); - suite('getRemoteCLIDir', () => { - test('returns standard path for stable', () => { - assert.strictEqual(getRemoteCLIDir('stable'), '~/.vscode-cli'); + suite('getRemoteCLIArchiveName', () => { + test('returns code for stable', () => { + assert.strictEqual(getRemoteCLIArchiveName('stable'), 'code'); + }); + + test('returns code-insiders for insider', () => { + assert.strictEqual(getRemoteCLIArchiveName('insider'), 'code-insiders'); + }); + + test('returns code-exploration for exploration', () => { + assert.strictEqual(getRemoteCLIArchiveName('exploration'), 'code-exploration'); + }); + + test('falls back to code-insiders for unknown qualities', () => { + // Dev builds with no `quality` end up here via the + // `_quality` getter's `'insider'` default, so the fallback + // shouldn't differ from insider. + assert.strictEqual(getRemoteCLIArchiveName('weirdbuild'), 'code-insiders'); + }); + + test('rejects unsafe quality strings', () => { + assert.throws(() => getRemoteCLIArchiveName('foo bar'), /Unsafe quality/); }); + }); - test('returns quality-suffixed path for insider', () => { - assert.strictEqual(getRemoteCLIDir('insider'), '~/.vscode-cli-insider'); + suite('getRemoteCLIInstallRoot', () => { + test('returns user-home anchored path under the server data folder', () => { + assert.strictEqual(getRemoteCLIInstallRoot('.vscode-server-insiders'), '~/.vscode-server-insiders'); }); - test('returns quality-suffixed path for exploration', () => { - assert.strictEqual(getRemoteCLIDir('exploration'), '~/.vscode-cli-exploration'); + test('rejects unsafe server data folder names', () => { + assert.throws(() => getRemoteCLIInstallRoot('foo bar'), /Unsafe server data folder name/); + assert.throws(() => getRemoteCLIInstallRoot('foo/bar'), /Unsafe server data folder name/); + assert.throws(() => getRemoteCLIInstallRoot('$(whoami)'), /Unsafe server data folder name/); }); }); suite('getRemoteCLIBin', () => { - test('returns code for stable', () => { - assert.strictEqual(getRemoteCLIBin('stable'), '~/.vscode-cli/code'); + const commit = 'abcdef0123456789abcdef0123456789abcdef01'; + + test('returns commit-keyed path under shared install root for stable', () => { + assert.strictEqual( + getRemoteCLIBin('.vscode-server', 'stable', commit), + `~/.vscode-server/code-${commit}`, + ); }); - test('returns code-insiders for insider', () => { - assert.strictEqual(getRemoteCLIBin('insider'), '~/.vscode-cli-insider/code-insiders'); + test('returns commit-keyed path for insider', () => { + assert.strictEqual( + getRemoteCLIBin('.vscode-server-insiders', 'insider', commit), + `~/.vscode-server-insiders/code-insiders-${commit}`, + ); + }); + + test('returns commit-keyed path for exploration', () => { + assert.strictEqual( + getRemoteCLIBin('.vscode-server-exploration', 'exploration', commit), + `~/.vscode-server-exploration/code-exploration-${commit}`, + ); + }); + + test('returns non-keyed path when commit is undefined (dev build)', () => { + assert.strictEqual( + getRemoteCLIBin('.vscode-server-oss', 'insider'), + '~/.vscode-server-oss/code-insiders', + ); + assert.strictEqual( + getRemoteCLIBin('.vscode-server', 'stable'), + '~/.vscode-server/code', + ); + }); + + test('rejects unsafe commit values', () => { + assert.throws(() => getRemoteCLIBin('.vscode-server', 'stable', 'foo;rm'), /Unsafe commit/); + }); + + test('rejects unsafe server data folder names', () => { + assert.throws(() => getRemoteCLIBin('foo bar', 'stable', commit), /Unsafe server data folder name/); }); }); @@ -156,19 +217,126 @@ suite('SSH Remote Agent Host Helpers', () => { }); suite('buildCLIDownloadUrl', () => { - test('constructs correct URL', () => { + const commit = 'abcdef0123456789abcdef0123456789abcdef01'; + + test('uses `latest` URL when commit is omitted', () => { assert.strictEqual( buildCLIDownloadUrl('linux', 'x64', 'insider'), 'https://update.code.visualstudio.com/latest/cli-linux-x64/insider' ); }); - test('works for darwin arm64 stable', () => { + test('works for darwin arm64 stable (no commit)', () => { assert.strictEqual( buildCLIDownloadUrl('darwin', 'arm64', 'stable'), 'https://update.code.visualstudio.com/latest/cli-darwin-arm64/stable' ); }); + + test('pins to commit when provided', () => { + assert.strictEqual( + buildCLIDownloadUrl('linux', 'x64', 'insider', commit), + `https://update.code.visualstudio.com/commit:${commit}/cli-linux-x64/insider`, + ); + }); + + test('pins to commit for darwin arm64 stable', () => { + assert.strictEqual( + buildCLIDownloadUrl('darwin', 'arm64', 'stable', commit), + `https://update.code.visualstudio.com/commit:${commit}/cli-darwin-arm64/stable`, + ); + }); + + test('rejects unsafe commit values', () => { + assert.throws(() => buildCLIDownloadUrl('linux', 'x64', 'insider', 'foo;rm'), /Unsafe commit/); + }); + }); + + suite('buildCleanupOldCLIsCommand', () => { + test('produces a snippet that keeps the 5 most recent commit-keyed CLIs for insider', () => { + const cmd = buildCleanupOldCLIsCommand('.vscode-server-insiders', 'insider'); + // Target the commit-keyed pattern (with 40 chars), under the shared install root. + assert.ok(cmd.includes('~/.vscode-server-insiders/code-insiders-'), `cmd missing install path: ${cmd}`); + assert.ok(/(\[0-9a-f\]){40}/.test(cmd), 'cmd should match exactly 40 hex chars'); + // Retention via sort + awk drop-first-N + xargs rm. + assert.ok(/ls -1t/.test(cmd), `cmd should sort by mtime: ${cmd}`); + assert.ok(/awk\s+'NR>5'/.test(cmd), `cmd should keep 5: ${cmd}`); + assert.ok(/xargs\s+-I\{\}\s+rm\s+-f\s+--/.test(cmd), `cmd should rm safely: ${cmd}`); + }); + + test('uses `code-` archive name for stable', () => { + const cmd = buildCleanupOldCLIsCommand('.vscode-server', 'stable'); + assert.ok(cmd.includes('~/.vscode-server/code-[0-9a-f]'), `cmd should target stable archive: ${cmd}`); + assert.ok(!cmd.includes('code-insiders-'), 'stable cmd should not mention insiders archive'); + }); + + test('rejects unsafe inputs', () => { + assert.throws(() => buildCleanupOldCLIsCommand('foo bar', 'stable'), /Unsafe server data folder name/); + assert.throws(() => buildCleanupOldCLIsCommand('.vscode-server', 'foo bar'), /Unsafe quality/); + }); + }); + + suite('buildFindFallbackCLICommand', () => { + test('lists commit-keyed candidates then legacy paths for insider', () => { + const cmd = buildFindFallbackCLICommand('.vscode-server-insiders', 'insider'); + // New commit-keyed candidates in shared install root, sorted newest-first. + assert.ok(cmd.includes('~/.vscode-server-insiders/code-insiders-'), `cmd missing new path: ${cmd}`); + assert.ok(/ls -1t/.test(cmd), 'should sort commit-keyed candidates by mtime'); + // Legacy single-binary path (insider has the `-insider` dir suffix). + assert.ok(cmd.includes('~/.vscode-cli-insider/code-insiders'), `cmd missing legacy path: ${cmd}`); + }); + + test('uses no-suffix legacy dir for stable', () => { + const cmd = buildFindFallbackCLICommand('.vscode-server', 'stable'); + assert.ok(cmd.includes('~/.vscode-cli/code'), `cmd missing stable legacy path: ${cmd}`); + assert.ok(!cmd.includes('.vscode-cli-stable'), 'stable should not get the - suffix'); + }); + + test('rejects unsafe inputs', () => { + assert.throws(() => buildFindFallbackCLICommand('foo bar', 'stable'), /Unsafe server data folder name/); + assert.throws(() => buildFindFallbackCLICommand('.vscode-server', 'foo bar'), /Unsafe quality/); + }); + }); + + suite('isValidFallbackCLIPath', () => { + const sdf = '.vscode-server-insiders'; + const q = 'insider'; + const hex = '0123456789abcdef0123456789abcdef01234567'; + + test('accepts commit-keyed path under the shared install root', () => { + assert.strictEqual(isValidFallbackCLIPath(`~/${sdf}/code-insiders-${hex}`, sdf, q), true); + }); + + test('accepts legacy ~/.vscode-cli-/ path for insider', () => { + assert.strictEqual(isValidFallbackCLIPath('~/.vscode-cli-insider/code-insiders', sdf, q), true); + }); + + test('accepts legacy ~/.vscode-cli/code path for stable', () => { + assert.strictEqual(isValidFallbackCLIPath('~/.vscode-cli/code', '.vscode-server', 'stable'), true); + }); + + test('rejects commit suffix with non-hex characters', () => { + const notHex = 'g'.repeat(40); + assert.strictEqual(isValidFallbackCLIPath(`~/${sdf}/code-insiders-${notHex}`, sdf, q), false); + }); + + test('rejects commit suffix with wrong length', () => { + assert.strictEqual(isValidFallbackCLIPath(`~/${sdf}/code-insiders-${hex.slice(0, 39)}`, sdf, q), false); + assert.strictEqual(isValidFallbackCLIPath(`~/${sdf}/code-insiders-${hex}a`, sdf, q), false); + }); + + test('rejects paths under an unexpected root', () => { + assert.strictEqual(isValidFallbackCLIPath(`~/.something-else/code-insiders-${hex}`, sdf, q), false); + }); + + test('rejects empty input', () => { + assert.strictEqual(isValidFallbackCLIPath('', sdf, q), false); + }); + + test('rejects shell metacharacters', () => { + assert.strictEqual(isValidFallbackCLIPath(`~/${sdf}/code-insiders-${hex}; rm -rf /`, sdf, q), false); + assert.strictEqual(isValidFallbackCLIPath(`~/${sdf}/code-insiders-${hex} && evil`, sdf, q), false); + }); }); suite('redactToken', () => { diff --git a/src/vs/platform/agentHost/test/node/sshRemoteAgentHostService.test.ts b/src/vs/platform/agentHost/test/node/sshRemoteAgentHostService.test.ts index 56c36c8138222..5bb0f745bb39d 100644 --- a/src/vs/platform/agentHost/test/node/sshRemoteAgentHostService.test.ts +++ b/src/vs/platform/agentHost/test/node/sshRemoteAgentHostService.test.ts @@ -252,7 +252,7 @@ class TestableSSHRemoteAgentHostMainService extends SSHRemoteAgentHostMainServic } protected override async _startRemoteAgentHost( - _client: unknown, _quality: string, _commandOverride?: string, + _client: unknown, _cliBin: string | undefined, _commandOverride?: string, ) { this.startCalled++; return { ...this.startResult, stream: new MockSSHChannel() as never }; @@ -397,10 +397,10 @@ suite('SSHRemoteAgentHostMainService - connect flow', () => { test('returns existing connection on duplicate connect without replacing relay', async () => { // First connect: uname, CLI check, findRunningAgentHost (no state), write state service.execResponses = [ + { stdout: '', code: 1 }, // cat state file (not found) { stdout: 'Linux\n', code: 0 }, // uname -s { stdout: 'x86_64\n', code: 0 }, // uname -m { stdout: '1.0.0\n', code: 0 }, // CLI --version (already installed) - { stdout: '', code: 1 }, // cat state file (not found) { stdout: '', code: 0 }, // echo state file (write) ]; @@ -424,10 +424,10 @@ suite('SSHRemoteAgentHostMainService - connect flow', () => { test('creates fresh relay on reconnect without restarting agent', async () => { // First connect: uname, CLI check, findRunningAgentHost (no state), write state service.execResponses = [ + { stdout: '', code: 1 }, // cat state file (not found) { stdout: 'Linux\n', code: 0 }, // uname -s { stdout: 'x86_64\n', code: 0 }, // uname -m { stdout: '1.0.0\n', code: 0 }, // CLI --version (already installed) - { stdout: '', code: 1 }, // cat state file (not found) { stdout: '', code: 0 }, // echo state file (write) ]; @@ -446,10 +446,10 @@ suite('SSHRemoteAgentHostMainService - connect flow', () => { test('reconnect does not fire onDidRelayClose for superseded relay', async () => { service.execResponses = [ + { stdout: '', code: 1 }, { stdout: 'Linux\n', code: 0 }, { stdout: 'x86_64\n', code: 0 }, { stdout: '1.0.0\n', code: 0 }, - { stdout: '', code: 1 }, { stdout: '', code: 0 }, ]; @@ -470,10 +470,10 @@ suite('SSHRemoteAgentHostMainService - connect flow', () => { test('reconnect suppresses synchronous close from old relay during replacement', async () => { service.execResponses = [ + { stdout: '', code: 1 }, { stdout: 'Linux\n', code: 0 }, { stdout: 'x86_64\n', code: 0 }, { stdout: '1.0.0\n', code: 0 }, - { stdout: '', code: 1 }, { stdout: '', code: 0 }, ]; @@ -493,10 +493,10 @@ suite('SSHRemoteAgentHostMainService - connect flow', () => { test('uses sshConfigHost as connection key when present', async () => { service.execResponses = [ + { stdout: '', code: 1 }, { stdout: 'Linux\n', code: 0 }, { stdout: 'x86_64\n', code: 0 }, { stdout: '1.0.0\n', code: 0 }, - { stdout: '', code: 1 }, { stdout: '', code: 0 }, ]; @@ -526,9 +526,6 @@ suite('SSHRemoteAgentHostMainService - connect flow', () => { test('reuses existing agent host when state file has valid PID', async () => { const existingState = stateJson(1234, 7777, 'existing-tok'); service.execResponses = [ - { stdout: 'Linux\n', code: 0 }, // uname -s - { stdout: 'x86_64\n', code: 0 }, // uname -m - { stdout: '1.0.0\n', code: 0 }, // CLI --version { stdout: existingState, code: 0 }, // cat state file (found) { stdout: '', code: 0 }, // kill -0 (PID alive) ]; @@ -543,15 +540,34 @@ suite('SSHRemoteAgentHostMainService - connect flow', () => { assert.strictEqual(result.connectionToken, 'existing-tok'); }); + test('agent-host reuse skips platform detection and CLI install', async () => { + // Regression: on the AH-reuse path we must not pay for `uname -s`, + // `uname -m`, `--version`, install, or cleanup — those are only + // needed when we're actually about to spawn a fresh agent host. + const existingState = stateJson(1234, 7777, 'existing-tok'); + service.execResponses = [ + { stdout: existingState, code: 0 }, // cat state file (found) + { stdout: '', code: 0 }, // kill -0 (PID alive) + ]; + + await service.connect(makeConfig()); + + const execCalls = service.mockClients[0].execCalls; + assert.ok(!execCalls.some(c => c.includes('uname')), `uname should not run on reuse; saw: ${JSON.stringify(execCalls)}`); + assert.ok(!execCalls.some(c => c.includes('--version')), `--version should not run on reuse; saw: ${JSON.stringify(execCalls)}`); + assert.ok(!execCalls.some(c => c.includes('test -x')), `test -x should not run on reuse; saw: ${JSON.stringify(execCalls)}`); + assert.ok(!execCalls.some(c => c.includes('curl')), `curl should not run on reuse; saw: ${JSON.stringify(execCalls)}`); + }); + test('starts fresh when state file PID is dead', async () => { const staleState = stateJson(9999, 7777, 'old-tok'); service.execResponses = [ - { stdout: 'Linux\n', code: 0 }, // uname -s - { stdout: 'x86_64\n', code: 0 }, // uname -m - { stdout: '1.0.0\n', code: 0 }, // CLI --version { stdout: staleState, code: 0 }, // cat state file { stdout: '', code: 1 }, // kill -0 (PID dead) { stdout: '', code: 0 }, // rm -f state file + { stdout: 'Linux\n', code: 0 }, // uname -s + { stdout: 'x86_64\n', code: 0 }, // uname -m + { stdout: '1.0.0\n', code: 0 }, // CLI --version { stdout: '', code: 0 }, // echo state file (write new) ]; @@ -566,15 +582,15 @@ suite('SSHRemoteAgentHostMainService - connect flow', () => { test('falls back to fresh start when relay to reused agent fails', async () => { const existingState = stateJson(1234, 7777, 'existing-tok'); service.execResponses = [ - { stdout: 'Linux\n', code: 0 }, // uname -s - { stdout: 'x86_64\n', code: 0 }, // uname -m - { stdout: '1.0.0\n', code: 0 }, // CLI --version { stdout: existingState, code: 0 }, // cat state file (found) { stdout: '', code: 0 }, // kill -0 (PID alive) // cleanup: cat state file, kill PID, rm state file { stdout: existingState, code: 0 }, { stdout: '', code: 0 }, { stdout: '', code: 0 }, + { stdout: 'Linux\n', code: 0 }, // uname -s + { stdout: 'x86_64\n', code: 0 }, // uname -m + { stdout: '1.0.0\n', code: 0 }, // CLI --version // write new state file after fresh start { stdout: '', code: 0 }, ]; @@ -600,11 +616,11 @@ suite('SSHRemoteAgentHostMainService - connect flow', () => { test('treats malformed legacy state as missing and starts fresh', async () => { const legacyState = JSON.stringify({ pid: 1234, port: 7777, connectionToken: 'existing-tok' }); service.execResponses = [ + { stdout: legacyState, code: 0 }, // cat lockfile (no schemaVersion) + { stdout: '', code: 0 }, // rm -f corrupt lockfile { stdout: 'Linux\n', code: 0 }, { stdout: 'x86_64\n', code: 0 }, { stdout: '1.0.0\n', code: 0 }, - { stdout: legacyState, code: 0 }, // cat lockfile (no schemaVersion) - { stdout: '', code: 0 }, // rm -f corrupt lockfile { stdout: '', code: 0 }, // write new lockfile ]; @@ -617,10 +633,10 @@ suite('SSHRemoteAgentHostMainService - connect flow', () => { test('does not retry when relay fails on freshly started agent', async () => { service.execResponses = [ + { stdout: '', code: 1 }, // no state file { stdout: 'Linux\n', code: 0 }, { stdout: 'x86_64\n', code: 0 }, { stdout: '1.0.0\n', code: 0 }, - { stdout: '', code: 1 }, // no state file { stdout: '', code: 0 }, // write state ]; @@ -635,10 +651,10 @@ suite('SSHRemoteAgentHostMainService - connect flow', () => { test('cleans up SSH client on error', async () => { service.execResponses = [ + { stdout: '', code: 1 }, { stdout: 'Linux\n', code: 0 }, { stdout: 'x86_64\n', code: 0 }, { stdout: '1.0.0\n', code: 0 }, - { stdout: '', code: 1 }, { stdout: '', code: 0 }, ]; @@ -886,10 +902,10 @@ suite('SSHRemoteAgentHostMainService - connect flow', () => { test('reconnect after disconnect establishes a new SSH connection', async () => { service.execResponses = [ + { stdout: '', code: 1 }, { stdout: 'Linux\n', code: 0 }, { stdout: 'x86_64\n', code: 0 }, { stdout: '1.0.0\n', code: 0 }, - { stdout: '', code: 1 }, { stdout: '', code: 0 }, ]; const r1 = await service.connect(makeConfig({ sshConfigHost: 'myhost' })); @@ -898,10 +914,10 @@ suite('SSHRemoteAgentHostMainService - connect flow', () => { await service.disconnect(r1.connectionId); service.execResponses = [ + { stdout: '', code: 1 }, { stdout: 'Linux\n', code: 0 }, { stdout: 'x86_64\n', code: 0 }, { stdout: '1.0.0\n', code: 0 }, - { stdout: '', code: 1 }, { stdout: '', code: 0 }, ]; @@ -915,10 +931,10 @@ suite('SSHRemoteAgentHostMainService - connect flow', () => { test('fires progress events during connect', async () => { service.execResponses = [ + { stdout: '', code: 1 }, { stdout: 'Linux\n', code: 0 }, { stdout: 'x86_64\n', code: 0 }, { stdout: '1.0.0\n', code: 0 }, - { stdout: '', code: 1 }, { stdout: '', code: 0 }, ]; @@ -1000,10 +1016,10 @@ suite('SSHRemoteAgentHostMainService - connect flow', () => { test('skips CLI download when CLI is already installed', async () => { service.execResponses = [ + { stdout: '', code: 1 }, // cat state file (not found) { stdout: 'Linux\n', code: 0 }, // uname -s { stdout: 'x86_64\n', code: 0 }, // uname -m { stdout: '1.0.0\n', code: 0 }, // CLI --version succeeds - { stdout: '', code: 1 }, // cat state file (not found) { stdout: '', code: 0 }, // echo state file (write) ]; @@ -1017,11 +1033,11 @@ suite('SSHRemoteAgentHostMainService - connect flow', () => { test('downloads CLI when version check fails', async () => { service.execResponses = [ + { stdout: '', code: 1 }, // cat state file (not found) { stdout: 'Linux\n', code: 0 }, // uname -s { stdout: 'x86_64\n', code: 0 }, // uname -m { stdout: '', code: 127 }, // CLI --version fails (not found) { stdout: '', code: 0 }, // curl | tar install - { stdout: '', code: 1 }, // cat state file (not found) { stdout: '', code: 0 }, // echo state file (write) ]; @@ -1032,6 +1048,128 @@ suite('SSHRemoteAgentHostMainService - connect flow', () => { 'should download CLI when not installed'); }); + // --- Commit-pinned install flow (release builds with productService.commit) --- + + suite('commit-pinned install', () => { + const commit = 'abcdef0123456789abcdef0123456789abcdef01'; + const cliBin = `~/.vscode-insiders/code-insiders-${commit}`; + let pinnedService: TestableSSHRemoteAgentHostMainService; + + setup(() => { + const logService = new NullLogService(); + const productService: Pick = { + _serviceBrand: undefined, + quality, + dataFolderName, + serverDataFolderName: '.vscode-insiders', + commit, + }; + pinnedService = new TestableSSHRemoteAgentHostMainService( + logService, + productService as IProductService, + ); + disposables.add(pinnedService); + }); + + test('always invokes cleanup of old commit-keyed CLIs', async () => { + pinnedService.execResponses = [ + { stdout: '', code: 1 }, // cat state (none) + { stdout: 'Linux\n', code: 0 }, + { stdout: 'x86_64\n', code: 0 }, + { stdout: '', code: 0 }, // test -x cliBin → present + { stdout: '', code: 0 }, // touch cliBin (refresh mtime on reuse) + { stdout: '', code: 0 }, // cleanup (runs after reuse decision) + { stdout: '', code: 0 }, // write state + ]; + await pinnedService.connect(makeConfig()); + + const execCalls = pinnedService.mockClients[0].execCalls; + // Retention snippet: `ls -1t ... | awk 'NR>5' | xargs rm` + assert.ok(execCalls.some(c => /ls -1t .*code-insiders-/.test(c) && /awk\s+'NR>5'/.test(c)), + `cleanup command should have run; saw: ${JSON.stringify(execCalls)}`); + }); + + test('reuses existing commit-keyed CLI without re-downloading', async () => { + pinnedService.execResponses = [ + { stdout: '', code: 1 }, // cat state (none) + { stdout: 'Linux\n', code: 0 }, + { stdout: 'x86_64\n', code: 0 }, + { stdout: '', code: 0 }, // test -x cliBin → 0 (present) + { stdout: '', code: 0 }, // touch cliBin + { stdout: '', code: 0 }, // cleanup + { stdout: '', code: 0 }, // write state + ]; + + await pinnedService.connect(makeConfig()); + + const execCalls = pinnedService.mockClients[0].execCalls; + assert.ok(execCalls.some(c => c.includes(`test -x ${cliBin}`)), + `should test for commit-keyed CLI; saw: ${JSON.stringify(execCalls)}`); + assert.ok(!execCalls.some(c => c.includes('curl')), + `should not download when commit-keyed CLI present; saw: ${JSON.stringify(execCalls)}`); + }); + + test('downloads from commit-pinned URL when CLI is missing', async () => { + pinnedService.execResponses = [ + { stdout: '', code: 1 }, // cat state (none) + { stdout: 'Linux\n', code: 0 }, + { stdout: 'x86_64\n', code: 0 }, + { stdout: '', code: 1 }, // test -x → missing + { stdout: '', code: 0 }, // mkdir+mktemp+curl|tar+mv+chmod+rm + { stdout: '1.0.0\n', code: 0 }, // --version validation + { stdout: '', code: 0 }, // cleanup (after successful install) + { stdout: '', code: 0 }, // write state + ]; + + await pinnedService.connect(makeConfig()); + + const execCalls = pinnedService.mockClients[0].execCalls; + const installCall = execCalls.find(c => c.includes('curl')); + assert.ok(installCall, `should have run curl install; saw: ${JSON.stringify(execCalls)}`); + assert.ok(installCall!.includes(`commit:${commit}`), + `install URL should be commit-pinned; got: ${installCall}`); + assert.ok(installCall!.includes(`mv `) && installCall!.includes(cliBin), + `install should atomic-mv into commit-keyed path; got: ${installCall}`); + }); + + test('falls back to any usable CLI when commit-pinned download fails', async () => { + const fallbackBin = `~/.vscode-insiders/code-insiders-0000000000000000000000000000000000000000`; + pinnedService.execResponses = [ + { stdout: '', code: 1 }, // cat state (none) + { stdout: 'Linux\n', code: 0 }, + { stdout: 'x86_64\n', code: 0 }, + { stdout: '', code: 1 }, // test -x → missing + { stdout: '', code: 7 }, // install fails (curl exit 7) + { stdout: `${fallbackBin}\n`, code: 0 }, // fallback finder lists old commit-keyed + { stdout: '1.0.0\n', code: 0 }, // fallback --version succeeds + { stdout: '', code: 0 }, // write state + ]; + + await pinnedService.connect(makeConfig()); + + const execCalls = pinnedService.mockClients[0].execCalls; + // Fallback finder snippet enumerates commit-keyed candidates by mtime. + assert.ok(execCalls.some(c => /ls -1t .*code-insiders-/.test(c) && c.includes('.vscode-cli-insider/code-insiders')), + `should have run fallback finder; saw: ${JSON.stringify(execCalls)}`); + // Should have --version-validated the fallback candidate. + assert.ok(execCalls.some(c => c.includes(`${fallbackBin} --version`)), + `should --version-validate fallback; saw: ${JSON.stringify(execCalls)}`); + }); + + test('propagates install error when no fallback CLI exists', async () => { + pinnedService.execResponses = [ + { stdout: '', code: 1 }, // cat state (none) + { stdout: 'Linux\n', code: 0 }, + { stdout: 'x86_64\n', code: 0 }, + { stdout: '', code: 1 }, // test -x → missing + { stdout: '', code: 7 }, // install fails + { stdout: '', code: 0 }, // fallback finder returns nothing + ]; + + await assert.rejects(pinnedService.connect(makeConfig())); + }); + }); + // --- Connection key formats --- test('uses host:port as connection key without sshConfigHost', async () => { @@ -1065,10 +1203,10 @@ suite('SSHRemoteAgentHostMainService - connect flow', () => { test('reconnect preserves connection token and address', async () => { service.execResponses = [ + { stdout: '', code: 1 }, { stdout: 'Linux\n', code: 0 }, { stdout: 'x86_64\n', code: 0 }, { stdout: '1.0.0\n', code: 0 }, - { stdout: '', code: 1 }, { stdout: '', code: 0 }, ]; @@ -1084,10 +1222,10 @@ suite('SSHRemoteAgentHostMainService - connect flow', () => { test('messages from superseded relay still arrive (only close is suppressed)', async () => { service.execResponses = [ + { stdout: '', code: 1 }, { stdout: 'Linux\n', code: 0 }, { stdout: 'x86_64\n', code: 0 }, { stdout: '1.0.0\n', code: 0 }, - { stdout: '', code: 1 }, { stdout: '', code: 0 }, ]; @@ -1115,10 +1253,10 @@ suite('SSHRemoteAgentHostMainService - connect flow', () => { test('reconnect cleans up SSH client when relay recreation fails', async () => { service.execResponses = [ + { stdout: '', code: 1 }, { stdout: 'Linux\n', code: 0 }, { stdout: 'x86_64\n', code: 0 }, { stdout: '1.0.0\n', code: 0 }, - { stdout: '', code: 1 }, { stdout: '', code: 0 }, ]; @@ -1156,10 +1294,10 @@ suite('SSHRemoteAgentHostMainService - connect flow', () => { // never sees a rejection and never retries — even after a window // reload, since the shared-process state survives. service.execResponses = [ + { stdout: '', code: 1 }, { stdout: 'Linux\n', code: 0 }, { stdout: 'x86_64\n', code: 0 }, { stdout: '1.0.0\n', code: 0 }, - { stdout: '', code: 1 }, { stdout: '', code: 0 }, ]; @@ -1194,10 +1332,10 @@ suite('SSHRemoteAgentHostMainService - connect flow', () => { test('reconnect removes old close/error listeners from shared SSH client', async () => { service.execResponses = [ + { stdout: '', code: 1 }, { stdout: 'Linux\n', code: 0 }, { stdout: 'x86_64\n', code: 0 }, { stdout: '1.0.0\n', code: 0 }, - { stdout: '', code: 1 }, { stdout: '', code: 0 }, ];