From 51e52d58492fecb1f2f06f18109db9cdf2068266 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 10 Oct 2026 10:16:07 -0500 Subject: [PATCH] fix(runtime): bind persistent computer homes into Claude runtime context Co-Authored-By: Paperclip --- .../src/contracts/runtime-context.test.ts | 7 +++++ .../src/contracts/runtime-context.ts | 12 +++++++- .../drivers/acpx/agent-files-binding.test.ts | 13 +++++++++ .../src/drivers/acpx/agent-files-binding.ts | 10 +++++-- .../src/drivers/acpx/runtime-host.test.ts | 28 +++++++++++++++++++ .../src/drivers/acpx/runtime-host.ts | 2 +- .../native-runtime/native-session-executor.ts | 3 ++ 7 files changed, 70 insertions(+), 5 deletions(-) diff --git a/packages/paperclip-runner/src/contracts/runtime-context.test.ts b/packages/paperclip-runner/src/contracts/runtime-context.test.ts index 09a931317cf..cc5a0332541 100644 --- a/packages/paperclip-runner/src/contracts/runtime-context.test.ts +++ b/packages/paperclip-runner/src/contracts/runtime-context.test.ts @@ -37,6 +37,13 @@ function savedContext(revision: string, text: string) { } describe("persisted native execution prompts", () => { + it("preserves an authenticated persistent home independently of instruction copies", () => { + const context = { ...savedContext("saved", "Instructions"), persistentAgentHome: { rootPath: "/computer/agent" } }; + expect(parseNativeRuntimeContext(context).persistentAgentHome).toEqual({ rootPath: "/computer/agent" }); + expect(parseNativeRuntimeContext(context).instructions.workingCopy).toBeUndefined(); + expect(() => parseNativeRuntimeContext({ ...context, persistentAgentHome: { rootPath: "" } })).toThrow(); + expect(() => parseNativeRuntimeContext({ ...context, persistentAgentHome: { rootPath: "/computer/agent", untrusted: true } })).toThrow(); + }); it.each([ ["snapshot-before-upgrade", "Saved instructions from before the upgrade."], ["future-prompt-fixture", " Saved instructions absent from this release.\n"], diff --git a/packages/paperclip-runner/src/contracts/runtime-context.ts b/packages/paperclip-runner/src/contracts/runtime-context.ts index 810556ac49f..40e32b82ad5 100644 --- a/packages/paperclip-runner/src/contracts/runtime-context.ts +++ b/packages/paperclip-runner/src/contracts/runtime-context.ts @@ -21,6 +21,8 @@ export interface NativeRuntimeContextSnapshot { /** Server-registered writable copy; excluded from the pinned prompt digest. */ workingCopy?: { rootPath: string; entryPath: string; kind?: "agent_files" }; }; + /** Authenticated controller grant for an attached computer; independent of instruction-copy ownership. */ + persistentAgentHome?: { rootPath: string }; skills: Array<{ key: string; runtimeName: string; versionId: string | null; bundle: NativeRuntimeAssetReference }>; mcp: { assignmentSetId: string; digest: string; bindingId: string | null }; connectionInstructions?: { text: string; digest: string }; @@ -80,6 +82,8 @@ function parseAsset(value: unknown, path: string): NativeRuntimeAssetReference { function aggregatePayload(value: Omit) { return { prompt: value.prompt, + // Registered filesystem locators (workingCopy and persistentAgentHome) + // are authenticated run authority, not pinned instruction content. instructions: { entryPath: value.instructions.entryPath, bundleDigest: value.instructions.bundle.digest }, skills: [...value.skills].sort((a, b) => a.key.localeCompare(b.key)).map((skill) => ({ key: skill.key, runtimeName: skill.runtimeName, versionId: skill.versionId, bundleDigest: skill.bundle.digest, @@ -100,7 +104,7 @@ export function nativeRuntimePromptDigest(): string { return sha256(PAPERCLIP_EX export function parseNativeRuntimeContext(value: unknown): NativeRuntimeContextSnapshot { const context = object(value, "input.runtimeContext"); - exact(context, ["prompt", "instructions", "skills", "mcp", "connectionInstructions", "aggregateDigest"], "input.runtimeContext"); + exact(context, ["prompt", "instructions", "persistentAgentHome", "skills", "mcp", "connectionInstructions", "aggregateDigest"], "input.runtimeContext"); const prompt = object(context.prompt, "input.runtimeContext.prompt"); exact(prompt, ["revision", "text", "digest"], "input.runtimeContext.prompt"); // New runs use the current constants. Recovery uses the immutable saved @@ -141,7 +145,13 @@ export function parseNativeRuntimeContext(value: unknown): NativeRuntimeContextS if (sha256(content) !== contentDigest) throw new NativeRuntimeContextError("Connection instruction digest does not match text"); connectionInstructions = { text: content, digest: contentDigest }; } + const persistentAgentHome = context.persistentAgentHome === undefined ? undefined + : object(context.persistentAgentHome, "input.runtimeContext.persistentAgentHome"); + if (persistentAgentHome) exact(persistentAgentHome, ["rootPath"], "input.runtimeContext.persistentAgentHome"); const parsed = { + ...(persistentAgentHome ? { persistentAgentHome: { + rootPath: text(persistentAgentHome.rootPath, "input.runtimeContext.persistentAgentHome.rootPath"), + } } : {}), prompt: { revision: promptRevision, text: promptText, digest: promptDigest }, instructions: { entryPath: safeRelativePath(instructions.entryPath, "input.runtimeContext.instructions.entryPath"), diff --git a/packages/paperclip-runner/src/drivers/acpx/agent-files-binding.test.ts b/packages/paperclip-runner/src/drivers/acpx/agent-files-binding.test.ts index 27437e8fde9..846e4df593b 100644 --- a/packages/paperclip-runner/src/drivers/acpx/agent-files-binding.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/agent-files-binding.test.ts @@ -18,6 +18,19 @@ function fixture() { } describe("registered ACP agent files", () => { + it("binds a persistent computer home without inventing an instruction copy", () => { + const { root, parent, context } = fixture(); + delete context.instructions.workingCopy; + context.persistentAgentHome = { rootPath: root }; + const nestedRuntime = join(root, ".paperclip-runtime"); mkdirSync(nestedRuntime); + const binding = bindAcpxAgentFiles(context, [nestedRuntime])!; + expect(binding.root.endsWith("registered-run-copy")).toBe(true); + for (const protectedRoot of [parent, root]) { + expect(() => bindAcpxAgentFiles(context, [protectedRoot])).toThrow("overlaps"); + } + renameSync(root, `${root}-old`); mkdirSync(root); + expect(() => binding.assertHeld()).toThrow("changed"); + }); it("does not grant absent or legacy instruction copies", () => { expect(bindAcpxAgentFiles(null, [])).toBeNull(); const { context } = fixture(); diff --git a/packages/paperclip-runner/src/drivers/acpx/agent-files-binding.ts b/packages/paperclip-runner/src/drivers/acpx/agent-files-binding.ts index 4208e7baa04..26891fc3c06 100644 --- a/packages/paperclip-runner/src/drivers/acpx/agent-files-binding.ts +++ b/packages/paperclip-runner/src/drivers/acpx/agent-files-binding.ts @@ -13,8 +13,9 @@ export function bindAcpxAgentFiles( protectedRoots: readonly string[], ): AcpxAgentFilesBinding | null { const copy = context?.instructions.workingCopy; - if (copy?.kind !== "agent_files") return null; - const declared = copy.rootPath; + const persistentHome = context?.persistentAgentHome; + if (!persistentHome && copy?.kind !== "agent_files") return null; + const declared = persistentHome?.rootPath ?? copy!.rootPath; if (!isAbsolute(declared) || declared.includes("\0") || resolve(declared) !== declared) { throw new Error("ACP agent directory must be an absolute normalized registered path"); } @@ -27,7 +28,10 @@ export function bindAcpxAgentFiles( for (const protectedRoot of protectedRoots) { // These roots belong to the verified runtime and sandbox, not user input. const canonical = realpathSync(protectedRoot); - if (contains(root, canonical) || contains(canonical, root)) { + // Attached-computer homes contain the runner's isolated state beneath + // .paperclip-runtime. This env pointer does not widen the provider policy; + // retain its protected roots, and never alias the home into one of them. + if (contains(canonical, root) || (!persistentHome && contains(root, canonical))) { throw new Error("ACP agent directory overlaps protected runtime state"); } } diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts index 2459dab2763..e83681f749d 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts @@ -901,6 +901,34 @@ describe("ACPX runtime host", () => { expect(fixture.commandClose).toHaveBeenCalledOnce(); }); + it("gives Claude shells only the registered persistent home while keeping provider HOME isolated", async () => { + const fixture = await hostFixture(); + const home = await realpath(await mkdtemp(join(tmpdir(), "paperclip-computer-agent-home-"))); + temporaryDirectories.push(home); + const context: NativeRuntimeContextSnapshot = { + prompt: { revision: PAPERCLIP_EXECUTION_PROMPT_REVISION, text: PAPERCLIP_EXECUTION_PROMPT, digest: nativeRuntimePromptDigest() }, + instructions: { entryPath: "AGENTS.md", bundle: { schema: NATIVE_RUNTIME_ASSET_SCHEMA, digest: "0".repeat(64), manifestDigest: "0".repeat(64), rootPath: home, fileCount: 0, totalBytes: 0 } }, + persistentAgentHome: { rootPath: home }, skills: [], + mcp: { assignmentSetId: "none", digest: "0".repeat(64), bindingId: null }, aggregateDigest: "", + }; + context.aggregateDigest = canonicalNativeRuntimeContextDigest(context); + let observed: { home: string; agentHome: string } | undefined; + const host = await AcpxRuntimeHost.open({ ...fixture.options, agent: "claude", model: "claude-sonnet-5", + environment: { AGENT_HOME: "/ambient/untrusted" }, runtimeContext: context, + }, fixture.dependencies({ openRuntime: async launch => { + observed = JSON.parse(execFileSync(process.execPath, ["-e", 'console.log(JSON.stringify({home:process.env.HOME,agentHome:process.env.AGENT_HOME}))'], { + env: launch.launchEnvironment, encoding: "utf8", timeout: 5_000, + })); + return runtimePort({ getStatus: async () => ({ models: { currentModelId: "claude-sonnet-5" } }) }); + } })); + try { + expect(observed?.agentHome).toBe(home); + expect(observed?.home).not.toBe(home); + expect(observed?.home).toContain("home"); + expect(context.instructions.workingCopy).toBeUndefined(); + } finally { await host.close({ reason: "persistent home verified" }); } + }); + it.each(["cursor", "copilot", "pi"] as const)("binds %s agent files from each registered run copy, never ambient roots", async (agent) => { const fixture = await hostFixture(); const copies = await mkdtemp(join(tmpdir(), "paperclip-agent-copies-")); diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts index 8be42da690f..22836bb95f6 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts @@ -483,7 +483,7 @@ export class AcpxRuntimeHost { // agent and run. Environment/config values cannot widen the grant. Each // resumed process receives the newly registered copy; collection already // requires verified provider shutdown in the native executor. - const agentFiles = ["cursor", "copilot", "pi"].includes(options.agent) + const agentFiles = options.runtimeContext?.persistentAgentHome || ["cursor", "copilot", "pi"].includes(options.agent) ? bindAcpxAgentFiles(options.runtimeContext, [sandbox.root, ...(installation.agentServerPackageJsonPath === null ? [] : [dirname(installation.agentServerPackageJsonPath)]), ]) : null; diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index 381b1af263b..66cc820ba21 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -11797,6 +11797,9 @@ async function createRunnerdBackendWithinSessionClaim( remoteRunnerFilesystemRoot && sourceRuntimeContext ? { ...sourceRuntimeContext, + ...(remoteTarget?.transport === "computer" ? { + persistentAgentHome: { rootPath: remoteTarget.fileAuthority.agentHome }, + } : {}), instructions: { ...sourceRuntimeContext.instructions, bundle: {