feat(acpx): stage workspace + route in-sandbox cwd for remote ACP lane (#10070)

## 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 <harold@paperclip.ing>
Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Nicky Leach 2026-07-22 22:24:30 -07:00 committed by GitHub
parent 5a00040c4c
commit 8a3cc86531
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 554 additions and 27 deletions

View File

@ -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<typeof import("@paperclipai/adapter-utils/execution-target")>();
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<Record<string, unknown>> = [];
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);
});
});

View File

@ -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<string, unknown> | null;
skillPromptInstructions: string;
skillsIdentity: Record<string, unknown>;
@ -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<PreparedAdapterExecutionTargetRuntime> {
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: {

View File

@ -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<string, string>;
stdin?: string;
timeoutMs?: number;
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
}) => {
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<string, unknown>;
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[] = [];

View File

@ -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<string, string>;
stdin?: string;
timeoutMs?: number;
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
}) => {
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<string, unknown>;
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({

View File

@ -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<string, string>;
stdin?: string;
timeoutMs?: number;
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
}) => {
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<string, unknown>;
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");