diff --git a/apps/server/src/terminal/Layers/Manager.test.ts b/apps/server/src/terminal/Layers/Manager.test.ts index 2ebf8481957..d09a077f21a 100644 --- a/apps/server/src/terminal/Layers/Manager.test.ts +++ b/apps/server/src/terminal/Layers/Manager.test.ts @@ -201,6 +201,7 @@ interface CreateManagerOptions { shellResolver?: () => string; platform?: NodeJS.Platform; env?: NodeJS.ProcessEnv; + sshAuthSockResolver?: (env: NodeJS.ProcessEnv) => string | undefined; subprocessInspector?: (terminalPid: number) => Effect.Effect<{ readonly hasRunningSubprocess: boolean; readonly childCommand: string | null; @@ -241,6 +242,9 @@ const createManager = ( ...(options.shellResolver !== undefined ? { shellResolver: options.shellResolver } : {}), ...(options.platform !== undefined ? { platform: options.platform } : {}), ...(options.env !== undefined ? { env: options.env } : {}), + ...(options.sshAuthSockResolver !== undefined + ? { sshAuthSockResolver: options.sshAuthSockResolver } + : {}), ...(options.subprocessInspector !== undefined ? { subprocessInspector: options.subprocessInspector } : {}), @@ -1229,6 +1233,53 @@ it.layer( }), ); + it.effect("hydrates missing SSH_AUTH_SOCK when spawning terminal sessions", () => + Effect.gen(function* () { + const resolverInputs: NodeJS.ProcessEnv[] = []; + const { manager, ptyAdapter } = yield* createManager(5, { + env: { + PATH: "/usr/bin", + }, + sshAuthSockResolver: (env) => { + resolverInputs.push(env); + return "/tmp/vscode-ssh-auth-forwarded.sock"; + }, + }); + + yield* manager.open(openInput()); + const spawnInput = ptyAdapter.spawnInputs[0]; + expect(spawnInput).toBeDefined(); + if (!spawnInput) return; + + assert.equal(spawnInput.env.SSH_AUTH_SOCK, "/tmp/vscode-ssh-auth-forwarded.sock"); + assert.deepEqual(resolverInputs, [{ PATH: "/usr/bin" }]); + }), + ); + + it.effect("lets runtime env override the resolved SSH_AUTH_SOCK", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(5, { + env: { + PATH: "/usr/bin", + }, + sshAuthSockResolver: () => "/tmp/vscode-ssh-auth-forwarded.sock", + }); + + yield* manager.open( + openInput({ + env: { + SSH_AUTH_SOCK: "/tmp/project-specific-agent.sock", + }, + }), + ); + const spawnInput = ptyAdapter.spawnInputs[0]; + expect(spawnInput).toBeDefined(); + if (!spawnInput) return; + + assert.equal(spawnInput.env.SSH_AUTH_SOCK, "/tmp/project-specific-agent.sock"); + }), + ); + it.effect("starts zsh with prompt spacer disabled to avoid `%` end markers", () => Effect.gen(function* () { if (process.platform === "win32") return; diff --git a/apps/server/src/terminal/Layers/Manager.ts b/apps/server/src/terminal/Layers/Manager.ts index cd490de1e3f..2f389fb1d53 100644 --- a/apps/server/src/terminal/Layers/Manager.ts +++ b/apps/server/src/terminal/Layers/Manager.ts @@ -10,6 +10,7 @@ import { type TerminalSummary, } from "@t3tools/contracts"; import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker"; +import { resolveSshAuthSock } from "@t3tools/shared/sshAgent"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -90,6 +91,8 @@ interface TerminalSubprocessInspector { ): Effect.Effect; } +type SshAuthSockResolver = (env: NodeJS.ProcessEnv) => string | undefined; + interface ShellCandidate { shell: string; args?: string[]; @@ -897,6 +900,7 @@ function shouldExcludeTerminalEnvKey(key: string): boolean { function createTerminalSpawnEnv( baseEnv: NodeJS.ProcessEnv, runtimeEnv?: Record | null, + sshAuthSockResolver?: SshAuthSockResolver, ): NodeJS.ProcessEnv { const spawnEnv: NodeJS.ProcessEnv = {}; for (const [key, value] of Object.entries(baseEnv)) { @@ -904,6 +908,10 @@ function createTerminalSpawnEnv( if (shouldExcludeTerminalEnvKey(key)) continue; spawnEnv[key] = value; } + const resolvedSshAuthSock = sshAuthSockResolver?.(baseEnv); + if (resolvedSshAuthSock) { + spawnEnv.SSH_AUTH_SOCK = resolvedSshAuthSock; + } if (runtimeEnv) { for (const [key, value] of Object.entries(runtimeEnv)) { spawnEnv[key] = value; @@ -928,6 +936,7 @@ interface TerminalManagerOptions { shellResolver?: () => string; platform?: NodeJS.Platform; env?: NodeJS.ProcessEnv; + sshAuthSockResolver?: SshAuthSockResolver; subprocessInspector?: TerminalSubprocessInspector; subprocessPollIntervalMs?: number; processKillGraceMs?: number; @@ -955,6 +964,13 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith const platform = options.platform ?? process.platform; const baseEnv = options.env ?? process.env; const shellResolver = options.shellResolver ?? (() => defaultShellResolver(platform, baseEnv)); + const sshAuthSockResolver = + options.sshAuthSockResolver ?? + ((env) => + resolveSshAuthSock({ + env, + platform, + })); const processRunner = yield* ProcessRunner.ProcessRunner; const subprocessInspector = options.subprocessInspector ?? @@ -1631,7 +1647,11 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith Effect.andThen( Effect.gen(function* () { const shellCandidates = resolveShellCandidates(shellResolver, platform, baseEnv); - const terminalEnv = createTerminalSpawnEnv(baseEnv, session.runtimeEnv); + const terminalEnv = createTerminalSpawnEnv( + baseEnv, + session.runtimeEnv, + sshAuthSockResolver, + ); const spawnResult = yield* trySpawn(shellCandidates, terminalEnv, session); ptyProcess = spawnResult.process; startedShell = spawnResult.shellLabel; diff --git a/packages/shared/package.json b/packages/shared/package.json index 97af1fa5840..b264d912d7c 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -39,6 +39,10 @@ "types": "./src/shell.ts", "import": "./src/shell.ts" }, + "./sshAgent": { + "types": "./src/sshAgent.ts", + "import": "./src/sshAgent.ts" + }, "./semver": { "types": "./src/semver.ts", "import": "./src/semver.ts" diff --git a/packages/shared/src/sshAgent.test.ts b/packages/shared/src/sshAgent.test.ts new file mode 100644 index 00000000000..249d05aa6f1 --- /dev/null +++ b/packages/shared/src/sshAgent.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it, vi } from "vite-plus/test"; +import { resolveSshAuthSock, type SshAgentSocketStats } from "./sshAgent.ts"; + +function socketStats(input: { + readonly uid?: number; + readonly mtimeMs?: number; + readonly socket?: boolean; +}): SshAgentSocketStats { + return { + isSocket: () => input.socket ?? true, + ...(input.mtimeMs !== undefined ? { mtimeMs: input.mtimeMs } : {}), + ...(input.uid !== undefined ? { uid: input.uid } : {}), + }; +} + +describe("resolveSshAuthSock", () => { + it("keeps a valid inherited SSH_AUTH_SOCK without scanning temp", () => { + const readdir = vi.fn<() => ReadonlyArray>(() => []); + const stat = vi.fn<(path: string) => SshAgentSocketStats>(() => + socketStats({ uid: 1000, mtimeMs: 1 }), + ); + + expect( + resolveSshAuthSock({ + env: { SSH_AUTH_SOCK: "/tmp/inherited.sock" }, + platform: "linux", + currentUid: 1000, + readdir, + stat, + }), + ).toBe("/tmp/inherited.sock"); + expect(stat).toHaveBeenCalledWith("/tmp/inherited.sock"); + expect(readdir).not.toHaveBeenCalled(); + }); + + it("finds the newest same-user VS Code forwarded SSH agent socket", () => { + const readdir = vi.fn<() => ReadonlyArray>(() => [ + "vscode-ssh-auth-11111111-1111-1111-1111-111111111111.sock", + "vscode-ssh-auth-22222222-2222-2222-2222-222222222222.sock", + "vscode-ssh-auth-33333333-3333-3333-3333-333333333333.sock", + "unrelated.sock", + ]); + const stat = vi.fn<(path: string) => SshAgentSocketStats>((path) => { + if (path.endsWith("111111111111.sock")) { + return socketStats({ uid: 1000, mtimeMs: 10 }); + } + if (path.endsWith("222222222222.sock")) { + return socketStats({ uid: 1000, mtimeMs: 20 }); + } + return socketStats({ uid: 1000, mtimeMs: 30, socket: false }); + }); + + expect( + resolveSshAuthSock({ + env: {}, + platform: "linux", + tmpDir: "/tmp", + currentUid: 1000, + readdir, + stat, + }), + ).toBe("/tmp/vscode-ssh-auth-22222222-2222-2222-2222-222222222222.sock"); + }); + + it("scans /tmp when the process temp directory is somewhere else", () => { + const readdir = vi.fn<(path: string) => ReadonlyArray>((path) => { + if (path === "/custom-tmp") { + return []; + } + if (path === "/tmp") { + return ["vscode-ssh-auth-11111111-1111-1111-1111-111111111111.sock"]; + } + return []; + }); + const stat = vi.fn<(path: string) => SshAgentSocketStats>(() => + socketStats({ uid: 1000, mtimeMs: 10 }), + ); + + expect( + resolveSshAuthSock({ + env: {}, + platform: "linux", + tmpDir: "/custom-tmp", + currentUid: 1000, + readdir, + stat, + }), + ).toBe("/tmp/vscode-ssh-auth-11111111-1111-1111-1111-111111111111.sock"); + expect(readdir).toHaveBeenCalledWith("/custom-tmp"); + expect(readdir).toHaveBeenCalledWith("/tmp"); + }); + + it("falls back from a stale inherited socket to a discovered VS Code socket", () => { + const readdir = vi.fn<() => ReadonlyArray>(() => [ + "vscode-ssh-auth-11111111-1111-1111-1111-111111111111.sock", + ]); + const stat = vi.fn<(path: string) => SshAgentSocketStats>((path) => { + if (path === "/tmp/stale.sock") { + throw new Error("missing"); + } + return socketStats({ uid: 1000, mtimeMs: 10 }); + }); + + expect( + resolveSshAuthSock({ + env: { SSH_AUTH_SOCK: "/tmp/stale.sock" }, + platform: "linux", + tmpDir: "/tmp", + currentUid: 1000, + readdir, + stat, + }), + ).toBe("/tmp/vscode-ssh-auth-11111111-1111-1111-1111-111111111111.sock"); + }); + + it("ignores forwarded sockets owned by another user", () => { + const readdir = vi.fn<() => ReadonlyArray>(() => [ + "vscode-ssh-auth-11111111-1111-1111-1111-111111111111.sock", + ]); + const stat = vi.fn<(path: string) => SshAgentSocketStats>(() => + socketStats({ uid: 2000, mtimeMs: 10 }), + ); + + expect( + resolveSshAuthSock({ + env: {}, + platform: "linux", + tmpDir: "/tmp", + currentUid: 1000, + readdir, + stat, + }), + ).toBeUndefined(); + }); + + it("does not scan for POSIX sockets on Windows", () => { + const readdir = vi.fn<() => ReadonlyArray>(() => [ + "vscode-ssh-auth-11111111-1111-1111-1111-111111111111.sock", + ]); + + expect( + resolveSshAuthSock({ + env: {}, + platform: "win32", + tmpDir: "/tmp", + currentUid: 1000, + readdir, + stat: () => socketStats({ uid: 1000, mtimeMs: 10 }), + }), + ).toBeUndefined(); + expect(readdir).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/shared/src/sshAgent.ts b/packages/shared/src/sshAgent.ts new file mode 100644 index 00000000000..189c55aa49d --- /dev/null +++ b/packages/shared/src/sshAgent.ts @@ -0,0 +1,118 @@ +// @effect-diagnostics nodeBuiltinImport:off +import { readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +export interface SshAgentSocketStats { + readonly isSocket: () => boolean; + readonly mtimeMs?: number; + readonly uid?: number; +} + +export interface SshAuthSockResolverOptions { + readonly env?: NodeJS.ProcessEnv; + readonly platform?: NodeJS.Platform; + readonly tmpDir?: string; + readonly currentUid?: number; + readonly readdir?: (path: string) => ReadonlyArray; + readonly stat?: (path: string) => SshAgentSocketStats; +} + +const VSCODE_SSH_AUTH_SOCK_PATTERN = /^vscode-ssh-auth-[0-9a-fA-F-]+\.sock$/; +const VSCODE_SSH_AUTH_SOCK_DIRECTORY = "/tmp"; + +function trimNonEmpty(value: string | null | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : undefined; +} + +function processUid(): number | undefined { + return typeof process.getuid === "function" ? process.getuid() : undefined; +} + +function sameUserSocket( + socketPath: string, + stat: (path: string) => SshAgentSocketStats, + currentUid: number | undefined, +): SshAgentSocketStats | null { + try { + const stats = stat(socketPath); + if (!stats.isSocket()) return null; + if (currentUid !== undefined && stats.uid !== undefined && stats.uid !== currentUid) { + return null; + } + return stats; + } catch { + return null; + } +} + +function newestSocket( + candidates: ReadonlyArray<{ readonly path: string; readonly stats: SshAgentSocketStats }>, +): string | undefined { + let selected: { readonly path: string; readonly mtimeMs: number } | null = null; + + for (const candidate of candidates) { + const mtimeMs = candidate.stats.mtimeMs ?? 0; + if (!selected || mtimeMs > selected.mtimeMs) { + selected = { path: candidate.path, mtimeMs }; + } + } + + return selected?.path; +} + +function listSocketSearchDirectories(primaryDirectory: string): ReadonlyArray { + const directories: string[] = []; + const seen = new Set(); + + for (const candidate of [primaryDirectory, VSCODE_SSH_AUTH_SOCK_DIRECTORY]) { + const directory = trimNonEmpty(candidate); + if (!directory || seen.has(directory)) continue; + + seen.add(directory); + directories.push(directory); + } + + return directories; +} + +export function resolveSshAuthSock(options: SshAuthSockResolverOptions = {}): string | undefined { + const env = options.env ?? process.env; + const inherited = trimNonEmpty(env.SSH_AUTH_SOCK); + const stat = options.stat ?? ((path: string) => statSync(path)); + const currentUid = options.currentUid ?? processUid(); + + if (inherited && sameUserSocket(inherited, stat, currentUid)) { + return inherited; + } + + const platform = options.platform ?? process.platform; + if (platform === "win32") { + return undefined; + } + + const directories = listSocketSearchDirectories(options.tmpDir ?? tmpdir()); + const readDirectory = options.readdir ?? ((path: string) => readdirSync(path)); + const sockets: Array<{ path: string; stats: SshAgentSocketStats }> = []; + for (const directory of directories) { + let entries: ReadonlyArray; + try { + entries = readDirectory(directory); + } catch { + continue; + } + + for (const entry of entries) { + if (!VSCODE_SSH_AUTH_SOCK_PATTERN.test(entry)) continue; + + const socketPath = join(directory, entry); + const socketStats = sameUserSocket(socketPath, stat, currentUid); + if (!socketStats) continue; + + sockets.push({ path: socketPath, stats: socketStats }); + } + } + + return newestSocket(sockets); +}