From d1a8c873b2ae80463974d2240305fb67bf5a78c5 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 13 May 2026 16:23:04 -0500 Subject: [PATCH] fix(remote-sandbox): harden host workspace resumes (#5922) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip orchestrates AI agents through a control plane while adapters execute work in local, remote, or sandboxed runtimes. > - Remote sandbox execution depends on a strict host-versus-remote workspace boundary: the host prepares/restores files, while the adapter command runs inside the sandbox cwd. > - Jannes' PR #5823 identified host-side failure modes that were not covered by replacement PR #5822. > - Persisting a remote pod cwd in session params could poison the next host heartbeat resume and make Paperclip inspect or upload system temp roots. > - Plugin sandbox providers also need a narrow way to receive model-provider API keys without exposing the full server environment to every plugin worker. > - This pull request ports the host-side fixes from #5823 in the current codebase style, with focused regression coverage. > - The benefit is safer remote sandbox resumes and plugin worker environment handling without broadening core plugin privileges. ## What Changed - Persist host workspace cwd, not remote sandbox cwd, in `claude_local` session params while retaining remote execution identity metadata. - Reject saved session cwds that point at system roots before heartbeat falls back to agent home workspace. - Skip sockets, FIFOs, devices, and other non-file entries during workspace restore snapshot capture/comparison. - Pass a small model-provider API-key allowlist only to plugins declaring `environment.drivers.register`. - Added focused regression tests for remote Claude session params, unsafe session cwd detection, plugin worker env filtering, and non-file snapshot entries. Credits: ports host-side fixes from Jannes' #5823. ## Verification - `pnpm vitest run packages/adapter-utils/src/workspace-restore-merge.test.ts server/src/services/session-workspace-cwd.test.ts server/src/__tests__/claude-local-execute.test.ts server/src/__tests__/plugin-database.test.ts` (25 passed, 7 skipped by existing embedded-Postgres host guard) - `pnpm --filter @paperclipai/adapter-utils typecheck` - `pnpm --filter @paperclipai/adapter-claude-local typecheck` - `pnpm --filter @paperclipai/server typecheck` ## Risks - Low risk: changes are scoped to remote sandbox/session metadata, workspace snapshot filtering, and plugin worker env setup. - Sandbox-provider plugins now receive only the explicit model-provider key allowlist; any provider needing another key name will need a deliberate allowlist update. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5-based coding agent, tool-enabled local code execution and repository editing. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- .../src/workspace-restore-merge.test.ts | 23 ++++++++++ .../src/workspace-restore-merge.ts | 6 +++ .../claude-local/src/server/execute.ts | 17 ++++++- .../adapters/claude-local/src/server/index.ts | 2 +- .../__tests__/claude-local-execute.test.ts | 31 +++++++++++-- server/src/__tests__/plugin-database.test.ts | 44 ++++++++++++++++++- server/src/services/heartbeat.ts | 10 ++++- server/src/services/plugin-loader.ts | 36 +++++++++++++-- .../services/session-workspace-cwd.test.ts | 27 ++++++++++++ server/src/services/session-workspace-cwd.ts | 24 ++++++++++ 10 files changed, 206 insertions(+), 14 deletions(-) create mode 100644 server/src/services/session-workspace-cwd.test.ts create mode 100644 server/src/services/session-workspace-cwd.ts diff --git a/packages/adapter-utils/src/workspace-restore-merge.test.ts b/packages/adapter-utils/src/workspace-restore-merge.test.ts index 32ad9582..ff0a5a12 100644 --- a/packages/adapter-utils/src/workspace-restore-merge.test.ts +++ b/packages/adapter-utils/src/workspace-restore-merge.test.ts @@ -1,4 +1,5 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import net from "node:net"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; @@ -58,4 +59,26 @@ describe("workspace restore merge", () => { readFile(path.join(targetDir, "manual-qa", "environment-matrix", "ssh", "codex_local.md"), "utf8"), ).resolves.toBe("ssh codex\n"); }); + + it("ignores non-file entries when capturing snapshots", async () => { + if (process.platform === "win32") return; + + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-restore-merge-")); + cleanupDirs.push(rootDir); + const socketPath = path.join(rootDir, "runtime.sock"); + const server = net.createServer(); + + try { + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(socketPath, resolve); + }); + + const snapshot = await captureDirectorySnapshot(rootDir, { exclude: [] }); + + expect(snapshot.entries.has("runtime.sock")).toBe(false); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); }); diff --git a/packages/adapter-utils/src/workspace-restore-merge.ts b/packages/adapter-utils/src/workspace-restore-merge.ts index df75b236..e89023e2 100644 --- a/packages/adapter-utils/src/workspace-restore-merge.ts +++ b/packages/adapter-utils/src/workspace-restore-merge.ts @@ -47,6 +47,10 @@ async function walkDirectory( const fullPath = path.join(root, nextRelative); const stats = await fs.lstat(fullPath); + if (!stats.isDirectory() && !stats.isSymbolicLink() && !stats.isFile()) { + continue; + } + if (stats.isDirectory()) { out.set(nextRelative, { kind: "dir" }); await walkDirectory(root, exclude, nextRelative, out); @@ -87,6 +91,8 @@ async function readSnapshotEntry(root: string, relative: string): Promise 0 && hasMatchingPromptBundle && - (runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(effectiveExecutionCwd)) && + claudeSessionCwdMatchesExecutionTarget({ + runtimeSessionCwd, + effectiveExecutionCwd, + executionTargetIsRemote, + }) && adapterExecutionTargetSessionMatches(runtimeRemoteExecution, runtimeExecutionTarget); const sessionId = canResumeSession ? runtimeSessionId : null; if ( @@ -853,7 +866,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise { const remoteWorkspace = path.join(root, "sandbox-$HOME"); const binDir = path.join(root, "bin"); const commandPath = path.join(binDir, "claude"); - const capturePath = path.join(remoteWorkspace, "capture.json"); + const capturePath1 = path.join(remoteWorkspace, "capture-1.json"); const claudeRoot = path.join(root, ".claude"); const previousHome = process.env.HOME; const previousPath = process.env.PATH; @@ -615,7 +615,7 @@ describe("claude execute", () => { command: commandPath, cwd: localWorkspace, env: { - PAPERCLIP_TEST_CAPTURE_PATH: capturePath, + PAPERCLIP_TEST_CAPTURE_PATH: capturePath1, }, promptTemplate: "Follow the paperclip heartbeat.", }, @@ -635,7 +635,17 @@ describe("claude execute", () => { }); expect(result.exitCode).toBe(0); - const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload; + expect(result.sessionParams).toMatchObject({ + cwd: localWorkspace, + remoteExecution: { + transport: "sandbox", + providerKey: "e2b", + environmentId: "env-1", + leaseId: "lease-1", + remoteCwd: remoteWorkspace, + }, + }); + const capture = JSON.parse(await fs.readFile(capturePath1, "utf8")) as CapturePayload; expect(capture.argv).toContain("--allowedTools"); expect(capture.argv).toContain( "Task AskUserQuestion Bash(*) CronCreate CronDelete CronList Edit EnterPlanMode EnterWorktree ExitPlanMode ExitWorktree Glob Grep Monitor NotebookEdit PushNotification Read RemoteTrigger ScheduleWakeup Skill TaskOutput TaskStop TodoWrite ToolSearch WebFetch WebSearch Write", @@ -655,6 +665,19 @@ describe("claude execute", () => { } }, 10_000); + it("allows remote session resumes when saved cwd is the host workspace", () => { + expect(claudeSessionCwdMatchesExecutionTarget({ + runtimeSessionCwd: "/host/workspace", + effectiveExecutionCwd: "/remote/workspace", + executionTargetIsRemote: true, + })).toBe(true); + expect(claudeSessionCwdMatchesExecutionTarget({ + runtimeSessionCwd: "/host/workspace", + effectiveExecutionCwd: "/remote/workspace", + executionTargetIsRemote: false, + })).toBe(false); + }); + it("reuses a stable Paperclip-managed Claude prompt bundle across equivalent runs", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-execute-bundle-")); const workspace = path.join(root, "workspace"); diff --git a/server/src/__tests__/plugin-database.test.ts b/server/src/__tests__/plugin-database.test.ts index 6392b78e..5ae677e5 100644 --- a/server/src/__tests__/plugin-database.test.ts +++ b/server/src/__tests__/plugin-database.test.ts @@ -25,7 +25,7 @@ import { validatePluginRuntimeExecute, validatePluginRuntimeQuery, } from "../services/plugin-database.js"; -import { pluginLoader } from "../services/plugin-loader.js"; +import { buildPluginWorkerEnv, pluginLoader } from "../services/plugin-loader.js"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; @@ -84,6 +84,48 @@ describe("plugin database SQL validation", () => { }); }); +describe("buildPluginWorkerEnv", () => { + const instanceInfo = { + deploymentMode: "authenticated", + deploymentExposure: "public", + }; + + it("passes only model provider keys through to environment driver plugins", () => { + const env = buildPluginWorkerEnv({ + manifest: { capabilities: ["environment.drivers.register"] }, + instanceInfo, + processEnv: { + ANTHROPIC_API_KEY: "anthropic-token", + OPENAI_API_KEY: "openai-token", + GEMINI_API_KEY: " ", + AWS_SECRET_ACCESS_KEY: "aws-secret", + }, + }); + + expect(env).toEqual({ + PAPERCLIP_DEPLOYMENT_MODE: "authenticated", + PAPERCLIP_DEPLOYMENT_EXPOSURE: "public", + ANTHROPIC_API_KEY: "anthropic-token", + OPENAI_API_KEY: "openai-token", + }); + }); + + it("does not pass provider keys to non-environment plugins", () => { + const env = buildPluginWorkerEnv({ + manifest: { capabilities: ["ui.slots.register"] }, + instanceInfo, + processEnv: { + OPENAI_API_KEY: "openai-token", + }, + }); + + expect(env).toEqual({ + PAPERCLIP_DEPLOYMENT_MODE: "authenticated", + PAPERCLIP_DEPLOYMENT_EXPOSURE: "public", + }); + }); +}); + describeEmbeddedPostgres("plugin database namespaces", () => { let db!: ReturnType; let tempDb: Awaited> | null = null; diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 419b0c82..3fa574b7 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -163,6 +163,7 @@ import { extractSkillMentionIds } from "@paperclipai/shared"; import { environmentService } from "./environments.js"; import { environmentRuntimeService } from "./environment-runtime.js"; import { environmentRunOrchestrator } from "./environment-run-orchestrator.js"; +import { isUnsafeSessionWorkspaceCwd } from "./session-workspace-cwd.js"; import type { PluginWorkerManager } from "./plugin-worker-manager.js"; const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024; @@ -3568,7 +3569,8 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) } const sessionCwd = readNonEmptyString(previousSessionParams?.cwd); - if (sessionCwd) { + const sessionCwdLooksUnsafe = isUnsafeSessionWorkspaceCwd(sessionCwd); + if (sessionCwd && !sessionCwdLooksUnsafe) { const sessionCwdExists = await fs .stat(sessionCwd) .then((stats) => stats.isDirectory()) @@ -3590,7 +3592,11 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) const cwd = resolveDefaultAgentWorkspaceDir(agent.id); await fs.mkdir(cwd, { recursive: true }); const warnings: string[] = []; - if (sessionCwd) { + if (sessionCwd && sessionCwdLooksUnsafe) { + warnings.push( + `Saved session workspace "${sessionCwd}" points at a system temp root and was rejected as untrusted. Using fallback workspace "${cwd}" for this run.`, + ); + } else if (sessionCwd) { warnings.push( `Saved session workspace "${sessionCwd}" is not available. Using fallback workspace "${cwd}" for this run.`, ); diff --git a/server/src/services/plugin-loader.ts b/server/src/services/plugin-loader.ts index 073620b4..94b0a9f3 100644 --- a/server/src/services/plugin-loader.ts +++ b/server/src/services/plugin-loader.ts @@ -79,6 +79,37 @@ export const DEFAULT_LOCAL_PLUGIN_DIR = path.join( const DEV_TSX_LOADER_PATH = path.resolve(__dirname, "../../../cli/node_modules/tsx/dist/loader.mjs"); +const ADAPTER_ENV_PASSTHROUGH = [ + "ANTHROPIC_API_KEY", + "OPENAI_API_KEY", + "GOOGLE_API_KEY", + "GEMINI_API_KEY", + "OPENROUTER_API_KEY", +]; + +export function buildPluginWorkerEnv(input: { + manifest: Pick; + instanceInfo: { deploymentMode?: string | null; deploymentExposure?: string | null }; + processEnv?: NodeJS.ProcessEnv; +}): Record { + const processEnv = input.processEnv ?? process.env; + const env: Record = { + PAPERCLIP_DEPLOYMENT_MODE: input.instanceInfo.deploymentMode ?? "", + PAPERCLIP_DEPLOYMENT_EXPOSURE: input.instanceInfo.deploymentExposure ?? "", + }; + const canRegisterEnvironmentDrivers = Array.isArray(input.manifest.capabilities) + && input.manifest.capabilities.includes("environment.drivers.register"); + if (!canRegisterEnvironmentDrivers) return env; + + for (const key of ADAPTER_ENV_PASSTHROUGH) { + const value = processEnv[key]; + if (value && value.trim().length > 0) { + env[key] = value; + } + } + return env; +} + // --------------------------------------------------------------------------- // Discovery result types // --------------------------------------------------------------------------- @@ -1820,10 +1851,7 @@ export function pluginLoader( databaseNamespace, hostHandlers, autoRestart: true, - env: { - PAPERCLIP_DEPLOYMENT_MODE: instanceInfo.deploymentMode ?? "", - PAPERCLIP_DEPLOYMENT_EXPOSURE: instanceInfo.deploymentExposure ?? "", - }, + env: buildPluginWorkerEnv({ manifest, instanceInfo }), }; // Repo-local plugin installs can resolve workspace TS sources at runtime diff --git a/server/src/services/session-workspace-cwd.test.ts b/server/src/services/session-workspace-cwd.test.ts new file mode 100644 index 00000000..92e3f28f --- /dev/null +++ b/server/src/services/session-workspace-cwd.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; + +import { isUnsafeSessionWorkspaceCwd } from "./session-workspace-cwd.js"; + +describe("isUnsafeSessionWorkspaceCwd", () => { + it("rejects system roots that can poison remote sandbox session resumes", () => { + expect(isUnsafeSessionWorkspaceCwd("/")).toBe(true); + expect(isUnsafeSessionWorkspaceCwd("/tmp")).toBe(true); + expect(isUnsafeSessionWorkspaceCwd("/tmp/")).toBe(true); + expect(isUnsafeSessionWorkspaceCwd("/private/tmp")).toBe(true); + expect(isUnsafeSessionWorkspaceCwd("/var/tmp")).toBe(true); + expect(isUnsafeSessionWorkspaceCwd("/var/run")).toBe(true); + expect(isUnsafeSessionWorkspaceCwd("/proc")).toBe(true); + expect(isUnsafeSessionWorkspaceCwd("/sys")).toBe(true); + expect(isUnsafeSessionWorkspaceCwd("/dev")).toBe(true); + expect(isUnsafeSessionWorkspaceCwd("/run")).toBe(true); + expect(isUnsafeSessionWorkspaceCwd("/tmp/.")).toBe(true); + expect(isUnsafeSessionWorkspaceCwd("/tmp/..")).toBe(true); + expect(isUnsafeSessionWorkspaceCwd("/var/./run")).toBe(true); + }); + + it("allows concrete workspace descendants", () => { + expect(isUnsafeSessionWorkspaceCwd("/tmp/paperclip-workspace")).toBe(false); + expect(isUnsafeSessionWorkspaceCwd("/Users/dotta/paperclip")).toBe(false); + expect(isUnsafeSessionWorkspaceCwd(null)).toBe(false); + }); +}); diff --git a/server/src/services/session-workspace-cwd.ts b/server/src/services/session-workspace-cwd.ts new file mode 100644 index 00000000..b1efc543 --- /dev/null +++ b/server/src/services/session-workspace-cwd.ts @@ -0,0 +1,24 @@ +import path from "node:path"; + +const SESSION_CWD_SYSTEM_ROOTS = new Set([ + "/", + "/tmp", + "/var", + "/var/tmp", + "/var/run", + "/usr", + "/etc", + "/proc", + "/sys", + "/dev", + "/run", + "/private", + "/private/tmp", +]); + +export function isUnsafeSessionWorkspaceCwd(cwd: string | null | undefined): boolean { + const value = typeof cwd === "string" && cwd.trim().length > 0 ? cwd.trim() : null; + if (!value) return false; + const normalized = path.normalize(value.replace(/\/+$/, "") || "/"); + return SESSION_CWD_SYSTEM_ROOTS.has(normalized); +}