From e318850909c0777565b43644a4d8114c6006da60 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 11 Oct 2026 07:17:23 -0500 Subject: [PATCH 1/2] Stabilize task lifecycle browser fixtures Co-Authored-By: Paperclip --- .../native-session-resume.test.ts | 8 +++++ tests/e2e/agent-chat-projects.spec.ts | 13 +++++-- tests/e2e/legacy-failure-continuation.spec.ts | 14 +++++++- tests/e2e/new-task-picker-touch.spec.ts | 36 +++++++++++++++++-- tests/e2e/signoff-policy.spec.ts | 31 ++++++++++++++++ 5 files changed, 95 insertions(+), 7 deletions(-) diff --git a/server/src/services/native-runtime/native-session-resume.test.ts b/server/src/services/native-runtime/native-session-resume.test.ts index d486db139f4..52f2f12606c 100644 --- a/server/src/services/native-runtime/native-session-resume.test.ts +++ b/server/src/services/native-runtime/native-session-resume.test.ts @@ -743,6 +743,13 @@ const recoveryFakeCodex = resolve( runnerDiagnostics.push(chunk.slice(-4_096)); if (runnerDiagnostics.length > 32) runnerDiagnostics.shift(); }; + // The production resolver prefers installed Codex over PATH. Explicitly + // bind this disposable fixture so it can never invoke a real provider. + const codexCommandResolver = await vi.importActual<{ resolveCodexCommand: () => string }>( + "../../../../packages/paperclip-runner/src/drivers/codex/codex-command.js", + ); + const resolveFixtureCodex = vi.spyOn(codexCommandResolver, "resolveCodexCommand") + .mockReturnValue(join(bin, "codex")); process.env.PAPERCLIP_RUNNER_STATE_DIR = stateBase; try { await Promise.all( @@ -1227,6 +1234,7 @@ const recoveryFakeCodex = resolve( Promise.resolve().then(() => session.close({ reason: "Recovery fixture cleanup" })), )); const closeFailures = closed.filter((result) => result.status === "rejected"); + resolveFixtureCodex.mockRestore(); runnerPrpWebSocketInternals.resetForTests(); server.closeAllConnections(); await new Promise((done) => server.close(() => done())); diff --git a/tests/e2e/agent-chat-projects.spec.ts b/tests/e2e/agent-chat-projects.spec.ts index d4979449e37..c8fcdc2345e 100644 --- a/tests/e2e/agent-chat-projects.spec.ts +++ b/tests/e2e/agent-chat-projects.spec.ts @@ -124,12 +124,19 @@ test("split handoff commits relevant plans before execution and never creates ch const runs = await json( await request.get(`/api/companies/${f.company.id}/heartbeat-runs`), ); - const run = runs.find( + const taskRuns = runs.filter( (run: any) => run.contextSnapshot?.issueId === task.id, ); - expect(run).toBeTruthy(); + expect(taskRuns.length).toBeGreaterThan(0); + const startedAt = taskRuns + .filter((run: any) => run.startedAt != null) + .map((run: any) => Date.parse(run.startedAt)); + // A newer queued/cancelled run need not have started. Preserve the + // invariant against the first actual execution, not list ordering. + expect(startedAt.length).toBeGreaterThan(0); + expect(startedAt.every(Number.isFinite)).toBe(true); expect(Date.parse(plan.updatedAt)).toBeLessThanOrEqual( - Date.parse(run.startedAt), + Math.min(...startedAt), ); } expect( diff --git a/tests/e2e/legacy-failure-continuation.spec.ts b/tests/e2e/legacy-failure-continuation.spec.ts index dc7875e862b..6645935cbab 100644 --- a/tests/e2e/legacy-failure-continuation.spec.ts +++ b/tests/e2e/legacy-failure-continuation.spec.ts @@ -99,9 +99,21 @@ for (const action of ["task_retry", "thread_retry", "inbox_retry", "message", "q } else if (action === "message") { await page.getByRole("textbox", { name: "editable markdown" }).fill("Please continue the pending follow-up."); await page.getByRole("button", { name: "Send", exact: true }).click(); + } else if (action === "inbox_retry") { + // Navigation can cancel the mutation before its request is sent. + const [response] = await Promise.all([ + page.waitForResponse((response) => { + const request = response.request(); + return request.method() === "POST" + && new URL(response.url()).pathname.endsWith(`/agents/${agent.id}/wakeup`) + && request.postDataJSON()?.failedRunId === sourceRunId; + }), + page.getByRole("button", { name: "Retry", exact: true }).click(), + ]); + expect(response.ok(), `${response.status()} ${await response.text()}`).toBe(true); + await page.goto(taskUrl); } else { await page.getByRole("button", { name: action === "thread_retry" ? "Try again" : "Retry", exact: true }).click(); - if (action === "inbox_retry") await page.goto(taskUrl); } await expect(page.getByText("Answered the pending follow-up once.", { exact: false })).toBeVisible({ timeout: 45_000 }); await expect(page.getByRole("status", { name: "Task recovery" })).toHaveCount(0); diff --git a/tests/e2e/new-task-picker-touch.spec.ts b/tests/e2e/new-task-picker-touch.spec.ts index b48d7084d67..6804125114e 100644 --- a/tests/e2e/new-task-picker-touch.spec.ts +++ b/tests/e2e/new-task-picker-touch.spec.ts @@ -21,13 +21,23 @@ async function swipeToLastOption(page: Page, picker: Locator, list = picker.getB }).toBeLessThanOrEqual(page.viewportSize()!.height); expect((await picker.boundingBox())!.y).toBeGreaterThanOrEqual(0); const session = await page.context().newCDPSession(page); + const targetHandle = await last.elementHandle(); + expect(targetHandle).not.toBeNull(); try { expect(await list.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true); for (let attempt = 0; attempt < 20; attempt += 1) { - const bounds = (await list.boundingBox())!; - const target = (await last.boundingBox())!; + // Momentum can finish between protocol round trips. Observe geometry and + // scrollTop together so a stale target cannot trigger a swipe at the end. + const { bounds, target, before } = await list.evaluate((element, option) => { + const container = element.getBoundingClientRect(); + const item = option!.getBoundingClientRect(); + return { + bounds: { x: container.x, y: container.y, width: container.width, height: container.height }, + target: { y: item.y, height: item.height }, + before: element.scrollTop, + }; + }, targetHandle); if (target.y >= bounds.y && target.y + target.height <= bounds.y + bounds.height) break; - const before = await list.evaluate((element) => element.scrollTop); const x = bounds.x + bounds.width / 2; const y = bounds.y + bounds.height - 20; const distance = Math.min(180, bounds.height - 40); @@ -49,6 +59,7 @@ async function swipeToLastOption(page: Page, picker: Locator, list = picker.getB expect(target.y).toBeGreaterThanOrEqual(bounds.y); expect(target.y + target.height).toBeLessThanOrEqual(bounds.y + bounds.height + 1); } finally { + await targetHandle?.dispose(); await session.detach(); } return last; @@ -56,6 +67,25 @@ async function swipeToLastOption(page: Page, picker: Locator, list = picker.getB test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true }); +test("touch scroll observation stays valid when momentum finishes between browser reads", async ({ page, browserName }) => { + test.skip(browserName !== "chromium", "Native touch drags use Chromium's input protocol."); + await page.setContent(`
+
+ ${Array.from({ length: 12 }, (_, index) => `
Option ${index}
`).join("")} +
`); + const picker = page.getByRole("dialog"), list = picker.getByRole("listbox"), last = list.getByRole("option").last(); + const boundingBox = last.boundingBox.bind(last); + // Reproduce the observed compositor interleaving: a returned target rectangle + // is stale by the next scrollTop read because inertia reached the list end. + last.boundingBox = async (options) => { + const beforeMomentum = await boundingBox(options); + await list.evaluate((element) => { element.scrollTop = element.scrollHeight - element.clientHeight; }); + return beforeMomentum; + }; + await swipeToLastOption(page, picker, list, last); + await expect(picker).toBeVisible(); +}); + test("new-task assignee, model, and project sheets scroll by touch and retain the selected values", async ({ page, request, browserName }, testInfo) => { test.skip(browserName !== "chromium", "Native touch drags use Chromium's input protocol."); const company = await json(await request.post("/api/companies", { diff --git a/tests/e2e/signoff-policy.spec.ts b/tests/e2e/signoff-policy.spec.ts index 471589cf3cc..da005094f2d 100644 --- a/tests/e2e/signoff-policy.spec.ts +++ b/tests/e2e/signoff-policy.spec.ts @@ -270,6 +270,29 @@ async function getIssueRunLockState(board: APIRequestContext, issueId: string): }; } +async function waitForFixtureRun( + board: APIRequestContext, + agentId: string, + issueId: string, + runId: string, +) { + // Negative authorization cases deliberately invoke a non-participant. Leave + // their API rejection intact instead of waiting for a run they cannot own. + if ((await getIssueRunLockState(board, issueId)).assigneeAgentId !== agentId) return; + const waitBudgetMs = wakeupWaitBudgetMs(); + const deadline = Date.now() + waitBudgetMs; + let status: string | null = null; + do { + const run = await fetchVerifiedRun(board, runId, agentId, issueId); + expect(run, "Fixture run must belong to the acting agent and issue").not.toBeNull(); + status = run!.status; + if (status === "succeeded") return; + expect(["queued", "running"], `Fixture run ${runId} ended as ${status}`).toContain(status); + await new Promise((resolve) => setTimeout(resolve, WAKEUP_POLL_INTERVAL_MS)); + } while (Date.now() < deadline); + throw new Error(`Fixture run ${runId} did not succeed within ${waitBudgetMs}ms (last status: ${status})`); +} + async function retryAgentPatchWithCurrentLockOnConflict( board: APIRequestContext, agent: AgentAuth, @@ -286,6 +309,9 @@ async function retryAgentPatchWithCurrentLockOnConflict( if (issueRunLock.assigneeAgentId !== agent.agentId) return res; const lockedRunId = issueRunLock.checkoutRunId ?? issueRunLock.executionRunId ?? fallbackRunId; + // A new lock can belong to a newly promoted fixture run, not the completed + // run we first invoked. Let that exact verified run settle before PATCH. + await waitForFixtureRun(board, agent.agentId, issueId, lockedRunId); res = await agent.request.patch(`${BASE_URL}/api/issues/${issueId}`, { headers: { "X-Paperclip-Run-Id": lockedRunId }, data: patchData, @@ -325,6 +351,9 @@ async function agentPatch( }: { maxAttempts?: number; backoffMs?: number; maxBackoffMs?: number } = {}, ) { const runId = await invokeHeartbeat(board, agent.agentId, issueId); + // The fixture process only prints "done". Let its startup and cleanup settle + // before a simulated decision reassigns the issue and cancels that process. + await waitForFixtureRun(board, agent.agentId, issueId, runId); const patchWith = (patchRunId: string) => agent.request.patch(`${BASE_URL}/api/issues/${issueId}`, { headers: { "X-Paperclip-Run-Id": patchRunId }, @@ -339,6 +368,7 @@ async function agentPatch( // run-lock race — leave it for the caller to assert on. if (issueRunLock.assigneeAgentId !== agent.agentId) break; const retryRunId = issueRunLock.checkoutRunId ?? issueRunLock.executionRunId ?? runId; + await waitForFixtureRun(board, agent.agentId, issueId, retryRunId); res = await patchWith(retryRunId); } return res; @@ -353,6 +383,7 @@ async function agentCheckoutAndPatch( patchData: Record, ) { const runId = await invokeHeartbeat(board, agent.agentId, issueId); + await waitForFixtureRun(board, agent.agentId, issueId, runId); const directPatchRes = await agent.request.patch(`${BASE_URL}/api/issues/${issueId}`, { headers: { "X-Paperclip-Run-Id": runId }, data: patchData, From 1525c4ef2794d5a914232adfaccdcd4cbe12826e Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 11 Oct 2026 07:32:05 -0500 Subject: [PATCH 2/2] Harden persistent runner transports and provider restoration Assemble the shared runtime prerequisite from the frozen Boat implementation, including its owning tests and the minimal computer target identity dependency. Co-Authored-By: Paperclip --- .dockerignore | 1 + .../adapter-utils/src/command-diagnostics.ts | 18 + .../src/command-managed-runtime.ts | 13 + .../src/execution-target-kind.ts | 8 + .../src/execution-target.test.ts | 36 ++ .../adapter-utils/src/execution-target.ts | 91 +++- .../src/runner-connectivity.test.ts | 26 + .../adapter-utils/src/runner-connectivity.ts | 9 +- .../adapter-utils/src/ssh-fixture.test.ts | 23 + packages/adapter-utils/src/ssh.ts | 43 +- .../src/server/claude-config.test.ts | 77 ++- .../claude-local/src/server/claude-config.ts | 28 +- .../src/server/cli-capabilities.ts | 1 + .../src/server/execute.remote.test.ts | 25 + .../claude-local/src/server/execute.ts | 41 +- .../adapters/claude-local/src/server/test.ts | 3 +- .../codex-local/src/server/execute.ts | 23 +- .../adapters/codex-local/src/server/test.ts | 3 +- packages/paperclip-runner/acpx-profiles.json | 4 +- .../runner-core/src/acpx_sidecar_transport.rs | 497 +++++++++++++++++- .../src/generated_acpx_profiles.rs | 2 +- .../scripts/generate-acpx-profiles.mjs | 2 +- .../src/cli/acpx-runtime-sidecar.ts | 1 + .../src/cli/acpx-sidecar-input.test.ts | 60 +++ .../src/cli/acpx-sidecar-input.ts | 15 + .../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 +- .../acpx/codex-runtime-adapter.test.ts | 53 ++ .../src/drivers/acpx/codex-runtime-adapter.ts | 18 +- .../drivers/acpx/copilot-installation.test.ts | 25 +- .../src/drivers/acpx/generated-profiles.ts | 4 +- .../acpx/installation-integrity.test.ts | 251 +++++++++ .../drivers/acpx/installation-integrity.ts | 336 +++++++----- .../src/drivers/acpx/runtime-host.test.ts | 76 +++ .../src/drivers/acpx/runtime-host.ts | 31 +- .../acpx/turn-admission-diagnostics.test.ts | 59 +++ .../acpx/turn-admission-diagnostics.ts | 70 +++ .../runtime-context-materializer.test.ts | 24 + .../drivers/runtime-context-materializer.ts | 9 + .../src/live/runnerd-codex-transport.test.ts | 76 +++ .../src/live/runnerd-codex-transport.ts | 58 +- .../src/native-session-runtime.test.ts | 14 +- .../src/native-session-runtime.ts | 8 +- .../copilot-profile-v18-identity.json | 43 ++ server/src/__tests__/adapter-registry.test.ts | 38 ++ .../claude-local-adapter-environment.test.ts | 9 +- .../__tests__/claude-local-execute.test.ts | 104 ++++ .../src/__tests__/codex-local-execute.test.ts | 95 ++++ .../ssh-artifact-diagnostics.test.ts | 60 +++ server/src/adapters/registry.ts | 10 +- .../heartbeat-github-launchers.test.ts | 8 +- .../services/heartbeat-github-launchers.ts | 6 +- .../native-github-access.test.ts | 39 +- .../native-runtime/native-github-access.ts | 8 +- .../native-session-executor.test.ts | 35 ++ .../native-runtime/native-session-executor.ts | 2 +- 58 files changed, 2404 insertions(+), 257 deletions(-) create mode 100644 packages/adapter-utils/src/command-diagnostics.ts create mode 100644 packages/adapter-utils/src/execution-target-kind.ts create mode 100644 packages/paperclip-runner/src/drivers/acpx/turn-admission-diagnostics.test.ts create mode 100644 packages/paperclip-runner/src/drivers/acpx/turn-admission-diagnostics.ts create mode 100644 packages/paperclip-runner/test/fixtures/copilot-profile-v18-identity.json create mode 100644 server/src/__tests__/ssh-artifact-diagnostics.test.ts diff --git a/.dockerignore b/.dockerignore index 30d3d533bf0..b96a7208641 100644 --- a/.dockerignore +++ b/.dockerignore @@ -44,6 +44,7 @@ packages/paperclip-runner/test/** packages/paperclip-runner/test/fixtures/** !packages/paperclip-runner/test/fixtures/copilot-profile-v16-identity.json !packages/paperclip-runner/test/fixtures/copilot-profile-v17-identity.json +!packages/paperclip-runner/test/fixtures/copilot-profile-v18-identity.json !packages/paperclip-runner/test-fixtures packages/paperclip-runner/test-fixtures/** !packages/paperclip-runner/test-fixtures/pi-acp diff --git a/packages/adapter-utils/src/command-diagnostics.ts b/packages/adapter-utils/src/command-diagnostics.ts new file mode 100644 index 00000000000..afb5229c12c --- /dev/null +++ b/packages/adapter-utils/src/command-diagnostics.ts @@ -0,0 +1,18 @@ +/** Closed controller-side phases; never pass command arguments or output here. */ +export const COMMAND_DIAGNOSTIC_PHASES = [ + "chunk_queued", "global_admitted", "global_released", "upload_queued", "upload_admitted", "upload_released", + "owner_guard_start", "owner_guard_complete", "ssh_auth_prepare", "ssh_auth_ready", + "ssh_spawned", "ssh_stdin_flushed", "ssh_stdin_error", "ssh_timeout", "ssh_kill", + "ssh_exit", "ssh_close", "ssh_spawn_error", "ssh_auth_cleanup", "ssh_auth_cleaned", + "command_completed", "command_timed_out", "command_failed", "command_rejected", + "chunk_completed", "chunk_failed", +] as const; +export type CommandDiagnosticPhase = (typeof COMMAND_DIAGNOSTIC_PHASES)[number]; +export type CommandDiagnosticObserver = (phase: CommandDiagnosticPhase) => void | Promise; + +/** Diagnostics cannot grant authority, delay work, or change its outcome. */ +export function reportCommandPhase(observer: CommandDiagnosticObserver | undefined, phase: CommandDiagnosticPhase): void { + if (!observer || !COMMAND_DIAGNOSTIC_PHASES.includes(phase)) return; + try { void Promise.resolve(observer(phase)).catch(() => undefined); } + catch { /* An observer is never part of command settlement. */ } +} diff --git a/packages/adapter-utils/src/command-managed-runtime.ts b/packages/adapter-utils/src/command-managed-runtime.ts index b46b2476f1c..05bc48bf921 100644 --- a/packages/adapter-utils/src/command-managed-runtime.ts +++ b/packages/adapter-utils/src/command-managed-runtime.ts @@ -1,3 +1,4 @@ +import type { CommandDiagnosticObserver } from "./command-diagnostics.js"; import { promises as fs } from "node:fs"; import { randomUUID } from "node:crypto"; import os from "node:os"; @@ -58,6 +59,16 @@ export interface CommandManagedDuplexChannel { } export interface CommandManagedRuntimeRunner { + /** + * Optional transport-shared backpressure for stdin artifact chunks. Acquire + * before execute starts its command deadline; this grants no execution + * authority. The callback must still use the owned execute wrapper. Queue + * waiting is bounded separately, and signal cancels only a queued operation. + */ + withStdinUploadSlot?( + operation: () => Promise, + options: { timeoutMs: number; signal?: AbortSignal; onPhase?: CommandDiagnosticObserver }, + ): Promise; /** * True when the provider verified the concurrent-sync opt-in. A native runner * carries the value from the effective capability snapshot @@ -83,6 +94,8 @@ export interface CommandManagedRuntimeRunner { timeoutMs?: number; onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise; onSpawn?: (meta: { pid: number; startedAt: string }) => Promise; + /** Opt-in closed transport stages, separate from raw command output. */ + onPhase?: CommandDiagnosticObserver; /** * Run this command through the lease's persistent session even when no run * step is active. A sandbox provider opens the session on the first diff --git a/packages/adapter-utils/src/execution-target-kind.ts b/packages/adapter-utils/src/execution-target-kind.ts new file mode 100644 index 00000000000..bf4f809363e --- /dev/null +++ b/packages/adapter-utils/src/execution-target-kind.ts @@ -0,0 +1,8 @@ +import type { AdapterCommandBackedExecutionTarget, AdapterExecutionTarget } from "./execution-target.js"; + +/** Command transport capability; sandbox allocation ownership is unchanged. */ +export function adapterExecutionTargetIsCommandBacked( + target: AdapterExecutionTarget | null | undefined, +): target is AdapterCommandBackedExecutionTarget { + return target?.kind === "remote" && (target.transport === "sandbox" || target.transport === "computer"); +} diff --git a/packages/adapter-utils/src/execution-target.test.ts b/packages/adapter-utils/src/execution-target.test.ts index 46a4df7d6d5..88919a99f37 100644 --- a/packages/adapter-utils/src/execution-target.test.ts +++ b/packages/adapter-utils/src/execution-target.test.ts @@ -5,6 +5,9 @@ import * as ssh from "./ssh.js"; import * as serverUtils from "./server-utils.js"; import { cleanupGitHubOperationLaunchers, + adapterExecutionTargetRuntimeRoot, + prepareAdapterExecutionTargetRuntime, + type AdapterComputerExecutionTarget, prepareGitHubOperationLaunchers, adapterExecutionTargetUsesManagedHome, ensureAdapterExecutionTargetRuntimeCommandInstalled, @@ -437,3 +440,36 @@ describe("GitHub launcher lifecycle", () => { expect(runner.execute).toHaveBeenCalledTimes(1); }); }); + + +describe("computer legacy runtime ownership", () => { + it.each(["claude", "codex"])("isolates %s assets and callback roots for owners sharing a checkout", async adapterKey => { + const execute = vi.fn(async () => ({ exitCode: 0, signal: null, timedOut: false, stdout: "", stderr: "", pid: 123, startedAt: "2026-10-10T00:00:00Z" })); + const target = (ownerId: string, generation = 1) => ({ + kind: "remote", transport: "computer", providerKey: "boat", environmentId: "environment", + leaseId: ownerId, remoteCwd: "/shared/project", listenerPort: 43127, + runner: { execute }, processRunner: { execute }, + resourceAuthority: { kind: "computer-owner", computerId: "computer", ownerId, generation }, + fileAuthority: { kind: "remote-persistent", placementId: "shared", root: "/shared/project", agentHome: `/homes/${ownerId}` }, + launch: vi.fn(), inspectProcess: vi.fn(), retainWarm: vi.fn(), retire: vi.fn(), + computerTool: { command: "cua-driver", args: ["mcp"] }, + } satisfies AdapterComputerExecutionTarget); + const prepare = (owner: AdapterComputerExecutionTarget) => prepareAdapterExecutionTargetRuntime({ + target: owner, runId: owner.leaseId!, adapterKey, workspaceLocalDir: "/unused-local-workspace", syncWorkspace: false, + }); + const first = await prepare(target("agent-one")); + const second = await prepare(target("agent-two")); + expect(first.workspaceRemoteDir).toBe("/shared/project"); + expect(second.workspaceRemoteDir).toBe(first.workspaceRemoteDir); + expect(first.runtimeRootDir).toBe(adapterExecutionTargetRuntimeRoot(target("agent-one"), adapterKey)); + expect(second.runtimeRootDir).not.toBe(first.runtimeRootDir); + expect(adapterExecutionTargetRuntimeRoot(target("agent-one", 2), adapterKey)).not.toBe(first.runtimeRootDir); + expect((await prepare(target("agent-one"))).runtimeRootDir).toBe(first.runtimeRootDir); + expect(first.runtimeRootDir).toMatch(new RegExp(`^/shared/project/\\.paperclip-runtime/${adapterKey}/owners/[a-f0-9]{64}$`)); + }); + + it("preserves the existing sandbox runtime root", () => { + const target = { kind: "remote", transport: "sandbox", remoteCwd: "/workspace" } as const; + expect(adapterExecutionTargetRuntimeRoot(target, "claude")).toBe("/workspace/.paperclip-runtime/claude"); + }); +}); diff --git a/packages/adapter-utils/src/execution-target.ts b/packages/adapter-utils/src/execution-target.ts index c70c02d7b91..19a62ca4262 100644 --- a/packages/adapter-utils/src/execution-target.ts +++ b/packages/adapter-utils/src/execution-target.ts @@ -1,10 +1,12 @@ +import { adapterExecutionTargetIsCommandBacked } from "./execution-target-kind.js"; +export { adapterExecutionTargetIsCommandBacked } from "./execution-target-kind.js"; import fs from "node:fs/promises"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import net from "node:net"; import os from "node:os"; import path from "node:path"; -import { randomBytes, randomUUID } from "node:crypto"; +import { createHash, randomBytes, randomUUID } from "node:crypto"; import { githubLauncherSource } from "./github-launcher.js"; import type { SshRemoteExecutionSpec } from "./ssh.js"; import { @@ -233,10 +235,30 @@ export interface AdapterSandboxExecutionTarget extends AdapterExecutionTargetWor duplexObservabilityRecorder?: DuplexObservabilityRecorder | null; } +/** A durable attached computer owns a process, never the physical machine. */ +export interface AdapterComputerExecutionTarget extends Omit { + transport: "computer"; + listenerPort: number; + /** Native process control survives attempt handoff only for the same process claim. */ + processRunner: CommandManagedRuntimeRunner; + resourceAuthority: { kind: "computer-owner"; computerId: string; ownerId: string; generation: number }; + fileAuthority: { kind: "remote-persistent"; placementId: string; root: string; agentHome: string }; + launch(input: { command: string; args?: string[]; cwd?: string; env?: Record }): Promise; + inspectProcess(): Promise<{ running: boolean; claim: unknown }>; + retainWarm(idleTimeoutMs: number): Promise; + /** False means admission superseded this owner; callers must not close its process. */ + retire(options?: { beforeStop?: () => Promise }): Promise; + computerTool: { command: string; args: string[] }; +} + +export type AdapterCommandBackedExecutionTarget = AdapterSandboxExecutionTarget | AdapterComputerExecutionTarget; + + export type AdapterExecutionTarget = | AdapterLocalExecutionTarget | AdapterSshExecutionTarget - | AdapterSandboxExecutionTarget; + | AdapterSandboxExecutionTarget + | AdapterComputerExecutionTarget; export type AdapterRemoteExecutionSpec = SshRemoteExecutionSpec; @@ -443,7 +465,7 @@ function isAdapterExecutionTargetInstance(value: unknown): value is AdapterExecu if (parsed.kind === "local") return true; if (parsed.kind !== "remote") return false; if (parsed.transport === "ssh") return parseSshRemoteExecutionSpec(parseObject(parsed.spec)) !== null; - if (parsed.transport !== "sandbox") return false; + if (parsed.transport !== "sandbox" && parsed.transport !== "computer") return false; return readStringMeta(parsed, "remoteCwd") !== null; } @@ -462,7 +484,7 @@ export function adapterExecutionTargetIsRemote( export function adapterExecutionTargetUsesManagedHome( target: AdapterExecutionTarget | null | undefined, ): boolean { - return target?.kind === "remote" && target.transport === "sandbox"; + return adapterExecutionTargetIsCommandBacked(target); } /** @@ -475,8 +497,7 @@ export function adapterExecutionTargetEnablesSandboxDuplexBridge( target: AdapterExecutionTarget | null | undefined, ): boolean { return ( - target?.kind === "remote" && - target.transport === "sandbox" && + adapterExecutionTargetIsCommandBacked(target) && target.enableSandboxDuplexBridge === true ); } @@ -489,7 +510,7 @@ export function adapterExecutionTargetEnablesSandboxDuplexBridge( export function adapterExecutionTargetDuplexObservabilityRecorder( target: AdapterExecutionTarget | null | undefined, ): DuplexObservabilityRecorder | null { - return target?.kind === "remote" && target.transport === "sandbox" + return adapterExecutionTargetIsCommandBacked(target) ? target.duplexObservabilityRecorder ?? null : null; } @@ -553,7 +574,7 @@ export function describeAdapterExecutionTarget( if (target.transport === "ssh") { return `SSH environment ${target.spec.username}@${target.spec.host}:${target.spec.port}`; } - return `sandbox environment${target.providerKey ? ` (${target.providerKey})` : ""}`; + return `${target.transport} environment${target.providerKey ? ` (${target.providerKey})` : ""}`; } export type AdapterExecutionTargetTimeoutSource = @@ -593,7 +614,7 @@ export function resolveAdapterExecutionTargetTimeout( // that usually apply their own shorter command defaults, so request an // explicit longer timeout for full adapter runs when the adapter leaves // timeoutSec unset. - if (target?.kind === "remote" && target.transport === "sandbox") { + if (adapterExecutionTargetIsCommandBacked(target)) { return { timeoutSec: DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC, source: "sandbox_default" }; } return { timeoutSec: 0, source: "unlimited" }; @@ -659,18 +680,18 @@ export function formatAdapterExecutionTimeoutStartLogLine( ); } -function requireSandboxRunner(target: AdapterSandboxExecutionTarget): CommandManagedRuntimeRunner { +function requireSandboxRunner(target: AdapterCommandBackedExecutionTarget): CommandManagedRuntimeRunner { if (target.runner) return target.runner; throw new Error( "Sandbox execution target is missing its provider runtime runner. Sandbox commands must execute through the environment runtime.", ); } -function preferredSandboxShell(target: AdapterSandboxExecutionTarget): "bash" | "sh" { +function preferredSandboxShell(target: AdapterCommandBackedExecutionTarget): "bash" | "sh" { return preferredShellForSandbox(target.shellCommand); } -type AdapterCommandCapableExecutionTarget = AdapterSshExecutionTarget | AdapterSandboxExecutionTarget; +type AdapterCommandCapableExecutionTarget = AdapterSshExecutionTarget | AdapterCommandBackedExecutionTarget; // The Secure Shell command runner's own output buffer. This value used to // derive from the bridge body limit (`DEFAULT_SANDBOX_CALLBACK_BRIDGE_MAX_BODY_BYTES @@ -697,7 +718,7 @@ function adapterExecutionTargetShellCommand(target: AdapterCommandCapableExecuti function adapterExecutionTargetTimeoutMs( target: AdapterCommandCapableExecutionTarget, ): number | null | undefined { - return target.transport === "sandbox" ? target.timeoutMs : undefined; + return adapterExecutionTargetIsCommandBacked(target) ? target.timeoutMs : undefined; } export async function ensureAdapterExecutionTargetCommandResolvable( @@ -707,7 +728,7 @@ export async function ensureAdapterExecutionTargetCommandResolvable( env: NodeJS.ProcessEnv, options: { installCommand?: string | null; timeoutSec?: number | null } = {}, ) { - if (target?.kind === "remote" && target.transport === "sandbox") { + if (adapterExecutionTargetIsCommandBacked(target)) { await ensureSandboxCommandResolvable( command, target, @@ -726,7 +747,7 @@ export async function ensureAdapterExecutionTargetCommandResolvable( async function probeSandboxCommandResolvable( command: string, - target: AdapterSandboxExecutionTarget, + target: AdapterCommandBackedExecutionTarget, env: Record, ): Promise<{ resolved: boolean; timedOut: boolean; stderr: string }> { const runner = requireSandboxRunner(target); @@ -747,7 +768,7 @@ async function probeSandboxCommandResolvable( async function ensureSandboxCommandResolvable( command: string, - target: AdapterSandboxExecutionTarget, + target: AdapterCommandBackedExecutionTarget, env: Record, installCommand: string | null, timeoutSec?: number | null, @@ -815,7 +836,7 @@ export async function resolveAdapterExecutionTargetCommandForLogs( cwd: string, env: NodeJS.ProcessEnv, ): Promise { - if (target?.kind === "remote" && target.transport === "sandbox") { + if (adapterExecutionTargetIsCommandBacked(target)) { return `sandbox://${target.providerKey ?? "provider"}/${target.leaseId ?? "lease"}/${target.remoteCwd} :: ${command}`; } return await resolveCommandForLogs(command, cwd, env, { @@ -859,7 +880,7 @@ export async function runAdapterExecutionTargetProcess( args: string[], options: AdapterExecutionTargetProcessOptions, ): Promise { - if (target?.kind === "remote" && target.transport === "sandbox") { + if (adapterExecutionTargetIsCommandBacked(target)) { const runner = requireSandboxRunner(target); const env = sanitizeRemoteExecutionEnv(options.env); await options.onRuntimeProgress?.({ @@ -1055,7 +1076,7 @@ export async function maybeRunSandboxInstallCommand(input: { timeoutSec?: number; }): Promise { const { target, adapterKey, installCommand } = input; - if (!target || target.kind !== "remote" || target.transport !== "sandbox") { + if (!adapterExecutionTargetIsCommandBacked(target)) { return null; } const trimmed = installCommand.trim(); @@ -1162,7 +1183,7 @@ export async function ensureAdapterExecutionTargetRuntimeCommandInstalled(input: onLog?: AdapterExecutionTargetShellOptions["onLog"]; }): Promise { const installCommand = input.installCommand?.trim(); - if (!installCommand || input.target?.kind !== "remote" || input.target.transport !== "sandbox") { + if (!installCommand || !adapterExecutionTargetIsCommandBacked(input.target)) { return; } @@ -1421,6 +1442,20 @@ export function readAdapterExecutionTarget(input: { ); } +/** Keep mutable legacy assets separate for owners sharing the same checkout. */ +export function adapterExecutionTargetRuntimeRoot( + target: Extract, + adapterKey: string, + baseRoot = path.posix.join(target.remoteCwd, ".paperclip-runtime", adapterKey), +): string { + if (target.transport !== "computer") return baseRoot; + const owner = target.resourceAuthority; + const scope = createHash("sha256").update(JSON.stringify([ + owner.computerId, owner.ownerId, owner.generation, + ])).digest("hex"); + return path.posix.join(baseRoot, "owners", scope); +} + export async function prepareAdapterExecutionTargetRuntime(input: { runId: string; target: AdapterExecutionTarget | null | undefined; @@ -1514,7 +1549,9 @@ export async function prepareAdapterExecutionTargetRuntime(input: { adapterKey: input.adapterKey, workspaceLocalDir: input.workspaceLocalDir, workspaceRemoteDir: input.workspaceRemoteDir, - runtimeRootDir: input.runtimeRootDir, + runtimeRootDir: target.transport === "computer" + ? adapterExecutionTargetRuntimeRoot(target, input.adapterKey, input.runtimeRootDir) + : input.runtimeRootDir, syncWorkspace: input.syncWorkspace, workspaceInboundMode: input.workspaceInboundMode, workspaceDurableSeed: input.workspaceDurableSeed, @@ -1562,7 +1599,7 @@ export function githubOperationLauncherDirectory(input: GitHubLauncherLocation): if (!/^[a-zA-Z0-9_-]+$/.test(input.runId)) throw new Error("Invalid GitHub launcher run ID"); return input.target?.kind === "remote" ? path.posix.join(input.target.remoteCwd, ".paperclip-runtime", - ...(input.target.transport === "sandbox" ? ["paperclip-runner"] : []), "github", input.runId) + ...(adapterExecutionTargetIsCommandBacked(input.target) ? ["paperclip-runner"] : []), "github", input.runId) : path.join(os.tmpdir(), "paperclip-github-runtime", input.runId); } @@ -1986,7 +2023,7 @@ export async function startAdapterExecutionTargetProcessSessionBridge(input: { // it does not start the 100 ms poll. Default OFF: the bridge keeps the poll. streamOutputViaSession?: boolean; }): Promise { - if (!input.target || input.target.kind !== "remote" || input.target.transport !== "sandbox") { + if (!adapterExecutionTargetIsCommandBacked(input.target)) { return null; } @@ -2012,7 +2049,7 @@ export async function startAdapterExecutionTargetProcessSessionBridge(input: { ? Math.trunc(input.timeoutSec * 1000) : target.timeoutMs ?? undefined; const bridgeRuntimeDir = path.posix.join( - input.runtimeRootDir?.trim() || path.posix.join(target.remoteCwd, ".paperclip-runtime", input.adapterKey), + input.runtimeRootDir?.trim() || adapterExecutionTargetRuntimeRoot(target, input.adapterKey), "process-sessions", ); const sessionId = randomUUID(); @@ -4381,7 +4418,7 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: { const runtimeRootDir = input.runtimeRootDir?.trim().length ? input.runtimeRootDir.trim() - : path.posix.join(target.remoteCwd, ".paperclip-runtime", input.adapterKey); + : adapterExecutionTargetRuntimeRoot(target, input.adapterKey); const bridgeRuntimeDir = path.posix.join(runtimeRootDir, "paperclip-bridge"); const queueDir = path.posix.join(bridgeRuntimeDir, "queue"); const assetRemoteDir = path.posix.join(bridgeRuntimeDir, "server"); @@ -4838,7 +4875,7 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: { // log line as the file path. The http2 path starts no file-bridge // worker, so create the log directory before the tail starts. let duplexRunLogTail: SandboxRunLogTailFactory | null = null; - if (target.transport === "sandbox" && target.streamRunLogs !== false) { + if (adapterExecutionTargetIsCommandBacked(target) && target.streamRunLogs !== false) { const duplexLogsDir = sandboxCallbackBridgeDirectories(queueDir).logsDir; await ensureSandboxRunLogDirectory({ runner, @@ -4930,7 +4967,7 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: { } let runLogTail: SandboxRunLogTailFactory | null = null; - if (target.transport === "sandbox" && target.streamRunLogs !== false) { + if (adapterExecutionTargetIsCommandBacked(target) && target.streamRunLogs !== false) { runLogTail = createSandboxRunLogTailFactory({ runner, remoteCwd: target.remoteCwd, diff --git a/packages/adapter-utils/src/runner-connectivity.test.ts b/packages/adapter-utils/src/runner-connectivity.test.ts index 68a8b6d36de..e345a7ccc82 100644 --- a/packages/adapter-utils/src/runner-connectivity.test.ts +++ b/packages/adapter-utils/src/runner-connectivity.test.ts @@ -210,3 +210,29 @@ 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/runner-connectivity.ts b/packages/adapter-utils/src/runner-connectivity.ts index c59dfeb5608..d499b9ebb4d 100644 --- a/packages/adapter-utils/src/runner-connectivity.ts +++ b/packages/adapter-utils/src/runner-connectivity.ts @@ -1,4 +1,5 @@ import type { AdapterExecutionTarget } from "./execution-target.js"; +import { adapterExecutionTargetIsCommandBacked } from "./execution-target-kind.js"; export const PAPERCLIP_RUNNER_INGRESS_PORT = 43_127; export const PAPERCLIP_RUNNER_CONNECT_PATH_PREFIX = "/api/runner/v1/connect"; @@ -133,7 +134,7 @@ export async function resolvePaperclipRunnerTransport(input: { } if ( - input.target.transport === "sandbox" && + adapterExecutionTargetIsCommandBacked(input.target) && input.target.effectiveCapabilities?.runnerWebSocketIngress === true ) { const ingressAuthorized = @@ -152,16 +153,18 @@ export async function resolvePaperclipRunnerTransport(input: { "The sandbox runner ingress provider is unavailable for this lease.", ); } + const port = input.target.transport === "computer" ? input.target.listenerPort : PAPERCLIP_RUNNER_INGRESS_PORT; + if (!Number.isInteger(port) || port < 1 || port > 65535) throw new PaperclipRunnerTransportError("runner_ingress_unavailable", "Invalid allocated runner port."); const path = connectPath(input.runId); const ingress = await getRunnerIngressEndpoint({ leaseId: input.target.leaseId, - port: PAPERCLIP_RUNNER_INGRESS_PORT, + port, path, }); return { mode: "provider_ingress", listenAddress: "0.0.0.0", - listenPort: PAPERCLIP_RUNNER_INGRESS_PORT, + listenPort: port, listenPath: path, ingress, }; diff --git a/packages/adapter-utils/src/ssh-fixture.test.ts b/packages/adapter-utils/src/ssh-fixture.test.ts index 39d3c52515e..bc2d6abc92b 100644 --- a/packages/adapter-utils/src/ssh-fixture.test.ts +++ b/packages/adapter-utils/src/ssh-fixture.test.ts @@ -6,6 +6,7 @@ import path from "node:path"; import { afterAll, afterEach, describe, expect, it } from "vitest"; import { buildSshSpawnTarget, + createSshCommandManagedRuntimeRunner, buildSshEnvLabFixtureConfig, getSshEnvLabSupport, prepareWorkspaceForSshExecution, @@ -181,6 +182,28 @@ 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/adapter-utils/src/ssh.ts b/packages/adapter-utils/src/ssh.ts index 68e61fc6390..e4052b3d49f 100644 --- a/packages/adapter-utils/src/ssh.ts +++ b/packages/adapter-utils/src/ssh.ts @@ -1,3 +1,4 @@ +import { reportCommandPhase, type CommandDiagnosticObserver } from "./command-diagnostics.js"; import { randomUUID } from "node:crypto"; import { execFile, spawn } from "node:child_process"; import { constants as fsConstants, createReadStream, createWriteStream, promises as fs } from "node:fs"; @@ -76,6 +77,7 @@ export function createSshCommandManagedRuntimeRunner(input: { try { const result = await runSshCommand(input.spec, remoteCommand, { stdin: commandInput.stdin, + onPhase: commandInput.onPhase, timeoutMs: commandInput.timeoutMs, maxBuffer: maxBufferBytes, }); @@ -225,6 +227,7 @@ async function spawnText( args: string[], options: { stdin?: string; + onPhase?: CommandDiagnosticObserver; timeout?: number; maxBuffer?: number; } = {}, @@ -234,11 +237,13 @@ async function spawnText( stdio: [options.stdin != null ? "pipe" : "ignore", "pipe", "pipe"], }); + reportCommandPhase(options.onPhase, "ssh_spawned"); const maxBuffer = options.maxBuffer ?? 1024 * 128; let stdout = ""; let stderr = ""; let settled = false; let timedOut = false; + let stdinError: Error | null = null; const finishReject = (error: Error & { stdout?: string; stderr?: string; code?: number | null; killed?: boolean }) => { if (settled) return; @@ -271,12 +276,14 @@ async function spawnText( const timeout = options.timeout && options.timeout > 0 ? setTimeout(() => { timedOut = true; + reportCommandPhase(options.onPhase, "ssh_timeout"); child.kill("SIGTERM"); // Escalate to SIGKILL after a 5s grace window so a hung remote // command that ignores SIGTERM cannot keep the child alive // indefinitely. killEscalation = setTimeout(() => { try { + reportCommandPhase(options.onPhase, "ssh_kill"); child.kill("SIGKILL"); } catch { // child may have already exited between the SIGTERM and the @@ -300,29 +307,49 @@ async function spawnText( }); child.on("error", (error) => { + reportCommandPhase(options.onPhase, "ssh_spawn_error"); clearTimers(); finishReject(Object.assign(error, { code: null })); }); + child.on("exit", () => reportCommandPhase(options.onPhase, "ssh_exit")); child.on("close", (code, signal) => { + reportCommandPhase(options.onPhase, "ssh_close"); clearTimers(); if (settled) return; settled = true; - if (code === 0) { + if (code === 0 && !stdinError) { resolve({ stdout, stderr }); return; } - reject(Object.assign(new Error(stderr.trim() || stdout.trim() || `Process exited with code ${code ?? -1}`), { + const inputFailure = stdinError ? "Process closed stdin before command input completed." : ""; + reject(Object.assign(new Error(stderr.trim() || inputFailure || stdout.trim() || `Process exited with code ${code ?? -1}`), { stdout, - stderr, - code, + stderr: stderr || inputFailure, + code: code === 0 && stdinError ? null : code, signal, killed: timedOut, })); }); if (options.stdin != null && child.stdin) { - child.stdin.end(options.stdin); + // SSH may exit or close its pipe while a multi-megabyte upload is still + // queued. A stream error is independent of the ChildProcess error event. + // Observe it before writing, then preserve the remote exit diagnostics + // when close arrives instead of letting EPIPE crash the controller. + // This is local pipe flush, not proof of guest receipt or execution. + child.stdin.on("finish", () => reportCommandPhase(options.onPhase, "ssh_stdin_flushed")); + child.stdin.on("error", (error) => { + reportCommandPhase(options.onPhase, "ssh_stdin_error"); + stdinError = error; + }); + try { + child.stdin.end(options.stdin); + } catch (error) { + reportCommandPhase(options.onPhase, "ssh_stdin_error"); + stdinError = error instanceof Error ? error : new Error(String(error)); + child.stdin.destroy(); + } } }); } @@ -1281,13 +1308,16 @@ export async function runSshCommand( options: { env?: Record; stdin?: string; + onPhase?: CommandDiagnosticObserver; timeoutMs?: number; maxBuffer?: number; } = {}, ): Promise { let cleanup: () => Promise = () => Promise.resolve(); try { + reportCommandPhase(options.onPhase, "ssh_auth_prepare"); const auth = await createSshAuthArgs(config); + reportCommandPhase(options.onPhase, "ssh_auth_ready"); cleanup = auth.cleanup; const sshArgs = [...auth.args]; const envEntries = Object.entries(options.env ?? {}) @@ -1330,6 +1360,7 @@ export async function runSshCommand( return options.stdin != null ? await spawnText("ssh", sshArgs, { stdin: options.stdin, + onPhase: options.onPhase, timeout: options.timeoutMs ?? 15_000, maxBuffer: options.maxBuffer ?? 1024 * 128, }) @@ -1338,7 +1369,9 @@ export async function runSshCommand( maxBuffer: options.maxBuffer ?? 1024 * 128, }); } finally { + reportCommandPhase(options.onPhase, "ssh_auth_cleanup"); await cleanup(); + reportCommandPhase(options.onPhase, "ssh_auth_cleaned"); } } diff --git a/packages/adapters/claude-local/src/server/claude-config.test.ts b/packages/adapters/claude-local/src/server/claude-config.test.ts index 5eb0b701db9..5a07f802fba 100644 --- a/packages/adapters/claude-local/src/server/claude-config.test.ts +++ b/packages/adapters/claude-local/src/server/claude-config.test.ts @@ -1,5 +1,7 @@ import * as fs from "node:fs/promises"; import os from "node:os"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target"; @@ -22,7 +24,7 @@ vi.mock("@paperclipai/adapter-utils/execution-target", async () => { }; }); -import { prepareClaudeConfigSeed, prepareSandboxClaudeProbeRuntime } from "./claude-config.js"; +import { buildRemoteClaudeConfigMaterializationCommand, prepareClaudeConfigSeed, prepareSandboxClaudeProbeRuntime } from "./claude-config.js"; describe("prepareClaudeConfigSeed", () => { const cleanupDirs: string[] = []; @@ -166,6 +168,51 @@ describe("prepareSandboxClaudeProbeRuntime managed-config diagnostics", () => { } }); + it("materializes config without syncing the persistent in-place workspace", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-in-place-probe-")); + cleanupDirs.push(root); + const remoteRoot = "/home/user/paperclip/company/agents/agent"; + const target: AdapterExecutionTarget = { + ...sandboxTarget, + workspaceRealization: { + mode: "in_place", + authoritativeRoot: remoteRoot, + pathAliases: [], + outboundRestorePaths: [], + }, + }; + const restoreWorkspace = vi.fn(async () => {}); + prepareAdapterExecutionTargetRuntime.mockImplementationOnce(async (input) => { + // Reproduce the production boundary: importing a host workspace would + // overwrite remote authority. Only the selected config asset may stage. + expect(input.syncWorkspace).toBe(false); + expect(input.workspaceRemoteDir).toBe(remoteRoot); + expect(input.assets).toEqual([{ key: "config-seed", localDir: root, followSymlinks: true }]); + return { + runtimeRootDir: `${remoteRoot}/.paperclip-runtime/claude`, + assetDirs: { "config-seed": `${remoteRoot}/.paperclip-runtime/claude/config-seed` }, + restoreWorkspace, + }; + }); + const env = { CLAUDE_CONFIG_DIR: root }; + const checks = await prepareSandboxClaudeProbeRuntime({ + managedAiConnection: true, + runId: "run-in-place", + target, + cwd: remoteRoot, + companyId: "company-1", + env, + installCommand: "install-claude", + detectCommand: "claude", + targetIsRemote: true, + targetIsSandbox: true, + helloProbeTimeoutSec: 30, + }); + expect(checks).toEqual([expect.objectContaining({ code: "claude_managed_config_dir", level: "info" })]); + expect(env.CLAUDE_CONFIG_DIR).toBe(`${remoteRoot}/.paperclip-runtime/claude/config`); + expect(restoreWorkspace).toHaveBeenCalledOnce(); + }); + it("keeps a thrown config-materialization error out of every check and the log", async () => { // The runtime preparation throws an error that carries two untrusted values: // an opaque credential marker and a proxy marker. Neither may reach a check @@ -222,3 +269,31 @@ describe("prepareSandboxClaudeProbeRuntime managed-config diagnostics", () => { warnSpy.mockRestore(); }); }); + + +describe("managed Claude user-scope skills", () => { + it("refreshes only its skill symlink and refuses an existing directory", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "claude-user-skills-")); + const config = path.join(root, "config"); + const seed = path.join(root, "seed"); + const first = path.join(root, "first-skills"); + const second = path.join(root, "second-skills"); + const run = (remoteSkillsDir: string) => promisify(execFile)("sh", ["-c", + buildRemoteClaudeConfigMaterializationCommand({ + remoteClaudeConfigDir: config, remoteClaudeConfigSeedDir: seed, remoteSkillsDir, + }), + ], { env: { PATH: process.env.PATH, HOME: root } }); + try { + await fs.mkdir(first); await fs.mkdir(second); + await run(first); + expect(await fs.realpath(path.join(config, "skills"))).toBe(await fs.realpath(first)); + await run(second); + expect(await fs.realpath(path.join(config, "skills"))).toBe(await fs.realpath(second)); + await fs.unlink(path.join(config, "skills")); + await fs.mkdir(path.join(config, "skills")); + await fs.writeFile(path.join(config, "skills", "keep.txt"), "user file"); + await expect(run(first)).rejects.toThrow(); + expect(await fs.readFile(path.join(config, "skills", "keep.txt"), "utf8")).toBe("user file"); + } finally { await fs.rm(root, { recursive: true, force: true }); } + }); +}); diff --git a/packages/adapters/claude-local/src/server/claude-config.ts b/packages/adapters/claude-local/src/server/claude-config.ts index 1f9710ca2cc..fd87ccb0d30 100644 --- a/packages/adapters/claude-local/src/server/claude-config.ts +++ b/packages/adapters/claude-local/src/server/claude-config.ts @@ -221,7 +221,16 @@ export async function prepareClaudeConfigSeed( export function buildRemoteClaudeConfigMaterializationCommand(input: { remoteClaudeConfigDir: string; remoteClaudeConfigSeedDir: string; + remoteSkillsDir?: string; }): string { + // --setting-sources user excludes --add-dir's project-scoped skills. Mount + // only the approved bundle into this isolated runtime's user skill scope. + const skillsLink = shellQuote(path.posix.join(input.remoteClaudeConfigDir, "skills")); + const mountSkills = input.remoteSkillsDir + ? ` && { if [ -e ${skillsLink} ] && [ ! -L ${skillsLink} ]; then ` + + `echo 'Managed Claude skills path is not a symlink' >&2; exit 1; fi; ` + + `ln -sfn ${shellQuote(input.remoteSkillsDir)} ${skillsLink}; }` + : ""; return `mkdir -p ${shellQuote(input.remoteClaudeConfigDir)} && ` + `if [ -d ${shellQuote(input.remoteClaudeConfigSeedDir)} ]; then ` + `cp -R ${shellQuote(`${input.remoteClaudeConfigSeedDir}/.`)} ${shellQuote(input.remoteClaudeConfigDir)}/; ` + @@ -230,7 +239,7 @@ export function buildRemoteClaudeConfigMaterializationCommand(input: { `if [ -n "\${HOME:-}" ] && [ -f "\${HOME}/.claude/\${file}" ] && [ ! -f ${shellQuote(input.remoteClaudeConfigDir)}/"\${file}" ]; then ` + `cp "\${HOME}/.claude/\${file}" ${shellQuote(input.remoteClaudeConfigDir)}/"\${file}"; ` + `fi; ` + - `done`; + `done` + mountSkills; } export async function materializeRemoteClaudeConfig(input: { @@ -238,17 +247,22 @@ export async function materializeRemoteClaudeConfig(input: { target: AdapterExecutionTarget | null | undefined; remoteClaudeConfigDir: string; remoteClaudeConfigSeedDir: string; + remoteSkillsDir?: string; options: AdapterExecutionTargetShellOptions; }): Promise { - await runAdapterExecutionTargetShellCommand( + const result = await runAdapterExecutionTargetShellCommand( input.runId, input.target, buildRemoteClaudeConfigMaterializationCommand({ remoteClaudeConfigDir: input.remoteClaudeConfigDir, remoteClaudeConfigSeedDir: input.remoteClaudeConfigSeedDir, + remoteSkillsDir: input.remoteSkillsDir, }), input.options, ); + if (result.exitCode !== 0 || result.timedOut) { + throw new Error("Could not materialize the managed Claude config and skills"); + } } function isNonEmptyString(value: unknown): value is string { @@ -302,8 +316,11 @@ export async function prepareSandboxClaudeProbeRuntime(input: { let preparedRuntime: Awaited> | null = null; try { const seedDir = input.managedAiConnection ? input.env.CLAUDE_CONFIG_DIR : await prepareClaudeConfigSeed(process.env, async () => {}, input.companyId); - const managedRemoteCwd = - input.target?.kind === "remote" ? input.target.remoteCwd : input.cwd; + const workspaceRealization = input.target?.kind === "remote" + ? input.target.workspaceRealization : undefined; + const managedRemoteCwd = workspaceRealization?.mode === "in_place" + ? workspaceRealization.authoritativeRoot + : input.target?.kind === "remote" ? input.target.remoteCwd : input.cwd; tempWorkspaceDir = await fs.mkdtemp( path.join(os.tmpdir(), "paperclip-claude-envtest-workspace-"), ); @@ -313,6 +330,9 @@ export async function prepareSandboxClaudeProbeRuntime(input: { adapterKey: "claude", workspaceLocalDir: tempWorkspaceDir, workspaceRemoteDir: managedRemoteCwd, + // A readiness probe only stages config assets. In-place workspaces are + // persistent authority and must never receive the empty probe workspace. + syncWorkspace: workspaceRealization?.mode !== "in_place", timeoutSec: Math.max(1, input.helloProbeTimeoutSec), assets: [ { diff --git a/packages/adapters/claude-local/src/server/cli-capabilities.ts b/packages/adapters/claude-local/src/server/cli-capabilities.ts index dec74c2f384..1d62f8167f6 100644 --- a/packages/adapters/claude-local/src/server/cli-capabilities.ts +++ b/packages/adapters/claude-local/src/server/cli-capabilities.ts @@ -16,6 +16,7 @@ function cacheKeyForTarget(command: string, target: AdapterExecutionTarget | nul if (target.kind === "local") { return `local:${target.environmentId ?? ""}:${target.leaseId ?? ""}:${command}`; } + if (target.transport === "computer") return `computer:${target.environmentId}:${target.resourceAuthority.computerId}:${command}`; if (target.transport === "sandbox") { return [ "sandbox", diff --git a/packages/adapters/claude-local/src/server/execute.remote.test.ts b/packages/adapters/claude-local/src/server/execute.remote.test.ts index cbb6ed55013..58118a838e6 100644 --- a/packages/adapters/claude-local/src/server/execute.remote.test.ts +++ b/packages/adapters/claude-local/src/server/execute.remote.test.ts @@ -231,6 +231,31 @@ describe("claude remote execution", () => { })); }); + it("keeps an authoritative remote workspace in place while delivering managed Claude assets", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-claude-in-place-")); + cleanupDirs.push(rootDir); + await execute({ + runId: "run-in-place", + agent: { id: "agent-1", companyId: "company-1", name: "Claude", adapterType: "claude_local", adapterConfig: {} }, + runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null }, + config: { engine: "cli", command: "claude", cwd: "/stale-host-cwd" }, + context: { paperclipWorkspace: { cwd: "/remote-only-agent-home", source: "agent_home" } }, + executionTarget: { + kind: "remote", transport: "ssh", remoteCwd: "/old-copied-workspace", + workspaceRealization: { mode: "in_place", authoritativeRoot: "/persistent-project", pathAliases: [], outboundRestorePaths: [] }, + spec: { host: "127.0.0.1", port: 2222, username: "fixture", remoteWorkspacePath: "/persistent-project", remoteCwd: "/persistent-project", + privateKey: "PRIVATE KEY", knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA", strictHostKeyChecking: true }, + }, + onLog: async () => {}, + }); + expect(prepareWorkspaceForSshExecution).not.toHaveBeenCalled(); + expect(restoreWorkspaceFromSshExecution).not.toHaveBeenCalled(); + expect(syncDirectoryToSsh).toHaveBeenCalledTimes(2); + expect(syncDirectoryToSsh).toHaveBeenCalledWith(expect.objectContaining({ remoteDir: "/persistent-project/.paperclip-runtime/claude/skills" })); + const call = runChildProcess.mock.calls[0] as unknown as [string, string, string[], { remoteExecution: { remoteCwd: string } }]; + expect(call[3].remoteExecution.remoteCwd).toBe("/persistent-project"); + }); + it("does not resume saved Claude sessions for remote SSH execution without a matching remote identity", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-claude-remote-resume-")); cleanupDirs.push(rootDir); diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index de03c588c03..40a7318b094 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -1,3 +1,4 @@ +import { adapterExecutionTargetIsCommandBacked } from "@paperclipai/adapter-utils/execution-target"; import { createProviderStoppedBoundary } from "@paperclipai/adapter-utils/provider-stopped-boundary"; import { createUsageCheckpointLog } from "@paperclipai/adapter-utils/usage-checkpoint"; import fs from "node:fs/promises"; @@ -205,7 +206,9 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise 0; - const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd; + const effectiveWorkspaceCwd = executionTarget?.workspaceRealization?.mode === "in_place" + ? executionTarget.workspaceRealization.authoritativeRoot + : useConfiguredInsteadOfAgentHome ? "" : workspaceCwd; const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd(); const executionTargetIsRemote = adapterExecutionTargetIsRemote(executionTarget); let effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd); @@ -216,7 +219,9 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise = { ...buildPaperclipEnv(agent, input.agentIdentity) }; @@ -426,7 +431,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0; - const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd; + const effectiveWorkspaceCwd = executionTarget?.workspaceRealization?.mode === "in_place" + ? executionTarget.workspaceRealization.authoritativeRoot + : useConfiguredInsteadOfAgentHome ? "" : workspaceCwd; const hasExplicitClaudeConfigDir = typeof configEnv.CLAUDE_CONFIG_DIR === "string" && configEnv.CLAUDE_CONFIG_DIR.trim().length > 0; const instructionsFilePath = asString(config.instructionsFilePath, "").trim(); - const instructionsFileDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : ""; + // Controller scratch supplies prompt bytes; persistent computer paths come + // from the registered instruction copy, including a nested entry file. + const persistentComputer = executionTarget?.kind === "remote" && executionTarget.transport === "computer"; + const promptInstructionsFilePath = persistentComputer + ? asString(workspaceContext.instructionsFilePath, "").trim() + : instructionsFilePath; + const instructionsFileDir = promptInstructionsFilePath ? `${(persistentComputer ? path.posix : path).dirname(promptInstructionsFilePath)}/` : ""; const runtimeConfig = await buildClaudeRuntimeConfig({ agentIdentity: ctx.agentIdentity, runId, @@ -482,7 +495,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise { await onLog( "stdout", - `[paperclip] Syncing workspace and Claude runtime assets to ${describeAdapterExecutionTarget(executionTarget)}.\n`, + `[paperclip] Syncing ${targetWorkspaceRealization?.mode === "in_place" ? "Claude runtime assets" : "workspace and Claude runtime assets"} to ${describeAdapterExecutionTarget(executionTarget)}.\n`, ); return await prepareAdapterExecutionTargetRuntime({ runId, @@ -624,6 +638,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise onLog("stdout", line), @@ -712,6 +729,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise preparedExecutionTargetRuntime.restoreWorkspace((line) => onLog("stdout", line)) : null; @@ -1089,15 +1090,23 @@ export async function execute(ctx: AdapterExecutionContext): Promise, stderr_tail: BoundedLogBuffer, stderr_categories: BTreeSet<&'static str>, + admission_diagnostic: Option<(&'static str, u64)>, poisoned: bool, } @@ -322,6 +323,7 @@ impl AcpxSidecarTransport { buffered_events: VecDeque::new(), stderr_tail: BoundedLogBuffer::new(32, 8 * 1024), stderr_categories: BTreeSet::new(), + admission_diagnostic: None, poisoned: false, }) } @@ -456,10 +458,12 @@ impl AcpxSidecarTransport { let error = response.error.expect("failed response has validated error"); return Ok(CommandOutcome::Rejected(LocalRunnerError::invalid( format!( - "ACPX sidecar command {} was rejected (retryable={}, classification={})", + "ACPX sidecar command {} was rejected (retryable={}, classification={}){}{}", command.as_str(), error.retryable, response_error_classification(&error), + response_error_module_fence(&error).map(|fence| format!(" moduleFence={fence}")).unwrap_or_default(), + self.diagnostic_suffix(), ), ))); } @@ -529,6 +533,20 @@ impl AcpxSidecarTransport { )); } self.validate_event_sequence(event.sequence)?; + if event.event_type == GeneratedAcpxSidecarEventType::RuntimeDiagnostic { + if let (Some(code), Some(message)) = ( + event.payload.get("code").and_then(Value::as_str), + event.payload.get("message").and_then(Value::as_str), + ) { + // These frames precede the response on stdout, unlike stderr + // whose reader may deliver a matching diagnostic later. + if let Some(progress) = parse_admission_diagnostic(&format!( + "[paperclip-acpx-sidecar] {code}: {message}" + )) { + self.record_admission_diagnostic(progress); + } + } + } self.buffered_events.push_back(event); Ok(()) } @@ -601,6 +619,7 @@ impl AcpxSidecarTransport { fn diagnostic_suffix(&self) -> String { let diagnostics = self.stderr_tail.snapshot().lines.join("\n"); + let admission = admission_diagnostic_suffix(self.admission_diagnostic); let categories = if self.stderr_categories.is_empty() { String::new() } else { @@ -614,20 +633,35 @@ impl AcpxSidecarTransport { ) }; if diagnostics.is_empty() { - categories + format!("{admission}{categories}") } else { - format!("{categories} stderrTail={diagnostics:?}") + format!("{admission}{categories} stderrTail={diagnostics:?}") } } fn record_stderr(&mut self, line: &str) { // Only fixed categories cross this boundary. Raw errors, stack paths, // identifiers, and credential-bearing strings remain fully redacted. + if let Some(progress) = parse_admission_diagnostic(line) { + self.record_admission_diagnostic(progress); + } self.stderr_categories .extend(stderr_diagnostic_categories(line)); self.stderr_tail.push(redact_diagnostic(line)); } + fn record_admission_diagnostic(&mut self, progress: (&'static str, u64)) { + // Preserve the failed step through cleanup and delayed duplicate stderr. + // Neither channel may move the observed admission clock backwards. + if self + .admission_diagnostic + .is_some_and(|current| progress.0 == "cleanup" || progress.1 < current.1) + { + return; + } + self.admission_diagnostic = Some(progress); + } + fn poison(&mut self) { if self.poisoned { return; @@ -787,6 +821,54 @@ fn nullable_identifier( Ok(Some(value.to_owned())) } +// Reuse the sidecar's diagnostic channel, retaining only a closed stage and +// bounded integer. Unknown lines remain fully redacted and never grant authority. +fn parse_admission_diagnostic(line: &str) -> Option<(&'static str, u64)> { + let (stage, elapsed) = line + .strip_prefix("[paperclip-acpx-sidecar] admission_")? + .split_once(": elapsedMs=")?; + let stage = match stage { + "binding" => "binding", + "turn_prepare" => "turn_prepare", + "turn_spawn" => "turn_spawn", + "turn_spawned" => "turn_spawned", + "turn_initialize" => "turn_initialize", + "turn_initialized" => "turn_initialized", + "turn_session_load" => "turn_session_load", + "turn_session_loaded" => "turn_session_loaded", + "turn_session_resume" => "turn_session_resume", + "turn_session_resumed" => "turn_session_resumed", + "turn_session_new" => "turn_session_new", + "turn_session_created" => "turn_session_created", + "turn_prompt_submitted" => "turn_prompt_submitted", + "turn_ownership" => "turn_ownership", + "turn_ownership_verified" => "turn_ownership_verified", + "installation" => "installation", + "sandbox" => "sandbox", + "lifetime" => "lifetime", + "agent_files" => "agent_files", + "skills" => "skills", + "command" => "command", + "tool_bridge" => "tool_bridge", + "handshake" => "handshake", + "verification" => "verification", + "ready" => "ready", + "cleanup" => "cleanup", + _ => return None, + }; + if elapsed.is_empty() || !elapsed.bytes().all(|byte| byte.is_ascii_digit()) { + return None; + } + let elapsed = elapsed.parse::().ok()?; + (elapsed <= 3_600_000).then_some((stage, elapsed)) +} + +fn admission_diagnostic_suffix(progress: Option<(&'static str, u64)>) -> String { + progress.map_or_else(String::new, |(stage, elapsed)| { + format!(" admissionStage={stage} admissionElapsedMs={elapsed}") + }) +} + fn redact_diagnostic(value: &str) -> String { if value.is_empty() { String::new() @@ -842,7 +924,34 @@ fn stderr_diagnostic_categories(value: &str) -> BTreeSet<&'static str> { categories } +// Only fixed loader fence codes may cross the private provider-stderr boundary. +fn response_error_module_fence(error: &ResponseError) -> Option<&'static str> { + match error.code.as_str() { + "AGENT_STARTUP_FAILED.UNVERIFIED_MODULE.RESOLUTION" => Some("resolution"), + "AGENT_STARTUP_FAILED.UNVERIFIED_MODULE.OPEN" => Some("open"), + "AGENT_STARTUP_FAILED.UNVERIFIED_MODULE.BOUNDS" => Some("bounds"), + "AGENT_STARTUP_FAILED.UNVERIFIED_MODULE.ANCESTRY" => Some("ancestry"), + "AGENT_STARTUP_FAILED.UNVERIFIED_MODULE.FORMAT" => Some("format"), + "AGENT_STARTUP_FAILED.UNVERIFIED_MODULE.PATH_IDENTITY" => Some("path_identity"), + "AGENT_STARTUP_FAILED.UNVERIFIED_MODULE.READ_RESULT" => Some("read_result"), + "AGENT_STARTUP_FAILED.UNVERIFIED_MODULE.SIZE" => Some("size"), + "AGENT_STARTUP_FAILED.UNVERIFIED_MODULE.DEVICE" => Some("device"), + "AGENT_STARTUP_FAILED.UNVERIFIED_MODULE.INODE" => Some("inode"), + "AGENT_STARTUP_FAILED.UNVERIFIED_MODULE.MODE" => Some("mode"), + "AGENT_STARTUP_FAILED.UNVERIFIED_MODULE.UID" => Some("uid"), + "AGENT_STARTUP_FAILED.UNVERIFIED_MODULE.GID" => Some("gid"), + "AGENT_STARTUP_FAILED.UNVERIFIED_MODULE.LINK_COUNT" => Some("link_count"), + "AGENT_STARTUP_FAILED.UNVERIFIED_MODULE.MTIME" => Some("mtime"), + "AGENT_STARTUP_FAILED.UNVERIFIED_MODULE.BYTE_CONTINUITY" => Some("byte_continuity"), + "AGENT_STARTUP_FAILED.UNVERIFIED_MODULE.REPEATED_CTIME" => Some("repeated_ctime"), + _ => None, + } +} + fn response_error_classification(error: &ResponseError) -> &'static str { + if response_error_module_fence(error).is_some() { + return "agent_startup_unverified_module"; + } match error.code.as_str() { "ACP_MODEL_UNSUPPORTED" => return "requested_model_unsupported", "AGENT_STARTUP_FAILED" => return "agent_startup_failed", @@ -901,6 +1010,67 @@ fn response_error_classification(error: &ResponseError) -> &'static str { "ACPX recovery identity does not match the persisted runtime record" => { "recovery_identity_mismatch" } + // Older pinned sidecars send these internal failures without a stable + // code. Keep only closed categories: messages can contain filesystem + // paths, provider output, or credentials and must never be surfaced. + "ACPX sidecar already owns a session or its cleanup" => "sidecar_session_owned", + "ACPX session profile differs from its initialization" => "sidecar_profile_mismatch", + "assigned native MCP launch binding is unavailable" => "native_mcp_binding_unavailable", + "Verified ACPX installation does not match its profile" => "installation_profile_mismatch", + "ACP agent directory must be an absolute normalized registered path" + | "ACP agent directory must be a real directory" + | "ACP agent directory cannot be a filesystem root" => "agent_directory_invalid", + "ACP agent directory overlaps protected runtime state" => "agent_directory_overlap", + "ACP registered agent directory changed during provider lifetime" => { + "agent_directory_changed" + } + "ACPX agent home must be a real directory" => "provider_home_invalid", + "ACPX agent home permissions are unsafe" => "provider_home_permissions", + "Managed Codex credential ownership could not be established" => { + "provider_lifetime_admission_failed" + } + "ACPX recovery runtime directory is unavailable" => "recovery_directory_unavailable", + "ACPX recovery runtime directory escaped its namespace" => "recovery_directory_escape", + "ACPX recovery workspace record is unavailable" => "recovery_workspace_record_unavailable", + "ACPX recovery workspace record is invalid" => "recovery_workspace_record_invalid", + "ACPX recovery workspace record changed while read" => "recovery_workspace_record_changed", + "ACPX recovery workspace is unavailable" => "recovery_workspace_unavailable", + "ACPX sandbox path must be a real directory" + | "ACPX sandbox directory changed during preparation" + | "ACPX sandbox directory escaped its private parent" => "sandbox_directory_invalid", + "runtime context asset root must be a directory" + | "staged runtime context root must be a directory" + | "staged runtime context asset must be a regular file" + | "materialized runtime context root must be a directory" + | "materialized runtime context asset must be a regular file" => { + "runtime_asset_type_invalid" + } + "runtime context skill name must be a safe relative path" + | "runtime context skill name must stay inside the skills home" + | "runtime context skill names must not overlap" => "runtime_skill_name_invalid", + "runtime context skills home must be a fresh destination" => { + "runtime_skills_destination_exists" + } + _ if error + .message + .starts_with("runtime context asset contains a symlink: ") => + { + "runtime_asset_symlink" + } + _ if error + .message + .starts_with("runtime context asset contains an unsupported file: ") => + { + "runtime_asset_type_invalid" + } + _ if error.message.starts_with("ENOENT: ") => "filesystem_entry_missing", + _ if error.message.starts_with("EACCES: ") || error.message.starts_with("EPERM: ") => { + "filesystem_permission_denied" + } + _ if error.message.starts_with("ELOOP: ") => "filesystem_link_rejected", + _ if error.message.starts_with("ENOTDIR: ") => "filesystem_not_directory", + _ if error.message.starts_with("EROFS: ") => "filesystem_read_only", + _ if error.message.starts_with("ENOSPC: ") => "filesystem_full", "ACPX provider lifetime lease is unavailable" => "provider_lifetime_unavailable", "Managed Codex credential home already has an active lease" => "provider_lifetime_owned", "ACPX session handshake exceeded its admission deadline" => "session_handshake_timeout", @@ -930,6 +1100,193 @@ fn response_error_classification(error: &ResponseError) -> &'static str { #[cfg(test)] mod tests { + #[test] + fn admission_diagnostics_accept_only_closed_stages_and_bounded_integer_timing() { + assert_eq!( + parse_admission_diagnostic( + "[paperclip-acpx-sidecar] admission_handshake: elapsedMs=321" + ), + Some(("handshake", 321)) + ); + for line in [ + "[paperclip-acpx-sidecar] admission_private-path: elapsedMs=321", + "[paperclip-acpx-sidecar] admission_handshake: elapsedMs=321 private-token", + "[paperclip-acpx-sidecar] admission_handshake: elapsedMs=-1", + "[paperclip-acpx-sidecar] admission_handshake: elapsedMs=NaN", + "[paperclip-acpx-sidecar] admission_handshake: elapsedMs=3600001", + "[paperclip-acpx-sidecar] admission_handshake: elapsedMs=18446744073709551616", + "private-prefix [paperclip-acpx-sidecar] admission_handshake: elapsedMs=321", + ] { + assert_eq!(parse_admission_diagnostic(line), None); + } + } + + #[cfg(unix)] + #[test] + fn admission_timeout_retains_last_safe_stage_without_untrusted_stderr() { + let config = AcpxSidecarTransportConfig { + command: PathBuf::from("/bin/sh"), + args: vec!["-c".into(), "printf '%s\\n' '[paperclip-acpx-sidecar] admission_sandbox: elapsedMs=1' '[paperclip-acpx-sidecar] admission_handshake: elapsedMs=24' '[paperclip-acpx-sidecar] admission_handshake: elapsedMs=25 /private/token-canary' '[paperclip-acpx-sidecar] admission_cleanup: elapsedMs=26' >&2; sleep 2".into()], + verified_launch: None, + request_timeout: Duration::from_millis(200), + shutdown_grace: Duration::from_millis(10), + }; + let mut transport = AcpxSidecarTransport::start(&config).unwrap(); + let error = transport + .request(GeneratedAcpxSidecarCommand::SessionOpen, json!({})) + .err() + .unwrap() + .to_string(); + assert!( + error.contains("request timed out at session.open"), + "{error}" + ); + assert!( + error.contains("admissionStage=handshake admissionElapsedMs=24"), + "{error}" + ); + assert!( + !error.contains("private") && !error.contains("token-canary"), + "{error}" + ); + assert!(transport + .request(GeneratedAcpxSidecarCommand::SessionOpen, json!({})) + .is_err()); + } + + #[cfg(unix)] + #[test] + fn turn_start_timeout_distinguishes_lazy_initialize_load_and_ownership() { + for stage in ["turn_initialize", "turn_session_load", "turn_ownership"] { + let script = format!( + "printf '%s\\n' '[paperclip-acpx-sidecar] admission_{stage}: elapsedMs=42' '[paperclip-acpx-sidecar] admission_turn_prompt_submitted: elapsedMs=43 /private/token-canary' >&2; sleep 2" + ); + let config = AcpxSidecarTransportConfig { + command: PathBuf::from("/bin/sh"), + args: vec!["-c".into(), script], + verified_launch: None, + request_timeout: Duration::from_millis(200), + shutdown_grace: Duration::from_millis(10), + }; + let mut transport = AcpxSidecarTransport::start(&config).unwrap(); + let error = transport + .request(GeneratedAcpxSidecarCommand::TurnStart, json!({})) + .err() + .unwrap() + .to_string(); + assert!(error.contains("request timed out at turn.start"), "{error}"); + assert!( + error.contains(&format!("admissionStage={stage} admissionElapsedMs=42")), + "{error}" + ); + assert!( + !error.contains("private") && !error.contains("canary"), + "{error}" + ); + } + } + + #[cfg(unix)] + #[test] + fn admission_rejection_retains_sequenced_stage_and_preserves_buffered_frames() { + let diagnostics = [ + ("admission_handshake", "elapsedMs=24"), + ("admission_sandbox", "elapsedMs=1"), + ("admission_handshake", "elapsedMs=25 /private/token-canary"), + ("admission_cleanup", "elapsedMs=26"), + ]; + let frames: Vec = diagnostics + .iter() + .enumerate() + .map(|(index, (code, message))| { + json!({ + "protocolVersion": GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION, + "sequence": index + 1, "eventType": "runtime.diagnostic", "runId": null, + "turnId": null, "payload": { "code": code, "message": message }, + }) + }) + .collect(); + let response = json!({ "protocolVersion": GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION, + "id": 1, "ok": false, "error": { "code": "AGENT_STARTUP_FAILED.UNVERIFIED_MODULE.REPEATED_CTIME", + "message": "/private/token-canary", "retryable": false } }); + let lines = frames + .iter() + .chain(std::iter::once(&response)) + .map(|frame| format!("'{}'", frame)) + .collect::>() + .join(" "); + let config = AcpxSidecarTransportConfig { + command: PathBuf::from("/bin/sh"), + args: vec![ + "-c".into(), + format!("read request; printf '%s\\n' {lines}; sleep 2"), + ], + verified_launch: None, + request_timeout: Duration::from_secs(2), + shutdown_grace: Duration::from_millis(10), + }; + let mut transport = AcpxSidecarTransport::start(&config).unwrap(); + let error = transport + .request(GeneratedAcpxSidecarCommand::SessionOpen, json!({})) + .unwrap_err() + .to_string(); + assert!( + error.contains( + "was rejected (retryable=false, classification=agent_startup_unverified_module)" + ), + "{error}" + ); + assert!(error.contains("moduleFence=repeated_ctime"), "{error}"); + assert!( + error.contains("admissionStage=handshake admissionElapsedMs=24"), + "{error}" + ); + assert!( + !error.contains("private") + && !error.contains("canary") + && !error.contains("AGENT_STARTUP_FAILED.UNVERIFIED_MODULE.REPEATED_CTIME"), + "{error}" + ); + assert!(!transport.poisoned); + for frame in frames { + let event = transport + .poll_event(Duration::from_millis(1)) + .unwrap() + .unwrap(); + assert_eq!(event.sequence, frame["sequence"].as_u64().unwrap()); + assert_eq!(event.payload, frame["payload"]); + } + assert!(transport.buffered_events.is_empty()); + // Delayed stderr must not regress the stage learned from sequenced stdout. + transport.record_stderr("[paperclip-acpx-sidecar] admission_binding: elapsedMs=0"); + assert_eq!(transport.admission_diagnostic, Some(("handshake", 24))); + } + + #[cfg(unix)] + #[test] + fn rejected_diagnostic_sequence_cannot_update_admission_progress() { + let config = AcpxSidecarTransportConfig { + command: PathBuf::from("/bin/sh"), + args: vec!["-c".into(), "sleep 2".into()], + verified_launch: None, + request_timeout: Duration::from_secs(2), + shutdown_grace: Duration::from_millis(10), + }; + let mut transport = AcpxSidecarTransport::start(&config).unwrap(); + let error = transport + .buffer_event(AcpxSidecarEvent { + sequence: 2, + event_type: GeneratedAcpxSidecarEventType::RuntimeDiagnostic, + run_id: None, + turn_id: None, + payload: json!({"code":"admission_ready", "message":"elapsedMs=25"}), + }) + .unwrap_err(); + assert!(error.to_string().contains("sequence has a gap")); + assert_eq!(transport.admission_diagnostic, None); + assert!(transport.buffered_events.is_empty()); + } + #[test] fn pi_credentials_require_a_bounded_binding_without_process_control_variables() { assert!(pi_credential_environment_keys(None).unwrap().is_empty()); @@ -1035,6 +1392,48 @@ mod tests { } } + #[test] + fn module_fence_diagnostics_are_closed_and_keep_the_top_level_category() { + for (suffix, expected) in [ + ("RESOLUTION", Some("resolution")), + ("OPEN", Some("open")), + ("BOUNDS", Some("bounds")), + ("ANCESTRY", Some("ancestry")), + ("FORMAT", Some("format")), + ("PATH_IDENTITY", Some("path_identity")), + ("READ_RESULT", Some("read_result")), + ("SIZE", Some("size")), + ("DEVICE", Some("device")), + ("INODE", Some("inode")), + ("MODE", Some("mode")), + ("UID", Some("uid")), + ("GID", Some("gid")), + ("LINK_COUNT", Some("link_count")), + ("MTIME", Some("mtime")), + ("BYTE_CONTINUITY", Some("byte_continuity")), + ("REPEATED_CTIME", Some("repeated_ctime")), + ("MODE private-canary", None), + ("MODE.PRIVATE_CANARY", None), + ("PRIVATE_CANARY", None), + ("", None), + ] { + let error = ResponseError { + code: format!("AGENT_STARTUP_FAILED.UNVERIFIED_MODULE.{suffix}"), + message: "/private/token-canary MODE".to_owned(), + retryable: false, + }; + assert_eq!(response_error_module_fence(&error), expected); + assert_eq!( + response_error_classification(&error), + if expected.is_some() { + "agent_startup_unverified_module" + } else { + "unclassified" + } + ); + } + } + #[test] fn candidate_auth_diagnostics_use_only_closed_codes_and_never_provider_text() { for (code, expected) in [ @@ -1061,6 +1460,98 @@ mod tests { } } + #[test] + fn pinned_sidecar_admission_failures_expose_only_closed_categories() { + for (message, expected) in [ + ( + "ACP agent directory overlaps protected runtime state", + "agent_directory_overlap", + ), + ( + "ACPX recovery workspace record is invalid", + "recovery_workspace_record_invalid", + ), + ( + "ACPX agent home permissions are unsafe", + "provider_home_permissions", + ), + ( + "assigned native MCP launch binding is unavailable", + "native_mcp_binding_unavailable", + ), + ( + "runtime context skills home must be a fresh destination", + "runtime_skills_destination_exists", + ), + ( + "runtime context asset contains a symlink: /private-token-canary", + "runtime_asset_symlink", + ), + ( + "runtime context asset contains an unsupported file: /private-token-canary", + "runtime_asset_type_invalid", + ), + ( + "ENOENT: no such file or directory, open '/private-token-canary'", + "filesystem_entry_missing", + ), + ( + "EACCES: permission denied, open '/private-token-canary'", + "filesystem_permission_denied", + ), + ( + "EPERM: operation not permitted '/private-token-canary'", + "filesystem_permission_denied", + ), + ( + "ELOOP: too many symbolic links '/private-token-canary'", + "filesystem_link_rejected", + ), + ( + "ENOTDIR: not a directory '/private-token-canary'", + "filesystem_not_directory", + ), + ( + "EROFS: read-only filesystem '/private-token-canary'", + "filesystem_read_only", + ), + ( + "ENOSPC: no space left '/private-token-canary'", + "filesystem_full", + ), + ("private-token-canary ENOENT: missing", "unclassified"), + ("ENOENT_EXTRA: private-token-canary", "unclassified"), + ( + "ACP agent directory overlaps protected runtime state private-token-canary", + "unclassified", + ), + ( + "unknown failure https://user:private-token-canary@example.invalid", + "unclassified", + ), + ] { + let error = ResponseError { + code: "acpx_sidecar_command_failed".to_owned(), + message: message.to_owned(), + retryable: false, + }; + let classification = response_error_classification(&error); + assert_eq!(classification, expected); + assert!(!classification.contains("private-token-canary")); + assert!(!classification.contains('/')); + } + // An established stable code keeps precedence over incidental text. + let error = ResponseError { + code: "AUTH_REQUIRED".to_owned(), + message: "ENOENT: private-token-canary".to_owned(), + retryable: true, + }; + assert_eq!( + response_error_classification(&error), + "authentication_required" + ); + } + #[test] fn classifies_only_allowlisted_internal_sidecar_failures() { let error = |code: &str, message: &str| ResponseError { diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs b/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs index 28f4d2a775a..73dc8fa23d5 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs @@ -47,7 +47,7 @@ pub(crate) fn acpx_release_profile(agent: &str) -> Option { agent_runtime_package: None, agent_runtime_version: None, command_digest: - "sha256:481d0852ae8272a90f9912723d540518a874a0da65a9070e33b52ea1a14cbd7f", + "sha256:e08c25d07389522abfcda22d081ea25bac370576c3e0ee8ad3fe96c4b653f036", requires_provider_policy: true, }, "claude" => AcpxReleaseProfile { diff --git a/packages/paperclip-runner/scripts/generate-acpx-profiles.mjs b/packages/paperclip-runner/scripts/generate-acpx-profiles.mjs index afdd2013b65..eca1ad44eea 100644 --- a/packages/paperclip-runner/scripts/generate-acpx-profiles.mjs +++ b/packages/paperclip-runner/scripts/generate-acpx-profiles.mjs @@ -39,7 +39,7 @@ const { commandDigest, ...attestation } = contract; assert.equal(commandDigest, `sha256:${createHash("sha256").update(canonicalJson(attestation)).digest("hex")}`); assert.equal(contract.acpxPatchSha256, createHash("sha256") .update(await readFile(new URL("../../patches/acpx@0.13.1.patch", root))).digest("hex")); -for (const [agent, path] of [["pi", "test-fixtures/pi-acp/profile-v22-identity.json"], ["copilot", "test/fixtures/copilot-profile-v17-identity.json"]]) { +for (const [agent, path] of [["pi", "test-fixtures/pi-acp/profile-v22-identity.json"], ["copilot", "test/fixtures/copilot-profile-v18-identity.json"]]) { const identity = await readJson(path); assert.equal(manifest.profiles[agent].agentProfileVersion, identity.declaration.agentProfileVersion); assert.equal(manifest.profiles[agent].commandDigest, identity.commandDigest); diff --git a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts index 63fc827217a..2580585bf6a 100644 --- a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts +++ b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts @@ -315,6 +315,7 @@ async function dispatch( }, { retainAdmissionCleanup: retainFailedAdmissionCleanup, + onAdmissionStage: (stage, elapsedMs) => diagnostic(`admission_${stage}`, `elapsedMs=${elapsedMs}`), reportRetainedCleanupFailure: reportRetainedAcpxCleanupFailure, openRuntime: (options) => openCodexAcpxRuntime(options, { diff --git a/packages/paperclip-runner/src/cli/acpx-sidecar-input.test.ts b/packages/paperclip-runner/src/cli/acpx-sidecar-input.test.ts index 12c80c49221..db636c99b75 100644 --- a/packages/paperclip-runner/src/cli/acpx-sidecar-input.test.ts +++ b/packages/paperclip-runner/src/cli/acpx-sidecar-input.test.ts @@ -126,6 +126,66 @@ describe("ACPX sidecar input sequencing", () => { expect(acpxSidecarErrorCode(new Error("wrapped", { cause: error }))).toBe(code); }); + it.each(["RESOLUTION", "OPEN", "BOUNDS", "ANCESTRY", "FORMAT", "PATH_IDENTITY", "READ_RESULT", + "SIZE", "DEVICE", "INODE", "MODE", "UID", "GID", "LINK_COUNT", "MTIME", "BYTE_CONTINUITY", "REPEATED_CTIME"])( + "preserves only closed module fence %s", fence => { + const classify = (marker: string) => acpxSidecarErrorCode(Object.assign(new Error("private-canary"), { + detailCode: "AGENT_STARTUP_FAILED", stderrSummary: `${marker}\nprivate-canary /secret`, exitCode: 1, + })); + expect(classify(`PAPERCLIP_ACPX_IMPORT_FAILURE:UNVERIFIED_MODULE:${fence}`)) + .toBe(`AGENT_STARTUP_FAILED.UNVERIFIED_MODULE.${fence}`); + for (const suffix of ["PRIVATE_CANARY", `${fence} private-canary`, `${fence}.PRIVATE_CANARY`]) { + expect(classify(`PAPERCLIP_ACPX_IMPORT_FAILURE:UNVERIFIED_MODULE:${suffix}`)) + .toBe("AGENT_STARTUP_FAILED.EXIT_NONZERO"); + } + }, + ); + + it.each(["RESOLUTION", "OPEN", "BOUNDS", "ANCESTRY", "FORMAT", "PATH_IDENTITY", "READ_RESULT", + "SIZE", "DEVICE", "INODE", "MODE", "UID", "GID", "LINK_COUNT", "MTIME", "BYTE_CONTINUITY", "REPEATED_CTIME"])( + "preserves delimited fence %s through ACPX stderr summarization", fence => { + // ACPX 0.13.1 summarizeStartupStderr collapses whitespace before exposing + // AgentStartupError.stderrSummary; line boundaries do not survive. + const summarize = (stderr: string) => stderr.replace(/\s+/gu, " ").trim().slice(0, 8192); + const classify = (stderr: string) => acpxSidecarErrorCode(Object.assign(new Error("private-canary"), { + detailCode: "AGENT_STARTUP_FAILED", stderrSummary: summarize(stderr), exitCode: 1, + })); + expect(classify(`prior diagnostic\nPAPERCLIP_ACPX_IMPORT_FAILURE:UNVERIFIED_MODULE:${fence}:END\nError: private-canary /secret\ncode: ERR_ACPX_UNVERIFIED_MODULE`)) + .toBe(`AGENT_STARTUP_FAILED.UNVERIFIED_MODULE.${fence}`); + for (const marker of [ + `UNVERIFIED_MODULE:${fence}:EN`, + `UNVERIFIED_MODULE:${fence}:END_PRIVATE_CANARY`, + `UNVERIFIED_MODULE:${fence}.PRIVATE_CANARY:END`, + "UNVERIFIED_MODULE:PRIVATE_CANARY:END", + ]) { + expect(classify(`PAPERCLIP_ACPX_IMPORT_FAILURE:${marker}\nprivate-canary`)) + .toBe("AGENT_STARTUP_FAILED.EXIT_NONZERO"); + } + }, + ); + + it.each(["OTHER", "UNVERIFIED_MODULE"])("classifies closed import marker %s without private stderr", category => { + const classify = (stderrSummary: string) => acpxSidecarErrorCode(Object.assign(new Error("private-canary"), { + detailCode: "AGENT_STARTUP_FAILED", stderrSummary, exitCode: 1, + })); + expect(classify(`PAPERCLIP_ACPX_IMPORT_FAILURE:${category}\nprivate-canary /secret`)) + .toBe(`AGENT_STARTUP_FAILED.${category}`); + expect(classify(`private-canary PAPERCLIP_ACPX_IMPORT_FAILURE:OTHER\n`)) + .toBe("AGENT_STARTUP_FAILED.EXIT_NONZERO"); + expect(classify(`PAPERCLIP_ACPX_IMPORT_FAILURE:OTHER private-canary\n`)) + .toBe("AGENT_STARTUP_FAILED.EXIT_NONZERO"); + }); + + it.each([ + ["Error [ERR_MODULE_NOT_FOUND]: private-canary", "MODULE_NOT_FOUND"], + ["SyntaxError: private-canary", "SYNTAX_ERROR"], + ])("preserves detailed closed startup category with a generic import marker (%s)", (detail, category) => { + expect(acpxSidecarErrorCode(Object.assign(new Error("private-canary"), { + detailCode: "AGENT_STARTUP_FAILED", + stderrSummary: `PAPERCLIP_ACPX_IMPORT_FAILURE:OTHER\n${detail}`, + }))).toBe(`AGENT_STARTUP_FAILED.${category}`); + }); + it("preserves stable ACPX error identities without copying startup stderr", () => { const missingModule = Object.assign(new Error("provider exited"), { detailCode: "AGENT_STARTUP_FAILED", diff --git a/packages/paperclip-runner/src/cli/acpx-sidecar-input.ts b/packages/paperclip-runner/src/cli/acpx-sidecar-input.ts index 6aeee58ca14..7243d383304 100644 --- a/packages/paperclip-runner/src/cli/acpx-sidecar-input.ts +++ b/packages/paperclip-runner/src/cli/acpx-sidecar-input.ts @@ -88,6 +88,17 @@ function directAcpxSidecarErrorCode(error: Error): string | null { const stderr = typeof details.stderrSummary === "string" ? details.stderrSummary : ""; + // Bootstrap writes this fixed marker before printing private error details and + // terminating. Never copy those details into the public classification. + // ACPX collapses stderr whitespace into one line. An explicit terminator + // preserves the token boundary without accepting arbitrary appended text. + // Retain the original line-delimited form for older immutable provider packs. + const importFailure = /(?:^|\s)PAPERCLIP_ACPX_IMPORT_FAILURE:(UNVERIFIED_MODULE|OTHER)(?::([A-Z_]+))?:END(?=\s|$)/.exec(stderr) + ?? /(?:^|\n)PAPERCLIP_ACPX_IMPORT_FAILURE:(UNVERIFIED_MODULE|OTHER)(?::([A-Z_]+))?(?:\r?\n|$)/.exec(stderr); + if (importFailure?.[1] === "UNVERIFIED_MODULE" && importFailure[2] === undefined) return "AGENT_STARTUP_FAILED.UNVERIFIED_MODULE"; + if (importFailure?.[1] === "UNVERIFIED_MODULE" && MODULE_FENCES.has(importFailure[2])) { + return `AGENT_STARTUP_FAILED.UNVERIFIED_MODULE.${importFailure[2]}`; + } if (/ERR_ACPX_UNVERIFIED_MODULE/.test(stderr)) { return "AGENT_STARTUP_FAILED.UNVERIFIED_MODULE"; } @@ -106,6 +117,7 @@ function directAcpxSidecarErrorCode(error: Error): string | null { if (/ERR_INVALID_ARG|invalid argument/i.test(stderr)) { return "AGENT_STARTUP_FAILED.INVALID_ARGUMENT"; } + if (importFailure?.[1] === "OTHER" && importFailure[2] === undefined) return "AGENT_STARTUP_FAILED.OTHER"; if (!stderr.trim()) return "AGENT_STARTUP_FAILED.NO_STDERR"; if (typeof details.signal === "string" && details.signal) { return "AGENT_STARTUP_FAILED.SIGNAL"; @@ -129,6 +141,8 @@ const GENERIC_ACPX_OUTPUT_CODES = new Set([ "USAGE", ]); +const MODULE_FENCES = new Set(["RESOLUTION", "OPEN", "BOUNDS", "ANCESTRY", "FORMAT", "PATH_IDENTITY", "READ_RESULT", "SIZE", "DEVICE", "INODE", "MODE", "UID", "GID", "LINK_COUNT", "MTIME", "BYTE_CONTINUITY", "REPEATED_CTIME"]); + const STABLE_ACPX_SIDECAR_CODES = new Set([ "COPILOT_POLICY_VIOLATION", "COPILOT_DETACHED_WORK_UNSUPPORTED", @@ -146,6 +160,7 @@ const STABLE_ACPX_SIDECAR_CODES = new Set([ "AGENT_STARTUP_FAILED.SIGNAL", "AGENT_STARTUP_FAILED.SYNTAX_ERROR", "AGENT_STARTUP_FAILED.UNVERIFIED_MODULE", + ...Array.from(MODULE_FENCES, fence => `AGENT_STARTUP_FAILED.UNVERIFIED_MODULE.${fence}`), "AUTH_REQUIRED", "CLAUDE_ACP_SESSION_CREATE_TIMEOUT", "SESSION_CONFIG_OPTION_REPLAY_FAILED", 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/codex-runtime-adapter.test.ts b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.test.ts index a5457816bff..588401a613a 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.test.ts @@ -1768,6 +1768,59 @@ describe("Codex ACPX runtime adapter", () => { await owner.command.close(); }); + it("reports deferred turn initialization, session load and ownership without acknowledging early", async () => { + const runtime = fakeRuntime(); + const child = fakeChild(); + const command = fakeCommand(); + vi.mocked(command.spawn).mockReturnValue(child); + let created!: AcpRuntimeOptions; + let submit!: () => void; + let verifyOwner!: () => void; + const promptStarted = new Promise(resolve => { submit = resolve; }); + const owned = new Promise(resolve => { verifyOwner = resolve; }); + const phases: string[] = []; + let observedOwnership!: () => void; + const ownershipEntered = new Promise(resolve => { observedOwnership = resolve; }); + const options = openOptions(command); + options.onTurnStage = stage => { + phases.push(stage); + if (stage === "turn_ownership") observedOwnership(); + }; + vi.mocked(runtime.startTurn).mockImplementation(() => { + created.spawnAgent!({ command: "private-command-canary", args: [], options: {} }); + created.onAgentSpawn!({ pid: 123, startedAt: "private-time-canary" }); + created.onAcpMessage!("outbound", { id: "private-id-canary", method: "initialize", params: { secret: "private-payload-canary" } }); + return { requestId: "resumed", promptStarted, events: { async *[Symbol.asyncIterator]() {} }, + result: new Promise(() => undefined), cancel: vi.fn(), closeStream: vi.fn() }; + }); + const port = await openCodexAcpxRuntime(options, { + createRegistry: () => registry(), createStore: () => store(), + awaitProviderOwnership: () => owned, awaitProviderExit: providerOwnershipEstablished, + createRuntime: value => { created = value; return runtime; }, + }); + const turn = port.startTurn({ text: "Resume", requestId: "resumed" }); + let acknowledged = false; + void turn.promptStarted.then(() => { acknowledged = true; }); + expect(phases.at(-1)).toBe("turn_initialize"); + await Promise.resolve(); + expect(acknowledged).toBe(false); + created.onAcpMessage!("inbound", { id: "private-id-canary", result: {} }); + created.onAcpMessage!("outbound", { id: 2, method: "session/load", params: { sessionId: "private-session-canary" } }); + expect(phases.at(-1)).toBe("turn_session_load"); + await Promise.resolve(); + expect(acknowledged).toBe(false); + created.onAcpMessage!("inbound", { id: 2, result: {} }); + submit(); + await ownershipEntered; + expect(phases.at(-1)).toBe("turn_ownership"); + expect(acknowledged).toBe(false); + verifyOwner(); + await expect(turn.promptStarted).resolves.toBeUndefined(); + expect(phases).toEqual(["turn_prepare", "turn_spawn", "turn_spawned", "turn_initialize", "turn_initialized", + "turn_session_load", "turn_session_loaded", "turn_prompt_submitted", "turn_ownership", "turn_ownership_verified"]); + expect(JSON.stringify(phases)).not.toContain("canary"); + }); + it("admits a verified provider that starts with the first recovered turn", async () => { const runtime = fakeRuntime(); const child = fakeChild(); diff --git a/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts index 9255b0bc94e..a7ab68ce7dd 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts @@ -1,3 +1,4 @@ +import { createTurnAdmissionDiagnostics } from "./turn-admission-diagnostics.js"; import { createPiThinkingAdmission, resolvePiThinkingLevel } from "./pi-thinking.js"; import type { ChildProcess } from "node:child_process"; @@ -190,6 +191,7 @@ export async function openQualifiedAcpxRuntime( const createRegistry = dependencies.createRegistry ?? createAgentRegistry; const createStore = dependencies.createStore ?? createRuntimeStore; const createRuntime = dependencies.createRuntime ?? createAcpRuntime; + const turnDiagnostics = createTurnAdmissionDiagnostics(options.onTurnStage); const runtimeCloseTimeoutMs = dependencies.runtimeCloseTimeoutMs ?? DEFAULT_RUNTIME_CLOSE_TIMEOUT_MS; const retainedCleanupOwners = new WeakSet>(); @@ -426,6 +428,8 @@ export async function openQualifiedAcpxRuntime( : null; }, onSessionNotification: acceptGoalNotification, + onAcpMessage: turnDiagnostics.onAcpMessage, + onAgentSpawn: () => turnDiagnostics.report("turn_spawned"), spawnEnvironment: () => ({ ...definedEnvironment(options.launchEnvironment), ...(options.profile.agent === "claude" @@ -447,6 +451,7 @@ export async function openQualifiedAcpxRuntime( options.signal?.throwIfAborted(); options.assertWorkspaceHeld?.(); commandLaunches.count += 1; + turnDiagnostics.report("turn_spawn"); return children.add( options.command.spawn(input.args, input.options, { credentialFenceFds, @@ -605,6 +610,7 @@ export async function openQualifiedAcpxRuntime( commandLaunches, permissionBoundary, extensionBoundary, + turnDiagnostics, options.profile.agent === "copilot" ? assertCopilotPromptPolicy : undefined, ); } catch (error) { @@ -1035,6 +1041,7 @@ function runtimePort( commandLaunches: { count: number; refreshConsumedCommand?: () => Promise }, permissionBoundary: { active: AbortController | null; hasAdmittedTurn: boolean; handler?: AcpRuntimeOptions["onPermissionRequest"] }, extensionBoundary: AcpxRuntimeExtensionBoundary, + turnDiagnostics: ReturnType, assertPromptPolicy?: (text: string) => void, ): AcpxRuntimePort { extensionBoundary.sessionIds = new Set([identity.backendSessionId]); @@ -1395,6 +1402,7 @@ function runtimePort( startTurn(input) { assertPromptPolicy?.(input.text); if (extensionBoundary.active) throw new Error("ACPX runtime already has an active turn"); + const turnProgress = turnDiagnostics.begin(); const approval = new AbortController(); const controller = new AbortController(); const extensionTurn: AcpxRuntimeExtensionTurn = { @@ -1405,6 +1413,7 @@ function runtimePort( }; extensionBoundary.active = extensionTurn; const releaseExtensionTurn = (): void => { + turnProgress.end(); controller.abort(new Error("ACPX extension turn expired")); if (extensionBoundary.active === extensionTurn) extensionBoundary.active = null; }; @@ -1433,7 +1442,11 @@ function runtimePort( void finishOwnershipAdmission().catch(() => undefined); throw error; } - const guarded = turnWithVerifiedLifetimeOwnership(turn, finishOwnershipAdmission); + const guarded = turnWithVerifiedLifetimeOwnership(turn, async () => { + turnProgress.report("turn_ownership"); + await finishOwnershipAdmission(); + turnProgress.report("turn_ownership_verified"); + }, () => turnProgress.report("turn_prompt_submitted")); extensionTurn.promptStarted = guarded.promptStarted; const result = guarded.result.then( (value) => { @@ -1484,6 +1497,7 @@ function runtimePort( function turnWithVerifiedLifetimeOwnership( turn: AcpxRuntimeTurn, finishOwnershipAdmission: () => Promise, + onPromptSubmitted: () => void, ): AcpxRuntimeTurn { // A persisted ACPX session can be loaded without starting an agent process. // Keep the narrowly scoped turn admission open until either the provider has @@ -1491,7 +1505,7 @@ function turnWithVerifiedLifetimeOwnership( // stable-empty seal in SpawnedChildSet then rejects every later spawn. const reachedAdmissionBoundary = Promise.race([ turn.promptStarted.then( - () => undefined, + () => onPromptSubmitted(), () => undefined, ), turn.result.then( diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts index 999efa6eed2..a7c2a5c0797 100644 --- a/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts @@ -87,8 +87,27 @@ describe("Copilot build-owned installation", () => { expect(QUALIFIED_ACPX_PROFILES.copilot.qualificationStatus).toBe("pending"); }); - it("admits the current v17 declaration and binds its source policy, receipts and patch hashes", () => { - const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v17-identity.json", import.meta.url), "utf8")); + it("preserves v17 history while binding admission stage diagnostics", async () => { + const prior = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v17-identity.json", import.meta.url), "utf8")); + const current = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v18-identity.json", import.meta.url), "utf8")); + expect(Object.keys(current.declaration).filter(key => current.declaration[key] !== prior.declaration[key]).sort()).toEqual(["agentProfileVersion", "semanticSidecarSourceSha256"]); + expect(prior.declaration.agentProfileVersion).toBe(17); + expect(current.declaration.agentProfileVersion).toBe(18); + const sorted = Object.fromEntries(Object.entries(prior.declaration).sort(([a], [b]) => a.localeCompare(b))); + expect(`sha256:${createHash("sha256").update(JSON.stringify(sorted)).digest("hex")}`).toBe(prior.commandDigest); + expect(current.commandDigest).not.toBe(prior.commandDigest); + expect(QUALIFIED_ACPX_PROFILES.copilot.qualificationStatus).toBe("pending"); + vi.mocked(verifyNativeAcpxInstallation).mockClear(); + for (const override of [ + { commandDigest: prior.commandDigest }, + { agentProfileVersion: prior.declaration.agentProfileVersion }, + { commandDigest: prior.commandDigest, agentProfileVersion: prior.declaration.agentProfileVersion }, + ]) await expect(verifyCopilotInstallation({ ...QUALIFIED_ACPX_PROFILES.copilot, ...override })).rejects.toThrow("exact pinned"); + expect(verifyNativeAcpxInstallation).not.toHaveBeenCalled(); + }); + + it("admits the current v18 declaration and binds its source policy, receipts and patch hashes", () => { + const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v18-identity.json", import.meta.url), "utf8")); expect(identity.declaration.systemInstructionDelivery).toBe(COPILOT_SYSTEM_INSTRUCTION_DELIVERY); expect(identity.declaration.sharedRuntimeContract).toBe("paperclip.acpx-runtime-contract.v1"); expect(identity.declaration.permissionContextContract).toBe(COPILOT_PERMISSION_CONTEXT_CONTRACT); @@ -119,7 +138,7 @@ describe("Copilot build-owned installation", () => { expect([...closure].filter(path => !bound.has(path))).toEqual(["negative-control:unbound-policy"]); }); it.each([...PERMISSION_POLICY_SOURCES, ...SEMANTIC_RECEIPT_SOURCES])("changing %s changes the candidate identity", (_path, field) => { - const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v17-identity.json", import.meta.url), "utf8")); + const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v18-identity.json", import.meta.url), "utf8")); identity.declaration[field] = "0".repeat(64); const sorted = Object.fromEntries(Object.entries(identity.declaration).sort(([a], [b]) => a.localeCompare(b))); expect(`sha256:${createHash("sha256").update(JSON.stringify(sorted)).digest("hex")}`).not.toBe(identity.commandDigest); diff --git a/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts b/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts index 3a0bf38cf33..68d93b9840f 100644 --- a/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts +++ b/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts @@ -48,12 +48,12 @@ export const QUALIFIED_ACPX_PROFILE_DATA = { "protocolVersion": 1, "acpxVersion": "0.13.1", "agent": "copilot", - "agentProfileVersion": 17, + "agentProfileVersion": 18, "agentServerPackage": "@github/copilot", "agentServerVersion": "1.0.88", "agentRuntimePackage": null, "agentRuntimeVersion": null, - "commandDigest": "sha256:481d0852ae8272a90f9912723d540518a874a0da65a9070e33b52ea1a14cbd7f", + "commandDigest": "sha256:e08c25d07389522abfcda22d081ea25bac370576c3e0ee8ad3fe96c4b653f036", "qualificationStatus": "pending", "permissionPolicy": "interactive" }, diff --git a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.test.ts b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.test.ts index 089aeec06bc..b3f17569448 100644 --- a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.test.ts @@ -3,11 +3,15 @@ import { constants } from "node:fs"; import { runInNewContext } from "node:vm"; import { fork, type ChildProcess } from "node:child_process"; import { once } from "node:events"; +import { pathToFileURL } from "node:url"; +import { acpxSidecarErrorCode } from "../../cli/acpx-sidecar-input.js"; import { chmod, link, mkdir, mkdtemp, + open, + type FileHandle, readFile, realpath, rename, @@ -15,6 +19,7 @@ import { symlink, stat, writeFile, + utimes, } from "node:fs/promises"; import { createServer, type Server } from "node:net"; import { tmpdir } from "node:os"; @@ -25,11 +30,14 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { resolveQualifiedAcpxProfile } from "./qualified-profiles.js"; import { verifyProvisionedGrokExecutable, + openVerifiedRuntimeExecutable, + inspectCommand, builtinGrokLauncherPath, awaitVerifiedAcpxProviderExit, awaitVerifiedAcpxProviderOwnership, createAcpxPackageJsonResolver, guardSnapshotModuleLookup, + readStableSnapshotModuleSource, guardSnapshotModuleResolution, reapCurrentProviderProcessGroup, sanitizedNodeEnvironment, @@ -54,6 +62,66 @@ afterEach(async () => { }); describe("ACPX installation integrity", () => { + describe.each(["runtime", "command"] as const)("%s executable hydration", (kind) => { + const verify = kind === "runtime" ? openVerifiedRuntimeExecutable : inspectCommand; + it.each(["once", "every_read", "wrong_digest", "mode", "mtime", "replacement", "symlink", "second_content"] as const)("requires stable full re-verification after a ctime-only transition (%s)", async (transition) => { + const directory = await mkdtemp(join(tmpdir(), "paperclip-runtime-hydration-")); + temporaryDirectories.push(directory); + const executable = join(directory, "runtime"); + const bytes = Buffer.from("qualified runtime fixture"); + await writeFile(executable, bytes, { mode: 0o755 }); + const digest = `sha256:${createHash("sha256").update(transition === "wrong_digest" ? "wrong" : bytes).digest("hex")}`; + const probe = await open(executable, "r"); + const prototype = Object.getPrototypeOf(probe) as FileHandle; + const originalRead = probe.read; + await probe.close(); + let reads = 0; + const readSpy = vi.spyOn(prototype, "read").mockImplementation(async function (this: FileHandle, ...args: Parameters) { + if (reads === 1 && transition === "second_content") { + await writeFile(executable, Buffer.alloc(bytes.length, 120)); + } + const result = await Reflect.apply(originalRead, this, args); + reads += 1; + if (reads === 1 || transition === "every_read") { + const before = await stat(executable, { bigint: true }); + // chmod to the existing mode models Boat hydration: ctime alone changes. + for (let attempt = 0; attempt < 20; attempt += 1) { + await chmod(executable, 0o755); + if ((await stat(executable, { bigint: true })).ctimeNs !== before.ctimeNs) break; + await new Promise((resolve) => setTimeout(resolve, 1)); + } + const after = await stat(executable, { bigint: true }); + expect(after.ctimeNs).not.toBe(before.ctimeNs); + expect([after.dev, after.ino, after.size, after.mode, after.mtimeNs]).toEqual([before.dev, before.ino, before.size, before.mode, before.mtimeNs]); + if (transition === "mode") await chmod(executable, 0o700); + if (transition === "mtime") await utimes(executable, new Date(0), new Date(0)); + if (transition === "replacement" || transition === "symlink") { + const moved = `${executable}.original`; + await rename(executable, moved); + if (transition === "symlink") await symlink(moved, executable); + else await writeFile(executable, bytes, { mode: 0o755 }); + } + } + return result; + }); + try { + if (transition === "once") { + const verified = await verify(executable, digest, "fixture"); + try { + const stable = await stat(executable, { bigint: true }); + if ("bytes" in verified) expect(verified.bytes).toEqual(bytes); + expect(verified.identity.changedNanoseconds).toBe(stable.ctimeNs.toString()); + expect(verified.identity.modifiedNanoseconds).toBe(stable.mtimeNs.toString()); + } finally { if ("handle" in verified) await verified.handle.close(); } + } else { + await expect(verify(executable, digest, "fixture")).rejects.toThrow(transition === "wrong_digest" ? "digest mismatch" : "changed while it was verified"); + } + expect(reads).toBe(["once", "every_read", "second_content"].includes(transition) ? 2 : 1); + } finally { readSpy.mockRestore(); } + }); + + }); + it.each([false, true])("admits only the declared qualified Codex platform in its npm-hoisted slot (published platform declaration: %s)", async (publishedDeclaration) => { const parent = await realpath(await mkdtemp(join(tmpdir(), "paperclip-codex-npm-hoist-"))); temporaryDirectories.push(parent); @@ -1642,6 +1710,189 @@ describe("ACPX installation integrity", () => { await expectFailure(child, "requires Linux descriptor-pinned paths"); } }); + describe("descriptor-pinned dependency read continuity", () => { + it.each(["stable", "once", "recurring", "changed_bytes", "dev", "ino", "mode", "uid", "gid", "nlink", "size", "mtimeNs", "not_regular", "oversized", "snapshot_first", "snapshot_second"] as const)( + "requires bounded complete read continuity (%s)", (transition) => { + const original = Buffer.from("qualified fixture bytes"); + const changed = Buffer.from("different fixture bytes"); + expect(changed.length).toBe(original.length); + const initial = { dev: 1n, ino: 2n, mode: 0o100444n, uid: 1000n, gid: 1000n, nlink: 1n, size: BigInt(original.length), + mtimeNs: 10n, ctimeNs: 20n, isFile: () => true }; + let pass = 0; + const verify = vi.fn((bytes: Buffer) => { + if (transition.startsWith("snapshot_")) { + const expected = createHash("sha256").update(original).digest("hex"); + if (createHash("sha256").update(bytes).digest("hex") !== expected) throw new Error("snapshot digest mismatch"); + } + }); + const read = () => readStableSnapshotModuleSource({ + before: { ...initial, ...(transition === "oversized" ? { size: 17n } : {}) }, + maxBytes: transition === "oversized" ? 16 : 64, + stat: () => { + const metadata = { ...initial, ctimeNs: transition === "stable" ? 20n : 21n }; + if (transition === "recurring" && pass === 2) metadata.ctimeNs = 22n; + if (["dev", "ino", "mode", "uid", "gid", "nlink", "size", "mtimeNs"].includes(transition)) { + const field = transition as "dev" | "ino" | "mode" | "uid" | "gid" | "nlink" | "size" | "mtimeNs"; + metadata[field] += 1n; + } + if (transition === "not_regular") metadata.isFile = () => false; + return metadata; + }, + read: (buffer, offset, length, position) => { + if (position === 0) pass += 1; + const source = (transition === "snapshot_first" + || ((transition === "changed_bytes" || transition === "snapshot_second") && pass === 2)) ? changed : original; + return source.copy(buffer, offset, position, position + length); + }, + verify, + }); + if (transition === "stable" || transition === "once") { + expect(read()).toEqual(original); + expect(pass).toBe(transition === "stable" ? 1 : 2); + expect(verify).toHaveBeenCalledTimes(pass); + } else { + expect(read).toThrow(); + const reasons: Record = { recurring: "REPEATED_CTIME", changed_bytes: "BYTE_CONTINUITY", + dev: "DEVICE", ino: "INODE", mode: "MODE", uid: "UID", gid: "GID", nlink: "LINK_COUNT", + size: "SIZE", mtimeNs: "MTIME", not_regular: "BOUNDS", oversized: "BOUNDS" }; + if (reasons[transition]) { + pass = 0; + expect(read).toThrow(expect.objectContaining({ code: "ERR_ACPX_UNVERIFIED_MODULE", moduleFence: reasons[transition] })); + } + expect(pass).toBeLessThanOrEqual(2); + if (transition === "snapshot_second") expect(verify).toHaveBeenCalledTimes(2); + } + }, + ); + }); + + it.runIf(process.platform !== "win32").each(["once", "recurring", "bytes", "mode", "rename", "replacement", "symlink"] as const)("loads dependency bytes only after stable ctime-only continuity (%s)", async (transition) => { + const fixture = await installationFixture(); + await writeFile(join(fixture.commandDirectory, "dependency.mjs"), 'export const value = "dependency-success";'); + const command = `import fs from 'node:fs'; + const read = fs.readSync.bind(fs), stat = fs.fstatSync.bind(fs); + let moduleFd, passes = 0; + fs.readSync = (fd, buffer, offset, length, position) => { + const count = read(fd, buffer, offset, length, position); + if (buffer.subarray(offset, offset + count).includes(Buffer.from('dependency-success'))) { + moduleFd = fd; passes += 1; + if (${JSON.stringify(transition)} === 'bytes' && passes === 2) buffer[offset] = 0; + if (passes === 1 && ['rename','replacement','symlink'].includes(${JSON.stringify(transition)})) { + const named = new URL('./dependency.mjs', import.meta.url); + fs.renameSync(named, new URL('./moved.mjs', import.meta.url)); + if (${JSON.stringify(transition)} === 'replacement') fs.writeFileSync(named, buffer.subarray(offset, offset + count)); + if (${JSON.stringify(transition)} === 'symlink') fs.symlinkSync('./moved.mjs', named); + } + } + return count; + }; + fs.fstatSync = (fd, options) => { + const metadata = stat(fd, options); + if (fd === moduleFd && options?.bigint) { + metadata.ctimeNs += ${JSON.stringify(transition)} === 'recurring' ? BigInt(passes) : 1n; + if (${JSON.stringify(transition)} === 'mode') metadata.mode ^= 0o100n; + } + return metadata; + }; + const dependency = await import('./dependency.mjs'); + process.stdout.write(dependency.value);`; + await writeFile(fixture.commandPath, command); + await writeFile(fixture.serverPackageJsonPath, JSON.stringify({ version: "0.0.33", bin: "bin/server.js", type: "module" })); + const installation = await verifyQualifiedAcpxInstallation({ ...fixture.profile, + commandDigest: `sha256:${createHash("sha256").update(command).digest("hex")}`, + }, fixture.resolve); + const lease = await installation.openCommand(); + const child = lease.spawn(); + const closed = once(child, "close"); + let stdout = ""; + child.stdout?.on("data", bytes => { stdout += String(bytes); }); + let stderr = ""; + child.stderr?.on("data", bytes => { stderr += String(bytes); }); + const timeout = setTimeout(() => child.kill("SIGKILL"), 4_000); + try { + const [code] = await closed; + expect(code).toBe(transition === "once" ? 0 : 1); + expect(stdout).toBe(transition === "once" ? "dependency-success" : ""); + const fences: Record = { recurring: "REPEATED_CTIME", bytes: "BYTE_CONTINUITY", mode: "MODE" }; + const fence = fences[transition]; + if (fence && !(transition === "bytes" && process.platform === "darwin")) { + expect(stderr).toContain(`PAPERCLIP_ACPX_IMPORT_FAILURE:UNVERIFIED_MODULE:${fence}:END\n`); + } + } finally { + clearTimeout(timeout); + if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + await closed; + await lease.close(); + } + }); + + it.runIf(process.platform !== "win32").each(["OTHER", "UNVERIFIED_MODULE"] as const)( + "settles a guarded ESM import failure without guardian retirement (%s)", + async (category) => { + const fixture = await installationFixture(); + const outside = join(fixture.root, "private-canary.mjs"); + await writeFile(outside, 'throw new Error("must-not-execute");'); + const command = category === "OTHER" + ? 'throw new Error("guarded-import-fixture-failed private-canary");' + : `import ${JSON.stringify(pathToFileURL(outside).href)};`; + await writeFile(fixture.commandPath, command); + await writeFile(fixture.serverPackageJsonPath, JSON.stringify({ + version: "0.0.33", bin: "bin/server.js", type: "module", + })); + const installation = await verifyQualifiedAcpxInstallation({ + ...fixture.profile, + commandDigest: `sha256:${createHash("sha256").update(command).digest("hex")}`, + }, fixture.resolve); + const credentialHome = join(fixture.root, "credential-home"); + await mkdir(credentialHome, { mode: 0o700 }); + const credential = await stageManagedCodexCredential({ + agentHomeDirectory: credentialHome, + environment: { PAPERCLIP_ACPX_CODEX_AUTH_JSON_SECRET: '{"fixture":true}' }, + }); + const lease = await installation.openCommand(); + const child = lease.spawn([], {}, { + credentialFenceFds: credential.lifetimeFenceFds, + activateCredentialFenceOwner: pid => credential.activateLifetimeOwner(pid), + }); + const exit = once(child, "exit"); + const closed = once(child, "close"); + let stderr = ""; + const failureSeen = new Promise(resolve => child.stderr?.on("data", bytes => { + stderr += String(bytes); + if (stderr.includes("PAPERCLIP_ACPX_IMPORT_FAILURE:")) resolve(); + })); + let timer: ReturnType | undefined; + let startupTimer: ReturnType | undefined; + try { + await Promise.race([failureSeen, new Promise((_, reject) => { + startupTimer = setTimeout(() => reject(new Error("fixture startup marker missing")), 3_000); + })]); + // Bound starts after the exact child error, excluding startup speed. + const settled = await Promise.race([exit.then(() => true), new Promise(resolve => { + timer = setTimeout(() => resolve(false), 500); + })]); + expect(settled).toBe(true); + await closed; + await awaitVerifiedAcpxProviderExit(child); + expect(child.exitCode === 0 && child.signalCode === null).toBe(false); + const code = acpxSidecarErrorCode(Object.assign(new Error("private-canary"), { + detailCode: "AGENT_STARTUP_FAILED", stderrSummary: stderr.replace(/\s+/gu, " ").trim().slice(0, 8192), + exitCode: child.exitCode, signal: child.signalCode, + })); + expect(code).toBe(category === "UNVERIFIED_MODULE" ? "AGENT_STARTUP_FAILED.UNVERIFIED_MODULE.ANCESTRY" : "AGENT_STARTUP_FAILED.OTHER"); + expect(code).not.toMatch(/canary|\//); + expect(stderr).not.toContain("must-not-execute"); + } finally { + if (timer) clearTimeout(timer); + if (startupTimer) clearTimeout(startupTimer); + if (child.exitCode === null && child.signalCode === null) child.kill("SIGTERM"); + await exit; + await lease.close(); + await credential.close(); + } + }, + ); + it.runIf(process.platform !== "win32")( "rejects incomplete or duplicate provider credential quorum descriptors", async () => { diff --git a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts index 05fd89fdc47..b51da6efe5b 100644 --- a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts +++ b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts @@ -7,7 +7,7 @@ import { type ChildProcess, type SpawnOptionsWithoutStdio, } from "node:child_process"; -import { closeSync, constants, existsSync, fstatSync, openSync, readFileSync, realpathSync } from "node:fs"; +import { type BigIntStats, closeSync, constants, existsSync, fstatSync, openSync, readFileSync, realpathSync } from "node:fs"; import { lstat, open, @@ -1093,7 +1093,7 @@ async function readBoundedRegularFile( return bytes; } -async function inspectCommand( +export async function inspectCommand( commandPath: string, expectedDigest: string, agent: string, @@ -1102,75 +1102,50 @@ async function inspectCommand( digest: string; identity: VerifiedAcpxCommandIdentity; }> { - const lexicalBefore = await lstat(commandPath, { bigint: true }).catch( - () => null, + const verified = await openVerifiedExecutable( + commandPath, + expectedDigest, + `ACPX ${agent} executable`, + { maxBytes: MAX_AGENT_COMMAND_BYTES, requireExecutable: false, captureBytes: true }, ); - if ( - lexicalBefore === null || - lexicalBefore.isSymbolicLink() || - !lexicalBefore.isFile() - ) { - throw new Error(`ACPX ${agent} executable must be a real regular file`); - } - let handle: Awaited>; try { - handle = await open( - commandPath, - verifiedExecutableOpenFlags(process.platform, constants.O_NOFOLLOW), - ); - } catch { - throw new Error( - `ACPX ${agent} executable could not be opened as a no-follow regular file`, - ); - } - try { - const before = await handle.stat({ bigint: true }); - if ( - !before.isFile() || - before.size < 1n || - before.size > BigInt(MAX_AGENT_COMMAND_BYTES) - ) { - throw new Error( - `ACPX ${agent} executable must be a bounded regular file`, - ); - } - const bytes = await readHandleAtStart(handle, Number(before.size)); - const after = await handle.stat({ bigint: true }); - const lexicalAfter = await lstat(commandPath, { bigint: true }).catch( - () => null, - ); - const beforeIdentity = fileIdentity(before); - const afterIdentity = fileIdentity(after); - if ( - bytes.length < 1 || - bytes.length > MAX_AGENT_COMMAND_BYTES || - lexicalAfter === null || - lexicalAfter.isSymbolicLink() || - !lexicalAfter.isFile() || - !sameIdentity(fileIdentity(lexicalBefore), fileIdentity(lexicalAfter)) || - !sameIdentity(fileIdentity(lexicalAfter), afterIdentity) || - !sameIdentity(beforeIdentity, afterIdentity) || - after.size !== BigInt(bytes.length) - ) { - throw new Error(`ACPX ${agent} executable changed while it was verified`); - } - const digest = `sha256:${createHash("sha256").update(bytes).digest("hex")}`; - if (digest !== expectedDigest) { - throw new Error(`ACPX ${agent} executable digest mismatch`); - } - return { bytes, digest, identity: afterIdentity }; - } catch (error) { - throw error; + return { + bytes: verified.bytes, + digest: verified.digest, + identity: verified.identity, + }; } finally { - await handle.close(); + await verified.handle.close(); } } -async function openVerifiedRuntimeExecutable( +export async function openVerifiedRuntimeExecutable( executablePath: string, expectedDigest: string, agent: string, ): Promise<{ handle: FileHandle; identity: VerifiedAcpxCommandIdentity }> { + const verified = await openVerifiedExecutable( + executablePath, + expectedDigest, + `ACPX ${agent} runtime executable`, + { maxBytes: MAX_ACPX_RUNTIME_EXECUTABLE_BYTES, requireExecutable: true, captureBytes: false }, + ); + return { handle: verified.handle, identity: verified.identity }; +} + +/** Verify command snapshots and native executables under the same identity fence. */ +async function openVerifiedExecutable( + executablePath: string, + expectedDigest: string, + label: string, + options: { maxBytes: number; requireExecutable: boolean; captureBytes: boolean }, +): Promise<{ + handle: FileHandle; + identity: VerifiedAcpxCommandIdentity; + digest: string; + bytes: Buffer; +}> { + const { maxBytes, requireExecutable, captureBytes } = options; const lexicalBefore = await lstat(executablePath, { bigint: true }).catch( () => null, ); @@ -1180,7 +1155,7 @@ async function openVerifiedRuntimeExecutable( !lexicalBefore.isFile() ) { throw new Error( - `ACPX ${agent} runtime executable must be a real regular file`, + `${label} must be a real regular file`, ); } @@ -1192,64 +1167,91 @@ async function openVerifiedRuntimeExecutable( ); } catch { throw new Error( - `ACPX ${agent} runtime executable could not be opened as a no-follow regular file`, + `${label} could not be opened as a no-follow regular file`, ); } try { - const before = await handle.stat({ bigint: true }); - if ( - !before.isFile() || - before.size < 1n || - before.size > BigInt(MAX_ACPX_RUNTIME_EXECUTABLE_BYTES) || - (before.mode & 0o111n) === 0n - ) { - throw new Error( - `ACPX ${agent} runtime executable must be a bounded executable file`, - ); - } - const hash = createHash("sha256"); - const buffer = Buffer.alloc(1024 * 1024); - let position = 0; - try { - while (position < Number(before.size)) { - const { bytesRead } = await handle.read( - buffer, - 0, - Math.min(buffer.length, Number(before.size) - position), - position, + let lexicalBaseline = lexicalBefore; + for (let attempt = 0; attempt < 2; attempt += 1) { + const before = await handle.stat({ bigint: true }); + if ( + !before.isFile() || + before.size < 1n || + before.size > BigInt(maxBytes) || + (requireExecutable && (before.mode & 0o111n) === 0n) + ) { + throw new Error( + `${label} must be a bounded ${requireExecutable ? "executable file" : "regular file"}`, ); - if (bytesRead === 0) break; - hash.update(buffer.subarray(0, bytesRead)); - position += bytesRead; } - } finally { - buffer.fill(0); - } - const after = await handle.stat({ bigint: true }); - const lexicalAfter = await lstat(executablePath, { bigint: true }).catch( - () => null, - ); - const beforeIdentity = fileIdentity(before); - const afterIdentity = fileIdentity(after); - if ( - position !== Number(before.size) || - lexicalAfter === null || - lexicalAfter.isSymbolicLink() || - !lexicalAfter.isFile() || - !sameIdentity(fileIdentity(lexicalBefore), fileIdentity(lexicalAfter)) || - !sameIdentity(fileIdentity(lexicalAfter), afterIdentity) || - !sameIdentity(beforeIdentity, afterIdentity) - ) { - throw new Error( - `ACPX ${agent} runtime executable changed while it was verified`, + const beforeIdentity = fileIdentity(before); + if ( + !sameIdentity(fileIdentity(lexicalBaseline), beforeIdentity) || + lexicalBaseline.mode !== before.mode + ) { + throw new Error( + `${label} changed while it was verified`, + ); + } + const hash = createHash("sha256"); + const buffer = Buffer.alloc(1024 * 1024); + const chunks: Buffer[] = []; + let position = 0; + try { + while (position < Number(before.size)) { + const { bytesRead } = await handle.read( + buffer, + 0, + Math.min(buffer.length, Number(before.size) - position), + position, + ); + if (bytesRead === 0) break; + hash.update(buffer.subarray(0, bytesRead)); + if (captureBytes) chunks.push(Buffer.from(buffer.subarray(0, bytesRead))); + position += bytesRead; + } + } finally { + buffer.fill(0); + } + const after = await handle.stat({ bigint: true }); + const lexicalAfter = await lstat(executablePath, { bigint: true }).catch( + () => null, ); + const afterIdentity = fileIdentity(after); + if ( + position !== Number(before.size) || + lexicalAfter === null || + lexicalAfter.isSymbolicLink() || + !lexicalAfter.isFile() || + !sameIdentity(fileIdentity(lexicalAfter), afterIdentity) || + lexicalAfter.mode !== after.mode || + before.mode !== after.mode || + !sameIdentity( + { ...beforeIdentity, changedNanoseconds: afterIdentity.changedNanoseconds }, + afterIdentity, + ) + ) { + throw new Error( + `${label} changed while it was verified`, + ); + } + const digest = `sha256:${hash.digest("hex")}`; + if (digest !== expectedDigest) { + throw new Error(`${label} digest mismatch`); + } + if (sameIdentity(beforeIdentity, afterIdentity)) { + return { handle, identity: afterIdentity, digest, bytes: Buffer.concat(chunks, captureBytes ? position : 0) }; + } + // A lazy filesystem restore can change ctime on the first read. Only + // already-qualified bytes with otherwise identical metadata may receive + // one complete re-read on this same descriptor. The final pass must be + // fully stable; later launch identity comparisons remain strict. + lexicalBaseline = lexicalAfter; } - const digest = `sha256:${hash.digest("hex")}`; - if (digest !== expectedDigest) { - throw new Error(`ACPX ${agent} runtime executable digest mismatch`); - } - return { handle, identity: afterIdentity }; + throw new Error( + `${label} changed while it was verified`, + ); } catch (error) { await handle.close(); throw error; @@ -1411,23 +1413,6 @@ function verifiedDirectoryOpenFlags( return constants.O_RDONLY | noFollowFlag | directoryFlag; } -async function readHandleAtStart( - handle: FileHandle, - size: number, -): Promise { - const bytes = Buffer.alloc(size); - let offset = 0; - while (offset < size) { - const read = await handle.read(bytes, offset, size - offset, offset); - if (read.bytesRead === 0) break; - offset += read.bytesRead; - } - if (offset !== size) { - throw new Error("ACPX provider executable ended during verification"); - } - return bytes; -} - function commandLease( commandDirectoryPath: string, commandName: string, @@ -1863,6 +1848,60 @@ export function sanitizedNodeEnvironment( return sanitized; } +/** + * Read continuity for descriptor-pinned dependency modules. Linux has no separate + * per-module expected digest here; equal reads do not create new provenance. + * Darwin's existing private-snapshot digest verifier still runs on every pass. + * This function is serialized into the child, so it must remain self-contained. + */ +export function readStableSnapshotModuleSource(input: { + before: Pick; + stat: () => Pick; + read: (buffer: Buffer, offset: number, length: number, position: number) => number; + verify: (bytes: Buffer) => void; + maxBytes: number; +}): Buffer { + const reject = (moduleFence: string): never => { + throw Object.assign(new Error("ACPX provider module changed while it was read"), { + code: "ERR_ACPX_UNVERIFIED_MODULE", moduleFence, + }); + }; + let before = input.before; + let first: Buffer | null = null; + for (let attempt = 0; attempt < 2; attempt += 1) { + if (!before.isFile() || before.size < 0n || before.size > BigInt(input.maxBytes)) reject("BOUNDS"); + const size = Number(before.size); + const buffer = Buffer.alloc(size + 1); + let offset = 0; + while (offset < buffer.length) { + const count = input.read(buffer, offset, buffer.length - offset, offset); + if (!Number.isSafeInteger(count) || count < 0 || count > buffer.length - offset) reject("READ_RESULT"); + if (count === 0) break; + offset += count; + } + const bytes = buffer.subarray(0, offset); + const after = input.stat(); + input.verify(bytes); + if (!after.isFile()) reject("BOUNDS"); + if (bytes.length !== size || BigInt(bytes.length) !== after.size || before.size !== after.size) reject("SIZE"); + if (before.dev !== after.dev) reject("DEVICE"); + if (before.ino !== after.ino) reject("INODE"); + if (before.mode !== after.mode) reject("MODE"); + if (before.uid !== after.uid) reject("UID"); + if (before.gid !== after.gid) reject("GID"); + if (before.nlink !== after.nlink) reject("LINK_COUNT"); + if (before.mtimeNs !== after.mtimeNs) reject("MTIME"); + if (first !== null && !first.equals(bytes)) reject("BYTE_CONTINUITY"); + if (before.ctimeNs === after.ctimeNs) return bytes; + if (attempt !== 0) reject("REPEATED_CTIME"); + first = bytes; + // Continue from the full final metadata, including ctime. A further drift + // between passes is therefore rejected, not granted another transition. + before = after; + } + return reject("REPEATED_CTIME"); +} + function snapshotBootstrap(format: AcpxCommandFormat, guarded = false): string { return [ 'const fs = require("node:fs");', @@ -1926,11 +1965,12 @@ function snapshotBootstrap(format: AcpxCommandFormat, guarded = false): string { `const snapshotDescriptorResolution = ${snapshotDescriptorResolution.toString()};`, "const dependencyAncestorIndex = (url) => { const recorded = dependencyAncestorByUrl.get(url); return recorded === undefined ? snapshotDescriptorAncestorIndex(url, directoryUrl, dependencyDirectoryUrls) : recorded; };", `const guardSnapshotModuleResolution = ${guardSnapshotModuleResolution.toString()};`, - 'const canonicalizeDescriptorResolution = (url) => { if (typeof url !== "string" || !url.startsWith("file:") || snapshotDescriptorAncestorIndex(url, directoryUrl, dependencyDirectoryUrls) < 0) return url; try { return pathToFileURL(fs.realpathSync(fileURLToPath(url))).href; } catch { const error = new Error("ACPX provider module could not be canonicalized through its retained descriptor"); error.code = "ERR_ACPX_UNVERIFIED_MODULE"; throw error; } };', + 'const canonicalizeDescriptorResolution = (url) => { if (typeof url !== "string" || !url.startsWith("file:") || snapshotDescriptorAncestorIndex(url, directoryUrl, dependencyDirectoryUrls) < 0) return url; try { return pathToFileURL(fs.realpathSync(fileURLToPath(url))).href; } catch { const error = new Error("ACPX provider module could not be canonicalized through its retained descriptor"); error.code = "ERR_ACPX_UNVERIFIED_MODULE"; error.moduleFence = "RESOLUTION"; throw error; } };', 'const rememberDependencyAncestor = (specifier, resolution) => { const canonicalUrl = canonicalizeDescriptorResolution(resolution?.url); const pinned = snapshotDescriptorResolution(canonicalUrl, directoryUrl, dependencyDirectoryUrls, canonicalDirectoryUrl, canonicalDependencyDirectoryUrls); guardSnapshotModuleResolution(isBuiltin(specifier), resolution?.url, pinned !== null); if (pinned !== null && typeof resolution?.url === "string") { for (const rememberedUrl of [resolution.url, canonicalUrl, pinned.url]) { if (typeof rememberedUrl !== "string") continue; dependencyAncestorByUrl.set(rememberedUrl, pinned.ancestorIndex); if (typeof resolution.format === "string") descriptorFormatByUrl.set(rememberedUrl, resolution.format); } } return pinned === null || pinned.url === resolution?.url ? resolution : { ...resolution, url: pinned.url }; };', `const source = fs.readFileSync(${COMMAND_SOURCE_FD});`, "let resolvingDescriptorBare = false;", "const resolveBareFromDescriptor = (specifier, dependencyDirectoryUrl) => { resolvingDescriptorBare = true; try { return require.resolve(specifier, { paths: [fileURLToPath(dependencyDirectoryUrl)] }); } finally { resolvingDescriptorBare = false; } };", + `const readStableSnapshotModuleSource = ${readStableSnapshotModuleSource.toString()};`, "registerHooks({ resolve(specifier, context, nextResolve) {", "if (resolvingDescriptorBare) return nextResolve(specifier, context);", "if (specifier === target) return { url: target, shortCircuit: true };", @@ -1952,7 +1992,7 @@ function snapshotBootstrap(format: AcpxCommandFormat, guarded = false): string { 'return new URL(segments.slice(ancestorLevels).join("/") || ".", dependencyDirectoryUrls[targetAncestorIndex]);', "};", "const pinnedSpecifier = relativeImport ? pinRelativeSpecifier() : null;", - 'if (relativeImport && pinnedSpecifier === null) { const error = new Error("ACPX provider relative module escaped its verified package"); error.code = "ERR_ACPX_UNVERIFIED_MODULE"; throw error; }', + 'if (relativeImport && pinnedSpecifier === null) { const error = new Error("ACPX provider relative module escaped its verified package"); error.code = "ERR_ACPX_UNVERIFIED_MODULE"; error.moduleFence = "ANCESTRY"; throw error; }', 'const lookupSpecifier = pinnedSpecifier === null ? specifier : context.conditions?.includes("require") ? fileURLToPath(pinnedSpecifier) : pinnedSpecifier.href;', "const snapshotImport = entryImport || parentDependencyAncestorIndex >= 0;", 'const bareImport = snapshotImport && !isBuiltin(specifier) && !specifier.startsWith("./") && !specifier.startsWith("../") && !specifier.startsWith("/") && !specifier.includes(":");', @@ -1982,30 +2022,36 @@ function snapshotBootstrap(format: AcpxCommandFormat, guarded = false): string { "if (!descriptorLookup) return nextLoad(url, context);", "const canonicalRootUrl = url.startsWith(directoryUrl) ? canonicalDirectoryUrl : canonicalDependencyDirectoryUrls[dependencyDescriptorIndex];", "let moduleFd;", - 'try { moduleFd = fs.openSync(fileURLToPath(url), fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); } catch { const error = new Error("ACPX provider module could not be opened without following its final component"); error.code = "ERR_ACPX_UNVERIFIED_MODULE"; throw error; }', + 'try { moduleFd = fs.openSync(fileURLToPath(url), fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); } catch { const error = new Error("ACPX provider module could not be opened without following its final component"); error.code = "ERR_ACPX_UNVERIFIED_MODULE"; error.moduleFence = "OPEN"; throw error; }', "try {", "const metadataBefore = fs.fstatSync(moduleFd, { bigint: true });", - `if (!metadataBefore.isFile() || metadataBefore.size > BigInt(${MAX_AGENT_COMMAND_BYTES})) { const error = new Error("ACPX provider module is not a bounded regular file"); error.code = "ERR_ACPX_UNVERIFIED_MODULE"; throw error; }`, + `if (!metadataBefore.isFile() || metadataBefore.size > BigInt(${MAX_AGENT_COMMAND_BYTES})) { const error = new Error("ACPX provider module is not a bounded regular file"); error.code = "ERR_ACPX_UNVERIFIED_MODULE"; error.moduleFence = "BOUNDS"; throw error; }`, 'const openedUrl = pathToFileURL(fs.realpathSync(privateSnapshot ? fileURLToPath(url) : "/proc/self/fd/" + moduleFd)).href;', - 'if (typeof canonicalRootUrl !== "string" || !openedUrl.startsWith(canonicalRootUrl)) { const error = new Error("ACPX provider module escaped descriptor-pinned ancestry"); error.code = "ERR_ACPX_UNVERIFIED_MODULE"; throw error; }', + 'if (typeof canonicalRootUrl !== "string" || !openedUrl.startsWith(canonicalRootUrl)) { const error = new Error("ACPX provider module escaped descriptor-pinned ancestry"); error.code = "ERR_ACPX_UNVERIFIED_MODULE"; error.moduleFence = "ANCESTRY"; throw error; }', "const packageFormat = url.startsWith(directoryUrl) ? serverPackageFormat : dependencyAncestorFormats[dependencyDescriptorIndex];", "const hintedFormat = descriptorFormatByUrl.get(url) || context.format;", "const extension = extname(fileURLToPath(url));", 'const moduleFormat = extension === ".mjs" ? "module" : extension === ".cjs" ? "commonjs" : extension === ".json" ? "json" : extension === ".node" ? "addon" : extension === ".js" ? (hintedFormat === "module" || hintedFormat === "commonjs" ? hintedFormat : packageFormat) : hintedFormat;', - 'if (moduleFormat !== "module" && moduleFormat !== "commonjs" && moduleFormat !== "json") { const error = new Error("ACPX provider module format is not supported by descriptor-pinned loading"); error.code = "ERR_ACPX_UNVERIFIED_MODULE"; throw error; }', - "const admittedModuleBytes = Number(metadataBefore.size);", - "const moduleBuffer = Buffer.alloc(admittedModuleBytes + 1);", - "let moduleBytesRead = 0;", - "while (moduleBytesRead < moduleBuffer.length) { const bytesRead = fs.readSync(moduleFd, moduleBuffer, moduleBytesRead, moduleBuffer.length - moduleBytesRead, moduleBytesRead); if (bytesRead === 0) break; moduleBytesRead += bytesRead; }", - "const moduleSource = moduleBuffer.subarray(0, moduleBytesRead);", - "const metadataAfter = fs.fstatSync(moduleFd, { bigint: true });", - "verifySnapshotBytes(fileURLToPath(url), moduleSource);", - `if (moduleSource.length > ${MAX_AGENT_COMMAND_BYTES} || moduleSource.length !== admittedModuleBytes || BigInt(moduleSource.length) !== metadataAfter.size || metadataBefore.dev !== metadataAfter.dev || metadataBefore.ino !== metadataAfter.ino || metadataBefore.size !== metadataAfter.size || metadataBefore.mtimeNs !== metadataAfter.mtimeNs || metadataBefore.ctimeNs !== metadataAfter.ctimeNs) { const error = new Error("ACPX provider module changed while it was read"); error.code = "ERR_ACPX_UNVERIFIED_MODULE"; throw error; }`, + 'if (moduleFormat !== "module" && moduleFormat !== "commonjs" && moduleFormat !== "json") { const error = new Error("ACPX provider module format is not supported by descriptor-pinned loading"); error.code = "ERR_ACPX_UNVERIFIED_MODULE"; error.moduleFence = "FORMAT"; throw error; }', + // Re-check both the retained descriptor's canonical path and its named + // inode after each pass; a rename is not harmless hydration just because + // its already-open descriptor still returns identical bytes. + 'const statModule = () => { const currentUrl = pathToFileURL(fs.realpathSync(privateSnapshot ? fileURLToPath(url) : "/proc/self/fd/" + moduleFd)).href; const named = fs.lstatSync(fileURLToPath(url), { bigint: true }); const metadata = fs.fstatSync(moduleFd, { bigint: true }); if (currentUrl !== openedUrl || !named.isFile() || named.dev !== metadata.dev || named.ino !== metadata.ino) { const error = new Error("ACPX provider module path changed while it was read"); error.code = "ERR_ACPX_UNVERIFIED_MODULE"; error.moduleFence = "PATH_IDENTITY"; throw error; } return metadata; };', + `const moduleSource = readStableSnapshotModuleSource({ before: metadataBefore, stat: statModule, read: (buffer, offset, length, position) => fs.readSync(moduleFd, buffer, offset, length, position), verify: (bytes) => verifySnapshotBytes(fileURLToPath(url), bytes), maxBytes: ${MAX_AGENT_COMMAND_BYTES} });`, "return { format: moduleFormat, source: moduleSource, shortCircuit: true };", "} finally { fs.closeSync(moduleFd); }", "});", "} });", - "import(target).catch((error) => { console.error(error); process.exitCode = 1; });", + // A guardian stream intentionally keeps the provider alive. A failed module + // import must use the existing owned-group reaper or ACP initialize waits + // until its deadline. process.exit can itself wait on the guardian pipe read. + // Capture these before provider code runs; emit a closed marker synchronously + // so the existing startup-error classifier survives truncated private stderr. + "const writeImportFailure = fs.writeSync.bind(fs);", + guarded + ? "const exitOnImportFailure = reapOnGuardianLoss;" + : "const exitOnImportFailure = process.exit.bind(process);", + 'import(target).catch((error) => { let category = "OTHER"; try { if (error?.code === "ERR_ACPX_UNVERIFIED_MODULE") { category = "UNVERIFIED_MODULE"; const fence = error.moduleFence; if (["RESOLUTION", "OPEN", "BOUNDS", "ANCESTRY", "FORMAT", "PATH_IDENTITY", "READ_RESULT", "SIZE", "DEVICE", "INODE", "MODE", "UID", "GID", "LINK_COUNT", "MTIME", "BYTE_CONTINUITY", "REPEATED_CTIME"].includes(fence)) category += ":" + fence; } } catch {} try { writeImportFailure(2, "PAPERCLIP_ACPX_IMPORT_FAILURE:" + category + ":END\\n"); console.error(error); } finally { exitOnImportFailure(1); } });', ].join(""); } @@ -2038,7 +2084,7 @@ export function guardSnapshotModuleResolution( const error = new Error( "ACPX provider module escaped descriptor-pinned ancestry", ); - Object.assign(error, { code: "ERR_ACPX_UNVERIFIED_MODULE" }); + Object.assign(error, { code: "ERR_ACPX_UNVERIFIED_MODULE", moduleFence: "ANCESTRY" }); throw error; } } 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..9af98b14c11 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts @@ -201,6 +201,54 @@ afterEach(async () => { }); describe("ACPX runtime host", () => { + it.each(["throw", "reject"] as const)("keeps admission diagnostics observational when the observer can %s", async (failure) => { + const fixture = await hostFixture(); + const port = runtimePort({ getStatus: async () => ({ models: { currentModelId: "claude-sonnet-5" } }) }); + const gate = deferred(); + const stages: Array<{ stage: string; elapsedMs: number }> = []; + const enteredRuntime = deferred(); + const openRuntime = vi.fn((_options: AcpxRuntimePortOpenOptions) => { + enteredRuntime.resolve(); + return gate.promise; + }); + const opening = trackAdmissionOpening(AcpxRuntimeHost.open({ + ...fixture.options, agent: "claude", model: "claude-sonnet-5", + semanticTools: { tools: [], handler: async () => ({}) }, + }, { + ...fixture.dependencies({ openRuntime }), + onAdmissionStage: (stage, elapsedMs) => { + stages.push({ stage, elapsedMs }); + if (failure === "throw") throw new Error("private-observer-canary"); + return Promise.reject(new Error("private-observer-canary")); + }, + })); + try { + await enteredRuntime.promise; + expect(openRuntime).toHaveBeenCalledOnce(); + expect(stages.at(-1)?.stage).toBe("handshake"); + } finally { + gate.resolve(port); + } + const host = await opening; + expect(stages.map(({ stage }) => stage)).toEqual([ + "binding", "installation", "sandbox", "lifetime", "agent_files", "skills", + "command", "tool_bridge", "handshake", "verification", "ready", + ]); + for (const [index, { elapsedMs }] of stages.entries()) { + expect(Number.isInteger(elapsedMs)).toBe(true); + expect(elapsedMs).toBeGreaterThanOrEqual(stages[index - 1]?.elapsedMs ?? 0); + expect(elapsedMs).toBeLessThanOrEqual(3_600_000); + } + expect(() => openRuntime.mock.calls[0]![0].onTurnStage?.("turn_initialize")).not.toThrow(); + await Promise.resolve(); + expect(stages.at(-1)?.stage).toBe("turn_initialize"); + expect(Number.isInteger(stages.at(-1)?.elapsedMs)).toBe(true); + expect(host.identity().acpxRecordId).toBe("record-1"); + await host.close({ reason: "diagnostic observer test complete" }); + expect(port.close).toHaveBeenCalledOnce(); + expect(fixture.commandClose).toHaveBeenCalledOnce(); + }); + it.each([undefined, "plan", "ask"] as const)("requires observed Cursor mode %s in the host identity", async selected => { const fixture = await hostFixture(); const mode = selected ?? "agent"; @@ -901,6 +949,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..64db6a4d836 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts @@ -1,3 +1,4 @@ +import type { AcpxTurnAdmissionStage } from "./turn-admission-diagnostics.js"; import { piProviderConfiguration } from "./pi-provider-config.js"; import { withAcpxTurnCancellation } from "./turn-cancellation.js"; import { resolveAcpxProviderMode } from "./provider-mode.js"; @@ -149,6 +150,8 @@ export interface AcpxRuntimePort { } export interface AcpxRuntimePortOpenOptions { + /** Closed active-turn progress, measured from the host admission origin. */ + onTurnStage?: (stage: AcpxTurnAdmissionStage) => void; /** A durable identity was admitted; restored native mode must already match. */ restoringSession?: boolean; /** Ephemeral provider extension metadata; mandatory ACP caps remain host-owned. */ @@ -199,7 +202,12 @@ export interface AcpxRetainedCleanupFailure { error: unknown; } +export type AcpxAdmissionStage = AcpxTurnAdmissionStage | "binding" | "installation" | "sandbox" | "lifetime" + | "agent_files" | "skills" | "command" | "tool_bridge" | "handshake" | "verification" | "ready" | "cleanup"; + export interface AcpxRuntimeHostDependencies { + /** Closed diagnostic progress only; observer failures cannot affect ownership. */ + onAdmissionStage?: (stage: AcpxAdmissionStage, elapsedMs: number) => void; verifyInstallation?: ( profile: QualifiedAcpxProfile, ) => Promise; @@ -325,6 +333,15 @@ export class AcpxRuntimeHost { options: OpenAcpxRuntimeHostOptions, dependencies: AcpxRuntimeHostDependencies, ): Promise { + const admissionStarted = performance.now(); + const reportStage = (stage: AcpxAdmissionStage): void => { + try { + void Promise.resolve(dependencies.onAdmissionStage?.(stage, + Math.max(0, Math.min(3_600_000, Math.round(performance.now() - admissionStarted))), + )).catch(() => undefined); + } catch { /* Diagnostics never own admission or cleanup. */ } + }; + reportStage("binding"); options.signal?.throwIfAborted(); if (options.agent === "pi" && typeof options.providerPolicy?.readOnly !== "boolean") { throw new Error("Pi admission requires an explicit task execution policy"); @@ -368,6 +385,7 @@ export class AcpxRuntimeHost { ); } + reportStage("installation"); const installation = await runAbortableAdmissionStage( options.signal, () => @@ -422,6 +440,7 @@ export class AcpxRuntimeHost { retainRuntimeHostCleanup(ownedCleanup); }; try { + reportStage("sandbox"); const sandbox = await runAbortableAdmissionStage( options.signal, () => @@ -443,6 +462,7 @@ export class AcpxRuntimeHost { }), dependencies.retainAdmissionCleanup, ); + reportStage("lifetime"); if (options.agent === "codex") { credential = await acquireAbortableAdmissionResource({ signal: options.signal, @@ -483,7 +503,8 @@ 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) + reportStage("agent_files"); + const agentFiles = options.runtimeContext?.persistentAgentHome || ["cursor", "copilot", "pi"].includes(options.agent) ? bindAcpxAgentFiles(options.runtimeContext, [sandbox.root, ...(installation.agentServerPackageJsonPath === null ? [] : [dirname(installation.agentServerPackageJsonPath)]), ]) : null; @@ -522,6 +543,7 @@ export class AcpxRuntimeHost { PAPERCLIP_PI_READ_ROOTS: JSON.stringify([...(options.providerPolicy?.readRoots ?? []), ...skills.readRoots]), }); } + reportStage("skills"); if (options.agent === "claude" || options.agent === "grok") { // The lifetime lease proves the previous provider has stopped. Refresh // the assigned snapshot before every launch, including durable resume; @@ -534,6 +556,7 @@ export class AcpxRuntimeHost { ); options.signal?.throwIfAborted(); } + reportStage("command"); command = await acquireAbortableAdmissionResource({ signal: options.signal, acquire: () => installation.openCommand({ signal: options.signal }), @@ -547,6 +570,7 @@ export class AcpxRuntimeHost { signal => installation.openCommand({ signal }), ); command = commandOwner.command; + reportStage("tool_bridge"); toolBridge = options.semanticTools ? await acquireAbortableAdmissionResource({ signal: options.signal, @@ -561,6 +585,7 @@ export class AcpxRuntimeHost { if (admittedLifetime === null) { throw new Error("ACPX provider lifetime lease is unavailable"); } + reportStage("handshake"); runtime = await acquireAbortableAdmissionResource({ signal: options.signal, acquire: async () => { @@ -568,6 +593,7 @@ export class AcpxRuntimeHost { agentFiles?.assertHeld(); await assertAcpxProfileWorkspace(options.agent, binding.workspacePath); return dependencies.openRuntime({ + onTurnStage: reportStage, ...(options.clientCapabilities === undefined ? {} : { clientCapabilities: structuredClone(options.clientCapabilities), }), @@ -628,6 +654,7 @@ export class AcpxRuntimeHost { }); }, }); + reportStage("verification"); const runtimeIdentity = await runAbortableAdmissionStage( options.signal, () => @@ -661,6 +688,7 @@ export class AcpxRuntimeHost { verifyExpectedAcpxIdentity(options.expectedIdentity, binding, identity); } options.signal?.throwIfAborted(); + reportStage("ready"); admissionSucceeded = true; return new AcpxRuntimeHost({ runtime, @@ -676,6 +704,7 @@ export class AcpxRuntimeHost { : [], }); } catch (error) { + reportStage("cleanup"); error = classifyAcpxProfileError(options.agent, error) ?? error; const cleanup = cleanupRuntimeResources( runtime, diff --git a/packages/paperclip-runner/src/drivers/acpx/turn-admission-diagnostics.test.ts b/packages/paperclip-runner/src/drivers/acpx/turn-admission-diagnostics.test.ts new file mode 100644 index 00000000000..7cde70399f1 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/turn-admission-diagnostics.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { createTurnAdmissionDiagnostics, type AcpxTurnAdmissionStage } from "./turn-admission-diagnostics.js"; + +describe("closed turn admission diagnostics", () => { + it.each([ + ["initialize", "turn_initialize", "turn_initialized"], + ["session/load", "turn_session_load", "turn_session_loaded"], + ["session/resume", "turn_session_resume", "turn_session_resumed"], + ["session/new", "turn_session_new", "turn_session_created"], + ] as const)("retains pending %s without exposing wire data", (method, sent, completed) => { + const stages: AcpxTurnAdmissionStage[] = []; + const diagnostics = createTurnAdmissionDiagnostics(stage => stages.push(stage)); + diagnostics.onAcpMessage("outbound", { id: 1, method }); + expect(stages).toEqual([]); + diagnostics.begin(); + diagnostics.onAcpMessage("outbound", { id: "private-id-canary", method, params: { path: "/secret-canary", token: "token-canary" } }); + expect(stages.at(-1)).toBe(sent); + diagnostics.onAcpMessage("inbound", { id: "unrelated", result: {} }); + expect(stages.at(-1)).toBe(sent); + diagnostics.onAcpMessage("inbound", { id: "private-id-canary", result: { content: "response-canary" } }); + expect(stages.at(-1)).toBe(completed); + expect(JSON.stringify(stages)).not.toMatch(/canary/); + }); + + it("ignores rejected, unknown, stale and oversized requests", () => { + const stages: AcpxTurnAdmissionStage[] = []; + const diagnostics = createTurnAdmissionDiagnostics(stage => stages.push(stage)); + const old = diagnostics.begin(); + diagnostics.onAcpMessage("outbound", { id: 1, method: "session/load" }); + diagnostics.onAcpMessage("inbound", { id: 1, error: { message: "private-canary" } }); + expect(stages.at(-1)).toBe("turn_session_load"); + diagnostics.onAcpMessage("outbound", { id: 2, method: "initialize" }); + diagnostics.begin(); + const count = stages.length; + old.report("turn_ownership_verified"); + old.end(); + diagnostics.onAcpMessage("inbound", { id: 2, result: {} }); + diagnostics.onAcpMessage("outbound", { id: 3, method: "constructor" }); + diagnostics.onAcpMessage("outbound", { id: "x".repeat(129), method: "initialize" }); + expect(stages).toHaveLength(count); + diagnostics.report("turn_spawn"); + expect(stages.at(-1)).toBe("turn_spawn"); + }); + + it.each([false, true])("isolates throwing/rejecting observers (async=%s)", async (async) => { + const diagnostics = createTurnAdmissionDiagnostics(() => { + if (async) return Promise.reject(new Error("observer-canary")); + throw new Error("observer-canary"); + }); + expect(() => { + const turn = diagnostics.begin(); + diagnostics.onAcpMessage("outbound", { id: 1, method: "initialize" }); + diagnostics.onAcpMessage("inbound", { id: 1, result: {} }); + turn.report("turn_prompt_submitted"); + turn.end(); + }).not.toThrow(); + await Promise.resolve(); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/turn-admission-diagnostics.ts b/packages/paperclip-runner/src/drivers/acpx/turn-admission-diagnostics.ts new file mode 100644 index 00000000000..337113ccb05 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/turn-admission-diagnostics.ts @@ -0,0 +1,70 @@ +/** Closed progress only. Wire IDs are correlated in memory and never reported. */ +export type AcpxTurnAdmissionStage = + | "turn_prepare" | "turn_spawn" | "turn_spawned" + | "turn_initialize" | "turn_initialized" + | "turn_session_load" | "turn_session_loaded" + | "turn_session_resume" | "turn_session_resumed" + | "turn_session_new" | "turn_session_created" + | "turn_prompt_submitted" | "turn_ownership" | "turn_ownership_verified"; + +type WireStage = readonly [AcpxTurnAdmissionStage, AcpxTurnAdmissionStage]; +const WIRE_STAGES: Readonly> = { + initialize: ["turn_initialize", "turn_initialized"], + "session/load": ["turn_session_load", "turn_session_loaded"], + "session/resume": ["turn_session_resume", "turn_session_resumed"], + "session/new": ["turn_session_new", "turn_session_created"], +}; + +export function createTurnAdmissionDiagnostics( + observe?: (stage: AcpxTurnAdmissionStage) => void, +) { + let active: symbol | null = null; + const pending = new Map(); + const report = (stage: AcpxTurnAdmissionStage): void => { + if (!active) return; + try { + void Promise.resolve(observe?.(stage)).catch(() => undefined); + } catch { /* Progress observers cannot change provider authority. */ } + }; + return { + begin() { + const token = Symbol(); + active = token; + pending.clear(); + report("turn_prepare"); + return { + report(stage: AcpxTurnAdmissionStage) { + if (active === token) report(stage); + }, + end() { + if (active !== token) return; + active = null; + pending.clear(); + }, + }; + }, + report, + onAcpMessage(direction: "inbound" | "outbound", value: unknown) { + if (!active || !value || typeof value !== "object" || Array.isArray(value)) return; + const message = value as Record; + const id = message.id; + if (!(typeof id === "string" && id.length <= 128) + && !(typeof id === "number" && Number.isSafeInteger(id))) return; + if (direction === "outbound") { + const method = message.method; + const stages = typeof method === "string" && Object.hasOwn(WIRE_STAGES, method) + ? WIRE_STAGES[method] : undefined; + if (!stages || pending.size >= 16) return; + pending.set(id, stages); + report(stages[0]); + } else { + const stages = pending.get(id); + if (!stages || Object.hasOwn(message, "method")) return; + pending.delete(id); + // This marks a successful wire response, not later auth/replay/checkpoint work. + // A rejected response is not evidence that initialize/load completed. + if (Object.hasOwn(message, "result") && !Object.hasOwn(message, "error")) report(stages[1]); + } + }, + }; +} 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 062482ecad5..4e68be2944b 100644 --- a/packages/paperclip-runner/src/drivers/runtime-context-materializer.test.ts +++ b/packages/paperclip-runner/src/drivers/runtime-context-materializer.test.ts @@ -1,3 +1,4 @@ +import { createRunnerdCodexAppServerArgs } from "../live/runnerd-codex-transport.js"; import { chmod, lstat, @@ -27,6 +28,7 @@ import { nativeMcpLaunchBinding } from "./native-mcp.js"; import { materializeNativeRuntimeSkills, prepareIsolatedCodexHome, + releaseMaterializedNativeRuntimeSkills, } from "./runtime-context-materializer.js"; const roots: string[] = []; @@ -93,6 +95,19 @@ 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" }; @@ -503,3 +518,12 @@ 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"); +}); diff --git a/packages/paperclip-runner/src/drivers/runtime-context-materializer.ts b/packages/paperclip-runner/src/drivers/runtime-context-materializer.ts index 973da0bf674..6ebbd037ad6 100644 --- a/packages/paperclip-runner/src/drivers/runtime-context-materializer.ts +++ b/packages/paperclip-runner/src/drivers/runtime-context-materializer.ts @@ -335,6 +335,8 @@ export async function prepareIsolatedCodexHome(input: { codexHome: string; sourceCodexHome?: string | null; nativeMcp?: NativeMcpLaunchBinding | null; + /** Trusted execution-environment capability, never inherited from host config. */ + computerTool?: { command: string; args: readonly string[] } | null; apiKey?: string | null; }): Promise { await materializeNativeRuntimeSkills( @@ -365,6 +367,13 @@ export async function prepareIsolatedCodexHome(input: { "", ] : []), + ...(input.computerTool ? [ + "[mcp_servers.paperclip_computer]", + `command = ${JSON.stringify(input.computerTool.command)}`, + `args = ${JSON.stringify(input.computerTool.args)}`, + 'default_tools_approval_mode = "approve"', + "", + ] : []), ].join("\n"), { mode: 0o600 }); const targetAuth = join(input.codexHome, "auth.json"); diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts index debd0c6e3fe..243a5c3c781 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts @@ -643,6 +643,46 @@ it("retargets only composed instruction framing and retains legacy custom instru }); +it.each([false, true])("preserves ACPX asset framing across cold, warm, and post-idle attachment (guidance=%s)", (guidance) => { + const source = assignedRuntimeContext("/controller/skills", "/controller/bundle"); + const cold = assignedRuntimeContext("/boat/skills", "/boat/context/cold"); + const resumed = assignedRuntimeContext("/boat/skills", "/boat/context/resumed"); + const custom = "Managed instructions with literal /controller/bundle example."; + const original = composeNativeSystemInstructions(source, custom); + const coldText = runnerdRecoveryInternals.retargetComposedInstructions( + runnerdRecoveryInternals.withComputerProcessInstructions(original, guidance, source), + source, + cold, + ); + const suffix = (context: NativeRuntimeContextSnapshot) => + composeNativeSystemInstructions(context, "").slice(context.prompt.text.length); + expect(coldText.endsWith(suffix(cold))).toBe(true); + const state = { runAttachTemplate: { provider: { + kind: "acpx", agent: "claude", runId: "cold-run", runtimeContext: cold, instructions: coldText, + } } }; + const desired = { + runnerInstanceId: "runner", environmentLeaseId: "workspace", runId: "warm-run", + normalizedSessionId: "same-session", turnId: "turn", itemId: "item", + }; + const warm = runnerdRecoveryInternals.rotatedRunAttachPayload(state, desired, null, undefined); + expect((warm.provider as { instructions: string }).instructions).toBe(coldText); + const recover = (text: string) => runnerdRecoveryInternals.rotatedRunAttachPayload( + { runAttachTemplate: warm }, { ...desired, runId: "post-idle-run" }, null, undefined, resumed, + { text, context: source }, + ).provider as { instructions: string; runtimeContext: NativeRuntimeContextSnapshot }; + const recovered = recover(composeNativeSystemInstructions(source, custom)); + expect(recovered.runtimeContext).toEqual(resumed); + expect(recovered.instructions.endsWith(suffix(resumed))).toBe(true); + // This is the immutable-prefix equality required by Claude's guest guard. + expect(recovered.instructions.slice(0, -suffix(resumed).length)) + .toBe(coldText.slice(0, -suffix(cold).length)); + expect(recovered.instructions).toContain(custom); + expect(recovered.instructions).not.toContain("Read-only instruction sibling root: /controller/"); + // Never silently substitute old managed instructions for current grants. + expect(recover(composeNativeSystemInstructions(source, "Changed managed instructions")).instructions) + .toContain("Changed managed instructions"); +}); + it("replays the durable run attachment outcome and latest provider identity", () => { expect( runnerdRecoveryInternals.recoveredRunAttachment({ @@ -2158,6 +2198,32 @@ it.each(["installed", "explicit"] as const)("records the selected %s Codex comma } }); +it.each([false, true])("binds detached-process guidance only for a trusted persistent computer (%s)", async (computer) => { + const root = await mkdtemp(join(tmpdir(), "paperclip-computer-process-guidance-")); + const runnerBinary = join(root, "runnerd"); + await writeFile(runnerBinary, "unexecuted fixture artifact", { mode: 0o700 }); + let instructions = ""; + const { transport } = createCapabilityRunnerdCodexTransport({ + provider: "codex", runnerBinary, stateDirectory: root, sourceCodexHome: "", + codexCommand: "/qualified/codex", persistentAgentHome: computer ? "/agent/home" : undefined, + controlPlaneRegistration: async (authority) => { + instructions = String((authority.store.state.runAttachTemplate!.provider as { instructions: string }).instructions); + throw new Error("fixture_stop_before_runner_launch"); + }, + runnerProcessLauncher: () => { throw new Error("must not launch"); }, + }); + try { + await expect(transport.request("thread/start", { cwd: root, model: "gpt-6.1-sol", dynamicTools: [], + developerInstructions: "Preserve the task instructions." })).rejects.toThrow("fixture_stop_before_runner_launch"); + expect(instructions).toContain("Preserve the task instructions."); + expect(instructions.includes("nohup setsid")).toBe(computer); + if (computer) { + expect(instructions).toContain("stop when its warm timeout expires"); + expect(instructions).toContain("Do not create services or change runner ownership"); + } + } finally { await transport.close(); await rm(root, { recursive: true, force: true }); } +}); + it("rejects remote Codex without a guest executable before resolving controller dependencies", async () => { const root = await mkdtemp(join(tmpdir(), "paperclip-runner-remote-codex-command-")); const runnerBinary = join(root, "runnerd"); @@ -2219,6 +2285,16 @@ it.each(["missing", "changed"] as const)("reuses the recorded Codex command when expect(JSON.stringify(template)).not.toContain(replacement); expect(resolver).not.toHaveBeenCalled(); expect(launch).not.toHaveBeenCalled(); + const persisted = JSON.parse(await readFile(join(root, "control-plane", "control-plane-state.json"), "utf8")); + expect(persisted.identity).toMatchObject(nextIdentity); + expect(persisted.runAttachTemplate).toEqual(template); + // Later command compaction must not erase the resumed epoch's launch authority. + expect(runnerdRecoveryInternals.rotatedRunAttachPayload( + { ...persisted, commands: [] }, + { ...nextIdentity, runId: "run-after-compaction" }, + null, + undefined, + )).toMatchObject({ provider: { command, model: "gpt-6.1-sol" } }); } finally { resolver.mockRestore(); await transport.close(); diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index 71103812f2a..c127a1955db 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -501,9 +501,20 @@ function retargetRunAttachPayload( // context, never the prior run's now-stale filesystem grant. if (runtimeContext !== undefined) { if (currentInstructions !== undefined) { - provider.instructions = currentInstructions.context - ? retargetComposedInstructions(currentInstructions.text, currentInstructions.context, runtimeContext) + // Controller guidance is part of the durable provider profile. A + // controller upgrade must not inject it into an existing session; new + // managed instructions still use this run's authenticated snapshot. + const instructions = typeof provider.instructions === "string" + && provider.instructions.includes(COMPUTER_PROCESS_INSTRUCTIONS) + ? withComputerProcessInstructions( + currentInstructions.text, + true, + currentInstructions.context, + ) : currentInstructions.text; + provider.instructions = currentInstructions.context + ? retargetComposedInstructions(instructions, currentInstructions.context, runtimeContext) + : instructions; } else if (typeof provider.instructions === "string" && provider.runtimeContext) { provider.instructions = retargetComposedInstructions( provider.instructions, @@ -1205,6 +1216,10 @@ export interface CapabilityRunnerdProcessEvidence { } export interface CapabilityRunnerdCodexTransportOptions { + /** Server-authorized local computer MCP on the selected execution environment. */ + computerTool?: { command: string; args: readonly string[] } | null; + /** Controller-authorized durable personal directory, independent of instructions. */ + persistentAgentHome?: string; provider?: "codex" | "opencode" | "claude_managed" | "aws_agentcore" | "acpx"; opencodePermissionMode?: NativeOpenCodePermissionMode; acpxAgent?: QualifiedAcpxAgent; @@ -3439,12 +3454,32 @@ export function trustedRuntimeReadOnlyRoots( return [...roots]; } +const COMPUTER_PROCESS_INSTRUCTIONS = `Computer runtime: files in AGENT_HOME persist across turns. A dev server needed between warm turns must survive the shell tool's process-group cleanup: use a detached session, for example \`nohup setsid dev-server.log 2>&1 &\`, and verify its listener from a separate command after the launching command returns. Detached processes still belong to this runner and stop when its warm timeout expires. Do not create services or change runner ownership to keep them alive.`; + +function withComputerProcessInstructions( + instructions: string, + computerRuntime: boolean, + context?: NativeRuntimeContextSnapshot | null, +): string { + if (!computerRuntime) return instructions; + if (context) { + const suffix = composeNativeSystemInstructions(context, "").slice(context.prompt.text.length); + if (suffix && instructions.startsWith(context.prompt.text) && instructions.endsWith(suffix)) { + // Keep the canonical asset block last: ACPX verifies this framing when + // rotating a registered filesystem grant after idle or process recovery. + return `${instructions.slice(0, -suffix.length)}\n\n${COMPUTER_PROCESS_INSTRUCTIONS}${suffix}`; + } + } + return `${instructions}\n\n${COMPUTER_PROCESS_INSTRUCTIONS}`; +} + export function createRunnerdCodexAppServerArgs(input: { environment: NodeJS.ProcessEnv | undefined; codexHome: string; codexCommand?: string; readOnlyRoots?: string[]; instructionWorkingCopyRoot?: string; + persistentAgentHome?: string; }): string[] { // The filesystem policy denies HOME and CODEX_HOME to keep credentials and // runner state outside provider reach. Always bind those names to the actual @@ -3455,9 +3490,10 @@ export function createRunnerdCodexAppServerArgs(input: { ...input.environment, HOME: input.codexHome, CODEX_HOME: input.codexHome, + ...(input.persistentAgentHome ? { AGENT_HOME: input.persistentAgentHome } : {}), }, [...(input.readOnlyRoots ?? []), ...codexExecutableReadOnlyRoots(input.environment ?? {}, input.codexCommand)], - input.instructionWorkingCopyRoot, + input.instructionWorkingCopyRoot ?? input.persistentAgentHome, ); } @@ -4731,6 +4767,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { this.options.environment?.CODEX_API_KEY ?? this.options.environment?.OPENAI_API_KEY, nativeMcp: nativeMcpLaunchBinding(this.options.environment), + computerTool: this.options.computerTool, }); } const opencodeProxyPath = @@ -4819,15 +4856,12 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { const includeCodexCollaborationInstructions = provider === "codex" && record(params.config).include_collaboration_mode_instructions !== false; - const unboundBaseInstructions = String( + const unboundBaseInstructions = withComputerProcessInstructions(String( params.developerInstructions ?? params.baseInstructions ?? "You are a Paperclip agent.", - ); + ), Boolean(this.options.persistentAgentHome), sourceRuntimeContext); const baseInstructions = sourceRuntimeContext && runtimeContext - ? unboundBaseInstructions.replaceAll( - sourceRuntimeContext.instructions.bundle.rootPath, - runtimeContext.instructions.bundle.rootPath, - ) + ? retargetComposedInstructions(unboundBaseInstructions, sourceRuntimeContext, runtimeContext) : unboundBaseInstructions; const acpxProfile = provider === "acpx" @@ -4966,6 +5000,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { codexHome, codexCommand: effectiveCodexCommand, instructionWorkingCopyRoot: runtimeContext?.instructions.workingCopy?.rootPath, + persistentAgentHome: this.options.persistentAgentHome, readOnlyRoots: [ ...trustedRuntimeReadOnlyRoots( this.options.environment, @@ -5450,6 +5485,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { codexHome, codexCommand: typeof recordedCommand === "string" ? recordedCommand : this.options.codexCommand, instructionWorkingCopyRoot: runtimeContext?.instructions.workingCopy?.rootPath, + persistentAgentHome: this.options.persistentAgentHome, readOnlyRoots: [ ...trustedRuntimeReadOnlyRoots(this.options.environment), ...(runtimeContext @@ -5465,6 +5501,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { }); } this.#runAttachTemplate = structuredClone(runAttachTemplate); + core.persistRunAttachTemplate(runAttachTemplate); core.queueCommand("run.attach", runAttachTemplate); } const committedEvents = core.store.state.committedEvents; @@ -5632,6 +5669,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { this.options.environment?.CODEX_API_KEY ?? this.options.environment?.OPENAI_API_KEY, nativeMcp: nativeMcpLaunchBinding(this.options.environment), + computerTool: this.options.computerTool, }); } const adoptedRunner = this.options.adoptExistingRunner; @@ -7073,6 +7111,8 @@ export const runnerdLaunchProfileInternals = Object.freeze({ }); export const runnerdRecoveryInternals = Object.freeze({ + withComputerProcessInstructions, + retargetComposedInstructions, completedMaintenanceTerminalReceipt, completedMaintenanceTerminalReplayMatches, readControlPlaneState, diff --git a/packages/paperclip-runner/src/native-session-runtime.test.ts b/packages/paperclip-runner/src/native-session-runtime.test.ts index 610813ef9c6..83ecfe439df 100644 --- a/packages/paperclip-runner/src/native-session-runtime.test.ts +++ b/packages/paperclip-runner/src/native-session-runtime.test.ts @@ -326,7 +326,7 @@ describe("executeNativeSession recovery", () => { const completeRun = vi.fn(async () => {}); try { const execution = executeNativeSession({ - input, turnTimeoutMs, + input, turnTimeoutMs, bootstrapTimeoutMs: 5, backend: { async descriptor() { return { kind: "mock", name: "long-lived", version: "1", capabilities }; }, async openSession() { return session; }, @@ -2256,15 +2256,18 @@ describe("executeNativeSession recovery", () => { runnerInstanceId: "runner-recovery", controlPlaneInstanceId: "control-recovery", timeoutMs: 5, + bootstrapTimeoutMs: 10, }); const execution = execute(); const rejection = expect(execution).rejects.toThrow( - "native session bootstrap timed out after 5ms", + "native session bootstrap timed out after 10ms", ); await bootstrapStarted; expect(bootstrapSignal?.aborted).toBe(false); await vi.advanceTimersByTimeAsync(5); + expect(bootstrapSignal?.aborted).toBe(false); + await vi.advanceTimersByTimeAsync(5); await rejection; expect(bootstrapSignal?.aborted).toBe(true); expect(openRun).not.toHaveBeenCalled(); @@ -2659,6 +2662,7 @@ describe("executeNativeSession recovery", () => { runnerInstanceId: "runner-recovery", controlPlaneInstanceId: "control-recovery", timeoutMs: 10, + bootstrapTimeoutMs: 100, keepSessionOpen: true, onSession: (current) => retainedSessions.push(current), }); @@ -5513,6 +5517,7 @@ describe("executeNativeSession recovery", () => { controlPlaneInstanceId: "control-recovery", persistedSession: structuredClone(checkpoint), timeoutMs: 5, + bootstrapTimeoutMs: 100, onSession, }); const rejection = expect(execution).rejects.toThrow( @@ -5648,15 +5653,18 @@ describe("executeNativeSession recovery", () => { controlPlaneInstanceId: "control-recovery", persistedSession: structuredClone(checkpoint), timeoutMs: 5, + bootstrapTimeoutMs: 10, onSession, }); const rejection = expect(execution).rejects.toThrow( - "native session replacement bootstrap timed out after 5ms", + "native session replacement bootstrap timed out after 10ms", ); await replacementStarted; expect(replacementSignal?.aborted).toBe(false); await vi.advanceTimersByTimeAsync(5); + expect(replacementSignal?.aborted).toBe(false); + await vi.advanceTimersByTimeAsync(5); await rejection; expect(replacementSignal?.aborted).toBe(true); expect(openRun).not.toHaveBeenCalled(); diff --git a/packages/paperclip-runner/src/native-session-runtime.ts b/packages/paperclip-runner/src/native-session-runtime.ts index e01dd745e23..51b43052d4c 100644 --- a/packages/paperclip-runner/src/native-session-runtime.ts +++ b/packages/paperclip-runner/src/native-session-runtime.ts @@ -207,6 +207,8 @@ export interface ExecuteNativeSessionOptions { remoteCleanupScope?: string; /** Operation bound; explicit values also preserve the legacy turn bound. */ timeoutMs?: number; + /** Initial/replacement provider creation only; leaves recovery, turns and finalization unchanged. */ + bootstrapTimeoutMs?: number; /** Total turn duration. Zero or no configured bound allows long-running work. */ turnTimeoutMs?: number; /** Abort admission while waiting for prior cleanup in the same domain. */ @@ -2094,8 +2096,8 @@ export async function executeNativeSession( workingDirectory: input.workspace.cwd ?? undefined, }; session = await runAbortableOperationWithin({ - timeoutMs: recoveryTimeoutMs, - timeoutMessage: `native session replacement bootstrap timed out after ${recoveryTimeoutMs}ms`, + timeoutMs: options.bootstrapTimeoutMs ?? recoveryTimeoutMs, + timeoutMessage: `native session replacement bootstrap timed out after ${options.bootstrapTimeoutMs ?? recoveryTimeoutMs}ms`, operation: (signal) => { const abortableReplacementInput = { ...replacementInput, signal }; return options.backend.openReplacementSession @@ -2121,7 +2123,7 @@ export async function executeNativeSession( // A fresh provider session is part of admission. Prove that it exists // before opening durable run state because ControlPlanePort intentionally // exposes no rollback for an admitted run. - const bootstrapTimeoutMs = options.timeoutMs ?? 900_000; + const bootstrapTimeoutMs = options.bootstrapTimeoutMs ?? options.timeoutMs ?? 900_000; const bootstrapInput = { identity, workingDirectory: input.workspace.cwd ?? undefined, diff --git a/packages/paperclip-runner/test/fixtures/copilot-profile-v18-identity.json b/packages/paperclip-runner/test/fixtures/copilot-profile-v18-identity.json new file mode 100644 index 00000000000..2f7f35c5220 --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-profile-v18-identity.json @@ -0,0 +1,43 @@ +{ + "commandDigest": "sha256:e08c25d07389522abfcda22d081ea25bac370576c3e0ee8ad3fe96c4b653f036", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "copilot", + "agentProfileVersion": 18, + "acpxVersion": "0.13.1", + "agentServerVersion": "1.0.88", + "protocolPolicyRevision": "copilot-agent-manual-v1", + "acpxPatchSha256": "00d6af17216cb0cad902b48633eb19bf0ec6b05837a984cb24661fca593f09c1", + "policySha256": "a4be69be43db84052560420a3ca546a665c9165573c24d543eb33d144ba138a3", + "distributionSourceSha256": "c0a65e247fc47b263bd369a3d5ab505c4b323d4e6493be8a9ecbeb0e9dbb9107", + "agentFilesBinding": "registered-runtime-context-v1", + "systemInstructionDelivery": "COPILOT_HOME/copilot-instructions.md:replace-under-lifetime-lease-before-launch:v1", + "sharedRuntimeContract": "paperclip.acpx-runtime-contract.v1", + "innerDistributionSourceSha256": "9bb08347eed01f1ab44e065c8b06ead9be3a25973d6e6329dc4c0a099096279a", + "upstreamAppSha256": "7b48282a19b5b0814a0c96ad5e3a125173d792cc55f9525b2effea962f6063c0", + "patchedAppSha256": "61f8eebe0c30cc03b311c6818b2acb158a6d16ca58c8bfac5ebd42db095c2679", + "messageIdentityContract": "copilot-native-message-id-v1", + "ownedDistributionDelivery": "COPILOT_CLI_DIST_DIR:lease-owned-guarded-inner-distribution:v1", + "permissionContextContract": "copilot-edit-permission-context-v1", + "permissionContextSourceSha256": "fe1bb611bea289d81b1c7c973fbb01b93de133a7caa8fe78b7ff4ee1f485b224", + "permissionAdapterSourceSha256": "49421a779c76e286ce65d75052026eab9630b1995af9ccf045a2d43d284633ce", + "permissionLocationsSourceSha256": "f39a81da395b6e23aa2a1a45572a28846e5e81bfd3d764d9a456f74a79c752e9", + "permissionRedactionSourceSha256": "ad6770c69269087951790e2e87e22ac088e3d432769adf17ebc865ed512e6c4f", + "permissionClassifierSourceSha256": "1161eabd82e26133e3b3db3ab567f65d52808b03f176e93497e02c8330349fb4", + "permissionIdentitySourceSha256": "770b47305e571a81f344883057d0e4a482a1cd59ac77d045836593a8e739deb1", + "semanticToolReceiptContract": "paperclip.semantic_tool_receipt.v2", + "semanticReceiptSourceSha256": "b2795726b2234a663fec1a6ca11f0551ae69882fe9e82aa39686e952d036e791", + "semanticBridgeSourceSha256": "cf136da18e2cdbefae8e01d23d2c93f64eb7aebccaec938e84c2887c888f87ee", + "toolEvidenceSourceSha256": "49d7d09eb6957e596a173b0afd763544afe582f0c1f05cc0992fe51916a8fdb3", + "semanticNormalizedInputContract": "validated-forwarded-input-commit-v1", + "semanticSidecarSourceSha256": "2ae36ef1c76b2a80d6a37587fdf5c42bfd940f08656de4fdb10aad60f2582a8b", + "semanticDirectDriverSourceSha256": "2b7b7eaadee658312816ecdcbcb311ce491521fedf39e2ecb571036c36ff7b8e", + "semanticValidationSourceSha256": "3b0ac8d3f3cb92747d0bcea472ab0419a140e1cfdc43047e2c22542acc2d105f", + "semanticNormalizationSourceSha256": "8b628b82e8eacc91cdcc6fdca2210ba4cf282f01e9c6f74e95ff271655b5047e", + "semanticCompletionContractSourceSha256": "fa2b0c81cb9a62965839c905db81b930794774d8d557a01bce077dae5830b594", + "semanticValidatorsSourceSha256": "4f0219ed00938c5d8bc1b11532ecab9c13230012b23bfd6b7dd6b992e58e98d5", + "semanticSchemaBundleSourceSha256": "fe82ac2cbf194d31ac801de6a3cacfb165cb34146c5c26557f283b8c87a5f01e", + "semanticSidecarProtocolSourceSha256": "334b67857bd81e9924186890258cb2ad41f92b1ef68ec60fa5c5d9fbb277673c", + "semanticSidecarReceiptCommitContract": "correlated-tool-resolve-v1" + } +} diff --git a/server/src/__tests__/adapter-registry.test.ts b/server/src/__tests__/adapter-registry.test.ts index 341079db147..05ff46ada71 100644 --- a/server/src/__tests__/adapter-registry.test.ts +++ b/server/src/__tests__/adapter-registry.test.ts @@ -313,6 +313,44 @@ describe("server adapter registry", () => { }); }); + it("probes native Codex through CLI on a persistent computer without changing agent config", async () => { + const config = { provider: "codex", model: "gpt-5.4", env: { OPENAI_API_KEY: "test-key" } }; + const execute = vi.fn(async (input: { command: string; args?: string[] }) => ({ + exitCode: 0, timedOut: false, stderr: "", signal: null, pid: null, startedAt: new Date().toISOString(), + stdout: input.args?.includes("--json") ? JSON.stringify({ type: "item.completed", item: { type: "agent_message", text: "hello" } }) + : input.args?.includes("--version") ? "codex-cli 0.135.0" : "", + })); + const result = await requireServerAdapter("paperclip_runner").testEnvironment({ + companyId: "company-1", adapterType: "paperclip_runner", config, + executionTarget: { kind: "remote", transport: "computer", remoteCwd: "/workspace", resourceAuthority: { computerId: "computer" }, + workspaceRealization: { mode: "in_place", authoritativeRoot: "/workspace", pathAliases: [], outboundRestorePaths: [] }, + runner: { execute }, + } as never, + }); + expect(result.checks).toContainEqual(expect.objectContaining({ code: "codex_hello_probe_passed", level: "info" })); + expect(result.checks.some(check => check.code === "adapter_engine_unavailable")).toBe(false); + expect(config).not.toHaveProperty("engine"); + }); + + it("verifies the Claude harness on a computer after checking its platform", async () => { + const calls: string[] = []; + const result = await requireServerAdapter("paperclip_runner").testEnvironment({ + companyId: "company-1", adapterType: "paperclip_runner", + config: { provider: "acpx", acpxAgent: "claude", model: "claude-sonnet-4-6", env: { ANTHROPIC_API_KEY: "test-key" } }, + executionTarget: { kind: "remote", transport: "computer", remoteCwd: "/workspace", resourceAuthority: { computerId: "computer" }, + runner: { execute: async (input: { command: string; args?: string[] }) => { + calls.push(input.command); + const stdout = input.command === "claude" ? JSON.stringify({ type: "result", result: "hello", usage: { input_tokens: 1, output_tokens: 1 } }) + : input.args?.some(arg => arg.includes("uname -s")) ? "Linux\nx86_64\n" : ""; + return { exitCode: 0, timedOut: false, stdout, stderr: "", signal: null, pid: null, startedAt: new Date().toISOString() }; + } }, + } as never, + }); + expect(calls).toContain("claude"); + expect(result.checks).toContainEqual(expect.objectContaining({ code: "claude_hello_probe_passed", level: "info" })); + expect(result.checks.some(check => check.level === "error")).toBe(false); + }); + it.each([true, false])("checks actual local ACPX installation readiness (%s)", async (ready) => { const probe = vi.mocked(probeAcpxClaudeInstallation); if (ready) probe.mockResolvedValueOnce(undefined); diff --git a/server/src/__tests__/claude-local-adapter-environment.test.ts b/server/src/__tests__/claude-local-adapter-environment.test.ts index 055e66d9ea4..50f2247d60f 100644 --- a/server/src/__tests__/claude-local-adapter-environment.test.ts +++ b/server/src/__tests__/claude-local-adapter-environment.test.ts @@ -298,7 +298,7 @@ describe("claude_local environment diagnostics", () => { expect(result.checks.some((check) => check.code === "claude_cwd_invalid")).toBe(false); }); - it("uses full permission bypass for sandbox hello probes", async () => { + it.each(["sandbox", "computer"] as const)("uses full permission bypass for %s hello probes", async (transport) => { const executeCalls: Array<{ command: string; args?: string[]; env?: Record }> = []; const result = await testEnvironment({ @@ -310,11 +310,12 @@ describe("claude_local environment diagnostics", () => { }, executionTarget: { kind: "remote", - transport: "sandbox", + transport, + resourceAuthority: { computerId: "probe-computer" }, providerKey: "cloudflare", remoteCwd: "/workspace/paperclip", runner: { - execute: async (input) => { + execute: async (input: { command: string; args?: string[]; env?: Record }) => { executeCalls.push({ command: input.command, args: input.args, env: input.env }); if (input.command === "claude") { return { @@ -345,7 +346,7 @@ describe("claude_local environment diagnostics", () => { }; }, }, - }, + } as never, environmentName: "QA Cloudflare", }); diff --git a/server/src/__tests__/claude-local-execute.test.ts b/server/src/__tests__/claude-local-execute.test.ts index adea792ca6e..3423cd406c5 100644 --- a/server/src/__tests__/claude-local-execute.test.ts +++ b/server/src/__tests__/claude-local-execute.test.ts @@ -983,6 +983,110 @@ describe("claude execute", () => { } }, 10_000); + it("uses registered persistent instruction paths in computer CLI prompts", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-execute-sandbox-")); + const localWorkspace = path.join(root, "workspace"); + const remoteWorkspace = path.join(root, "sandbox-$HOME"); + const binDir = path.join(root, "bin"); + const commandPath = path.join(binDir, "claude"); + const capturePath1 = path.join(remoteWorkspace, "capture-1.json"); + const claudeRoot = path.join(root, ".claude"); + const localInstructions = path.join(localWorkspace, "AGENTS.md"); + const remoteInstructions = path.join(remoteWorkspace, "nested", "AGENTS.md"); + const runner = createLocalSandboxRunner(); + const previousHome = process.env.HOME; + const previousPath = process.env.PATH; + + await fs.mkdir(localWorkspace, { recursive: true }); + await fs.mkdir(remoteWorkspace, { recursive: true }); + await fs.mkdir(binDir, { recursive: true }); + await fs.mkdir(claudeRoot, { recursive: true }); + await fs.writeFile(path.join(claudeRoot, "settings.json"), JSON.stringify({ theme: "test" }), "utf8"); + await writeFakeClaudeCommand(commandPath); + + await fs.writeFile(localInstructions, "Persistent guidance contents."); + process.env.HOME = root; + process.env.PATH = `${binDir}${path.delimiter}${process.env.PATH ?? ""}`; + + try { + const result = await execute({ + runId: "run-sandbox-auth", + agent: { + id: "agent-1", + companyId: "company-1", + name: "Claude Coder", + adapterType: "claude_local", + adapterConfig: { engine: "cli" }, + }, + runtime: { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: null, + }, + config: { + engine: "cli", + managedAiConnection: true, + command: commandPath, + cwd: localWorkspace, + instructionsFilePath: localInstructions, + env: { + CLAUDE_CONFIG_DIR: claudeRoot, + PAPERCLIP_TEST_CAPTURE_PATH: capturePath1, + }, + promptTemplate: "Follow the paperclip heartbeat.", + }, + context: { paperclipWorkspace: { instructionsFilePath: remoteInstructions, agentHome: remoteWorkspace } }, + executionTarget: { + kind: "remote", + transport: "computer", + providerKey: "e2b", + environmentId: "env-1", + leaseId: "lease-1", + remoteCwd: remoteWorkspace, + timeoutMs: 30_000, + runner, + processRunner: runner, + listenerPort: 12345, + resourceAuthority: { kind: "computer-owner", computerId: "computer-1", ownerId: "owner-1", generation: 1 }, + fileAuthority: { kind: "remote-persistent", placementId: "placement-1", root: remoteWorkspace, agentHome: remoteWorkspace }, + workspaceRealization: { mode: "in_place", authoritativeRoot: remoteWorkspace }, + launch: async () => ({}), + inspectProcess: async () => ({ running: false, claim: null }), + retainWarm: async () => {}, + retire: async () => true, + computerTool: { command: "unused", args: [] }, + }, + authToken: "run-jwt-token", + onLog: async () => {}, + }); + + expect(result.exitCode).toBe(0); + const capture = JSON.parse(await fs.readFile(capturePath1, "utf8")) as CapturePayload; + expect(capture.argv).toContain("--dangerously-skip-permissions"); + expect(capture.argv).not.toContain("--allowedTools"); + expect(capture.claudeConfigDir).toContain(path.join(remoteWorkspace, ".paperclip-runtime", "claude", "owners")); + expect(capture.argv.slice(capture.argv.indexOf("--setting-sources"), capture.argv.indexOf("--setting-sources") + 2)).toEqual(["--setting-sources", "user"]); + const userSkills = path.join(capture.claudeConfigDir!, "skills"); + expect(await fs.realpath(userSkills)).toBe(await fs.realpath(path.join(capture.addDir!, ".claude", "skills"))); + expect(await fs.readFile(path.join(userSkills, "paperclip", "SKILL.md"), "utf8")).toContain("name: paperclip"); + expect(capture.instructionsContents).toContain("Persistent guidance contents."); + expect(capture.claudeConfigEntries).toContain("settings.json"); + expect(capture.prompt).toContain(remoteInstructions); + expect(capture.prompt).toContain(`${path.dirname(remoteInstructions)}/`); + expect(capture.prompt).not.toContain(localInstructions); + expect(capture.paperclipApiUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); + expect(capture.paperclipApiKey).not.toBe("run-jwt-token"); + expect(capture.paperclipApiBridgeMode).toBe("queue_v1"); + } finally { + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + if (previousPath === undefined) delete process.env.PATH; + else process.env.PATH = previousPath; + await fs.rm(root, { recursive: true, force: true }); + } + }, 10_000); + it("omits --effort for sandbox-managed runs when the installed Claude CLI does not advertise it", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-execute-sandbox-effort-")); const { workspace, commandPath, capturePath, restore } = await setupExecuteEnv(root, { diff --git a/server/src/__tests__/codex-local-execute.test.ts b/server/src/__tests__/codex-local-execute.test.ts index 29a3a2b2255..265a7d3ef3e 100644 --- a/server/src/__tests__/codex-local-execute.test.ts +++ b/server/src/__tests__/codex-local-execute.test.ts @@ -538,6 +538,101 @@ describe("codex execute", () => { } }); + it("uses registered persistent instruction paths in computer CLI prompts", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-sandbox-")); + const localWorkspace = path.join(root, "workspace"); + const remoteWorkspace = path.join(root, "sandbox"); + const binDir = path.join(root, "bin"); + const commandPath = path.join(binDir, "codex"); + const capturePath = path.join(remoteWorkspace, "capture.json"); + const localInstructions = path.join(localWorkspace, "AGENTS.md"); + const remoteInstructions = path.join(remoteWorkspace, "nested", "AGENTS.md"); + const runner = createLocalSandboxRunner(); + const previousHome = process.env.HOME; + const previousPath = process.env.PATH; + + await fs.mkdir(localWorkspace, { recursive: true }); + await fs.mkdir(remoteWorkspace, { recursive: true }); + await fs.mkdir(binDir, { recursive: true }); + await writeFakeCodexCommand(commandPath); + + await fs.writeFile(localInstructions, "Persistent guidance contents."); + process.env.HOME = root; + process.env.PATH = `${binDir}${path.delimiter}${process.env.PATH ?? ""}`; + await seedSharedCodexAuth(root); + + try { + const result = await execute({ + runId: "run-sandbox-auth", + agent: { + id: "agent-1", + companyId: "company-1", + name: "Codex Coder", + adapterType: "codex_local", + adapterConfig: { engine: "cli" }, + }, + runtime: { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: null, + }, + config: { + engine: "cli", + command: commandPath, + cwd: localWorkspace, + instructionsFilePath: localInstructions, + env: { + PAPERCLIP_TEST_CAPTURE_PATH: capturePath, + }, + promptTemplate: "Follow the paperclip heartbeat.", + }, + context: { paperclipWorkspace: { instructionsFilePath: remoteInstructions, agentHome: remoteWorkspace } }, + executionTarget: { + kind: "remote", + transport: "computer", + providerKey: "e2b", + environmentId: "env-1", + leaseId: "lease-1", + remoteCwd: remoteWorkspace, + timeoutMs: 30_000, + runner, + processRunner: runner, + listenerPort: 12345, + resourceAuthority: { kind: "computer-owner", computerId: "computer-1", ownerId: "owner-1", generation: 1 }, + fileAuthority: { kind: "remote-persistent", placementId: "placement-1", root: remoteWorkspace, agentHome: remoteWorkspace }, + workspaceRealization: { mode: "in_place", authoritativeRoot: remoteWorkspace }, + launch: async () => ({}), + inspectProcess: async () => ({ running: false, claim: null }), + retainWarm: async () => {}, + retire: async () => true, + computerTool: { command: "unused", args: [] }, + }, + authToken: "run-jwt-token", + onLog: async () => {}, + }); + + expect(result.exitCode).toBe(0); + expect(result.errorMessage).toBeNull(); + + const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload; + expect(capture.codexHome).toContain(path.join(remoteWorkspace, ".paperclip-runtime", "codex", "owners")); + expect(capture.prompt).toContain("Persistent guidance contents."); + expect(capture.prompt).toContain(remoteInstructions); + expect(capture.prompt).toContain(`${path.dirname(remoteInstructions)}/`); + expect(capture.prompt).not.toContain(localInstructions); + expect(capture.paperclipApiUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); + expect(capture.paperclipApiKey).not.toBe("run-jwt-token"); + expect(capture.paperclipApiBridgeMode).toBe("queue_v1"); + } finally { + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + if (previousPath === undefined) delete process.env.PATH; + else process.env.PATH = previousPath; + await fs.rm(root, { recursive: true, force: true }); + } + }); + it.each([false, true])("delivers oversized wake context through stdin (sandbox=%s)", async (sandbox) => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-wake-")); const workspace = path.join(root, "workspace"); diff --git a/server/src/__tests__/ssh-artifact-diagnostics.test.ts b/server/src/__tests__/ssh-artifact-diagnostics.test.ts new file mode 100644 index 00000000000..0799e447071 --- /dev/null +++ b/server/src/__tests__/ssh-artifact-diagnostics.test.ts @@ -0,0 +1,60 @@ +import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createSshCommandManagedRuntimeRunner } from "@paperclipai/adapter-utils/ssh"; +import { COMMAND_DIAGNOSTIC_PHASES, reportCommandPhase, type CommandDiagnosticPhase } from "@paperclipai/adapter-utils/command-diagnostics"; + +const roots: string[] = []; +afterEach(async () => { + vi.unstubAllEnvs(); + await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); +}); +async function fixture(script: string) { + const root = await mkdtemp(join(tmpdir(), "ssh-artifact-diagnostics-")); roots.push(root); + await writeFile(join(root, "ssh"), `#!${process.execPath}\n${script}\n`); + await chmod(join(root, "ssh"), 0o700); + vi.stubEnv("PATH", `${root}:${process.env.PATH}`); + return createSshCommandManagedRuntimeRunner({ spec: { + host: "private-host-canary.invalid", port: 22, username: "private-user-canary", + remoteWorkspacePath: "/private-path-canary", remoteCwd: "/private-path-canary", + privateKey: null, knownHosts: null, strictHostKeyChecking: true, + } }); +} +describe("opt-in artifact SSH phase diagnostics", () => { + it("records pipe flush, exit, close and cleanup without exposing command or output", async () => { + const runner = await fixture("process.stdin.resume(); process.stdin.on('end', () => { process.stderr.write('private-output-canary'); process.exitCode = 7; });"); + const phases: CommandDiagnosticPhase[] = []; + const result = await runner.execute({ command: "private-command-canary", args: ["private-arg-canary"], + env: { PRIVATE_TOKEN: "private-token-canary" }, stdin: "private-stdin-canary", timeoutMs: 3000, + onPhase: phase => { phases.push(phase); } }); + expect(result.exitCode).toBe(7); + expect(result.stderr).toBe("private-output-canary"); + expect(phases).toEqual(["ssh_auth_prepare", "ssh_auth_ready", "ssh_spawned", "ssh_stdin_flushed", + "ssh_exit", "ssh_close", "ssh_auth_cleanup", "ssh_auth_cleaned"]); + expect(JSON.stringify(phases)).not.toContain("canary"); + }); + it("retains the timeout and close boundary when a command never finishes", async () => { + const runner = await fixture("process.stdin.resume(); setInterval(() => {}, 1000);"); + const phases: CommandDiagnosticPhase[] = []; + const result = await runner.execute({ command: "unused", stdin: "payload", timeoutMs: 200, + onPhase: phase => { phases.push(phase); } }); + expect(result.timedOut).toBe(true); + expect(phases).toContain("ssh_timeout"); + expect(phases.indexOf("ssh_timeout")).toBeLessThan(phases.indexOf("ssh_close")); + expect(phases.slice(-2)).toEqual(["ssh_auth_cleanup", "ssh_auth_cleaned"]); + }); + it.each(["throw", "reject"])("cannot change successful execution when its observer %ss", async kind => { + const runner = await fixture("process.stdin.resume(); process.stdin.on('end', () => process.stdout.write('ok'));"); + const result = await runner.execute({ command: "unused", stdin: "payload", timeoutMs: 3000, + onPhase: () => { if (kind === "throw") throw new Error("observer canary"); return Promise.reject(new Error("observer canary")); } }); + expect(result).toMatchObject({ exitCode: 0, stdout: "ok", timedOut: false }); + }); + it("rejects unrecognized phase strings before calling the observer", () => { + const observer = vi.fn(); + reportCommandPhase(observer, "ssh_close private-path-canary" as CommandDiagnosticPhase); + expect(observer).not.toHaveBeenCalled(); + for (const phase of COMMAND_DIAGNOSTIC_PHASES) reportCommandPhase(observer, phase); + expect(observer).toHaveBeenCalledTimes(COMMAND_DIAGNOSTIC_PHASES.length); + }); +}); diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index 54b72aa3bd5..f35d9b4b233 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -432,6 +432,12 @@ const paperclipRunnerAdapter: ServerAdapterModule = { if (!((os === "Linux" && arch === "x86_64") || (os === "Darwin" && (arch === "arm64" || (profile.acpxAgent !== "grok" && arch === "x86_64"))))) { throw new Error(`ACPX ${profile.acpxAgent} requires a qualified Linux x64 or macOS architecture.`); } + if (target.transport === "computer" && profile.acpxAgent === "claude") { + const hello = await claudeTestEnvironment({ ...context, adapterType: "claude_local", + config: { ...context.config, engine: "cli", model: profile.model, + dangerouslySkipPermissions: context.config.acpxPermissionMode === "approve-all" } }); + return { ...hello, adapterType: "paperclip_runner" }; + } return { adapterType: "paperclip_runner", status: "warn" as const, testedAt: new Date().toISOString(), checks: [{ code: "acpx_remote_runtime_unverified", level: "warn" as const, @@ -485,7 +491,9 @@ const paperclipRunnerAdapter: ServerAdapterModule = { } const result = profile.provider === "opencode" ? await openCodeTestEnvironment(context) - : await codexTestEnvironment(context); + : await codexTestEnvironment(context.executionTarget?.kind === "remote" && context.executionTarget.transport === "computer" + ? { ...context, config: { ...context.config, engine: "cli" } } + : context); return { ...result, adapterType: "paperclip_runner" }; }, listSkills: listCodexSkills, diff --git a/server/src/services/heartbeat-github-launchers.test.ts b/server/src/services/heartbeat-github-launchers.test.ts index 5bb4038bacb..3d5fd005259 100644 --- a/server/src/services/heartbeat-github-launchers.test.ts +++ b/server/src/services/heartbeat-github-launchers.test.ts @@ -8,10 +8,11 @@ import type { CommandManagedRuntimeRunner } from "@paperclipai/adapter-utils/com import { describe, expect, it, vi } from "vitest"; import { prepareHeartbeatGitHubLaunchers } from "./heartbeat-github-launchers.js"; +const computerTarget = { kind: "remote", transport: "computer", remoteCwd: "/workspace" } as Parameters[0]["target"]; const target = { kind: "remote" as const, transport: "sandbox" as const, providerKey: "daytona", remoteCwd: "/workspace" }; describe("heartbeat GitHub launcher lifetime", () => { - it.each([target, null])("defers native managed authorization/staging to the session owner (%j)", async (target) => { + it.each([target, computerTarget, null])("defers native managed authorization/staging to the session owner (%j)", async (target) => { const prepare = vi.fn(); const mint = vi.fn(); const result = await prepareHeartbeatGitHubLaunchers({ @@ -23,7 +24,7 @@ describe("heartbeat GitHub launcher lifetime", () => { expect(result.cleanupLocation).toBeNull(); expect(result.env).toMatchObject({ GH_TOKEN: "", PAPERCLIP_GITHUB_BROKER_TOKEN: "" }); }); - it("keeps anonymous native sandbox launchers stable without issuing a run capability", async () => { + it.each([target, computerTarget])("keeps anonymous native launchers stable without issuing a run capability (%j)", async (target) => { const createBrokerToken = vi.fn(() => "run-secret"); const prepareLaunchers = vi.fn(async (input) => input.env); const inputs = { native: true, githubConfigured: false, agentId: "agent-a", target, @@ -50,7 +51,7 @@ describe("heartbeat GitHub launcher lifetime", () => { expect(cleanupLaunchers).toHaveBeenCalledExactlyOnceWith({ runId: "failed-run", target }); }); - it("does not remove shared anonymous wrappers when staging a later run fails", async () => { + it.each([target, computerTarget])("does not remove shared anonymous wrappers when staging a later run fails (%j)", async (target) => { const stagingError = new Error("remote launcher staging failed"); const cleanupLaunchers = vi.fn(async () => undefined); await expect(prepareHeartbeatGitHubLaunchers({ @@ -64,6 +65,7 @@ describe("heartbeat GitHub launcher lifetime", () => { it.each([ { native: false, githubConfigured: true, target }, { native: false, githubConfigured: false, target }, + { native: false, githubConfigured: false, target: computerTarget }, { native: true, githubConfigured: false, target: null }, ])("preserves run-scoped managed capabilities outside anonymous native sandboxes: %j", async (mode) => { const createBrokerToken = vi.fn(() => "current-run-secret"); diff --git a/server/src/services/heartbeat-github-launchers.ts b/server/src/services/heartbeat-github-launchers.ts index 83786a48eb2..5740b330b31 100644 --- a/server/src/services/heartbeat-github-launchers.ts +++ b/server/src/services/heartbeat-github-launchers.ts @@ -1,6 +1,6 @@ import { createHash } from "node:crypto"; import { githubBrokerEnvironment } from "@paperclipai/adapter-utils/github-launcher"; -import { cleanupGitHubOperationLaunchers, prepareGitHubOperationLaunchers } from "@paperclipai/adapter-utils/execution-target"; +import { adapterExecutionTargetIsCommandBacked, cleanupGitHubOperationLaunchers, prepareGitHubOperationLaunchers } from "@paperclipai/adapter-utils/execution-target"; type LauncherInput = Parameters[0]; @@ -21,12 +21,12 @@ export async function prepareHeartbeatGitHubLaunchers( if (input.native && input.githubConfigured) { return { env: githubBrokerEnvironment(input.env, { url: "", token: "" }), cleanupLocation: null }; } - // An unconfigured sandbox has no managed GitHub identity to broker. Its + // An unconfigured command-backed environment has no managed GitHub identity to broker. Its // token-free wrappers still isolate image credentials, but may live as long // as the workspace so a warm provider never inherits a deleted run path. // Other adapter paths retain their run-scoped capability/retirement rules. const anonymous = input.native && !input.githubConfigured && - input.target?.kind === "remote" && input.target.transport === "sandbox"; + adapterExecutionTargetIsCommandBacked(input.target); const location = { runId: anonymous ? `anonymous-${input.agentId}-${createHash("sha256").update(input.env.PATH ?? "").digest("hex").slice(0, 16)}` diff --git a/server/src/services/native-runtime/native-github-access.test.ts b/server/src/services/native-runtime/native-github-access.test.ts index e6c1aaea539..65f07102e31 100644 --- a/server/src/services/native-runtime/native-github-access.test.ts +++ b/server/src/services/native-runtime/native-github-access.test.ts @@ -2,6 +2,7 @@ import { afterEach, expect, it, vi } from "vitest"; import { spawn, execFile } from "node:child_process"; import { mkdtemp, mkdir, readFile, rm, writeFile, access } from "node:fs/promises"; import { promisify } from "node:util"; +import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target"; import type { CommandManagedRuntimeRunner } from "@paperclipai/adapter-utils/command-managed-runtime"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -13,7 +14,7 @@ const cleanups: (() => Promise)[] = []; afterEach(async () => { for (const cleanup of cleanups.splice(0).reverse()) await cleanup(); }); async function broker(resolveCredentials = vi.fn(async (binding: ReturnType) => ({ status: "available", env: { GH_TOKEN: `fixture-${binding.runId}` }, -})), remote = false, bridgeUnavailable = false, onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise) { +})), remote: boolean | "computer" = false, bridgeUnavailable = false, onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise) { const root = await mkdtemp(path.join(tmpdir(), "native-github-reuse-")); cleanups.push(() => rm(root, { recursive: true, force: true })); const bin = path.join(root, "real-bin"); @@ -29,20 +30,42 @@ async function broker(resolveCredentials = vi.fn(async (binding: ReturnType[0]) => { + if (!processLive) throw new Error("computer_process_claim_stale"); + return execute(input); + }) }; + const target: AdapterExecutionTarget | null = remote === "computer" ? { + kind: "remote", transport: "computer", remoteCwd: root, + runner: { execute: async (input: Parameters[0]) => { + if (generation !== 1) throw new Error("computer_attempt_superseded"); + return execute(input); + } }, processRunner, + listenerPort: 43127, + resourceAuthority: { kind: "computer-owner", computerId: "computer-a", ownerId: "owner-a", generation: 1 }, + fileAuthority: { kind: "remote-persistent", placementId: "placement-a", root, agentHome: root }, + launch: async () => { throw new Error("Fixture runner is already launched"); }, + inspectProcess: async () => ({ running: processLive, claim: { nonce: "fixture-process" } }), + retainWarm: async () => undefined, + retire: async () => { processLive = false; return true; }, + computerTool: { command: "fixture-computer-tool", args: [] }, + } : remote ? { + kind: "remote" as const, transport: "sandbox" as const, providerKey: "test", + remoteCwd: root, runner: { execute }, streamRunLogs: false, + } : null; const result = await createNativeGitHubAccess({ scope, target, cwd: root, env: { PATH: `${bin}:${path.dirname(process.execPath)}:/usr/bin:/bin`, PAPERCLIP_API_KEY: "old-run-api-key" }, resolveCredentials, onLog }, bridgeUnavailable ? async () => { throw new Error("fixture bridge unavailable"); } : undefined); cleanups.push(result.stop); - return { ...result, root, resolveCredentials }; + return { ...result, root, resolveCredentials, processRunner, advanceGeneration: () => { generation++; }, retireProcess: () => { processLive = false; } }; } function request(broker: NativeGitHubAccess, extra: RequestInit = {}, endpoint = "/runtime-tools/github/credentials") { return fetch(broker.env.PAPERCLIP_GITHUB_BROKER_URL + endpoint, { method: "POST", body: "{}", headers: { authorization: `Bearer ${broker.env.PAPERCLIP_GITHUB_BRIDGE_TOKEN}`, "content-type": "application/json" }, ...extra, }); } -it.each([false, true])("keeps one live parent and its original launcher environment across two authorized runs (callback bridge: %s)", async (remote) => { +it.each([false, true, "computer"] as const)("keeps one live parent and its original launcher environment across two authorized runs (callback bridge: %s)", async (remote) => { const b = await broker(undefined, remote); const parent = spawn(process.execPath, ["-e", ` const {execFile}=require('node:child_process'); @@ -61,6 +84,7 @@ it.each([false, true])("keeps one live parent and its original launcher environm expect(a.value).toBe("fixture-run-a"); releaseA(); expect((await operation()).value).toBe("anonymous"); + b.advanceGeneration(); const releaseB = b.activate(run("run-b")); releaseA(); // A's delayed cleanup cannot revoke B. const second = await operation(); @@ -70,6 +94,11 @@ it.each([false, true])("keeps one live parent and its original launcher environm expect(await readFile(path.join(b.env.PAPERCLIP_GITHUB_LAUNCHER_DIR, "gh"), "utf8")).not.toContain(b.env.PAPERCLIP_GITHUB_BROKER_TOKEN); releaseB(); await b.stop(); await expect(access(b.env.PAPERCLIP_GITHUB_LAUNCHER_DIR)).rejects.toThrow(); + if (remote === "computer") { + expect(b.processRunner.execute).toHaveBeenCalled(); + b.retireProcess(); + await expect(b.processRunner.execute({ command: "true" })).rejects.toThrow("computer_process_claim_stale"); + } }, 45_000); it("rejects other scopes, concurrent bindings, browser requests and forged authority", async () => { const b = await broker(); diff --git a/server/src/services/native-runtime/native-github-access.ts b/server/src/services/native-runtime/native-github-access.ts index a9711329ff3..8872ca1930d 100644 --- a/server/src/services/native-runtime/native-github-access.ts +++ b/server/src/services/native-runtime/native-github-access.ts @@ -25,7 +25,13 @@ export async function createNativeGitHubAccess(input: { onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise; }, startBridge = startAdapterExecutionTargetPaperclipBridge) { const token = randomBytes(32).toString("hex"); - const location = { runId: `native-session-${randomUUID()}`, target: input.target }; + // The broker belongs to the retained process, while its activate() binding + // grants one run at a time. A computer attempt advances generation on each + // turn; polling must retain the exact process capability across that handoff. + const target = input.target?.kind === "remote" && input.target.transport === "computer" + ? { ...input.target, runner: input.target.processRunner } + : input.target; + const location = { runId: `native-session-${randomUUID()}`, target }; let active: Binding | null = null; let stopped = false; let ready = true; 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 6ae27897a98..212c729d4fb 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -7400,6 +7400,41 @@ describe("native warm session supervision", () => { expect(secondCopy.collectStopped).toHaveBeenCalledOnce(); }); + it.each(["same", "generation", "owner", "computer"])("preserves command-transport directory identity across %s changes", async change => { + const name = `managed-computer-identity-${change}`; + const current = { ...execution, binding: { ...execution.binding, runId: `${name}-one`, executionWorkspaceId: name }, + session: { ...execution.session, normalizedSessionId: name, lifecyclePolicy: { mode: "warm", idleTimeoutMs: 60_000 } } } as NativeExecutionInputV1; + const close = vi.fn(async () => undefined); + const collectStopped = vi.fn(async () => undefined); + const target = { kind: "remote", transport: "computer", environmentId: name, remoteCwd: `/tmp/${name}`, + resourceAuthority: { kind: "computer-owner", computerId: name, ownerId: name, generation: 1 } }; + state.execute.mockReset().mockImplementationOnce(async options => { await options.onSession?.({ close }); return result; }); + await executePaperclipNativeSession({ db: leaseDb(current), execution: current, runnerInstanceId: name, + runnerExecutionTarget: target as never, + instructionWorkingCopy: { runId: current.binding.runId, root: `/tmp/${name}/home`, collectStopped, + checkpointWarm: async () => true, hasChanges: async () => false } }); + const canReuse = vi.fn(async () => true); + const nextAuthority = { ...target.resourceAuthority, + ...(change === "generation" ? { generation: 2 } : {}), + ...(change === "owner" ? { ownerId: "different-owner" } : {}), + ...(change === "computer" ? { computerId: "different-computer" } : {}), + }; + const reservation = await reserveWarmNativeInstructionDirectory({ companyId: current.binding.companyId, + agentId: current.binding.agentId, previousRunId: current.binding.runId, runId: `${name}-two`, canReuse, + target: { ...target, resourceAuthority: nextAuthority } as never }); + if (change === "same" || change === "generation") { + expect(reservation?.reuseRunId).toBe(current.binding.runId); + expect(canReuse).toHaveBeenCalledOnce(); + expect(close).not.toHaveBeenCalled(); + await reservation!.release(); + } else { + expect(reservation).toBeNull(); + expect(canReuse).not.toHaveBeenCalled(); + expect(close).toHaveBeenCalledOnce(); + expect(collectStopped).toHaveBeenCalledOnce(); + } + }); + it("stops before collecting when a checkpoint cannot stabilize", async () => { const f = await start("managed-fallback", false); expect(f.close).toHaveBeenCalledOnce(); diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index 6b5275fc9d5..8947dbb6bd0 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -550,7 +550,7 @@ export async function claimWarmNativeInstructionCopy(input: { function instructionTargetIdentity(target?: AdapterExecutionTarget | null): string { return JSON.stringify(target?.kind === "remote" ? { environmentId: target.environmentId, cwd: target.remoteCwd, - providerLeaseId: target.transport === "sandbox" ? target.sandboxLeaseAcquisition?.providerLeaseId : target.spec, + providerLeaseId: target.transport === "sandbox" ? target.sandboxLeaseAcquisition?.providerLeaseId : target.transport === "computer" ? [target.resourceAuthority.computerId, target.resourceAuthority.ownerId] : target.spec, } : { kind: "local", environmentId: target?.environmentId }); }