diff --git a/doc/architecture/runner-rich-acp-capabilities.md b/doc/architecture/runner-rich-acp-capabilities.md index 54467eaa033..db6c14b318a 100644 --- a/doc/architecture/runner-rich-acp-capabilities.md +++ b/doc/architecture/runner-rich-acp-capabilities.md @@ -21,6 +21,18 @@ recommends Cursor and Copilot, followed by Pi, using the existing qualified ACPX path. Codex app-server is the richness benchmark. The legacy Cursor and Pi adapters are outside this change. +## Registered persistent agent files + +Candidate ACP processes receive `AGENT_HOME` only from the authenticated +runtime context's server-registered `agent_files` working copy. Ambient +environment values cannot grant a directory. Admission rejects symlink roots, +filesystem roots and overlap with provider runtime state; directory identity is +rechecked at launch and before each turn. Each provider reopen receives the +current run's registered copy. The native executor requires provider shutdown +before collecting and synchronizing the copy. Pi additionally receives a +runner-owned native-tool root binding; its task read-only policy still applies. +These checks do not by themselves qualify provider-specific native file tools. + ## Branches and evidence ownership | Branch | Deliverable | diff --git a/packages/paperclip-runner/src/backends/codex-acpx-native-backend.ts b/packages/paperclip-runner/src/backends/codex-acpx-native-backend.ts index 19483fb4253..6e92677a2dc 100644 --- a/packages/paperclip-runner/src/backends/codex-acpx-native-backend.ts +++ b/packages/paperclip-runner/src/backends/codex-acpx-native-backend.ts @@ -14,7 +14,7 @@ import { export interface CodexAcpxNativeSessionBackendOptions extends Omit< CodexAcpxDriverOptions, - "model" | "permissionMode" | "systemInstructions" | "providerPolicy" + "model" | "permissionMode" | "systemInstructions" | "providerPolicy" | "runtimeContext" > {} export type AcpxNativeSessionBackendOptions = @@ -73,6 +73,7 @@ export function createAcpxNativeSessionBackend( model: input.provider.model, permissionMode: input.provider.permissionMode ?? "approve-reads", systemInstructions, + runtimeContext: "runtimeContext" in input ? input.runtimeContext : null, providerPolicy: { readOnly: "executionMode" in input && input.executionMode === "plan" }, }), ); 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 new file mode 100644 index 00000000000..27437e8fde9 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/agent-files-binding.test.ts @@ -0,0 +1,56 @@ +import { mkdtempSync, mkdirSync, renameSync, rmSync, symlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import type { NativeRuntimeContextSnapshot } from "../../contracts/runtime-context.js"; +import { bindAcpxAgentFiles } from "./agent-files-binding.js"; + +const roots: string[] = []; +afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); +function fixture() { + const parent = mkdtempSync(join(tmpdir(), "acpx-agent-files-")); + roots.push(parent); + const root = join(parent, "registered-run-copy"); + const protectedRoot = join(parent, "provider-state"); + mkdirSync(root); mkdirSync(protectedRoot); + const context = { instructions: { workingCopy: { kind: "agent_files", rootPath: root, entryPath: "AGENTS.md" } } } as NativeRuntimeContextSnapshot; + return { parent, root, protectedRoot, context }; +} + +describe("registered ACP agent files", () => { + it("does not grant absent or legacy instruction copies", () => { + expect(bindAcpxAgentFiles(null, [])).toBeNull(); + const { context } = fixture(); + delete context.instructions.workingCopy!.kind; + expect(bindAcpxAgentFiles(context, [])).toBeNull(); + }); + it("binds only the registered copy and detects replaced roots", () => { + const { root, context, protectedRoot } = fixture(); + const binding = bindAcpxAgentFiles(context, [protectedRoot])!; + expect(binding.root.endsWith("/registered-run-copy")).toBe(true); + expect(() => binding.assertHeld()).not.toThrow(); + renameSync(root, `${root}-old`); mkdirSync(root); + expect(() => binding.assertHeld()).toThrow("changed"); + }); + it("rejects symlink roots and symlink replacement", () => { + const { root, context, protectedRoot } = fixture(); + const binding = bindAcpxAgentFiles(context, [])!; + renameSync(root, `${root}-old`); symlinkSync(protectedRoot, root); + expect(() => binding.assertHeld()).toThrow("changed"); + expect(() => bindAcpxAgentFiles(context, [])).toThrow("real directory"); + }); + it("rejects both directions of protected-root overlap", () => { + const { parent, root, context } = fixture(); + const child = join(root, "nested-runtime"); mkdirSync(child); + for (const protectedRoot of [parent, root, child]) { + expect(() => bindAcpxAgentFiles(context, [protectedRoot])).toThrow("overlaps"); + } + }); + it("rejects relative, non-normalized and filesystem-root grants", () => { + const { context } = fixture(); + for (const rootPath of ["relative", "/tmp/../tmp", "/", "/tmp\0invalid"]) { + context.instructions.workingCopy!.rootPath = rootPath; + expect(() => bindAcpxAgentFiles(context, [])).toThrow(); + } + }); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/agent-files-binding.ts b/packages/paperclip-runner/src/drivers/acpx/agent-files-binding.ts new file mode 100644 index 00000000000..4208e7baa04 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/agent-files-binding.ts @@ -0,0 +1,48 @@ +import { lstatSync, realpathSync } from "node:fs"; +import { isAbsolute, relative, resolve } from "node:path"; +import type { NativeRuntimeContextSnapshot } from "../../contracts/runtime-context.js"; + +export interface AcpxAgentFilesBinding { + readonly root: string; + assertHeld(): void; +} + +/** The authenticated runtime context, never an ambient environment value, grants this run copy. */ +export function bindAcpxAgentFiles( + context: NativeRuntimeContextSnapshot | null | undefined, + protectedRoots: readonly string[], +): AcpxAgentFilesBinding | null { + const copy = context?.instructions.workingCopy; + if (copy?.kind !== "agent_files") return null; + const declared = copy.rootPath; + if (!isAbsolute(declared) || declared.includes("\0") || resolve(declared) !== declared) { + throw new Error("ACP agent directory must be an absolute normalized registered path"); + } + const metadata = lstatSync(declared); + if (!metadata.isDirectory() || metadata.isSymbolicLink()) { + throw new Error("ACP agent directory must be a real directory"); + } + const root = realpathSync(declared); + if (resolve(root, "..") === root) throw new Error("ACP agent directory cannot be a filesystem root"); + 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)) { + throw new Error("ACP agent directory overlaps protected runtime state"); + } + } + const assertHeld = () => { + const current = lstatSync(declared); + if (!current.isDirectory() || current.isSymbolicLink() + || current.dev !== metadata.dev || current.ino !== metadata.ino + || realpathSync(declared) !== root) { + throw new Error("ACP registered agent directory changed during provider lifetime"); + } + }; + return Object.freeze({ root, assertHeld }); +} + +function contains(root: string, path: string): boolean { + const rel = relative(root, path); + return rel === "" || (!rel.startsWith("../") && rel !== ".." && !isAbsolute(rel)); +} diff --git a/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts b/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts index 6647d1555ff..1c901df0691 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts @@ -141,6 +141,7 @@ export interface CodexAcpxDriverOptions { model: string; permissionMode?: NativeAcpxPermissionMode; providerPolicy?: { readOnly: boolean }; + runtimeContext?: OpenAcpxRuntimeHostOptions["runtimeContext"]; systemInstructions?: string; environment?: NodeJS.ProcessEnv; managedCodexCredentialSourcePath?: string; @@ -453,6 +454,7 @@ export class CodexAcpxDriver implements HarnessDriver { model: this.#options.model, permissionMode: this.#options.permissionMode ?? "approve-all", providerPolicy: this.#options.providerPolicy, + runtimeContext: this.#options.runtimeContext, systemInstructions: this.#options.systemInstructions, environment: this.#options.environment, managedCodexCredentialSourcePath: 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 3a2dd3ece64..0004949c10e 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -832,6 +832,43 @@ describe("ACPX runtime host", () => { expect(openRuntime).toHaveBeenCalledOnce(); }); + 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-")); + temporaryDirectories.push(copies); + const model = agent === "pi" ? "openrouter/deepseek/deepseek-v4-flash-0731" : "explicit-test-model"; + const options = { ...fixture.options, agent, model, permissionMode: "approve-all" as const, + providerPolicy: { readOnly: false }, environment: { AGENT_HOME: "/ambient/other-agent", PAPERCLIP_PI_AGENT_HOME: "/ambient/other-agent", + ...(agent === "pi" ? { OPENROUTER_API_KEY: "test" } : agent === "cursor" ? { CURSOR_API_KEY: "test" } : { COPILOT_GITHUB_TOKEN: "test" }), + }, + }; + const opened: AcpxRuntimePortOpenOptions[] = []; + const dependencies = fixture.dependencies({ openRuntime: async launch => { + opened.push(launch); + return runtimePort({ getStatus: async () => ({ models: { currentModelId: model } }) }); + } }); + // Missing trusted context must not turn ambient values into authority. + const withoutCopy = await AcpxRuntimeHost.open(options, dependencies); + expect(opened.at(-1)!.launchEnvironment.AGENT_HOME).toBeUndefined(); + expect(opened.at(-1)!.launchEnvironment.PAPERCLIP_PI_AGENT_HOME).toBeUndefined(); + await withoutCopy.close({ reason: "no registered copy" }); + for (const run of ["first", "second"]) { + const rootPath = join(copies, run); await mkdir(rootPath); + const runtimeContext = { instructions: { workingCopy: { kind: "agent_files", rootPath, entryPath: "AGENTS.md" } }, skills: [], mcp: { bindingId: null } } as unknown as NativeRuntimeContextSnapshot; + const host = await AcpxRuntimeHost.open({ ...options, runtimeContext }, dependencies); + const launch = opened.at(-1)!; + expect(launch.launchEnvironment.AGENT_HOME?.endsWith(`/${run}`)).toBe(true); + expect(launch.launchEnvironment.PAPERCLIP_PI_AGENT_HOME).toBe(agent === "pi" ? launch.launchEnvironment.AGENT_HOME : undefined); + expect(launch.launchEnvironment.HOME).not.toBe(launch.launchEnvironment.AGENT_HOME); + if (run === "second") { + await rename(rootPath, `${rootPath}-replaced`); await mkdir(rootPath); + expect(() => launch.assertWorkspaceHeld?.()).toThrow("changed"); + expect(() => host.startTurn({ text: "test", requestId: "stale-directory" })).toThrow("changed"); + } + await host.close({ reason: "registered copy test complete" }); + } + }); + it("selects and verifies Claude's qualified reported model", async () => { const fixture = await hostFixture(); let selected = false; diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts index 1f2d83919c2..ba5ff88cab2 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts @@ -1,4 +1,5 @@ import { dirname, join } from "node:path"; +import { bindAcpxAgentFiles } from "./agent-files-binding.js"; import { assertAcpxProfileEnvironment, assertAcpxProfileWorkspace, classifyAcpxProfileError, verifyAcpxProfileInstallation } from "./profile-installation.js"; import { createAcpxRuntimeSkillLease } from "./runtime-skill-lease.js"; import { stageManagedGrokCredential } from "./grok-credentials.js"; @@ -278,6 +279,7 @@ export class AcpxRuntimeHost { readonly #command: VerifiedAcpxCommandLease; readonly #toolBridge: RunnerToolBridge | null; readonly #claudeSkillNames: readonly string[]; + readonly #assertAgentFilesHeld: (() => void) | undefined; #activeTurn: AcpxRuntimeTurn | null = null; #closingStarted = false; #closePromise: Promise | null = null; @@ -292,6 +294,7 @@ export class AcpxRuntimeHost { command: VerifiedAcpxCommandLease; toolBridge: RunnerToolBridge | null; claudeSkillNames: readonly string[]; + assertAgentFilesHeld?: () => void; }) { this.#runtime = input.runtime; this.#binding = input.binding; @@ -301,6 +304,7 @@ export class AcpxRuntimeHost { this.#command = input.command; this.#toolBridge = input.toolBridge; this.#claudeSkillNames = [...input.claudeSkillNames]; + this.#assertAgentFilesHeld = input.assertAgentFilesHeld; } static async open( @@ -458,6 +462,19 @@ export class AcpxRuntimeHost { } assertAcpxProfileEnvironment(options.agent, sandbox.launchEnvironment); let launchEnvironment = sandbox.launchEnvironment; + // This copy is registered by the authenticated controller for its company, + // 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) + ? bindAcpxAgentFiles(options.runtimeContext, [sandbox.root, + ...(installation.agentServerPackageJsonPath === null ? [] : [dirname(installation.agentServerPackageJsonPath)]), + ]) : null; + if (agentFiles) { + launchEnvironment = Object.freeze({ ...launchEnvironment, AGENT_HOME: agentFiles.root, + ...(options.agent === "pi" ? { PAPERCLIP_PI_AGENT_HOME: agentFiles.root } : {}), + }); + } if (options.agent === "pi") { const skills = await acquireAbortableAdmissionResource({ signal: options.signal, @@ -476,7 +493,7 @@ export class AcpxRuntimeHost { await skills.close(); }, }; - launchEnvironment = Object.freeze({ ...sandbox.launchEnvironment, + launchEnvironment = Object.freeze({ ...launchEnvironment, PAPERCLIP_PI_READ_ROOTS: JSON.stringify([...(options.providerPolicy?.readRoots ?? []), ...skills.readRoots]), }); } @@ -523,6 +540,7 @@ export class AcpxRuntimeHost { signal: options.signal, acquire: async () => { options.assertWorkspaceHeld?.(); + agentFiles?.assertHeld(); await assertAcpxProfileWorkspace(options.agent, binding.workspacePath); return dependencies.openRuntime({ ...(options.clientCapabilities === undefined ? {} : { @@ -543,9 +561,9 @@ export class AcpxRuntimeHost { activateCredentialFenceOwner: admittedLifetime.activateLifetimeOwner.bind(admittedLifetime), systemInstructions: boundedInstructions(options.systemInstructions), - ...(options.assertWorkspaceHeld === undefined + ...(options.assertWorkspaceHeld === undefined && !agentFiles ? {} - : { assertWorkspaceHeld: options.assertWorkspaceHeld }), + : { assertWorkspaceHeld: () => { options.assertWorkspaceHeld?.(); agentFiles?.assertHeld(); } }), ...(options.signal === undefined ? {} : { signal: options.signal }), mcpServers: [ ...(toolBridge ? [{ name: "paperclip", url: toolBridge.url, @@ -618,6 +636,7 @@ export class AcpxRuntimeHost { credential, command, toolBridge, + assertAgentFilesHeld: agentFiles?.assertHeld, claudeSkillNames: options.agent === "claude" ? (options.runtimeContext?.skills.map((skill) => skill.runtimeName) ?? []) : [], @@ -741,6 +760,7 @@ export class AcpxRuntimeHost { } startTurn(input: AcpxRuntimeTurnInput): AcpxRuntimeTurn { + this.#assertAgentFilesHeld?.(); if (this.#closed || this.#closingStarted) { throw new Error("ACPX runtime host is closing"); }