From 8a3cc86531e800629282e83a5b7a70945ec6cf0c Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Wed, 22 Jul 2026 22:24:30 -0700 Subject: [PATCH] feat(acpx): stage workspace + route in-sandbox cwd for remote ACP lane (#10070) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The shared ACP engine (`packages/adapter-utils/src/acpx-engine/execute.ts`) is responsible for launching local and remote agent processes via the ACP protocol > - On runner-backed remote sandbox (Daytona) targets, `buildRuntime` never crossed the CLI's staging seam: it never called `prepareAdapterExecutionTargetRuntime`, left `runtimeRootDir: null` in both the paperclip and process-session bridges, and handed the agent the **HOST filesystem path** as the `session/new` cwd — meaning Claude/Gemini silently operated on a path that does not exist inside the sandbox (Codex additionally crashes on its HOST home path, addressed in a follow-up PR) > - The fix must cross the staging seam for remote sandboxes, thread the real `runtimeRootDir` through both bridges, and bind the in-sandbox workspace path as the session cwd — without touching local ACP runs or the runner-less ACP→CLI fallback > - This pull request introduces a `stageAcpRemoteRuntime` helper that calls `prepareAdapterExecutionTargetRuntime` for runner-backed remote runs, captures `{ workspaceRemoteDir, runtimeRootDir, assetDirs, restoreWorkspace }`, and reuses the in-sandbox `workspaceRemoteDir` as the single `sessionCwd` across `session/new`, the fingerprint, compatibility check, persistence, `ensureSession`, the process-session bridge cwd, and the error path > - The benefit is that remote ACP runs now operate in the correct in-sandbox cwd and receive a non-null `runtimeRootDir` in both bridges — fixing silent wrong-cwd degradation for Claude/Gemini on Daytona targets; this is PR 1 of 3 and seeds no credential material ## Linked Issues or Issue Description No public GitHub issue exists for this change. Inline description follows the feature request template: ### Subsystem affected packages/adapters — agent adapter implementations ### Problem or motivation The shared ACP engine (`packages/adapter-utils/src/acpx-engine/execute.ts`) never crossed the CLI's staging seam on runner-backed remote sandbox targets. It never called `prepareAdapterExecutionTargetRuntime`, always passed `runtimeRootDir: null` to both the paperclip and process-session bridges, and handed the agent the **HOST filesystem path** as the ACP `session/new` cwd. As a result, Claude and Gemini silently operated on a cwd that does not exist inside the sandbox; Codex crashed with a fatal error on the HOST `CODEX_HOME` path (that crash is in a follow-up PR). ### Proposed solution Gate on `usesRunnerBackedSandbox` (`kind === "remote" && transport === "sandbox" && runner`). For runs that pass the gate, call `prepareAdapterExecutionTargetRuntime` via a new `stageAcpRemoteRuntime` helper that ships the workspace into the sandbox and captures `{ workspaceRemoteDir, runtimeRootDir, assetDirs, restoreWorkspace }`. Thread the real `runtimeRootDir` into both bridges. Bind a single `sessionCwd` (the in-sandbox `workspaceRemoteDir`) and use it at every cwd-keyed session site (`session/new`, fingerprint, compat, persist, `ensureSession`, process-session bridge, error path) so a warm/resumable session is reused rather than invalidated. For local runs and the runner-less ACP→CLI fallback, `sessionCwd` resolves to the HOST cwd — byte-identical to the previous behavior. ### Alternatives considered Patching each per-adapter bridge individually — rejected because the bug is in the shared engine layer and the fix belongs there so all three adapters (Codex, Claude, Gemini) benefit without per-adapter duplication. ### Roadmap alignment Internal correctness fix enabling remote ACP to work as designed; no new user-facing features. This is PR 1 of 3 in a sequential chain: PR 1 (this PR) stages the workspace and routes the cwd; PR 2 adds per-adapter home seeding and copy-back; PR 3 wires session-lifecycle restore. ## What Changed - **`packages/adapter-utils/src/acpx-engine/execute.ts`** — added `stageAcpRemoteRuntime` helper that calls `prepareAdapterExecutionTargetRuntime` for runner-backed remote sandboxes and returns `{ sessionCwd, runtimeRootDir, stagedRuntime }`; `buildRuntime` now uses this helper to derive `sessionCwd` (in-sandbox `workspaceRemoteDir` for remote; HOST cwd unchanged for local/runner-less) and threads the real `runtimeRootDir` to both the paperclip bridge and process-session bridge; `stagedRuntime` is stashed for the follow-up credential PR - **`packages/adapter-utils/src/acpx-engine/execute.test.ts`** — new engine-level unit tests: staging seam crossed with no credential asset, non-null `runtimeRootDir` in both bridges, in-sandbox `session/new` cwd, warm-handle reuse after the cwd change, local-unchanged; 60 tests green - **`packages/adapters/codex-local/src/server/acp.test.ts`** — new per-adapter test: runner-backed remote asserts `ensureSession` cwd == `workspaceRemoteDir`; runner-less sandbox falls back to CLI - **`packages/adapters/claude-local/src/server/acp.test.ts`** — same per-adapter coverage for Claude - **`packages/adapters/gemini-local/src/server/acp.test.ts`** — same per-adapter coverage for Gemini ## Verification - `adapter-utils` typecheck clean; `codex/claude/gemini-local` typecheck clean - Engine units (`acpx-engine/execute.test.ts`): 60/60 green — staging seam crossed with no credential asset, non-null `runtimeRootDir` to both bridges, in-sandbox `session/new` cwd, warm-handle reuse after cwd change, local unchanged - Per-adapter ACP test suites (`codex/claude/gemini-local` `acp.test.ts`): 103 tests green — runner-backed remote asserts `ensureSession` cwd == `workspaceRemoteDir`; runner-less sandbox falls back to CLI - CI green on PR (in progress) ## Risks This is PR 1 of 3 in a strictly sequential chain; it seeds **no credential material** (no `assets`, no `installCommand`). The per-adapter home seeding is deferred to PR 2, which consumes the `stagedRuntime` object stashed here. The `restoreWorkspace` callback is carried on `stagedRuntime` for PR 3's session-lifecycle wiring (see the `stageAcpRemoteRuntime` function comment). Local ACP runs and the runner-less ACP→CLI fallback are untouched — `sessionCwd` resolves to the HOST cwd for those paths, preserving existing behavior. The `stageAcpRemoteRuntime` helper is gated on `usesRunnerBackedSandbox`, so there is no regression risk for local or CLI-lane runs. ## Model Used Claude Sonnet 4.6 (`claude-sonnet-4-6`) via Paperclip ACPX engine — extended context, tool use enabled, co-authored with Paperclip agent orchestration. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`, `feat/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Harold Kim Co-authored-by: Paperclip --- .../src/acpx-engine/execute.test.ts | 157 +++++++++++++++++- .../adapter-utils/src/acpx-engine/execute.ts | 121 +++++++++++--- .../claude-local/src/server/acp.test.ts | 99 +++++++++++ .../codex-local/src/server/acp.test.ts | 105 ++++++++++++ .../gemini-local/src/server/acp.test.ts | 99 +++++++++++ 5 files changed, 554 insertions(+), 27 deletions(-) diff --git a/packages/adapter-utils/src/acpx-engine/execute.test.ts b/packages/adapter-utils/src/acpx-engine/execute.test.ts index 1947ffeef3..d79e26588b 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.test.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.test.ts @@ -1,10 +1,30 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { AcpRuntimeOptions } from "acpx/runtime"; import type { AdapterRuntimeMcpAccess } from "@paperclipai/adapter-utils"; -import { DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC } from "@paperclipai/adapter-utils/execution-target"; +import { + DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC, + prepareAdapterExecutionTargetRuntime, + startAdapterExecutionTargetPaperclipBridge, + startAdapterExecutionTargetProcessSessionBridge, +} from "@paperclipai/adapter-utils/execution-target"; + +// Wrap the staging seam + both sandbox bridges in call-recording spies that +// still delegate to the real implementations (a runner-backed sandbox test +// exercises them end-to-end against a local runner). This lets the staging +// tests assert the exact `runtimeRootDir`/`workspaceLocalDir`/`assets` the +// engine threads without changing any real behavior for the other tests. +vi.mock("@paperclipai/adapter-utils/execution-target", async (importActual) => { + const actual = await importActual(); + return { + ...actual, + prepareAdapterExecutionTargetRuntime: vi.fn(actual.prepareAdapterExecutionTargetRuntime), + startAdapterExecutionTargetPaperclipBridge: vi.fn(actual.startAdapterExecutionTargetPaperclipBridge), + startAdapterExecutionTargetProcessSessionBridge: vi.fn(actual.startAdapterExecutionTargetProcessSessionBridge), + }; +}); import { createAcpxEngineExecutor, findAncestorBin, @@ -1674,3 +1694,136 @@ describe("summarizeAcpxTurnUsage no-report turns", () => { expect(summary.cumulativeCostUsd).toBeCloseTo(0.75); }); }); + +describe("ACPX engine remote sandbox staging seam (PR 1: workspace + cwd)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + async function setupRemoteSandbox() { + const root = await makeTempRoot(); + const stateDir = path.join(root, "state"); + const localCwd = path.join(root, "worktree"); + const remoteCwd = path.join(root, "remote-workspace"); + await fs.mkdir(localCwd, { recursive: true }); + await fs.mkdir(remoteCwd, { recursive: true }); + // A file present only in the HOST worktree proves the workspace is shipped + // into the sandbox: the local runner extracts the staged tar into remoteCwd. + await fs.writeFile(path.join(localCwd, "hello.txt"), "hi", "utf8"); + const runner = createLocalSandboxRunner(); + const executionTarget = { + kind: "remote", + transport: "sandbox", + providerKey: "fake-plugin", + remoteCwd, + runner, + }; + return { root, stateDir, localCwd, remoteCwd, executionTarget }; + } + + it("test_remote_buildRuntime_crosses_staging_seam", async () => { + const { stateDir, localCwd, remoteCwd, executionTarget } = await setupRemoteSandbox(); + const { sessionInputs } = await runExecutor( + { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd }, + { authToken: "real-run-jwt", executionTarget }, + ); + + // Staging seam crossed exactly once, shipping the HOST worktree. + expect(vi.mocked(prepareAdapterExecutionTargetRuntime)).toHaveBeenCalledTimes(1); + const stageArgs = vi.mocked(prepareAdapterExecutionTargetRuntime).mock.calls[0]![0]; + expect(stageArgs.workspaceLocalDir).toBe(localCwd); + expect(stageArgs.target).toMatchObject({ kind: "remote", transport: "sandbox" }); + // No credential/home asset staged in PR 1 (that is PR 2's per-adapter seed). + expect(stageArgs.assets ?? []).toEqual([]); + expect(stageArgs.installCommand ?? null).toBeNull(); + + // Both bridges receive the real (non-null) runtimeRootDir from staging. + const paperclipArgs = vi.mocked(startAdapterExecutionTargetPaperclipBridge).mock.calls[0]![0]; + const processArgs = vi.mocked(startAdapterExecutionTargetProcessSessionBridge).mock.calls[0]![0]; + expect(paperclipArgs.runtimeRootDir).toBeTruthy(); + expect(processArgs.runtimeRootDir).toBeTruthy(); + expect(String(paperclipArgs.runtimeRootDir)).toContain(".paperclip-runtime"); + expect(processArgs.runtimeRootDir).toBe(paperclipArgs.runtimeRootDir); + + // The workspace really landed in the sandbox workspace dir. + await expect(fs.readFile(path.join(remoteCwd, "hello.txt"), "utf8")).resolves.toBe("hi"); + // And session/new is created on the in-sandbox workspace cwd. + expect(sessionInputs[0]?.cwd).toBe(remoteCwd); + }); + + it("test_remote_session_new_uses_in_sandbox_cwd", async () => { + const { stateDir, localCwd, remoteCwd, executionTarget } = await setupRemoteSandbox(); + const { sessionInputs, runtimeOptions } = await runExecutor( + { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd }, + { authToken: "real-run-jwt", executionTarget }, + ); + + // The ACP runtime + session/new both bind to the in-sandbox workspace dir, + // not the HOST worktree path. + expect(runtimeOptions[0]?.cwd).toBe(remoteCwd); + expect(sessionInputs[0]?.cwd).toBe(remoteCwd); + expect(sessionInputs[0]?.cwd).not.toBe(localCwd); + }); + + it("test_remote_warm_handle_reused_after_cwd_change", async () => { + const { stateDir, localCwd, remoteCwd, executionTarget } = await setupRemoteSandbox(); + const ensureInputs: Array> = []; + const execute = createAcpxEngineExecutor({ + warmHandles: new Map(), + createRuntime: () => buildRuntime(undefined, (input) => ensureInputs.push(input)) as never, + }); + const base = { + agent: { id: "agent-1", companyId: "company-1" }, + config: { + agent: "custom", + agentCommand: "node ./fake-acp.js", + stateDir, + cwd: localCwd, + mode: "persistent", + warmHandleIdleMs: 60_000, + }, + context: {}, + authToken: "real-run-jwt", + executionTarget, + onLog: async () => {}, + onMeta: async () => {}, + }; + + const first = await execute({ runId: "run-remote-a", runtime: {}, ...base } as never); + const second = await execute({ + runId: "run-remote-b", + runtime: { sessionParams: first.sessionParams }, + ...base, + } as never); + + expect(first.exitCode).toBe(0); + expect(second.exitCode).toBe(0); + // Both runs resolve session/new to the in-sandbox cwd... + expect(ensureInputs[0]?.cwd).toBe(remoteCwd); + expect(ensureInputs[1]?.cwd).toBe(remoteCwd); + // ...and the second run RESUMES the first session: fingerprint/compat/persist + // all read the same in-sandbox `sessionCwd`, so a handle created with the + // in-sandbox cwd is reused, not invalidated, after the HOST→sandbox cwd swap. + expect(ensureInputs[1]?.resumeSessionId).toBe(first.sessionId); + }); + + it("test_local_foundation_unchanged", async () => { + const root = await makeTempRoot(); + const localCwd = path.join(root, "worktree"); + await fs.mkdir(localCwd, { recursive: true }); + const { sessionInputs, runtimeOptions } = await runExecutor({ + agent: "custom", + agentCommand: "node ./fake-acp.js", + stateDir: path.join(root, "state"), + cwd: localCwd, + }); + + // A local (non-remote) run never crosses the staging seam or starts a + // bridge, and session/new stays on the HOST cwd — byte-identical to today. + expect(vi.mocked(prepareAdapterExecutionTargetRuntime)).not.toHaveBeenCalled(); + expect(vi.mocked(startAdapterExecutionTargetPaperclipBridge)).not.toHaveBeenCalled(); + expect(vi.mocked(startAdapterExecutionTargetProcessSessionBridge)).not.toHaveBeenCalled(); + expect(sessionInputs[0]?.cwd).toBe(localCwd); + expect(runtimeOptions[0]?.cwd).toBe(localCwd); + }); +}); diff --git a/packages/adapter-utils/src/acpx-engine/execute.ts b/packages/adapter-utils/src/acpx-engine/execute.ts index 243c7adffe..1ed603cf3a 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.ts @@ -14,15 +14,19 @@ import type { } from "@paperclipai/adapter-utils"; import { adapterExecutionTargetSessionIdentity, + describeAdapterExecutionTarget, formatAdapterExecutionTimeoutErrorMessage, formatAdapterExecutionTimeoutStartLogLine, + prepareAdapterExecutionTargetRuntime, readAdapterExecutionTarget, resolveAdapterExecutionTargetTimeout, startAdapterExecutionTargetPaperclipBridge, startAdapterExecutionTargetProcessSessionBridge, + type AdapterExecutionTarget, type AdapterExecutionTargetPaperclipBridgeHandle, type AdapterExecutionTargetProcessSessionBridgeHandle, type AdapterExecutionTargetTimeoutResolution, + type PreparedAdapterExecutionTargetRuntime, } from "@paperclipai/adapter-utils/execution-target"; import { DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, @@ -176,6 +180,12 @@ interface AcpxPreparedRuntime { agentRegistry: AcpAgentRegistry; processSessionBridge: AdapterExecutionTargetProcessSessionBridgeHandle | null; paperclipBridge: AdapterExecutionTargetPaperclipBridgeHandle | null; + // The workspace/runtime staged into a runner-backed remote sandbox (null for + // local runs and the runner-less ACP→CLI fallback). PR 1 stages the workspace + // + cwd only; the `assetDirs`/`runtimeRootDir`/`restoreWorkspace` it carries + // are what PR 2 (managed-home seeding + codex copy-back) and PR 3 (session + // lifecycle re-staging) build on. + stagedRuntime: PreparedAdapterExecutionTargetRuntime | null; remoteExecutionIdentity: Record | null; skillPromptInstructions: string; skillsIdentity: Record; @@ -963,6 +973,38 @@ async function writePaperclipClaudeSettings(input: { }; } +// Cross the CLI's staging seam for a runner-backed remote sandbox: ship the +// workspace into the sandbox and obtain the in-sandbox `workspaceRemoteDir` +// plus the non-null `runtimeRootDir`/`assetDirs` the bridges and later PRs +// consume. This is the shared-engine mirror of the CLI lanes (codex/claude/ +// gemini `*-local/execute.ts`). PR 1 stages the workspace + cwd ONLY: it ships +// no managed-home credential/home asset (no `assets`, no per-adapter home +// seed) — that is PR 2. The returned `restoreWorkspace` is carried on the +// prepared runtime for PR 3's session-lifecycle wiring. +async function stageAcpRemoteRuntime(input: { + runId: string; + target: AdapterExecutionTarget; + adapterKey: string; + workspaceLocalDir: string; + timeoutSec: number; + onLog: AdapterExecutionContext["onLog"]; + onRuntimeProgress: AdapterExecutionContext["onRuntimeProgress"]; +}): Promise { + await input.onLog( + "stdout", + `[paperclip] Syncing workspace to ${describeAdapterExecutionTarget(input.target)}.\n`, + ); + return await prepareAdapterExecutionTargetRuntime({ + runId: input.runId, + target: input.target, + adapterKey: input.adapterKey, + timeoutSec: input.timeoutSec, + workspaceLocalDir: input.workspaceLocalDir, + onProgress: (line) => input.onLog("stdout", line), + onRuntimeProgress: input.onRuntimeProgress, + }); +} + async function buildRuntime(input: { ctx: AdapterExecutionContext; engine: AcpxEngineSettings; @@ -1196,17 +1238,46 @@ async function buildRuntime(input: { } const childStderrDir = path.join(stateDir, "run-stderr"); const childStderrLogPath = agentCommand ? path.join(childStderrDir, `${runId}.log`) : null; - let paperclipBridge: AdapterExecutionTargetPaperclipBridgeHandle | null = null; - if ( + // A runner-backed remote sandbox is the only lane that crosses the staging + // seam: the runner-less ACP→CLI fallback (no `runner`) and local runs keep + // their historical behavior untouched. This is the single gate shared by the + // workspace stage and both sandbox bridges. + const useRemoteProcessSession = executionTarget?.kind === "remote" && executionTarget.transport === "sandbox" && Boolean(executionTarget.runner) && - agentCommandShell - ) { + Boolean(agentCommandShell); + // Ship the workspace into the sandbox and capture `{ workspaceRemoteDir, + // runtimeRootDir, assetDirs, restoreWorkspace }`. Done once here, before the + // bridges, so both bridges receive the real (non-null) `runtimeRootDir`. + const stagedRuntime: PreparedAdapterExecutionTargetRuntime | null = useRemoteProcessSession + ? await stageAcpRemoteRuntime({ + runId, + target: executionTarget, + adapterKey: input.engine.adapterType, + workspaceLocalDir: cwd, + timeoutSec, + onLog: input.ctx.onLog, + onRuntimeProgress: input.ctx.onRuntimeProgress, + }) + : null; + // `stagedRuntime.restoreWorkspace` is intentionally NOT invoked in this PR: + // copy-back of the sandbox edits onto the host workspace is wired into the + // run/session teardown path in the follow-up PR (session-lifecycle wiring). + // See `stageAcpRemoteRuntime()` above for the full deferral note. + // The ACP `session/new` cwd and every cwd-keyed session-state site + // (fingerprint, compat, persist, ensureSession, error) bind to THIS single + // value so a warm/resumable session created with the in-sandbox cwd is reused + // — not invalidated — on the next run. Remote runner-backed → the staged + // in-sandbox workspace dir; local and the runner-less fallback → the HOST cwd, + // byte-identical to today. + const sessionCwd = stagedRuntime?.workspaceRemoteDir ?? cwd; + let paperclipBridge: AdapterExecutionTargetPaperclipBridgeHandle | null = null; + if (useRemoteProcessSession) { paperclipBridge = await startAdapterExecutionTargetPaperclipBridge({ runId, target: { ...executionTarget, streamRunLogs: false }, - runtimeRootDir: null, + runtimeRootDir: stagedRuntime?.runtimeRootDir ?? null, adapterKey: input.engine.adapterType, timeoutSec, hostApiToken: env.PAPERCLIP_API_KEY, @@ -1224,24 +1295,20 @@ async function buildRuntime(input: { ); let processSessionBridge: AdapterExecutionTargetProcessSessionBridgeHandle | null = null; try { - processSessionBridge = - executionTarget?.kind === "remote" && - executionTarget.transport === "sandbox" && - Boolean(executionTarget.runner) && - agentCommandShell - ? await startAdapterExecutionTargetProcessSessionBridge({ - runId, - target: executionTarget, - runtimeRootDir: null, - adapterKey: input.engine.adapterType, - command: "sh", - args: ["-lc", `exec ${agentCommandShell}`], - cwd: effectiveExecutionCwd, - env: runtimeEnv, - timeoutSec, - onLog: input.ctx.onLog, - }) - : null; + processSessionBridge = useRemoteProcessSession + ? await startAdapterExecutionTargetProcessSessionBridge({ + runId, + target: executionTarget, + runtimeRootDir: stagedRuntime?.runtimeRootDir ?? null, + adapterKey: input.engine.adapterType, + command: "sh", + args: ["-lc", `exec ${agentCommandShell}`], + cwd: sessionCwd, + env: runtimeEnv, + timeoutSec, + onLog: input.ctx.onLog, + }) + : null; } catch (err) { await paperclipBridge?.stop().catch(() => {}); throw err; @@ -1252,7 +1319,7 @@ async function buildRuntime(input: { const fingerprint = shortHash({ acpxAgent, agentCommand: agentCommand ?? acpxAgent, - cwd: path.resolve(cwd), + cwd: path.resolve(sessionCwd), mode, permissionMode, nonInteractivePermissions, @@ -1291,7 +1358,10 @@ async function buildRuntime(input: { return { acpxAgent, mode, - cwd, + // Remote runner-backed → the in-sandbox workspace dir; local / runner-less + // → the HOST cwd (`sessionCwd` resolves both). Every cwd-keyed session site + // reads `prepared.cwd`, so binding it once here keeps them consistent. + cwd: sessionCwd, workspaceId, workspaceRepoUrl, workspaceRepoRef, @@ -1311,6 +1381,7 @@ async function buildRuntime(input: { agentRegistry, processSessionBridge, paperclipBridge, + stagedRuntime, remoteExecutionIdentity, skillPromptInstructions, skillsIdentity: { diff --git a/packages/adapters/claude-local/src/server/acp.test.ts b/packages/adapters/claude-local/src/server/acp.test.ts index 19cfcd57e4..200f350241 100644 --- a/packages/adapters/claude-local/src/server/acp.test.ts +++ b/packages/adapters/claude-local/src/server/acp.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import type { AdapterExecutionContext, AdapterInvocationMeta } from "@paperclipai/adapter-utils"; +import { runChildProcess } from "@paperclipai/adapter-utils/server-utils"; import { buildClaudeAcpConfig, createClaudeAcpExecutor, @@ -13,6 +14,35 @@ import { testClaudeAcpEnvironment, } from "./acp.js"; +// A local stand-in for a sandbox runner: runs the managed-runtime staging +// scripts (mkdir/tar/find) as real child processes so the remote ACP lane can +// be exercised end-to-end against the host filesystem. +function createLocalSandboxRunner() { + let counter = 0; + return { + execute: async (input: { + command: string; + args?: string[]; + cwd?: string; + env?: Record; + stdin?: string; + timeoutMs?: number; + onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise; + }) => { + counter += 1; + const command = input.command === "bash" ? "/bin/bash" : input.command; + return await runChildProcess(`claude-acp-sandbox-run-${counter}`, command, input.args ?? [], { + cwd: input.cwd ?? process.cwd(), + env: input.env ?? {}, + stdin: input.stdin, + timeoutSec: Math.max(1, Math.ceil((input.timeoutMs ?? 30_000) / 1000)), + graceSec: 5, + onLog: input.onLog ?? (async () => {}), + }); + }, + }; +} + type FakeRuntimeOptions = Record; type FakeRuntimeEvent = { type: string; text?: string; stream?: string; tag?: string }; type FakeRuntimeHandle = { @@ -456,6 +486,75 @@ describe("claude_local ACP lane", () => { expect(settings.permissions.allow).toEqual(expect.arrayContaining(["Bash(curl:*)", "Bash(env)"])); }); + it("creates the ACP session on the in-sandbox workspace cwd for runner-backed remote runs", async () => { + const root = await makeTempRoot("paperclip-claude-acp-remote-cwd-"); + const localCwd = path.join(root, "worktree"); + const remoteCwd = path.join(root, "remote-workspace"); + await fs.mkdir(localCwd, { recursive: true }); + await fs.mkdir(remoteCwd, { recursive: true }); + await fs.writeFile(path.join(localCwd, "hello.txt"), "hi", "utf8"); + + const runtimes: FakeRuntime[] = []; + const execute = createClaudeAcpExecutor({ + createRuntime: (options: FakeRuntimeOptions) => { + const runtime = new FakeRuntime(options); + runtimes.push(runtime); + return runtime as never; + }, + }); + + const result = await execute( + buildContext(localCwd, { + config: { + engine: "acp", + cwd: localCwd, + // Throwaway ACP command so the process-session bridge does not require + // a real claude-agent-acp binary in the local sandbox stand-in. + agentCommand: "node ./fake-acp.js", + stateDir: path.join(root, "state"), + promptTemplate: "Do the assigned work.", + }, + context: { + issueId: "issue-1", + paperclipTaskMarkdown: "Task context", + paperclipWorkspace: { cwd: localCwd, source: "project_workspace", workspaceId: "workspace-1" }, + }, + executionTarget: { + kind: "remote", + transport: "sandbox", + providerKey: "fake-plugin", + remoteCwd, + runner: createLocalSandboxRunner(), + } as never, + authToken: "real-run-jwt", + }), + ); + + expect(result.exitCode).toBe(0); + await expect(fs.readFile(path.join(remoteCwd, "hello.txt"), "utf8")).resolves.toBe("hi"); + expect(runtimes[0]?.ensureInputs[0]?.cwd).toBe(remoteCwd); + expect(runtimes[0]?.ensureInputs[0]?.cwd).not.toBe(localCwd); + }); + + it("falls back to the CLI lane for a runner-less sandbox even when the ACP command is set", async () => { + setNodeVersion("v22.13.0"); + await expect( + resolveClaudeExecutionEngineForRun({ + config: { agentCommand: "claude-agent-acp" }, + executionTarget: { + kind: "remote", + transport: "sandbox", + providerKey: "fake-plugin", + remoteCwd: "/work", + }, + }), + ).resolves.toMatchObject({ + engine: "cli", + explicit: false, + fallbackReason: expect.stringContaining("bidirectional remote process"), + }); + }); + it("resumes compatible ACP sessions on later Claude ACP runs", async () => { const root = await makeTempRoot("paperclip-claude-acp-resume-"); const runtimes: FakeRuntime[] = []; diff --git a/packages/adapters/codex-local/src/server/acp.test.ts b/packages/adapters/codex-local/src/server/acp.test.ts index 501f089800..cee2ab0577 100644 --- a/packages/adapters/codex-local/src/server/acp.test.ts +++ b/packages/adapters/codex-local/src/server/acp.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import type { AdapterExecutionContext, AdapterInvocationMeta } from "@paperclipai/adapter-utils"; +import { runChildProcess } from "@paperclipai/adapter-utils/server-utils"; import { buildCodexAcpConfig, createCodexAcpExecutor, @@ -13,6 +14,35 @@ import { testCodexAcpEnvironment, } from "./acp.js"; +// A local stand-in for a sandbox runner: runs the managed-runtime staging +// scripts (mkdir/tar/find) as real child processes so the remote ACP lane can +// be exercised end-to-end against the host filesystem. +function createLocalSandboxRunner() { + let counter = 0; + return { + execute: async (input: { + command: string; + args?: string[]; + cwd?: string; + env?: Record; + stdin?: string; + timeoutMs?: number; + onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise; + }) => { + counter += 1; + const command = input.command === "bash" ? "/bin/bash" : input.command; + return await runChildProcess(`codex-acp-sandbox-run-${counter}`, command, input.args ?? [], { + cwd: input.cwd ?? process.cwd(), + env: input.env ?? {}, + stdin: input.stdin, + timeoutSec: Math.max(1, Math.ceil((input.timeoutMs ?? 30_000) / 1000)), + graceSec: 5, + onLog: input.onLog ?? (async () => {}), + }); + }, + }; +} + type FakeRuntimeOptions = Record; type FakeRuntimeEvent = { type: string; text?: string; stream?: string; tag?: string }; type FakeRuntimeHandle = { @@ -488,6 +518,81 @@ describe("codex_local ACP lane", () => { }); }); + it("creates the ACP session on the in-sandbox workspace cwd for runner-backed remote runs", async () => { + const root = await makeTempRoot("paperclip-codex-acp-remote-cwd-"); + const localCwd = path.join(root, "worktree"); + const remoteCwd = path.join(root, "remote-workspace"); + await fs.mkdir(localCwd, { recursive: true }); + await fs.mkdir(remoteCwd, { recursive: true }); + await fs.writeFile(path.join(localCwd, "hello.txt"), "hi", "utf8"); + + const runtimes: FakeRuntime[] = []; + const execute = createCodexAcpExecutor({ + createRuntime: (options: FakeRuntimeOptions) => { + const runtime = new FakeRuntime(options); + runtimes.push(runtime); + return runtime as never; + }, + }); + + const result = await execute( + buildContext(localCwd, { + config: { + engine: "acp", + cwd: localCwd, + // Use a throwaway ACP command so the process-session bridge does not + // require a real codex-acp binary in the local sandbox stand-in. + agentCommand: "node ./fake-acp.js", + stateDir: path.join(root, "state"), + env: { CODEX_HOME: path.join(root, "codex-home") }, + promptTemplate: "Do the assigned work.", + }, + context: { + issueId: "issue-1", + paperclipTaskMarkdown: "Task context", + paperclipWorkspace: { cwd: localCwd, source: "project_workspace", workspaceId: "workspace-1" }, + }, + executionTarget: { + kind: "remote", + transport: "sandbox", + providerKey: "fake-plugin", + remoteCwd, + runner: createLocalSandboxRunner(), + } as never, + authToken: "real-run-jwt", + }), + ); + + expect(result.exitCode).toBe(0); + // The workspace was shipped into the sandbox and session/new was created on + // the in-sandbox workspace dir — not the HOST worktree path. + await expect(fs.readFile(path.join(remoteCwd, "hello.txt"), "utf8")).resolves.toBe("hi"); + expect(runtimes[0]?.ensureInputs[0]?.cwd).toBe(remoteCwd); + expect(runtimes[0]?.ensureInputs[0]?.cwd).not.toBe(localCwd); + }); + + it("falls back to the CLI lane for a runner-less sandbox even when the ACP command is set", async () => { + setNodeVersion("v22.13.0"); + // Isolate the missing bidirectional runner as the sole fallback cause: + // provide a valid ACP command and Node version so the only difference from + // the runner-backed ACP case is the absent `runner`. + await expect( + resolveCodexExecutionEngineForRun({ + config: { agentCommand: "codex-acp" }, + executionTarget: { + kind: "remote", + transport: "sandbox", + providerKey: "fake-plugin", + remoteCwd: "/work", + }, + }), + ).resolves.toMatchObject({ + engine: "cli", + explicit: false, + fallbackReason: expect.stringContaining("bidirectional remote process"), + }); + }); + it("classifies ACP refresh-token auth failures", async () => { const root = await makeTempRoot("paperclip-codex-acp-refresh-token-"); const execute = createCodexAcpExecutor({ diff --git a/packages/adapters/gemini-local/src/server/acp.test.ts b/packages/adapters/gemini-local/src/server/acp.test.ts index 6ec366c6b7..9c67890a11 100644 --- a/packages/adapters/gemini-local/src/server/acp.test.ts +++ b/packages/adapters/gemini-local/src/server/acp.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import type { AdapterExecutionContext, AdapterInvocationMeta } from "@paperclipai/adapter-utils"; +import { runChildProcess } from "@paperclipai/adapter-utils/server-utils"; import { buildGeminiAcpConfig, createGeminiAcpExecutor, @@ -12,6 +13,35 @@ import { testGeminiAcpEnvironment, } from "./acp.js"; +// A local stand-in for a sandbox runner: runs the managed-runtime staging +// scripts (mkdir/tar/find) as real child processes so the remote ACP lane can +// be exercised end-to-end against the host filesystem. +function createLocalSandboxRunner() { + let counter = 0; + return { + execute: async (input: { + command: string; + args?: string[]; + cwd?: string; + env?: Record; + stdin?: string; + timeoutMs?: number; + onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise; + }) => { + counter += 1; + const command = input.command === "bash" ? "/bin/bash" : input.command; + return await runChildProcess(`gemini-acp-sandbox-run-${counter}`, command, input.args ?? [], { + cwd: input.cwd ?? process.cwd(), + env: input.env ?? {}, + stdin: input.stdin, + timeoutSec: Math.max(1, Math.ceil((input.timeoutMs ?? 30_000) / 1000)), + graceSec: 5, + onLog: input.onLog ?? (async () => {}), + }); + }, + }; +} + type FakeRuntimeOptions = Record; type FakeRuntimeEvent = { type: string; text?: string; stream?: string; tag?: string }; type FakeRuntimeHandle = { @@ -387,6 +417,75 @@ describe("gemini_local ACP lane", () => { expect(logs.some((entry) => entry.text.includes("\"type\":\"acpx.session\""))).toBe(true); }); + it("creates the ACP session on the in-sandbox workspace cwd for runner-backed remote runs", async () => { + const root = await makeTempRoot("paperclip-gemini-acp-remote-cwd-"); + process.env.HOME = path.join(root, "home"); + const localCwd = path.join(root, "worktree"); + const remoteCwd = path.join(root, "remote-workspace"); + await fs.mkdir(localCwd, { recursive: true }); + await fs.mkdir(remoteCwd, { recursive: true }); + await fs.writeFile(path.join(localCwd, "hello.txt"), "hi", "utf8"); + + const runtime = new FakeRuntime({}); + const execute = createGeminiAcpExecutor({ + createRuntime: (options) => { + Object.assign(runtime.options, options); + return runtime as never; + }, + }); + + const result = await execute( + buildContext(localCwd, { + config: { + engine: "acp", + cwd: localCwd, + // Throwaway ACP command so the process-session bridge does not require + // a real gemini binary in the local sandbox stand-in. + agentCommand: "node ./fake-acp.js", + stateDir: path.join(root, "state"), + promptTemplate: "Do the assigned work.", + }, + context: { + issueId: "issue-1", + paperclipTaskMarkdown: "Task context", + paperclipWorkspace: { cwd: localCwd, source: "project_workspace", workspaceId: "workspace-1" }, + }, + executionTarget: { + kind: "remote", + transport: "sandbox", + providerKey: "fake-plugin", + remoteCwd, + runner: createLocalSandboxRunner(), + } as never, + authToken: "real-run-jwt", + }), + ); + + expect(result.exitCode).toBe(0); + await expect(fs.readFile(path.join(remoteCwd, "hello.txt"), "utf8")).resolves.toBe("hi"); + expect(runtime.ensureInputs[0]?.cwd).toBe(remoteCwd); + expect(runtime.ensureInputs[0]?.cwd).not.toBe(localCwd); + }); + + it("falls back to the CLI lane for a runner-less sandbox even when the ACP command is set", async () => { + setNodeVersion("v22.13.0"); + await expect( + resolveGeminiExecutionEngineForRun({ + config: { agentCommand: "gemini --acp" }, + executionTarget: { + kind: "remote", + transport: "sandbox", + providerKey: "fake-plugin", + remoteCwd: "/work", + }, + }), + ).resolves.toMatchObject({ + engine: "cli", + explicit: false, + fallbackReason: expect.stringContaining("bidirectional remote process"), + }); + }); + it("reports Gemini ACP environment readiness", async () => { const root = await makeTempRoot("paperclip-gemini-acp-env-"); const bin = path.join(root, "bin");