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:
parent
5a00040c4c
commit
8a3cc86531
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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[] = [];
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Reference in New Issue