fix(remote-sandbox): harden host workspace resumes (#5922)

## 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 <noreply@paperclip.ing>
This commit is contained in:
Dotta
2026-05-13 16:23:04 -05:00
committed by GitHub
parent 012a738729
commit d1a8c873b2
10 changed files with 206 additions and 14 deletions
@@ -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<void>((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<void>((resolve) => server.close(() => resolve()));
}
});
});
@@ -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<Snapsh
target: await fs.readlink(fullPath),
};
}
if (!stats.isFile()) return null;
return {
kind: "file",
mode: stats.mode,
@@ -89,6 +89,15 @@ interface ClaudeRuntimeConfig {
extraArgs: string[];
}
export function claudeSessionCwdMatchesExecutionTarget(input: {
runtimeSessionCwd: string;
effectiveExecutionCwd: string;
executionTargetIsRemote: boolean;
}): boolean {
if (input.executionTargetIsRemote || input.runtimeSessionCwd.length === 0) return true;
return path.resolve(input.runtimeSessionCwd) === path.resolve(input.effectiveExecutionCwd);
}
function buildLoginResult(input: {
proc: RunProcessResult;
loginUrl: string | null;
@@ -591,7 +600,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const canResumeSession =
runtimeSessionId.length > 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<AdapterExec
const resolvedSessionParams = resolvedSessionId
? ({
sessionId: resolvedSessionId,
cwd: effectiveExecutionCwd,
cwd,
promptBundleKey: promptBundle.bundleKey,
...(executionTargetIsRemote
? {
@@ -1,4 +1,4 @@
export { execute, runClaudeLogin } from "./execute.js";
export { claudeSessionCwdMatchesExecutionTarget, execute, runClaudeLogin } from "./execute.js";
export { listClaudeSkills, syncClaudeSkills } from "./skills.js";
export { listClaudeModels } from "./models.js";
export { testEnvironment } from "./test.js";
@@ -3,7 +3,7 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { runChildProcess } from "@paperclipai/adapter-utils/server-utils";
import { execute } from "@paperclipai/adapter-claude-local/server";
import { claudeSessionCwdMatchesExecutionTarget, execute } from "@paperclipai/adapter-claude-local/server";
async function writeFailingClaudeCommand(
commandPath: string,
@@ -580,7 +580,7 @@ describe("claude execute", () => {
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");
+43 -1
View File
@@ -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<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
+8 -2
View File
@@ -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.`,
);
+32 -4
View File
@@ -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<PaperclipPluginManifestV1, "capabilities">;
instanceInfo: { deploymentMode?: string | null; deploymentExposure?: string | null };
processEnv?: NodeJS.ProcessEnv;
}): Record<string, string> {
const processEnv = input.processEnv ?? process.env;
const env: Record<string, string> = {
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
@@ -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);
});
});
@@ -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);
}