Skip to content

Commit ab05103

Browse files
roblourensCopilot
andcommitted
agentHost: Remember and reuse CLI processes for ssh
Co-authored-by: Copilot <copilot@github.com>
1 parent b564ded commit ab05103

File tree

4 files changed

+1220
-103
lines changed

4 files changed

+1220
-103
lines changed
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
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

Comments
 (0)