From 7f646066e86ac23d8b144cae05874df1f45adc22 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 10 Oct 2026 11:51:35 -0500 Subject: [PATCH 1/2] Document provider cache computer-owner capability requirement Co-Authored-By: Paperclip --- .../src/services/native-runtime/remote-provider-pack-cache.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/server/src/services/native-runtime/remote-provider-pack-cache.ts b/server/src/services/native-runtime/remote-provider-pack-cache.ts index 97a8454d56..c086a927fb 100644 --- a/server/src/services/native-runtime/remote-provider-pack-cache.ts +++ b/server/src/services/native-runtime/remote-provider-pack-cache.ts @@ -108,6 +108,9 @@ export async function prepareComputerProviderPackCache(input: { cacheRoot: string; sessionKey: string; owner: { ownerId: string; generation: number }; + // Must be the computer processRunner: it fences retired owners and places + // every command in their owner slice, including commands using bypassSession. + // Cross-owner receipt replacement relies on that admission/retirement fence. runner: CommandManagedRuntimeRunner; verify: (root: string) => Promise; stage: (root: string, runner: CommandManagedRuntimeRunner) => Promise; From 469da8b2c28a7b0d20a1b1bcdacf1ec620b232e6 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 10 Oct 2026 11:56:35 -0500 Subject: [PATCH 2/2] fix(boat): stage immutable context per bootstrap attempt Bind generated instructions and skills to fresh attempt-owned snapshot paths, retaining old immutable snapshots without touching persistent user files or provider HOME. Record possible process dispatch only at the actual launch RPC boundary so preparation failures can retire their exact owner safely. Co-Authored-By: Paperclip --- .../native-session-executor.test.ts | 76 +++++++++++++++++++ .../native-runtime/native-session-executor.ts | 17 +++-- 2 files changed, 88 insertions(+), 5 deletions(-) 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 d40cf055da..4fc159cb76 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -13977,3 +13977,79 @@ describe("computer graceful idle reservation", () => { else expect(detach).toHaveBeenCalledOnce(); }); }); + +describe("computer managed context snapshots", () => { + it("stages a retry into fresh immutable references without touching earlier snapshots or provider HOME", async () => { + const root = await mkdtemp(join(tmpdir(), "computer-context-retry-")); + const source = join(root, "source"), agentHome = join(root, "agent-home"); + await mkdir(source); await mkdir(agentHome); + await writeFile(join(source, "AGENTS.md"), "snapshot instruction", { mode: 0o400 }); + await chmod(source, 0o500); + const remoteExecute = async (command: { command: string; args?: string[]; stdin?: string }) => ({ + exitCode: 0, timedOut: false, signal: null, stderr: "", + stdout: execFileSync(command.command, command.args, { input: command.stdin, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }), + }); + const target = { kind: "remote", transport: "computer", environmentId: "snapshot-env", remoteCwd: agentHome, + fileAuthority: { kind: "remote-persistent", agentHome, root: agentHome, placementId: "placement" }, + runner: { execute: remoteExecute }, processRunner: { execute: remoteExecute } }; + const sourceContext = nativeRuntimeContextFixture(); + sourceContext.instructions.bundle.rootPath = source; + const run = { ...execution, binding: { ...execution.binding, agentId: "snapshot-agent", runId: "snapshot-retry" }, + runtimeContext: sourceContext }; + const previousStateDirectory = process.env.PAPERCLIP_RUNNER_STATE_DIR; + const contexts: ReturnType[] = []; + const homes: string[] = []; + try { + for (let attempt = 0; attempt < 2; attempt += 1) { + // Factory-only fixture has no live PRP controller; isolate its local + // journals while retaining the exact same remote session and run. + process.env.PAPERCLIP_RUNNER_STATE_DIR = join(root, `controller-${attempt}`); + await createRunnerdBackend({ db: leaseDb(run), execution: run, runnerInstanceId: "snapshot-runner", runnerExecutionTarget: target as never }); + state.createBackend.mock.calls.at(-1)![1].codexTransportFactory!(); + const transport = state.createTransport.mock.calls.at(-1)![0] as RunnerTransportOptions & { + runnerRuntimeContext: ReturnType; + }; + contexts.push(transport.runnerRuntimeContext); + const destination = transport.runnerRuntimeContext.instructions.bundle.rootPath; + expect(destination).toMatch(/\/context\/snapshots\/[0-9a-f-]+\/instructions$/); + expect(transport.runnerRuntimeContext.instructions.entryPath).toBe("AGENTS.md"); + const home = remoteRunnerStorageRoots(target as never, run.session.normalizedSessionId!).sessionRoot + "/filesystem/codex-home"; + homes.push(home); + await mkdir(home, { recursive: true }); + if (attempt === 0) await writeFile(join(home, "provider-session"), "persistent conversation"); + await stageRemoteRunnerDirectory({ target: target as never, runner: target.runner as never, sourcePath: source, targetPath: destination, mode: 0o500 }); + expect(await readFile(join(destination, "AGENTS.md"), "utf8")).toBe("snapshot instruction"); + expect((await lstat(destination)).mode & 0o777).toBe(0o500); + expect(await readFile(join(home, "provider-session"), "utf8")).toBe("persistent conversation"); + } + expect(contexts[0]!.instructions.bundle.rootPath).not.toBe(contexts[1]!.instructions.bundle.rootPath); + expect(homes[0]).toBe(homes[1]); + expect((await lstat(contexts[0]!.instructions.bundle.rootPath)).mode & 0o777).toBe(0o500); + expect(await readFile(join(contexts[0]!.instructions.bundle.rootPath, "AGENTS.md"), "utf8")).toBe("snapshot instruction"); + } finally { + if (previousStateDirectory === undefined) delete process.env.PAPERCLIP_RUNNER_STATE_DIR; + else process.env.PAPERCLIP_RUNNER_STATE_DIR = previousStateDirectory; + execFileSync("chmod", ["-R", "u+w", root]); + await rm(root, { recursive: true, force: true }); + } + }); + + it.each(["prepare-failed", "dispatch-lost-ack"])("fences prelaunch retry at the physical launch boundary (%s)", async condition => { + let dispatched = false; + const retire = vi.fn(async () => true); + const inspectProcess = vi.fn(async () => ({ running: false, claim: null })); + const launch = vi.fn(async () => { throw new Error("lost launch acknowledgement"); }); + const target = { kind: "remote", transport: "computer", remoteCwd: "/remote", launch, retire, inspectProcess }; + const launcher = createRemoteRunnerProcessLauncher({ target: target as never, runner: { execute: vi.fn() } as never, + remoteBinary: "/runtime/runnerd", processIdentityPath: "/runtime/identity", stateDirectory: "/runtime/state", + diagnosticsDirectory: "/runtime/diagnostics", runnerInstanceId: "physical-launch", + ensureArtifact: async () => { if (condition === "prepare-failed") throw new Error("read-only context staging failed"); }, + onLaunchAttempt: () => { dispatched = true; }, + }); + const handle = launcher({ command: "/controller/runnerd", args: [], cwd: "/controller", environment: {} }); + await expect(handle.completion).rejects.toThrow(condition === "prepare-failed" ? "context staging failed" : "lost launch acknowledgement"); + await retireUnlaunchedComputerAttempt(target as never, dispatched); + if (condition === "prepare-failed") { expect(launch).not.toHaveBeenCalled(); expect(retire).toHaveBeenCalledOnce(); } + else { expect(launch).toHaveBeenCalledOnce(); expect(inspectProcess).not.toHaveBeenCalled(); expect(retire).not.toHaveBeenCalled(); } + }); +}); diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index 8f61f5674a..b4c7c07d1d 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -11262,7 +11262,6 @@ export function createRemoteRunnerProcessLauncher(input: { }): (spec: RunnerProcessLaunchSpec) => RunnerProcessHandle { const runner = input.runner; return (spec) => { - input.onLaunchAttempt?.(); let launchedIdentity: { nonce: string; pid: number; @@ -11353,6 +11352,9 @@ export function createRemoteRunnerProcessLauncher(input: { // into its own session instead; its own bounded diagnostics directory and // durable PRP state remain the authorities, and the controller monitors // the exact persisted process identity below. + // From this exact boundary onward, a lost RPC acknowledgement may mean + // a live process. Asset preparation failures above prove no dispatch. + input.onLaunchAttempt?.(); const launchResult = input.target.transport === "computer" ? await input.target.launch({ command: "sh", args: ["-c", 'mkdir -p -- "$(dirname -- "$1")"; ' + REMOTE_RUNNER_CHILD_LAUNCH_SCRIPT, "paperclip-runner-child", input.processIdentityPath, identityNonce, input.runnerInstanceId, @@ -12024,6 +12026,13 @@ async function createRunnerdBackendWithinSessionClaim( : null; const sourceRuntimeContext = "runtimeContext" in input.execution ? input.execution.runtimeContext : null; + // Computer bundles are immutable. Each bootstrap attempt publishes new + // references instead of overlaying a previous read-only snapshot. Old + // snapshots remain until future bounded maintenance; provider HOME and user + // files never participate in this materialization. + const remoteContextRoot = remoteRunnerFilesystemRoot + ? posix.join(remoteRunnerFilesystemRoot, "context", ...(remoteTarget?.transport === "computer" ? ["snapshots", randomUUID()] : [])) + : null; const remoteRuntimeContext: NativeRuntimeContextSnapshot | null = remoteRunnerFilesystemRoot && sourceRuntimeContext ? { @@ -12036,8 +12045,7 @@ async function createRunnerdBackendWithinSessionClaim( bundle: { ...sourceRuntimeContext.instructions.bundle, rootPath: posix.join( - remoteRunnerFilesystemRoot, - "context", + remoteContextRoot!, "instructions", ), }, @@ -12047,8 +12055,7 @@ async function createRunnerdBackendWithinSessionClaim( bundle: { ...skill.bundle, rootPath: posix.join( - remoteRunnerFilesystemRoot, - "context", + remoteContextRoot!, "skills", `${index}-${skill.bundle.digest.slice(0, 12)}`, ),