Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions apps/server/src/terminal/Layers/Manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 }
: {}),
Expand Down Expand Up @@ -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;
Expand Down
22 changes: 21 additions & 1 deletion apps/server/src/terminal/Layers/Manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -90,6 +91,8 @@ interface TerminalSubprocessInspector {
): Effect.Effect<TerminalSubprocessInspectResult, TerminalSubprocessCheckError>;
}

type SshAuthSockResolver = (env: NodeJS.ProcessEnv) => string | undefined;

interface ShellCandidate {
shell: string;
args?: string[];
Expand Down Expand Up @@ -897,13 +900,18 @@ function shouldExcludeTerminalEnvKey(key: string): boolean {
function createTerminalSpawnEnv(
baseEnv: NodeJS.ProcessEnv,
runtimeEnv?: Record<string, string> | null,
sshAuthSockResolver?: SshAuthSockResolver,
): NodeJS.ProcessEnv {
const spawnEnv: NodeJS.ProcessEnv = {};
for (const [key, value] of Object.entries(baseEnv)) {
if (value === undefined) continue;
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;
Expand All @@ -928,6 +936,7 @@ interface TerminalManagerOptions {
shellResolver?: () => string;
platform?: NodeJS.Platform;
env?: NodeJS.ProcessEnv;
sshAuthSockResolver?: SshAuthSockResolver;
subprocessInspector?: TerminalSubprocessInspector;
subprocessPollIntervalMs?: number;
processKillGraceMs?: number;
Expand Down Expand Up @@ -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 ??
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions packages/shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
153 changes: 153 additions & 0 deletions packages/shared/src/sshAgent.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>>(() => []);
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<string>>(() => [
"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<string>>((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<string>>(() => [
"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<string>>(() => [
"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<string>>(() => [
"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();
});
});
Loading
Loading