Dev #11
@@ -12,6 +12,7 @@ import {
|
|||||||
renderPaperclipWakePrompt,
|
renderPaperclipWakePrompt,
|
||||||
runningProcesses,
|
runningProcesses,
|
||||||
runChildProcess,
|
runChildProcess,
|
||||||
|
sanitizeSshRemoteEnv,
|
||||||
shapePaperclipWorkspaceEnvForExecution,
|
shapePaperclipWorkspaceEnvForExecution,
|
||||||
stringifyPaperclipWakePayload,
|
stringifyPaperclipWakePayload,
|
||||||
} from "./server-utils.js";
|
} 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", () => {
|
describe("materializePaperclipSkillCopy", () => {
|
||||||
it("refuses to materialize into an ancestor of the source", async () => {
|
it("refuses to materialize into an ancestor of the source", async () => {
|
||||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-skill-copy-"));
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-skill-copy-"));
|
||||||
|
|||||||
@@ -1039,6 +1039,56 @@ function quoteForCmd(arg: string) {
|
|||||||
return /[\s"&<>|^()]/.test(escaped) ? `"${escaped}"` : escaped;
|
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<string, string>,
|
||||||
|
inheritedEnv: NodeJS.ProcessEnv = process.env,
|
||||||
|
): Record<string, string> {
|
||||||
|
const sanitized: Record<string, string> = {};
|
||||||
|
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 {
|
function resolveWindowsCmdShell(env: NodeJS.ProcessEnv): string {
|
||||||
const fallbackRoot = env.SystemRoot || process.env.SystemRoot || "C:\\Windows";
|
const fallbackRoot = env.SystemRoot || process.env.SystemRoot || "C:\\Windows";
|
||||||
return path.join(fallbackRoot, "System32", "cmd.exe");
|
return path.join(fallbackRoot, "System32", "cmd.exe");
|
||||||
@@ -1064,9 +1114,9 @@ async function resolveSpawnTarget(
|
|||||||
spec: remote,
|
spec: remote,
|
||||||
command,
|
command,
|
||||||
args,
|
args,
|
||||||
env: Object.fromEntries(
|
env: sanitizeSshRemoteEnv(Object.fromEntries(
|
||||||
Object.entries(options.remoteEnv ?? {}).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
|
Object.entries(options.remoteEnv ?? {}).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
|
||||||
),
|
)),
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
command: sshResolved,
|
command: sshResolved,
|
||||||
|
|||||||
Reference in New Issue
Block a user