Path: blob/main/src/vs/platform/agentHost/test/node/sshRemoteAgentHostHelpers.test.ts
13399 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import assert from 'assert';6import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';7import { NullLogService } from '../../../log/common/log.js';8import {9buildCLIDownloadUrl,10cleanupRemoteAgentHost,11findRunningAgentHost,12getAgentHostStateFile,13getRemoteCLIBin,14getRemoteCLIDir,15redactToken,16resolveRemotePlatform,17shellEscape,18validateShellToken,19writeAgentHostState,20type ISshExec,21} from '../../node/sshRemoteAgentHostHelpers.js';2223suite('SSH Remote Agent Host Helpers', () => {2425ensureNoDisposablesAreLeakedInTestSuite();2627const logService = new NullLogService();2829suite('validateShellToken', () => {30test('accepts alphanumeric strings', () => {31assert.strictEqual(validateShellToken('insider', 'quality'), 'insider');32assert.strictEqual(validateShellToken('stable', 'quality'), 'stable');33assert.strictEqual(validateShellToken('exploration', 'quality'), 'exploration');34});3536test('accepts dots, dashes, and underscores', () => {37assert.strictEqual(validateShellToken('my-build_1.0', 'quality'), 'my-build_1.0');38});3940test('rejects strings with spaces', () => {41assert.throws(() => validateShellToken('foo bar', 'quality'), /Unsafe quality/);42});4344test('rejects strings with shell metacharacters', () => {45assert.throws(() => validateShellToken('foo;rm -rf /', 'quality'), /Unsafe quality/);46assert.throws(() => validateShellToken('$(whoami)', 'quality'), /Unsafe quality/);47assert.throws(() => validateShellToken('foo\'bar', 'quality'), /Unsafe quality/);48});4950test('rejects empty string', () => {51assert.throws(() => validateShellToken('', 'quality'), /Unsafe quality/);52});53});5455suite('getRemoteCLIDir', () => {56test('returns standard path for stable', () => {57assert.strictEqual(getRemoteCLIDir('stable'), '~/.vscode-cli');58});5960test('returns quality-suffixed path for insider', () => {61assert.strictEqual(getRemoteCLIDir('insider'), '~/.vscode-cli-insider');62});6364test('returns quality-suffixed path for exploration', () => {65assert.strictEqual(getRemoteCLIDir('exploration'), '~/.vscode-cli-exploration');66});67});6869suite('getRemoteCLIBin', () => {70test('returns code for stable', () => {71assert.strictEqual(getRemoteCLIBin('stable'), '~/.vscode-cli/code');72});7374test('returns code-insiders for insider', () => {75assert.strictEqual(getRemoteCLIBin('insider'), '~/.vscode-cli-insider/code-insiders');76});77});7879suite('shellEscape', () => {80test('wraps simple string in single quotes', () => {81assert.strictEqual(shellEscape('hello'), '\'hello\'');82});8384test('escapes embedded single quotes', () => {85assert.strictEqual(shellEscape('it\'s'), '\'it\'\\\'\'s\'');86});8788test('handles empty string', () => {89assert.strictEqual(shellEscape(''), '\'\'');90});9192test('passes through special chars safely wrapped', () => {93assert.strictEqual(shellEscape('$(rm -rf /)'), '\'$(rm -rf /)\'');94});95});9697suite('resolveRemotePlatform', () => {98test('detects Linux x64', () => {99assert.deepStrictEqual(resolveRemotePlatform('Linux', 'x86_64'), { os: 'linux', arch: 'x64' });100});101102test('detects Linux amd64', () => {103assert.deepStrictEqual(resolveRemotePlatform('Linux', 'amd64'), { os: 'linux', arch: 'x64' });104});105106test('detects Linux arm64 (aarch64)', () => {107assert.deepStrictEqual(resolveRemotePlatform('Linux', 'aarch64'), { os: 'linux', arch: 'arm64' });108});109110test('detects Linux arm64', () => {111assert.deepStrictEqual(resolveRemotePlatform('Linux', 'arm64'), { os: 'linux', arch: 'arm64' });112});113114test('detects Linux armhf', () => {115assert.deepStrictEqual(resolveRemotePlatform('Linux', 'armv7l'), { os: 'linux', arch: 'armhf' });116});117118test('detects Darwin x64', () => {119assert.deepStrictEqual(resolveRemotePlatform('Darwin', 'x86_64'), { os: 'darwin', arch: 'x64' });120});121122test('detects Darwin arm64', () => {123assert.deepStrictEqual(resolveRemotePlatform('Darwin', 'arm64'), { os: 'darwin', arch: 'arm64' });124});125126test('handles whitespace in uname output', () => {127assert.deepStrictEqual(resolveRemotePlatform(' Linux\n', ' x86_64\n'), { os: 'linux', arch: 'x64' });128});129130test('returns undefined for Windows', () => {131assert.strictEqual(resolveRemotePlatform('MINGW64_NT-10.0-19041', 'x86_64'), undefined);132});133134test('returns undefined for unknown OS', () => {135assert.strictEqual(resolveRemotePlatform('FreeBSD', 'amd64'), undefined);136});137138test('returns undefined for unknown arch', () => {139assert.strictEqual(resolveRemotePlatform('Linux', 'ppc64le'), undefined);140});141});142143suite('buildCLIDownloadUrl', () => {144test('constructs correct URL', () => {145assert.strictEqual(146buildCLIDownloadUrl('linux', 'x64', 'insider'),147'https://update.code.visualstudio.com/latest/cli-linux-x64/insider'148);149});150151test('works for darwin arm64 stable', () => {152assert.strictEqual(153buildCLIDownloadUrl('darwin', 'arm64', 'stable'),154'https://update.code.visualstudio.com/latest/cli-darwin-arm64/stable'155);156});157});158159suite('redactToken', () => {160test('redacts token in WebSocket URL', () => {161assert.strictEqual(162redactToken('ws://127.0.0.1:12345?tkn=secret123'),163'ws://127.0.0.1:12345?tkn=***'164);165});166167test('redacts token with following whitespace', () => {168assert.strictEqual(169redactToken('ws://127.0.0.1:12345?tkn=abc123 done'),170'ws://127.0.0.1:12345?tkn=*** done'171);172});173174test('preserves text without tokens', () => {175assert.strictEqual(redactToken('no token here'), 'no token here');176});177178test('redacts multiple tokens', () => {179assert.strictEqual(180redactToken('?tkn=one and ?tkn=two'),181'?tkn=*** and ?tkn=***'182);183});184});185186suite('getAgentHostStateFile', () => {187test('returns path under CLI dir', () => {188assert.strictEqual(189getAgentHostStateFile('insider'),190'~/.vscode-cli-insider/.agent-host-state'191);192});193194test('returns path for stable', () => {195assert.strictEqual(196getAgentHostStateFile('stable'),197'~/.vscode-cli/.agent-host-state'198);199});200});201202suite('findRunningAgentHost', () => {203204function createMockExec(responses: Map<string, { stdout: string; stderr: string; code: number }>): ISshExec {205return async (command: string, _opts?: { ignoreExitCode?: boolean }) => {206for (const [pattern, response] of responses) {207if (command.includes(pattern)) {208return response;209}210}211return { stdout: '', stderr: '', code: 1 };212};213}214215test('returns undefined when no state file exists', async () => {216const exec = createMockExec(new Map([217['cat', { stdout: '', stderr: '', code: 1 }],218]));219const result = await findRunningAgentHost(exec, logService, 'insider');220assert.strictEqual(result, undefined);221});222223test('returns undefined when state file is empty', async () => {224const exec = createMockExec(new Map([225['cat', { stdout: ' \n', stderr: '', code: 0 }],226]));227const result = await findRunningAgentHost(exec, logService, 'insider');228assert.strictEqual(result, undefined);229});230231test('cleans up corrupt state file', async () => {232const commands: string[] = [];233const exec: ISshExec = async (command: string) => {234commands.push(command);235if (command.includes('cat')) {236return { stdout: 'not json at all', stderr: '', code: 0 };237}238return { stdout: '', stderr: '', code: 0 };239};240const result = await findRunningAgentHost(exec, logService, 'insider');241assert.strictEqual(result, undefined);242assert.ok(commands.some(c => c.includes('rm -f')));243});244245test('cleans up state file with missing pid', async () => {246const commands: string[] = [];247const exec: ISshExec = async (command: string) => {248commands.push(command);249if (command.includes('cat')) {250return { stdout: JSON.stringify({ pid: 0, port: 8080, connectionToken: null }), stderr: '', code: 0 };251}252return { stdout: '', stderr: '', code: 0 };253};254const result = await findRunningAgentHost(exec, logService, 'insider');255assert.strictEqual(result, undefined);256assert.ok(commands.some(c => c.includes('rm -f')));257});258259test('cleans up state file with missing port', async () => {260const commands: string[] = [];261const exec: ISshExec = async (command: string) => {262commands.push(command);263if (command.includes('cat')) {264return { stdout: JSON.stringify({ pid: 1234, port: 0, connectionToken: null }), stderr: '', code: 0 };265}266return { stdout: '', stderr: '', code: 0 };267};268const result = await findRunningAgentHost(exec, logService, 'insider');269assert.strictEqual(result, undefined);270assert.ok(commands.some(c => c.includes('rm -f')));271});272273test('rejects state file with string pid', async () => {274const exec = createMockExec(new Map([275['cat', { stdout: JSON.stringify({ pid: '1234', port: 8080, connectionToken: null }), stderr: '', code: 0 }],276]));277const result = await findRunningAgentHost(exec, logService, 'insider');278assert.strictEqual(result, undefined);279});280281test('rejects state file with negative pid', async () => {282const exec = createMockExec(new Map([283['cat', { stdout: JSON.stringify({ pid: -1, port: 8080, connectionToken: null }), stderr: '', code: 0 }],284]));285const result = await findRunningAgentHost(exec, logService, 'insider');286assert.strictEqual(result, undefined);287});288289test('rejects state file with non-integer port', async () => {290const exec = createMockExec(new Map([291['cat', { stdout: JSON.stringify({ pid: 1234, port: 8080.5, connectionToken: null }), stderr: '', code: 0 }],292]));293const result = await findRunningAgentHost(exec, logService, 'insider');294assert.strictEqual(result, undefined);295});296297test('rejects state file with numeric connectionToken', async () => {298const exec = createMockExec(new Map([299['cat', { stdout: JSON.stringify({ pid: 1234, port: 8080, connectionToken: 42 }), stderr: '', code: 0 }],300]));301const result = await findRunningAgentHost(exec, logService, 'insider');302assert.strictEqual(result, undefined);303});304305test('rejects state file with port above 65535', async () => {306const exec = createMockExec(new Map([307['cat', { stdout: JSON.stringify({ pid: 1234, port: 70000, connectionToken: null }), stderr: '', code: 0 }],308]));309const result = await findRunningAgentHost(exec, logService, 'insider');310assert.strictEqual(result, undefined);311});312313test('cleans up stale state when PID is not running', async () => {314const state = { pid: 9999, port: 8080, connectionToken: 'tok123' };315const commands: string[] = [];316const exec: ISshExec = async (command: string) => {317commands.push(command);318if (command.includes('cat')) {319return { stdout: JSON.stringify(state), stderr: '', code: 0 };320}321if (command.includes('kill -0')) {322return { stdout: '', stderr: '', code: 1 }; // PID not running323}324return { stdout: '', stderr: '', code: 0 };325};326const result = await findRunningAgentHost(exec, logService, 'insider');327assert.strictEqual(result, undefined);328assert.ok(commands.some(c => c.includes('rm -f')));329});330331test('returns port and token when PID is alive', async () => {332const state = { pid: 1234, port: 8080, connectionToken: 'mytoken' };333const exec = createMockExec(new Map([334['cat', { stdout: JSON.stringify(state), stderr: '', code: 0 }],335['kill -0', { stdout: '', stderr: '', code: 0 }],336]));337const result = await findRunningAgentHost(exec, logService, 'insider');338assert.deepStrictEqual(result, { port: 8080, connectionToken: 'mytoken' });339});340341test('returns undefined connectionToken when state has null token', async () => {342const state = { pid: 1234, port: 8080, connectionToken: null };343const exec = createMockExec(new Map([344['cat', { stdout: JSON.stringify(state), stderr: '', code: 0 }],345['kill -0', { stdout: '', stderr: '', code: 0 }],346]));347const result = await findRunningAgentHost(exec, logService, 'insider');348assert.deepStrictEqual(result, { port: 8080, connectionToken: undefined });349});350});351352suite('writeAgentHostState', () => {353354test('does not write when pid is undefined', async () => {355const commands: string[] = [];356const exec: ISshExec = async (command: string) => {357commands.push(command);358return { stdout: '', stderr: '', code: 0 };359};360await writeAgentHostState(exec, logService, 'insider', undefined, 8080, 'token');361assert.strictEqual(commands.length, 0);362});363364test('does not write when pid is 0', async () => {365const commands: string[] = [];366const exec: ISshExec = async (command: string) => {367commands.push(command);368return { stdout: '', stderr: '', code: 0 };369};370await writeAgentHostState(exec, logService, 'insider', 0, 8080, 'token');371assert.strictEqual(commands.length, 0);372});373374test('writes state file with correct JSON', async () => {375const commands: string[] = [];376const exec: ISshExec = async (command: string) => {377commands.push(command);378return { stdout: '', stderr: '', code: 0 };379};380await writeAgentHostState(exec, logService, 'insider', 1234, 8080, 'mytoken');381assert.strictEqual(commands.length, 1);382assert.ok(commands[0].includes('.agent-host-state'));383// Verify the JSON content is present in the echo command384assert.ok(commands[0].includes('"pid":1234'));385assert.ok(commands[0].includes('"port":8080'));386assert.ok(commands[0].includes('"connectionToken":"mytoken"'));387// Verify restrictive umask in a subshell is used to protect the connection token388assert.ok(commands[0].includes('rm -f'));389assert.ok(commands[0].includes('(umask 077'));390});391392test('writes null connectionToken when undefined', async () => {393const commands: string[] = [];394const exec: ISshExec = async (command: string) => {395commands.push(command);396return { stdout: '', stderr: '', code: 0 };397};398await writeAgentHostState(exec, logService, 'insider', 1234, 8080, undefined);399assert.strictEqual(commands.length, 1);400assert.ok(commands[0].includes('"connectionToken":null'));401});402403test('logs warning when write command fails', async () => {404const exec: ISshExec = async () => {405return { stdout: '', stderr: 'Permission denied', code: 1 };406};407const warnings: string[] = [];408const capturingLog = new NullLogService();409capturingLog.warn = (...args: unknown[]) => { warnings.push(args.map(String).join(' ')); };410await writeAgentHostState(exec, capturingLog, 'insider', 1234, 8080, 'tok');411// Should log a warning with exit code and stderr412assert.strictEqual(warnings.length, 1);413assert.ok(warnings[0].includes('Failed to write'));414assert.ok(warnings[0].includes('exit code 1'));415assert.ok(warnings[0].includes('Permission denied'));416});417});418419suite('cleanupRemoteAgentHost', () => {420421test('removes state file even when no state exists', async () => {422const commands: string[] = [];423const exec: ISshExec = async (command: string) => {424commands.push(command);425if (command.includes('cat')) {426return { stdout: '', stderr: '', code: 1 };427}428return { stdout: '', stderr: '', code: 0 };429};430await cleanupRemoteAgentHost(exec, logService, 'insider');431assert.ok(commands.some(c => c.includes('rm -f')));432});433434test('kills process and removes state file', async () => {435const state = { pid: 5678, port: 9090, connectionToken: null };436const commands: string[] = [];437const exec: ISshExec = async (command: string) => {438commands.push(command);439if (command.includes('cat')) {440return { stdout: JSON.stringify(state), stderr: '', code: 0 };441}442return { stdout: '', stderr: '', code: 0 };443};444await cleanupRemoteAgentHost(exec, logService, 'insider');445assert.ok(commands.some(c => c.includes('kill 5678')));446assert.ok(commands.some(c => c.includes('rm -f')));447});448449test('handles corrupt state file gracefully', async () => {450const commands: string[] = [];451const exec: ISshExec = async (command: string) => {452commands.push(command);453if (command.includes('cat')) {454return { stdout: '{invalid json', stderr: '', code: 0 };455}456return { stdout: '', stderr: '', code: 0 };457};458// Should not throw459await cleanupRemoteAgentHost(exec, logService, 'insider');460// Should still clean up the file461assert.ok(commands.some(c => c.includes('rm -f')));462// Should not have attempted to kill anything463assert.ok(!commands.some(c => c.startsWith('kill')));464});465466test('does not kill when pid is 0', async () => {467const state = { pid: 0, port: 9090, connectionToken: null };468const commands: string[] = [];469const exec: ISshExec = async (command: string) => {470commands.push(command);471if (command.includes('cat')) {472return { stdout: JSON.stringify(state), stderr: '', code: 0 };473}474return { stdout: '', stderr: '', code: 0 };475};476await cleanupRemoteAgentHost(exec, logService, 'insider');477assert.ok(!commands.some(c => c.match(/^kill \d/)));478assert.ok(commands.some(c => c.includes('rm -f')));479});480481test('kills process for stable quality', async () => {482const state = { pid: 1234, port: 8080, connectionToken: null };483const commands: string[] = [];484const exec: ISshExec = async (command: string) => {485commands.push(command);486if (command.includes('cat')) {487return { stdout: JSON.stringify(state), stderr: '', code: 0 };488}489return { stdout: '', stderr: '', code: 0 };490};491await cleanupRemoteAgentHost(exec, logService, 'stable');492assert.ok(commands.some(c => c.includes('kill 1234')));493});494});495});496497498