src/vs/platform/agentHost/node/sshRemoteAgentHostHelpers.ts

449 LOC · 438 covered · 11 uncovered · 85 ranges · 226 concepts · 59 introducers · 180 tests

File neighbourhood

The centred file is linked to every concept that introduces one of its ranges, every test that runs code from the file, and the gray connector concepts standing between those tests and the file's own introducer concepts. Undirected links join concepts to every file where they introduce source and concepts to the tests they introduce; arrows show specialization between the displayed concepts and bridge only concepts omitted from this view. Concept colors match the source ranges below; connector concepts have no source color and are shown in gray.

Focused file, its introducer and connector concepts, their introduced files, and tests that run code from the file

In the embedded map, ordinary wheel input scrolls the page; use the visible controls to zoom and drag to pan. Open the full-screen map for canvas navigation: wheel pans, Ctrl/Command plus wheel zooms, and arrow keys pan when this region is focused. On touch screens, open the full-screen map to pan or pinch. If JavaScript or WebGL is unavailable, use the related-file, concept, and source links on this page.

Graph controls are ready.

Interactive rendering requires JavaScript and WebGL. Use the related-file, concept, and source links on this page while the interactive map is unavailable.

1 > /*--------------------------------------------------------------------------------------------- sshRemoteAgentHostHelpers.ts ×20
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 > import { createRemoteAgentHostState, parseRemoteAgentHostState } from '../common/remoteAgentHostMetadata.js';
8 >
9 > const LOG_PREFIX = '[SSHRemoteAgentHost]';
10 >
11 > /**
12 > * Validate that a quality string is safe for bare interpolation in shell commands.
13 > * Quality comes from `productService.quality` (not user input) but we validate
14 > * as defense-in-depth since these values end up in unquoted shell paths (the `~`
15 > * prefix requires shell expansion, so we cannot single-quote the entire path).
16 > */
17 > export function validateShellToken(value: string, label: string): string {
18 > if (!/^[a-zA-Z0-9._-]+$/.test(value)) { sshRemoteAgentHostHelpers.ts ×1
19 > throw new Error(`Unsafe ${label} value for shell interpolation: ${JSON.stringify(value)}`); sshRemoteAgentHostHelpers.ts ×1
20 > }
22 > }
24 > /**
25 > * Validate and normalize a commit SHA. Returns the lowercase form.
26 > *
27 > * The commit-keyed install layout, the cleanup glob (`[0-9a-f]{40}`), and
28 > * the fallback discovery glob all assume an exactly-40-char lowercase hex
29 > * commit. If a caller ever supplies a non-SHA value (or uppercase hex),
30 > * the cleanup pass would silently miss those binaries and the
31 > * commit-pinned download URL could 404. Enforce the shape at the source.
32 > *
33 > * `productService.commit` is already lowercase hex in practice; the
34 > * normalization is defense-in-depth for any future callers.
35 > */
36 > export function validateCommit(commit: string): string {
37 > const normalized = commit.toLowerCase(); sshRemoteAgentHostHelpers.ts ×1
38 > if (!/^[0-9a-f]{40}$/.test(normalized)) {
39 > throw new Error(`Unsafe commit value (expected 40-char hex SHA): ${JSON.stringify(commit)}`); sshRemoteAgentHostHelpers.ts ×1
40 > }
41 > return normalized; sshRemoteAgentHostHelpers.ts ×1
42 > }
44 > /**
45 > * Name of the CLI binary as it appears inside the downloaded archive,
46 > * derived from product quality. Matches the names used by Remote-SSH's
47 > * exec-server installer so that CLI binaries can be shared between the
48 > * two features.
49 > */
50 > export function getRemoteCLIArchiveName(quality: string): string {
51 > const q = validateShellToken(quality, 'quality'); sshRemoteAgentHostHelpers.ts ×1
52 > switch (q) {
53 > case 'stable': return 'code';
54 > case 'exploration': return 'code-exploration';
55 > default: return 'code-insiders';
56 > }
57 > }
59 > /**
60 > * Install root for the VS Code CLI on the remote machine. Shared with
61 > * Remote-SSH's exec-server installer so the two features can reuse each
62 > * other's installations. Also the parent of the agent host lockfile dir.
63 > */
64 > export function getRemoteCLIInstallRoot(serverDataFolderName: string): string {
65 > const d = validateShellToken(serverDataFolderName, 'server data folder name'); sshRemoteAgentHostHelpers.ts ×1
66 > return `~/${d}`;
67 > }
69 > /**
70 > * Per-machine launcher data dir for `code agent host` (and the embedded
71 > * CLI machinery it inherits). Passed as `--cli-data-dir` so the CLI's
72 > * downloads cache, unpacked server installs, supervisor logs, and other
73 > * launcher state land under the same root Remote-SSH's `command-shell`
74 > * uses (e.g. `~/.vscode-server/cli`). Without this flag the CLI would
75 > * default to `~/.vscode-cli{,-<quality>}/` and split state across two
76 > * roots.
77 > *
78 > * The lockfile is unaffected: the Rust CLI anchors it on
79 > * `serverDataFolderName` regardless of `--cli-data-dir` (see
80 > * `cli/src/state.rs::agent_host_root`).
81 > */
82 > export function getRemoteCLIDataDir(serverDataFolderName: string): string {
83 > return `${getRemoteCLIInstallRoot(serverDataFolderName)}/cli`; sshRemoteAgentHostHelpers.ts ×1
84 > }
86 > /**
87 > * Full path to the installed CLI binary on the remote.
88 > *
89 > * When `commit` is provided, the path is keyed on commit (e.g.
90 > * `~/.vscode-server/code-insiders-<40hex>`) so we can install the CLI
91 > * matching the current desktop without disturbing other installs. This
92 > * mirrors Remote-SSH's exec-server layout.
93 > *
94 > * When `commit` is undefined (dev/OSS builds with no commit in product
95 > * metadata), the path is just `<root>/<archive>` — a single, non-keyed
96 > * filename. Caller code should keep the loose `--version`-based reuse
97 > * check in that case.
98 > */
99 > export function getRemoteCLIBin(serverDataFolderName: string, quality: string, commit?: string): string {
100 > const archive = getRemoteCLIArchiveName(quality); sshRemoteAgentHostHelpers.ts ×1
101 > const root = getRemoteCLIInstallRoot(serverDataFolderName);
102 > if (commit) {
103 > const c = validateCommit(commit); sshRemoteAgentHostHelpers.ts ×1
104 > return `${root}/${archive}-${c}`;
105 > }
106 > return `${root}/${archive}`; sshRemoteAgentHostHelpers.ts ×1
107 > }
109 > /** Escape a string for use as a single shell argument (single-quote wrapping). */
110 > export function shellEscape(s: string): string {
111 > // Wrap in single quotes; escape embedded single quotes as: '\'' sshRemoteAgentHostHelpers.ts ×1
112 > const escaped = s.replace(/'/g, '\'\\\'\'');
113 > return `'${escaped}'`;
114 > }
116 > /**
117 > * Construct the bare command that launches the agent host on the remote.
118 > *
119 > * `--cli-data-dir` is passed up-front so the embedded CLI's launcher
120 > * state (downloads cache, unpacked server installs, supervisor log) lands
121 > * under `<cliDataDir>` — the same root Remote-SSH uses. The supervisor
122 > * propagates this flag to its detached child (see
123 > * `cli/src/commands/agent_host.rs`), so the entire AH process tree
124 > * agrees on one launcher root.
125 > *
126 > * Inputs must already be safe for unquoted shell interpolation; callers
127 > * build them via {@link getRemoteCLIBin} / {@link getRemoteCLIDataDir}
128 > * which validate their components.
129 > */
130 > export function buildAgentHostBaseCommand(cliBin: string, cliDataDir: string): string {
131 > return `${cliBin} --cli-data-dir ${cliDataDir} agent host --port 0`; sshRemoteAgentHostHelpers.ts ×1
132 > }
134 > export function resolveRemotePlatform(unameS: string, unameM: string): { os: string; arch: string } | undefined {
135 > const os = unameS.trim().toLowerCase(); sshRemoteAgentHostHelpers.ts ×3
136 > const machine = unameM.trim().toLowerCase();
137 >
138 > let platformOs: string;
139 > if (os === 'linux') {
140 > platformOs = 'linux'; sshRemoteAgentHostHelpers.ts ×1
141 > } else if (os === 'darwin') { sshRemoteAgentHostHelpers.ts ×3
142 > platformOs = 'darwin'; sshRemoteAgentHostHelpers.ts ×1
144 > return undefined; sshRemoteAgentHostHelpers.ts ×1
145 > }
147 > let arch: string;
148 > if (machine === 'x86_64' || machine === 'amd64') { sshRemoteAgentHostHelpers.ts ×3
149 > arch = 'x64'; sshRemoteAgentHostHelpers.ts ×1
150 > } else if (machine === 'aarch64' || machine === 'arm64') { sshRemoteAgentHostHelpers.ts ×2
151 > arch = 'arm64'; sshRemoteAgentHostHelpers.ts ×1
152 > } else if (machine === 'armv7l') { sshRemoteAgentHostHelpers.ts ×1
153 > arch = 'armhf'; sshRemoteAgentHostHelpers.ts ×1
155 > return undefined; sshRemoteAgentHostHelpers.ts ×1
156 > }
158 > return { os: platformOs, arch };
159 > }
161 > /**
162 > * URL of the CLI download artifact.
163 > *
164 > * When `commit` is provided, uses the commit-pinned URL form so we get
165 > * the exact CLI matching the current desktop build (mirrors Remote-SSH).
166 > * When `commit` is undefined (dev/OSS builds), falls back to `latest`.
167 > */
168 > export function buildCLIDownloadUrl(os: string, arch: string, quality: string, commit?: string): string {
169 > const base = 'https://update.code.visualstudio.com'; sshRemoteAgentHostHelpers.ts ×1
170 > const artifact = `cli-${os}-${arch}`;
171 > if (commit) {
172 > // Defense-in-depth: same validation as getRemoteCLIBin so the URL sshRemoteAgentHostHelpers.ts ×1
173 > // can never be formed with a non-SHA commit (would 404) and stays
174 > // consistent with the commit-keyed install path.
175 > const c = validateCommit(commit);
176 > return `${base}/commit:${c}/${artifact}/${quality}`;
177 > }
178 > return `${base}/latest/${artifact}/${quality}`; sshRemoteAgentHostHelpers.ts ×1
179 > }
181 > /**
182 > * Shell snippet that prunes older commit-keyed CLI binaries from the
183 > * install root, keeping the 5 most recently modified. Mirrors the
184 > * retention policy in Remote-SSH's exec-server installer.
185 > *
186 > * The glob is tightened to exactly 40 hex chars (`[0-9a-f]`-only) so we
187 > * never accidentally delete (or hand to `xargs`) any filename that
188 > * happens to start with `<archive>-` but isn't actually one of our
189 > * commit-keyed binaries — both for correctness and to avoid passing
190 > * attacker-controlled filenames through `xargs rm` with option/whitespace
191 > * splitting hazards. We also use `rm -f --` and `xargs -I{}` (which
192 > * skips the command entirely on empty input on both GNU and BSD `xargs`).
193 > */
194 > export function buildCleanupOldCLIsCommand(serverDataFolderName: string, quality: string): string {
195 > const root = getRemoteCLIInstallRoot(serverDataFolderName); sshRemoteAgentHostHelpers.ts ×1
196 > const archive = getRemoteCLIArchiveName(quality);
197 > const commitGlob = '[0-9a-f]'.repeat(40);
198 > // `ls -1t` sorts by mtime newest-first on both Linux (coreutils) and
199 > // macOS (BSD). `awk 'NR>5'` drops the 5 most recent entries we want to
200 > // keep. `xargs -I{} rm -f -- {}` is one-rm-per-line — slow but safe
201 > // against whitespace splitting and option injection, and a no-op when
202 > // input is empty on both BSDs and GNU.
203 > return `ls -1t -- ${root}/${archive}-${commitGlob} 2>/dev/null | awk 'NR>5' | xargs -I{} rm -f -- {} 2>/dev/null; true`;
204 > }
206 > /**
207 > * Shell snippet that prints candidate CLI binary paths that could be
208 > * used as a fallback when the commit-pinned download fails. Order: any
209 > * commit-keyed binaries in the shared install root (newest mtime first),
210 > * then the legacy single-binary paths from the previous installer
211 > * (`~/.vscode-cli{,-<quality>}/<archive>`).
212 > *
213 > * Each line is a single path. The glob for commit-keyed candidates is
214 > * restricted to exactly 40 hex chars so the output can only contain
215 > * filenames we recognise (callers should still re-validate with
216 > * {@link isValidFallbackCLIPath}). The legacy paths are fixed strings
217 > * derived from validated tokens, so they cannot contain shell
218 > * metacharacters either.
219 > */
220 > export function buildFindFallbackCLICommand(serverDataFolderName: string, quality: string): string {
221 > const root = getRemoteCLIInstallRoot(serverDataFolderName); sshRemoteAgentHostHelpers.ts ×1
222 > const archive = getRemoteCLIArchiveName(quality);
223 > const commitGlob = '[0-9a-f]'.repeat(40);
224 > const q = validateShellToken(quality, 'quality');
225 > const legacyDir = q === 'stable' ? '~/.vscode-cli' : `~/.vscode-cli-${q}`;
226 > const legacyBin = `${legacyDir}/${archive}`;
227 > return [
228 > `ls -1t -- ${root}/${archive}-${commitGlob} 2>/dev/null`,
229 > `ls -1 -- ${legacyBin} 2>/dev/null`,
230 > 'true',
231 > ].join('; ');
232 > }
234 > /**
235 > * Validate that a candidate path string returned by the remote shell
236 > * matches one of the two shapes we expect from
237 > * {@link buildFindFallbackCLICommand}:
238 > *
239 > * - `<installRoot>/<archive>-<40 hex chars>` — commit-keyed install
240 > * - `<legacyDir>/<archive>` — legacy single-binary install
241 > *
242 > * Anything else is rejected. This guards against the candidate being
243 > * interpolated into a follow-up shell command (`<candidate> --version`,
244 > * agent host spawn) with attacker-controlled metacharacters in the
245 > * event that something unexpected ends up in the install root.
246 > */
247 > export function isValidFallbackCLIPath(candidate: string, serverDataFolderName: string, quality: string): boolean {
248 > const root = getRemoteCLIInstallRoot(serverDataFolderName); sshRemoteAgentHostHelpers.ts ×1
249 > const archive = getRemoteCLIArchiveName(quality);
250 > const q = validateShellToken(quality, 'quality');
251 > const legacyDir = q === 'stable' ? '~/.vscode-cli' : `~/.vscode-cli-${q}`;
252 > const legacyBin = `${legacyDir}/${archive}`;
253 > if (candidate === legacyBin) {
255 > }
256 > const pinnedPrefix = `${root}/${archive}-`; sshRemoteAgentHostHelpers.ts ×1
257 > if (candidate.startsWith(pinnedPrefix)) {
258 > const suffix = candidate.slice(pinnedPrefix.length); sshRemoteAgentHostHelpers.ts ×1
259 > return /^[0-9a-f]{40}$/.test(suffix);
260 > }
261 > return false; sshRemoteAgentHostHelpers.ts ×1
262 > }
264 > /** Redact connection tokens from log output. */
265 > export function redactToken(text: string): string {
266 > return text.replace(/\?tkn=[^\s&]+/g, '?tkn=***'); sshRemoteAgentHostHelpers.ts ×1
267 > }
269 > /**
270 > * Match the `ws://127.0.0.1:PORT[?tkn=TOKEN]` URL emitted by `code agent host`
271 > * on stdout/stderr. Shared by SSH and WSL agent-host transports — both spawn
272 > * the CLI inside a posix shell and scrape its first line of output to discover
273 > * the WebSocket endpoint.
274 > */
275 > const AGENT_HOST_WS_URL_RE = /ws:\/\/(?:127\.0\.0\.1|localhost):(\d+)(?:\?tkn=([^\s&]+))?/;
276 >
277 > /**
278 > * Extract the `ws://` URL printed by `code agent host` from a line or buffer
279 > * of mixed output. Returns the full URL plus its parsed components, or
280 > * `undefined` if no match is found.
281 > */
282 > export function extractAgentHostWebSocketURL(text: string): { url: string; host: string; port: number; token: string | undefined } | undefined {
283 const match = text.match(AGENT_HOST_WS_URL_RE);
284 if (!match) {
285 return undefined;
286 }
287 return {
288 url: match[0],
289 host: '127.0.0.1',
290 port: parseInt(match[1], 10),
291 token: match[2] || undefined,
292 };
293 }
295 > /**
296 > * Path to the per-quality agent host lockfile written by `code agent host`.
297 > *
298 > * Mirrors the Rust CLI's launcher path layout (see
299 > * `cli/src/state.rs::agent_host_root`). The Rust CLI anchors the agent host
300 > * lockfile on `serverDataFolderName` (exposed to the CLI build as
301 > * `VSCODE_CLI_SERVER_DATA_FOLDER_NAME`, derived from
302 > * `IProductConfiguration.serverDataFolderName`) so a `code agent host`
303 > * started locally and the supervisor spawned by the SSH `command-shell`
304 > * path agree on the same lockfile regardless of `--cli-data-dir`.
305 > */
306 > export function getAgentHostLockfile(serverDataFolderName: string, quality: string): string {
307 > const d = validateShellToken(serverDataFolderName, 'server data folder name'); sshRemoteAgentHostHelpers.ts ×1
308 > const q = validateShellToken(quality, 'quality');
309 > return `~/${d}/cli/agent-host-${q}.lock`;
310 > }
312 > /**
313 > * Abstraction over SSH command execution to enable testing without a real SSH connection.
314 > */
315 > export interface ISshExec {
316 > (command: string, opts?: { ignoreExitCode?: boolean }): Promise<{ stdout: string; stderr: string; code: number }>;
317 > }
318 >
319 > export type FindRunningAgentHostResult =
320 > | { readonly kind: 'notFound' }
321 > | { readonly kind: 'compatible'; readonly host: string; readonly port: number; readonly connectionToken: string | undefined };
322 >
323 > /**
324 > * Try to find a running agent host on the remote by reading the lockfile and
325 > * verifying the recorded PID is still alive.
326 > */
327 > export async function findRunningAgentHost( sshRemoteAgentHostHelpers.ts ×2
328 > exec: ISshExec,
329 > logService: ILogService,
330 > serverDataFolderName: string,
331 > quality: string,
332 > ): Promise<FindRunningAgentHostResult> {
333 > const stateFile = getAgentHostLockfile(serverDataFolderName, quality);
334 > const { stdout, code } = await exec(`cat ${stateFile} 2>/dev/null`, { ignoreExitCode: true });
335 > if (code !== 0 || !stdout.trim()) {
336 > return { kind: 'notFound' }; sshRemoteAgentHostHelpers.ts ×1
337 > }
339 > let parsed: unknown;
340 > try {
341 > parsed = JSON.parse(stdout.trim());
342 > } catch {
343 > // fall through remoteAgentHostMetadata.ts ×1
344 > }
345 > const state = parseRemoteAgentHostState(parsed); sshRemoteAgentHostHelpers.ts ×2
346 > if (!state) {
347 > logService.info(`${LOG_PREFIX} Invalid agent host state file ${stateFile}, removing`); sshRemoteAgentHostHelpers.ts ×1
348 > await exec(`rm -f ${stateFile}`, { ignoreExitCode: true });
349 > return { kind: 'notFound' };
350 > }
352 > // Verify the PID is still alive
353 > const { code: killCode } = await exec(`kill -0 ${state.pid} 2>/dev/null`, { ignoreExitCode: true });
354 > if (killCode !== 0) {
355 > logService.info(`${LOG_PREFIX} Stale agent host state in ${stateFile} (PID ${state.pid} not running), cleaning up`); sshRemoteAgentHostHelpers.ts ×1
356 > await exec(`rm -f ${stateFile}`, { ignoreExitCode: true });
357 > return { kind: 'notFound' };
358 > }
360 > // We deliberately do not gate on `protocolVersion` here: the remote
361 > // agent host server is downloaded on demand and may speak a newer
362 > // protocol than this desktop was built with. The renderer↔AH
363 > // handshake will surface a real incompatibility; for the SSH-side
364 > // reuse decision we treat any live process as a candidate, and the
365 > // caller (sshRemoteAgentHostService) already falls back to spawning
366 > // fresh if the relay fails to connect.
367 > logService.info(`${LOG_PREFIX} Found running agent host via ${stateFile}: PID ${state.pid}, port ${state.port}`);
368 > return {
369 > kind: 'compatible',
370 > host: dialAgentHostHost(state.host),
371 > port: state.port,
372 > connectionToken: state.connectionToken ?? undefined,
374 > }
376 > /**
377 > * Map a recorded `host` value from the agent host lockfile to a dialable
378 > * loopback address. The supervisor records the literal `--host` value it
379 > * was given (e.g. `0.0.0.0`, `::1`, `localhost`); local callers (SSH
380 > * relay, tunnel reuse-forward, renderer bridge) want a target they can
381 > * actually open a socket to. Wildcards are mapped to their corresponding
382 > * loopback; specific hosts pass through unchanged. Missing `host`
383 > * (lockfile written by an older CLI) falls back to IPv4 loopback to
384 > * preserve the prior behaviour.
385 > */
386 > export function dialAgentHostHost(bound: string | undefined): string {
387 > if (!bound || bound === '0.0.0.0' || bound === '::' || bound === '[::]') { sshRemoteAgentHostHelpers.ts ×1
388 > return '127.0.0.1'; sshRemoteAgentHostHelpers.ts ×1
389 > }
390 > return bound; sshRemoteAgentHostHelpers.ts ×1
391 > }
393 > /**
394 > * After starting an agent host, record its PID/port/token in the lockfile on
395 > * the remote so that future connections can reuse the process.
396 > */
397 > export async function writeAgentHostState( sshRemoteAgentHostHelpers.ts ×1
398 > exec: ISshExec,
399 > logService: ILogService,
400 > serverDataFolderName: string,
401 > quality: string,
402 > pid: number | undefined,
403 > port: number,
404 > connectionToken: string | undefined,
405 > ): Promise<void> {
406 > if (!pid) {
407 > logService.info(`${LOG_PREFIX} Agent host PID unknown, state file not written`); sshRemoteAgentHostHelpers.ts ×1
408 > return;
409 > }
411 > const stateFile = getAgentHostLockfile(serverDataFolderName, quality);
412 > const state = createRemoteAgentHostState({ pid, port, connectionToken, quality });
413 > const json = JSON.stringify(state);
414 > // Remove any existing file first so `>` creates a fresh inode with the
415 > // new umask (overwriting an existing file preserves its old permissions).
416 > // Use a subshell with restrictive umask (077) so the file is created with
417 > // owner-only permissions (0600), protecting the connection token.
418 > // The CLI itself stores its token file with the same permissions.
419 > const result = await exec(`mkdir -p $(dirname ${stateFile}) && rm -f ${stateFile} && (umask 077 && printf %s ${shellEscape(json)} > ${stateFile})`, { ignoreExitCode: true });
420 > if (result.code !== 0) {
421 > logService.warn(`${LOG_PREFIX} Failed to write agent host state to ${stateFile} (exit code ${result.code})${result.stderr ? `: ${result.stderr.trim()}` : ''}`); sshRemoteAgentHostHelpers.ts ×1
422 > return;
423 > }
424 > logService.info(`${LOG_PREFIX} Wrote agent host state to ${stateFile}: PID ${pid}, port ${port}`); sshRemoteAgentHostHelpers.ts ×1
425 > }
427 > /**
428 > * Kill a remote agent host tracked by our lockfile and remove the lockfile.
429 > */
430 > export async function cleanupRemoteAgentHost( sshRemoteAgentHostHelpers.ts ×2
431 > exec: ISshExec,
432 > logService: ILogService,
433 > serverDataFolderName: string,
434 > quality: string,
435 > ): Promise<void> {
436 > const stateFile = getAgentHostLockfile(serverDataFolderName, quality);
437 > const { stdout, code } = await exec(`cat ${stateFile} 2>/dev/null`, { ignoreExitCode: true });
438 > if (code === 0 && stdout.trim()) {
439 > let state: { readonly pid: number } | undefined; sshRemoteAgentHostHelpers.ts ×2
440 > try {
441 > state = parseRemoteAgentHostState(JSON.parse(stdout.trim()));
442 > } catch { /* ignore parse errors */ }
443 > if (state) {
444 > logService.info(`${LOG_PREFIX} Killing remote agent host PID ${state.pid} (from ${stateFile})`); sshRemoteAgentHostHelpers.ts ×1
445 > await exec(`kill ${state.pid} 2>/dev/null`, { ignoreExitCode: true });
446 > }
448 > await exec(`rm -f ${stateFile}`, { ignoreExitCode: true }); sshRemoteAgentHostHelpers.ts ×2
449 > }