diff --git a/packages/adapter-utils/src/server-utils.test.ts b/packages/adapter-utils/src/server-utils.test.ts index 1b74aefe..9de5e06c 100644 --- a/packages/adapter-utils/src/server-utils.test.ts +++ b/packages/adapter-utils/src/server-utils.test.ts @@ -12,6 +12,7 @@ import { renderPaperclipWakePrompt, runningProcesses, runChildProcess, + sanitizeSshRemoteEnv, shapePaperclipWorkspaceEnvForExecution, stringifyPaperclipWakePayload, } from "./server-utils.js"; @@ -61,6 +62,86 @@ describe("buildInvocationEnvForLogs", () => { }); }); +describe("sanitizeSshRemoteEnv", () => { + it("drops inherited host shell identity variables for SSH remote execution", () => { + expect( + sanitizeSshRemoteEnv( + { + PATH: "/host/bin:/usr/bin", + HOME: "/Users/local", + NVM_DIR: "/Users/local/.nvm", + TMPDIR: "/var/folders/local/T", + XDG_CONFIG_HOME: "/Users/local/.config", + SAFE_VALUE: "visible", + }, + { + PATH: "/host/bin:/usr/bin", + HOME: "/Users/local", + NVM_DIR: "/Users/local/.nvm", + TMPDIR: "/var/folders/local/T", + XDG_CONFIG_HOME: "/Users/local/.config", + }, + ), + ).toEqual({ + SAFE_VALUE: "visible", + }); + }); + + it("preserves explicit remote overrides even for filtered key names", () => { + expect( + sanitizeSshRemoteEnv( + { + PATH: "/custom/remote/bin:/usr/bin", + HOME: "/home/agent", + TMPDIR: "/tmp", + SAFE_VALUE: "visible", + }, + { + PATH: "/host/bin:/usr/bin", + HOME: "/Users/local", + TMPDIR: "/var/folders/local/T", + }, + ), + ).toEqual({ + PATH: "/custom/remote/bin:/usr/bin", + HOME: "/home/agent", + TMPDIR: "/tmp", + SAFE_VALUE: "visible", + }); + }); + + it("filters identity keys via case-insensitive match against the inherited env", () => { + expect( + sanitizeSshRemoteEnv( + { + // Caller passed PATH in upper case while the inherited (Windows-style) + // host env exposes it as Path. The lookup must still treat them as + // equal so the leaked host PATH gets stripped. + PATH: "/host/bin:/usr/bin", + HOME: "/host/home", + }, + { + Path: "/host/bin:/usr/bin", + home: "/host/home", + }, + ), + ).toEqual({}); + }); + + it("preserves explicitly-set identity keys when the inherited env disagrees in case but not in value", () => { + expect( + sanitizeSshRemoteEnv( + { + PATH: "/explicit/remote/bin", + }, + { + Path: "/host/bin:/usr/bin", + }, + ), + ).toEqual({ PATH: "/explicit/remote/bin" }); + }); +}); + describe("materializePaperclipSkillCopy", () => { it("refuses to materialize into an ancestor of the source", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-skill-copy-")); diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index cc6e4566..1a07f643 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -1039,6 +1039,56 @@ function quoteForCmd(arg: string) { return /[\s"&<>|^()]/.test(escaped) ? `"${escaped}"` : escaped; } +const SSH_REMOTE_ENV_IDENTITY_KEYS = new Set([ + "PATH", + "HOME", + "PWD", + "SHELL", + "USER", + "LOGNAME", + "NVM_DIR", + "TMPDIR", + "TMP", + "TEMP", + "XDG_CONFIG_HOME", + "XDG_CACHE_HOME", + "XDG_DATA_HOME", + "XDG_STATE_HOME", + "XDG_RUNTIME_DIR", +]); + +function readEnvValueCaseInsensitive(env: NodeJS.ProcessEnv, key: string): string | undefined { + const direct = env[key]; + if (typeof direct === "string") return direct; + const upper = key.toUpperCase(); + for (const [candidateKey, candidateValue] of Object.entries(env)) { + if (candidateKey.toUpperCase() === upper && typeof candidateValue === "string") { + return candidateValue; + } + } + return undefined; +} + +export function sanitizeSshRemoteEnv( + env: Record, + inheritedEnv: NodeJS.ProcessEnv = process.env, +): Record { + const sanitized: Record = {}; + for (const [key, value] of Object.entries(env)) { + const normalizedKey = key.toUpperCase(); + if (!SSH_REMOTE_ENV_IDENTITY_KEYS.has(normalizedKey)) { + sanitized[key] = value; + continue; + } + const inheritedValue = readEnvValueCaseInsensitive(inheritedEnv, key); + if (typeof inheritedValue === "string" && inheritedValue === value) { + continue; + } + sanitized[key] = value; + } + return sanitized; +} + function resolveWindowsCmdShell(env: NodeJS.ProcessEnv): string { const fallbackRoot = env.SystemRoot || process.env.SystemRoot || "C:\\Windows"; return path.join(fallbackRoot, "System32", "cmd.exe"); @@ -1064,9 +1114,9 @@ async function resolveSpawnTarget( spec: remote, command, args, - env: Object.fromEntries( + env: sanitizeSshRemoteEnv(Object.fromEntries( Object.entries(options.remoteEnv ?? {}).filter((entry): entry is [string, string] => typeof entry[1] === "string"), - ), + )), }); return { command: sshResolved,