From 25c12848a23026eb26eb803791024cffcf53f0cc Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 10 Oct 2026 10:10:19 -0500 Subject: [PATCH 1/6] fix(runtime): allow supervised identity reads within startup deadline Co-Authored-By: Paperclip --- .../native-session-executor.test.ts | 33 +++++++++++++++++++ .../native-runtime/native-session-executor.ts | 5 ++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts index 8e1ce40548f..8d85ab20b71 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -711,6 +711,39 @@ describe("remote runner launch fingerprint compatibility", () => { }); describe("remote runner process supervision", () => { + it("admits a valid computer identity when the supervised RPC takes longer than two seconds", async () => { + vi.useFakeTimers(); + try { + let nonce = ""; + const launch = vi.fn(async (request: { args: string[] }) => { nonce = request.args[4]!; }); + const execute = vi.fn(async (request: { args: string[]; timeoutMs?: number }) => { + const label = request.args[2]; + const result = { exitCode: 0, timedOut: false, signal: null, stdout: "", stderr: "" }; + if (label === "paperclip-runner-process-identity") { + const elapsed = Math.min(3_000, request.timeoutMs ?? 0); + await new Promise(resolve => setTimeout(resolve, elapsed)); + return { ...result, timedOut: elapsed < 3_000, + stdout: `${nonce}\n4321\n2026-09-06T00:00:00.000Z\ncomputer-runner\nlinux:abcd-1234:100\n` }; + } + if (label === "paperclip-runner-monitor") return { ...result, exitCode: 3 }; + if (label === "paperclip-runner-diagnostics") return result; + return { ...result, stdout: Buffer.from("{}").toString("base64") }; + }); + const onSpawn = vi.fn(); + const launcher = createRemoteRunnerProcessLauncher({ + target: { kind: "remote", transport: "computer", remoteCwd: "/home/user/project", launch } as never, + runner: { execute } as never, remoteBinary: "/runtime/runnerd", processIdentityPath: "/runtime/state/identity", + stateDirectory: "/runtime/state", diagnosticsDirectory: "/runtime/logs", runnerInstanceId: "computer-runner", onSpawn, + }); + const handle = launcher({ command: "/controller/runnerd", args: ["--runner-id", "computer-runner"], cwd: "/controller", environment: {} }); + await vi.advanceTimersByTimeAsync(3_100); + expect(onSpawn).toHaveBeenCalledExactlyOnceWith({ pid: 4321, processGroupId: null, startedAt: "2026-09-06T00:00:00.000Z" }); + await expect(handle.completion).resolves.toMatchObject({ code: null }); + expect(execute.mock.calls.filter(([request]) => request.args[2] === "paperclip-runner-process-identity")).toHaveLength(1); + expect(execute.mock.calls.some(([request]) => request.args[2] === "paperclip-runner-identity-failure-cleanup")).toBe(false); + } finally { vi.useRealTimers(); } + }); + it.each(["timeout", "ssh_failure", "rejected_rpc"] as const)( "keeps the admitted process after a transient %s monitor failure", async failure => { vi.useFakeTimers(); diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index cfb6791ea1d..381b1af263b 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -10964,7 +10964,10 @@ async function waitForRemoteRunnerProcessIdentity(input: { input.identityPath, ], bypassSession: true, - timeoutMs: 2_000, + // An owned remote command includes SSH and supervisor admission before + // reading this tiny file. Its normal roundtrip can exceed two seconds. + // Give the RPC room while preserving the total identity-wait deadline. + timeoutMs: Math.max(1, Math.min(10_000, deadline - Date.now())), }) .catch(() => null); const identity = From 03542b0845cc6de06f61bc2d4ae700c4408a836e Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 10 Oct 2026 10:16:15 -0500 Subject: [PATCH 2/6] Authenticate Boat initial checkouts with run-scoped Git credentials Pass the existing workspace credential provider through computer realization separately from durable metadata, and supply its URL-scoped helper only to the clone subprocess. Verify authenticated clone behavior and credential non-retention. Co-Authored-By: Paperclip --- .../environment-run-orchestrator.test.ts | 17 ++++++++ .../modules/computers/adapters/boat.test.ts | 40 ++++++++++++++++++- .../computers/adapters/remote-program.ts | 12 ++++-- .../computers/application/service.test.ts | 10 +++++ .../modules/computers/application/service.ts | 2 + .../services/computer-environment-driver.ts | 3 +- .../services/environment-run-orchestrator.ts | 6 +++ server/src/services/environment-runtime.ts | 2 + server/src/services/heartbeat.ts | 1 + 9 files changed, 87 insertions(+), 6 deletions(-) diff --git a/server/src/__tests__/environment-run-orchestrator.test.ts b/server/src/__tests__/environment-run-orchestrator.test.ts index d8273d84984..d81533cbf6e 100644 --- a/server/src/__tests__/environment-run-orchestrator.test.ts +++ b/server/src/__tests__/environment-run-orchestrator.test.ts @@ -303,6 +303,23 @@ describe("environmentRunOrchestrator — realizeForRun", () => { expect(mockResolveEnvironmentExecutionTarget).toHaveBeenCalledOnce(); }); + it("resolves run-scoped checkout credentials without persisting them in workspace metadata", async () => { + const runtime = makeMockRuntime(); + const orchestrator = environmentRunOrchestrator(mockDb, { environmentRuntime: runtime }); + const request = mockBuildWorkspaceRealizationRequest.getMockImplementation()!(); + request.source.repoUrl = "https://github.com/company/private-repo.git"; + mockBuildWorkspaceRealizationRequest.mockReturnValue(request); + const gitAuth = { configArgs: ["-c", "credential.helper="], env: { PAPERCLIP_GIT_TOKEN: "scoped-fixture-token" }, source: "managed_connection" as const, secretName: null }; + const resolveGitAuth = vi.fn(async () => gitAuth); + await orchestrator.realizeForRun({ ...makeRealizeInput({ environment: makeEnvironment("computer") }), resolveGitAuth }); + expect(resolveGitAuth).toHaveBeenCalledExactlyOnceWith(request.source.repoUrl); + expect(runtime.realizeWorkspace).toHaveBeenCalledWith(expect.objectContaining({ gitAuth })); + const dispatched = vi.mocked(runtime.realizeWorkspace).mock.calls[0]![0]; + expect(JSON.stringify(dispatched.workspace)).not.toContain("scoped-fixture-token"); + expect(JSON.stringify(mockUpdateLeaseMetadata.mock.calls)).not.toContain("scoped-fixture-token"); + expect(JSON.stringify(mockUpdateExecutionWorkspace.mock.calls)).not.toContain("scoped-fixture-token"); + }); + it("uses an in-place authoritative root on the adapter execution target", async () => { mockResolveEnvironmentExecutionTarget.mockResolvedValue({ kind: "remote", diff --git a/server/src/modules/computers/adapters/boat.test.ts b/server/src/modules/computers/adapters/boat.test.ts index d6f96f78bd3..7e2240c1958 100644 --- a/server/src/modules/computers/adapters/boat.test.ts +++ b/server/src/modules/computers/adapters/boat.test.ts @@ -1,5 +1,6 @@ import { mkdtempSync, + readFileSync, realpathSync, rmSync, symlinkSync, @@ -12,6 +13,7 @@ import { spawnSync } from "node:child_process"; import { remoteProgram } from "./remote-program.js"; import { afterEach, describe, it, expect, vi } from "vitest"; import { boatBackend } from "./boat.js"; +import { buildGitAuthInvocation } from "../../../services/git-credentials.js"; import type { ComputerRecord } from "../domain/ledger.js"; const record: ComputerRecord = { id: "computer", @@ -112,11 +114,11 @@ function fixture() { const temp = realpathSync(mkdtempSync(join(tmpdir(), "computer-files-"))); roots.push(temp); const root = join(temp, "home"); - const call = (input: Record) => { + const call = (input: Record, env = process.env) => { const result = spawnSync( "python3", ["-c", remoteProgram.replaceAll("/home/user/paperclip/", `${temp}/`)], - { input: JSON.stringify({ ...input, root }), encoding: "utf8" }, + { input: JSON.stringify({ ...input, root }), encoding: "utf8", env }, ); if (result.status !== 0) throw new Error(result.stderr); return JSON.parse(result.stdout); @@ -218,6 +220,40 @@ describe("confined computer files", () => { expect(f.call({ action: "list", limit: 2 }).entries).toHaveLength(2); expect(f.call({ action: "list", limit: 1001 })).toEqual({ error: "invalid" }); }); + it("clones a private checkout with operation-scoped auth and leaves no credential material behind", () => { + const f = fixture(); + const source = join(f.temp, "source"); + mkdirSync(source); + const realGit = spawnSync("which", ["git"], { encoding: "utf8" }).stdout.trim(); + for (const args of [["init"], ["-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "--allow-empty", "-m", "base"]]) { + expect(spawnSync(realGit, args, { cwd: source, encoding: "utf8" }).status).toBe(0); + } + const bin = join(f.temp, "bin"); + mkdirSync(bin); + // Stand in for an authenticated GitHub transport, using real Git and the + // production URL-scoped credential helper, without any external network. + writeFileSync(join(bin, "git"), `#!${process.execPath} +const {spawnSync}=require("node:child_process"); +const args=process.argv.slice(2), clone=args.indexOf("clone"); +if(clone<0) process.exit(9); +if(args.some(arg=>arg.includes("scoped-fixture-token"))) process.exit(10); +const credential=spawnSync(process.env.FIXTURE_REAL_GIT,[...args.slice(0,clone),"credential","fill"],{input:"protocol=https\\nhost=github.com\\n\\n",encoding:"utf8"}); +if(credential.status!==0||!credential.stdout.includes("password=scoped-fixture-token")) process.exit(11); +args[clone+2]=process.env.FIXTURE_SOURCE; +const result=spawnSync(process.env.FIXTURE_REAL_GIT,args,{encoding:"utf8"}); +process.stdout.write(result.stdout||"");process.stderr.write(result.stderr||"");process.exit(result.status??1); +`, { mode: 0o700 }); + const env = { ...process.env, PATH: `${bin}:${process.env.PATH}`, FIXTURE_REAL_GIT: realGit, FIXTURE_SOURCE: source }; + const request = { action: "workspace", mode: "shared", repositoryUrl: "https://github.com/company/private.git" }; + expect(f.call(request, env)).toEqual({ error: "command_failed" }); + const gitAuth = buildGitAuthInvocation({ token: "scoped-fixture-token", source: "managed_connection", secretName: null }); + const result = f.call({ ...request, gitAuth }, env); + expect(result.remoteCwd).toBe(join(f.root, "checkout")); + const config = readFileSync(join(result.remoteCwd, ".git", "config"), "utf8"); + expect(config).not.toContain("scoped-fixture-token"); + expect(config).not.toContain("credential"); + expect(spawnSync(realGit, ["rev-parse", "HEAD"], { cwd: result.remoteCwd }).status).toBe(0); + }); it.each([false, true])("uses the requested worktree branch separately from its start ref (explicit base: %s)", explicitBase => { const f = fixture(); const checkout = join(f.root, "checkout"); diff --git a/server/src/modules/computers/adapters/remote-program.ts b/server/src/modules/computers/adapters/remote-program.ts index 2f658d45fb1..fa937dbf408 100644 --- a/server/src/modules/computers/adapters/remote-program.ts +++ b/server/src/modules/computers/adapters/remote-program.ts @@ -5,8 +5,8 @@ p=json.load(sys.stdin) def fail(code): print(json.dumps({'error':code}));sys.exit(0) def digest(data):return hashlib.sha256(data).hexdigest() -def run(args,cwd=None): - r=subprocess.run(args,cwd=cwd,capture_output=True,text=True) +def run(args,cwd=None,env=None): + r=subprocess.run(args,cwd=cwd,env=env,capture_output=True,text=True) if r.returncode:fail('command_failed') return r.stdout.strip() root=p['root'] @@ -65,7 +65,13 @@ if act=='workspace': checkout=os.path.join(root,'checkout') if repo: if not (repo.startswith('https://') or repo.startswith('ssh://') or repo.startswith('git@')) or '\n' in repo:fail('invalid') - if not os.path.exists(checkout):run(['git','clone','--',repo,checkout]) + if not os.path.exists(checkout): + # Resolve credentials on the controller for this operation. Only the clone + # child receives the helper environment; no credential file or Git config persists. + auth=p.get('gitAuth') or {};git_env=dict(os.environ) + git_env.update({'GIT_TERMINAL_PROMPT':'0','GIT_CONFIG_GLOBAL':'/dev/null','GIT_CONFIG_NOSYSTEM':'1'}) + git_env.update(auth.get('env',{})) + run(['git']+auth.get('configArgs',[])+['clone','--',repo,checkout],env=git_env) elif run(['git','remote','get-url','origin'],checkout)!=repo:fail('conflict') else: os.makedirs(checkout,exist_ok=True) diff --git a/server/src/modules/computers/application/service.test.ts b/server/src/modules/computers/application/service.test.ts index fa962a61589..80be803a887 100644 --- a/server/src/modules/computers/application/service.test.ts +++ b/server/src/modules/computers/application/service.test.ts @@ -113,6 +113,16 @@ function fixture() { }; } describe("computer ownership", () => { + it("passes checkout credentials transiently without retaining them in computer state", async () => { + const f = fixture(); + await f.attach(); + const binding = await f.admit(); + const gitAuth = { configArgs: ["-c", "credential.helper="], env: { PAPERCLIP_GIT_TOKEN: "scoped-fixture-token" } }; + await f.service.realizeWorkspace({ ...f.scope, owner: binding.owner, projectId: "project", mode: "shared", + repositoryUrl: "https://github.com/company/private.git", gitAuth }); + expect(f.backend.remote).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ action: "workspace", gitAuth })); + expect(JSON.stringify(await f.repository.get(f.scope))).not.toContain("scoped-fixture-token"); + }); it("explains an unavailable dev server without masking provider errors", async () => { const f = fixture(); await f.attach(); diff --git a/server/src/modules/computers/application/service.ts b/server/src/modules/computers/application/service.ts index 44e4d7602d0..64e1e8c37c9 100644 --- a/server/src/modules/computers/application/service.ts +++ b/server/src/modules/computers/application/service.ts @@ -767,6 +767,8 @@ finally: projectId?: string; taskId?: string; repositoryUrl?: string; + /** Transient Git helper configuration; sent over stdin, never retained in the ledger. */ + gitAuth?: { configArgs: string[]; env: Record }; branch?: string; baseRef?: string; mode: "shared" | "worktree"; diff --git a/server/src/services/computer-environment-driver.ts b/server/src/services/computer-environment-driver.ts index b89e70dffc1..9818dc8fc72 100644 --- a/server/src/services/computer-environment-driver.ts +++ b/server/src/services/computer-environment-driver.ts @@ -77,11 +77,12 @@ export function createComputerEnvironmentDriver(db: Db): EnvironmentRuntimeDrive if (!result.retired) throw new Error("computer_owner_retirement_superseded"); return { providerLeaseId: lease.providerLeaseId, state: "stopped" }; }, - async realizeWorkspace({ lease, workspace }) { + async realizeWorkspace({ lease, workspace, gitAuth }) { const request = workspace.metadata?.workspaceRealizationRequest as { source?: { projectId?: string; repoUrl?: string; repoRef?: string; branchName?: string; strategy?: string }; issueId?: string } | undefined; const result = await computers.realizeWorkspace({ ...scope(lease), owner: computerOwnerFromLease(lease), projectId: request?.source?.projectId ?? undefined, taskId: request?.issueId ?? undefined, repositoryUrl: request?.source?.repoUrl ?? undefined, + gitAuth, branch: request?.source?.branchName ?? request?.source?.repoRef ?? undefined, baseRef: request?.source?.repoRef ?? undefined, mode: request?.source?.strategy === "git_worktree" ? "worktree" : "shared" }); diff --git a/server/src/services/environment-run-orchestrator.ts b/server/src/services/environment-run-orchestrator.ts index 82d7997efc7..c5cbb0ab95f 100644 --- a/server/src/services/environment-run-orchestrator.ts +++ b/server/src/services/environment-run-orchestrator.ts @@ -51,6 +51,7 @@ import { buildWorkspaceRealizationRequest } from "./workspace-realization.js"; import { executionWorkspaceService } from "./execution-workspaces.js"; import { logActivity } from "./activity-log.js"; import { logger } from "../middleware/logger.js"; +import type { GitRemoteAuthProvider } from "./git-credentials.js"; import { parseObject } from "../adapters/utils.js"; import type { RealizedExecutionWorkspace } from "./workspace-runtime.js"; import type { PluginWorkerManager } from "./plugin-worker-manager.js"; @@ -393,6 +394,8 @@ export function environmentRunOrchestrator( * target. Absent keeps the safe no-op default in the bridge. */ duplexObservabilityRecorder?: DuplexObservabilityRecorder | null; + /** Run-scoped auth for initial persistent checkouts; never persisted as workspace metadata. */ + resolveGitAuth?: GitRemoteAuthProvider; }): Promise { const { environment, @@ -430,6 +433,9 @@ export function environmentRunOrchestrator( const workspaceRealizationResult = await environmentRuntime.realizeWorkspace({ environment, lease, + gitAuth: environment.driver === "computer" && workspaceRealizationRequest.source.repoUrl + ? await input.resolveGitAuth?.(workspaceRealizationRequest.source.repoUrl) ?? undefined + : undefined, workspace: { localPath: executionWorkspace.cwd, remotePath: remoteCwd, diff --git a/server/src/services/environment-runtime.ts b/server/src/services/environment-runtime.ts index 94558bb4dd4..b3a93ca67dc 100644 --- a/server/src/services/environment-runtime.ts +++ b/server/src/services/environment-runtime.ts @@ -526,6 +526,8 @@ export interface EnvironmentDriverLeaseInput { } export interface EnvironmentDriverRealizeWorkspaceInput extends EnvironmentDriverLeaseInput { + /** Ephemeral authentication for the initial checkout, excluded from durable metadata. */ + gitAuth?: { configArgs: string[]; env: Record }; workspace: { localPath?: string; remotePath?: string; diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index ba705de6557..c6c7cbe50ce 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -4529,6 +4529,7 @@ export function heartbeatService( effectiveExecutionWorkspaceMode, persistedExecutionWorkspace, duplexObservabilityRecorder, + resolveGitAuth: workspaceGitAuthProvider, }); nativeRunnerPreparationSpans.push({ name: "environment.workspace.realize", From ec80163f2fb40e437ba797e4392c1041daec448e Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 10 Oct 2026 10:15:33 -0500 Subject: [PATCH 3/6] fix(computers): delete large agent files without downloading Hash remote files through confined descriptors in bounded chunks before editor mutations. Preserve the remote compare-and-swap removal check and read-size cap; cover a real 17 MiB file, stale deletion, and path confinement. Co-Authored-By: Paperclip --- .../agent-directory-working-copies.test.ts | 30 ++++++++++++++++++- .../modules/computers/adapters/boat.test.ts | 20 +++++++++++++ .../computers/adapters/remote-program.ts | 12 ++++++++ .../modules/computers/application/service.ts | 3 ++ server/src/services/agent-file-store.ts | 6 ++-- server/src/services/persistent-agent-files.ts | 6 ++++ 6 files changed, 73 insertions(+), 4 deletions(-) diff --git a/server/src/__tests__/agent-directory-working-copies.test.ts b/server/src/__tests__/agent-directory-working-copies.test.ts index 1c2f199795d..0f86e1b18a0 100644 --- a/server/src/__tests__/agent-directory-working-copies.test.ts +++ b/server/src/__tests__/agent-directory-working-copies.test.ts @@ -129,7 +129,12 @@ describe("persistent agent directories", () => { remoteFiles.set(relative, bytes); return { sha256: fileHash(bytes) }; }); const seedBytes = vi.fn(); - const remote = { root: remoteRoot, list: async () => [], listPage: async () => ({ entries: [], truncated: false }), readBytes, writeBytes, seedBytes }; + const hash = vi.fn(async (relative: string) => { + const bytes = remoteFiles.get(relative); + if (!bytes) throw { code: "not_found" }; + return { sha256: fileHash(bytes), size: bytes.length }; + }); + const remote = { root: remoteRoot, list: async () => [], listPage: async () => ({ entries: [], truncated: false }), readBytes, hash, writeBytes, seedBytes }; const lookup = vi.spyOn(persistentFiles, "persistentAgentFiles").mockResolvedValue(remote as never); const shell = vi.spyOn(executionTargetTools, "runAdapterExecutionTargetShellCommand"); try { @@ -164,6 +169,29 @@ describe("persistent agent directories", () => { } finally { lookup.mockRestore(); shell.mockRestore(); } }); + it("deletes oversized remote files by hash without downloading and rejects stale deletion", async () => { + let bytes: Buffer | null = Buffer.alloc(17 * 1024 * 1024, 7); + const correctHash = fileHash(bytes); + const hash = vi.fn(async () => bytes ? { sha256: fileHash(bytes), size: bytes.length } : Promise.reject({ code: "not_found" })); + const readBytes = vi.fn(async () => { throw new Error("Remote read exceeds 16 MiB"); }); + const remove = vi.fn(async (_relative: string, expected: string) => { + if (!bytes || fileHash(bytes) !== expected) throw { code: "conflict" }; + bytes = null; + }); + const remote = { root: "/home/user/paperclip/test/agents/target", listPage: async () => ({ entries: [], truncated: false }), hash, readBytes, remove }; + const lookup = vi.spyOn(persistentFiles, "persistentAgentFiles").mockResolvedValue(remote as never); + try { + const store = agentFileStore(db); + await expect(store.write({ ...target(), path: "large.bin", bytes: null, baseHash: "stale" }, board())).rejects.toMatchObject({ status: 409 }); + expect(remove).not.toHaveBeenCalled(); + expect(bytes?.length).toBe(17 * 1024 * 1024); + await expect(store.write({ ...target(), path: "large.bin", bytes: null, baseHash: correctHash }, board())).resolves.toEqual({ contentHash: null, changed: true }); + expect(remove).toHaveBeenCalledWith("large.bin", correctHash); + expect(bytes).toBeNull(); + expect(readBytes).not.toHaveBeenCalled(); + } finally { lookup.mockRestore(); } + }); + describe("warm directory ownership", () => { async function nextRun() { const runId = randomUUID(); diff --git a/server/src/modules/computers/adapters/boat.test.ts b/server/src/modules/computers/adapters/boat.test.ts index 7e2240c1958..7ee00b3def2 100644 --- a/server/src/modules/computers/adapters/boat.test.ts +++ b/server/src/modules/computers/adapters/boat.test.ts @@ -7,6 +7,7 @@ import { writeFileSync, mkdirSync, } from "node:fs"; +import { createHash } from "node:crypto"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { spawnSync } from "node:child_process"; @@ -170,6 +171,25 @@ describe("confined computer files", () => { error: "invalid", }); }); + it("hashes and deletes files over the read limit while fencing changed bytes", () => { + const f = fixture(); + f.call({ action: "seed", files: {} }); + const bytes = Buffer.alloc(17 * 1024 * 1024, 7); + writeFileSync(join(f.root, "large.bin"), bytes); + const expected = createHash("sha256").update(bytes).digest("hex"); + expect(f.call({ action: "read", path: "large.bin" })).toEqual({ error: "invalid" }); + expect(f.call({ action: "hash", path: "large.bin" })).toEqual({ sha256: expected, size: bytes.length }); + bytes[0] = 8; + writeFileSync(join(f.root, "large.bin"), bytes); + expect(f.call({ action: "remove", path: "large.bin", expectedSha256: expected })).toEqual({ error: "conflict" }); + const current = f.call({ action: "hash", path: "large.bin" }); + expect(current.sha256).not.toBe(expected); + expect(f.call({ action: "remove", path: "large.bin", expectedSha256: current.sha256 })).toEqual({}); + expect(f.call({ action: "hash", path: "large.bin" })).toEqual({ error: "not_found" }); + symlinkSync(join(f.temp, "outside"), join(f.root, "escape")); + expect(f.call({ action: "hash", path: "escape" })).toEqual({ error: "invalid" }); + expect(f.call({ action: "hash", path: "../outside" })).toEqual({ error: "invalid" }); + }); it("rejects stale edits and uses no-clobber move semantics", () => { const f = fixture(); f.call({ action: "seed", files: {} }); diff --git a/server/src/modules/computers/adapters/remote-program.ts b/server/src/modules/computers/adapters/remote-program.ts index fa937dbf408..709727c5c45 100644 --- a/server/src/modules/computers/adapters/remote-program.ts +++ b/server/src/modules/computers/adapters/remote-program.ts @@ -143,6 +143,18 @@ try: finally:os.close(parentfd) if not stat.S_ISDIR(info.st_mode) and not stat.S_ISREG(info.st_mode):fail('invalid') print(json.dumps({'name':name,'kind':'directory' if stat.S_ISDIR(info.st_mode) else 'file','size':info.st_size,'mtimeMs':info.st_mtime*1000})) + elif act=='hash': + parentfd,name=parent(path) + try:fd=os.open(name,os.O_RDONLY|os.O_NOFOLLOW,dir_fd=parentfd) + finally:os.close(parentfd) + with os.fdopen(fd,'rb') as f: + if not stat.S_ISREG(os.fstat(f.fileno()).st_mode):fail('invalid') + h=hashlib.sha256();size=0 + while True: + block=f.read(65536) + if not block:break + h.update(block);size+=len(block) + print(json.dumps({'sha256':h.hexdigest(),'size':size})) elif act=='read': parentfd,name=parent(path) try:fd=os.open(name,os.O_RDONLY|os.O_NOFOLLOW,dir_fd=parentfd) diff --git a/server/src/modules/computers/application/service.ts b/server/src/modules/computers/application/service.ts index 64e1e8c37c9..947506a1c0f 100644 --- a/server/src/modules/computers/application/service.ts +++ b/server/src/modules/computers/application/service.ts @@ -702,6 +702,9 @@ finally: async stat(path: string): Promise<{ name: string; kind: "file" | "directory"; size: number; mtimeMs: number }> { return remote({ action: "stat", path }); }, + async hash(path: string): Promise<{ sha256: string; size: number }> { + return remote({ action: "hash", path }); + }, async remove(path: string, expectedSha256: string) { await remote({ action: "remove", path, expectedSha256 }); }, diff --git a/server/src/services/agent-file-store.ts b/server/src/services/agent-file-store.ts index 4d0a3b7ae57..998ea5bddd3 100644 --- a/server/src/services/agent-file-store.ts +++ b/server/src/services/agent-file-store.ts @@ -1,4 +1,4 @@ -import { persistentAgentFiles, readPersistentAgentFile, seedPersistentAgentHome } from "./persistent-agent-files.js"; +import { persistentAgentFiles, hashPersistentAgentFile, readPersistentAgentFile, seedPersistentAgentHome } from "./persistent-agent-files.js"; import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target"; import { createHash } from "node:crypto"; import fs from "node:fs/promises"; @@ -232,8 +232,8 @@ export function agentFileStore(db: Db) { locked(input.companyId, input.agentId, actor, true, async (tx, agent, root, bound, remote) => { const relative = agentFilePath(input.path); if (input.bytes) assertFileSize(input.bytes.length, relative); - const remotePrevious = remote ? await readPersistentAgentFile(remote, relative) : null; - const previous = remote ? (remotePrevious ? { hash: remotePrevious.sha256, size: remotePrevious.bytes.length } : null) : await inspectAgentFile(root, relative); + const remotePrevious = remote ? await hashPersistentAgentFile(remote, relative) : null; + const previous = remote ? (remotePrevious ? { hash: remotePrevious.sha256, size: remotePrevious.size } : null) : await inspectAgentFile(root, relative); const currentHash = previous?.hash ?? null; const incomingHash = input.bytes === null ? null : fileHash(input.bytes); if (currentHash === incomingHash) return { contentHash: currentHash, changed: false }; diff --git a/server/src/services/persistent-agent-files.ts b/server/src/services/persistent-agent-files.ts index 8b39c0c7f60..0f5966dfe3a 100644 --- a/server/src/services/persistent-agent-files.ts +++ b/server/src/services/persistent-agent-files.ts @@ -38,6 +38,12 @@ export async function readPersistentAgentFile(files: NonNullable>>, relative: string) { + try { return await files.hash(instructionPath(relative)); } + catch (error) { if (isMissingRemoteFile(error)) return null; throw error; } +} + /** First attachment only. Existing directories are adopted, including empty ones. * Later turns never read/upload the controller copy of personal files. */ export async function seedPersistentAgentHome(files: NonNullable>>, localRoot: string) { From 00d1d4ea6146f98830f7f308e75b6a3d05dc7b23 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 10 Oct 2026 10:16:07 -0500 Subject: [PATCH 4/6] 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: { From 180bfdcb0a460ff279f3d27078f851baad1cd3b3 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 10 Oct 2026 10:17:23 -0500 Subject: [PATCH 5/6] Keep extended runner acceptance cases in the stacked feature PR Preserve the original baseline suites on the backend branch while the full Boat feature branch retains all added runtime-context, transport, and SSH acceptance coverage. Co-Authored-By: Paperclip --- .../src/runner-connectivity.test.ts | 26 ----------------- .../adapter-utils/src/ssh-fixture.test.ts | 23 --------------- .../src/contracts/runtime-context.test.ts | 7 ----- .../drivers/acpx/agent-files-binding.test.ts | 13 --------- .../src/drivers/acpx/runtime-host.test.ts | 28 ------------------- .../runtime-context-materializer.test.ts | 24 ---------------- 6 files changed, 121 deletions(-) diff --git a/packages/adapter-utils/src/runner-connectivity.test.ts b/packages/adapter-utils/src/runner-connectivity.test.ts index e345a7ccc82..68a8b6d36de 100644 --- a/packages/adapter-utils/src/runner-connectivity.test.ts +++ b/packages/adapter-utils/src/runner-connectivity.test.ts @@ -210,29 +210,3 @@ describe("paperclip runner transport routing", () => { ).toThrow(); }); }); - -it("uses each computer owner's allocated port without changing the sandbox default", async () => { - const { adapterExecutionTargetIsCommandBacked } = await import("./execution-target.js"); - const endpoint = ingress(); - const getRunnerIngressEndpoint = vi.fn(async () => endpoint); - const base = { - kind: "remote" as const, transport: "computer" as const, providerKey: "boat", remoteCwd: "/home/user/project", - leaseId: "attempt-lease", listenerPort: 45101, effectiveCapabilities: capabilities, getRunnerIngressEndpoint, - processRunner: { execute: async () => ({ exitCode: 0, signal: null, timedOut: false, stdout: "", stderr: "", pid: null, startedAt: new Date().toISOString() }) }, - resourceAuthority: { kind: "computer-owner" as const, computerId: "machine", ownerId: "owner-a", generation: 2 }, - fileAuthority: { kind: "remote-persistent" as const, placementId: "placement", root: "/home/user/project", agentHome: "/home/user/agent-a" }, - launch: async () => ({}), inspectProcess: async () => ({ running: true, claim: {} }), - retainWarm: async () => {}, retire: async () => true, computerTool: { command: "cua-driver", args: ["mcp"] }, - }; - expect(adapterExecutionTargetIsCommandBacked(base)).toBe(true); - for (const port of [45101, 45102]) { - const resolved = await resolvePaperclipRunnerTransport({ target: { ...base, listenerPort: port }, runId: "run-1", - localConnectUrl: "ws://localhost/unused", runnerIngressAuthorized: true }); - expect(resolved).toMatchObject({ mode: "provider_ingress", listenPort: port }); - expect(getRunnerIngressEndpoint).toHaveBeenLastCalledWith({ leaseId: "attempt-lease", port, path: "/api/runner/v1/connect/run-1" }); - } - const { listenerPort, processRunner, resourceAuthority, fileAuthority, launch, inspectProcess, retainWarm, retire, computerTool, ...common } = base; - const resolved = await resolvePaperclipRunnerTransport({ target: { ...common, transport: "sandbox" }, runId: "sandbox-run", - localConnectUrl: "ws://localhost/unused", runnerIngressAuthorized: true }); - expect(resolved).toMatchObject({ mode: "provider_ingress", listenPort: 43127 }); -}); diff --git a/packages/adapter-utils/src/ssh-fixture.test.ts b/packages/adapter-utils/src/ssh-fixture.test.ts index bc2d6abc92b..39d3c52515e 100644 --- a/packages/adapter-utils/src/ssh-fixture.test.ts +++ b/packages/adapter-utils/src/ssh-fixture.test.ts @@ -6,7 +6,6 @@ import path from "node:path"; import { afterAll, afterEach, describe, expect, it } from "vitest"; import { buildSshSpawnTarget, - createSshCommandManagedRuntimeRunner, buildSshEnvLabFixtureConfig, getSshEnvLabSupport, prepareWorkspaceForSshExecution, @@ -182,28 +181,6 @@ describe("ssh env-lab fixture", () => { // this drains it too instead of stranding a listener until the process exits. afterAll(drainFixtureTeardowns); - it.each([0, 7])("handles a closed SSH input pipe without losing exit diagnostics (exit %i)", async (exitCode) => { - const rootDir = await createFixtureRootDir(); - const previousPath = process.env.PATH; - await writeFile(path.join(rootDir, "ssh"), `#!/bin/sh -exec 0<&- -printf 'remote rejected upload' >&2 -sleep 0.05 -exit ${exitCode} -`, { mode: 0o700 }); - process.env.PATH = `${rootDir}${path.delimiter}${previousPath ?? ""}`; - try { - const runner = createSshCommandManagedRuntimeRunner({ spec: UNREACHABLE_SSH_SPEC }); - const result = await runner.execute({ command: "cat", stdin: "x".repeat(8 * 1024 * 1024), timeoutMs: 5000 }); - expect(result.exitCode).toBe(exitCode === 0 ? null : exitCode); - expect(result.stderr).toBe("remote rejected upload"); - expect(result.timedOut).toBe(false); - } finally { - if (previousPath === undefined) delete process.env.PATH; - else process.env.PATH = previousPath; - } - }); - it("starts an isolated sshd fixture and executes commands through it", async () => { const rootDir = await createFixtureRootDir(); const statePath = path.join(rootDir, "state.json"); diff --git a/packages/paperclip-runner/src/contracts/runtime-context.test.ts b/packages/paperclip-runner/src/contracts/runtime-context.test.ts index cc5a0332541..09a931317cf 100644 --- a/packages/paperclip-runner/src/contracts/runtime-context.test.ts +++ b/packages/paperclip-runner/src/contracts/runtime-context.test.ts @@ -37,13 +37,6 @@ 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/drivers/acpx/agent-files-binding.test.ts b/packages/paperclip-runner/src/drivers/acpx/agent-files-binding.test.ts index 846e4df593b..27437e8fde9 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,19 +18,6 @@ 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/runtime-host.test.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts index e83681f749d..2459dab2763 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts @@ -901,34 +901,6 @@ 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/runtime-context-materializer.test.ts b/packages/paperclip-runner/src/drivers/runtime-context-materializer.test.ts index 4e68be2944b..062482ecad5 100644 --- a/packages/paperclip-runner/src/drivers/runtime-context-materializer.test.ts +++ b/packages/paperclip-runner/src/drivers/runtime-context-materializer.test.ts @@ -1,4 +1,3 @@ -import { createRunnerdCodexAppServerArgs } from "../live/runnerd-codex-transport.js"; import { chmod, lstat, @@ -28,7 +27,6 @@ import { nativeMcpLaunchBinding } from "./native-mcp.js"; import { materializeNativeRuntimeSkills, prepareIsolatedCodexHome, - releaseMaterializedNativeRuntimeSkills, } from "./runtime-context-materializer.js"; const roots: string[] = []; @@ -95,19 +93,6 @@ function context( } describe("runtime context materialization", () => { - it("installs only the trusted environment computer binding and removes it on refresh without that capability", async () => { - const root = await mkdtemp(join(tmpdir(), "paperclip-computer-context-")); - roots.push(root); - const codexHome = join(root, "codex-home"); - await prepareIsolatedCodexHome({ context: null, codexHome, - computerTool: { command: "/opt/ascii/cua-driver/cua-driver", args: ["mcp", "--socket", "/run/ascii-cua/driver.sock"] } }); - const config = await readFile(join(codexHome, "config.toml"), "utf8"); - expect(config).toContain("[mcp_servers.paperclip_computer]"); - expect(config).toContain('command = "/opt/ascii/cua-driver/cua-driver"'); - await releaseMaterializedNativeRuntimeSkills(join(codexHome, "skills")); - await prepareIsolatedCodexHome({ context: null, codexHome }); - expect(await readFile(join(codexHome, "config.toml"), "utf8")).not.toContain("paperclip_computer"); - }); it("restores the legacy working-copy contract without changing its digest or adding new fields", () => { const old = context("/skills", "/instructions"); old.instructions.workingCopy = { rootPath: "/old-run/copy", entryPath: "AGENTS.md" }; @@ -518,12 +503,3 @@ command = "untrusted-command" await expect(stat(join(symlinkCodexHome, "auth.json"))).rejects.toThrow(); }); }); - -it("exposes a trusted persistent personal directory without instruction files", () => { - const options = { environment: { AGENT_HOME: "/untrusted", HOME: "/ambient" }, codexHome: "/private-provider" }; - const supplied = createRunnerdCodexAppServerArgs({ ...options, persistentAgentHome: "/personal/agent" }).join("\n"); - expect(supplied).toContain('AGENT_HOME="/personal/agent"'); - expect(supplied).toContain('"/private-provider"="none"'); - expect(supplied).not.toContain('AGENT_HOME="/untrusted"'); - expect(createRunnerdCodexAppServerArgs(options).join("\n")).not.toContain("AGENT_HOME"); -}); From 4d2113e1883d4b4edf0ed49156a0703136228d20 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 10 Oct 2026 10:16:51 -0500 Subject: [PATCH 6/6] fix(runtime): retain safe private Codex installation diagnostics Co-Authored-By: Paperclip --- server/src/services/native-runtime/native-session-executor.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index 66cc820ba21..7bfe19a9adc 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -12237,7 +12237,7 @@ async function createRunnerdBackendWithinSessionClaim( timeoutMs: 180_000, }); if (installResult.exitCode !== 0 || installResult.timedOut) { - throw new Error("runner_remote_codex_install_failed"); + throw new Error(`runner_remote_codex_install_failed exitCode=${installResult.exitCode} timedOut=${installResult.timedOut}: ${redactSensitiveText(installResult.stderr).slice(-2_000).trim()}`); } } await measureNativeRunnerSpan(