feat(acpx): per-adapter managed-home seed (x3) + Codex auth copy-back for remote ACP lane (#10073)

## Thinking Path

> - Paperclip is a control plane that orchestrates AI agents and adapter
execution for human operators.
> - Agents run across local and remote execution contexts, and
reliability in remote sessions depends on consistent adapter
bootstrapping.
> - The ACP path must prepare per-adapter runtime homes so managed
credentials and config are available in sandboxed runner environments.
> - Before this change, the new remote ACP lane did not yet consistently
stage managed-home paths for all affected adapters or restore Codex auth
state on teardown.
> - We added a shared per-adapter seam in the ACP engine, then wired
Codex, Claude, and Gemini remote lanes to seed managed homes and remap
to in-sandbox locations.
> - Codex additionally reuses the existing atomic auth restore flow to
copy auth back on teardown, matching CLI behavior.
> - This improves remote runner parity with existing CLI behavior and
avoids credential drift in shared-code-path executions.

## Linked Issues or Issue Description

- This change continues the ACP remote managed-home work by completing
per-adapter remote bootstrapping and Codex auth restore behavior for the
remote ACP lane.
- It specifically covers: `acpx-engine`, `codex-local`, `claude-local`,
and `gemini-local`.
- Related prior work in this repo: PR #10070.

## What Changed

- Add a per-adapter remote managed-home seam
(`prepareRemoteManagedHome`) in `acpx-engine` and thread it through ACP
execution options.
- Implement Codex ACP preparation to:
- stage `CODEX_HOME` (auth/config/skills) into a sandboxed remote home,
  - repoint `CODEX_HOME` to the in-sandbox path,
- and wire teardown copy-back through the existing managed-auth restore
path.
- Implement Claude ACP preparation to seed a sanitized `config-seed`
into `CLAUDE_CONFIG_DIR` and remap that directory to the sandbox root.
- Implement Gemini ACP preparation to seed `~/.gemini/skills`, set
`HOME` to managed runtime root, and preselect API-key auth in
`settings.json`.
- Keep local and runner-less ACP→CLI behavior unchanged by only invoking
the remote managed-home seam when running in remote ACP mode.
- Preserve existing authorization and activity boundaries in the shared
engine and adapter layers.

## Verification

- `git log --oneline
origin/master..origin/feat/acp-remote-managed-home-seed` confirms only
the expected 4 commits.
- `tsc --noEmit` is clean in `adapter-utils`, `codex-local`,
`claude-local`, and `gemini-local`.
- Vitest selection used during validation passed (120 tests across the
ACP-related suites).

## Risks

- If remote sandbox teardown occurs after token rotation but before
restore timing, Codex credentials can become stale and require re-auth
on next startup.
- Partial provisioning of managed-home assets would cause adapter
bootstrap failures in runner-backed ACP sessions.
- This change is scoped to execution-path behavior; it should not affect
CLI behavior.

## Model Used

None — human-authored.

## 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/...`)
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
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] 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-23 06:42:52 -07:00 committed by GitHub
parent 8a3cc86531
commit e1cc63328a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 1205 additions and 19 deletions

View File

@ -32,6 +32,7 @@ import {
parseGeminiVersionParts,
rewriteGeminiAcpFlagForVersion,
summarizeAcpxTurnUsage,
type AcpxEngineExecutorOptions,
} from "./execute.js";
import { runChildProcess } from "../server-utils.js";
@ -143,6 +144,7 @@ async function runExecutor(
authToken?: string;
executionTarget?: Record<string, unknown>;
runtimeMcp?: AdapterRuntimeMcpAccess;
prepareRemoteManagedHome?: AcpxEngineExecutorOptions["prepareRemoteManagedHome"];
} = {},
) {
const runtimeOptions: Record<string, unknown>[] = [];
@ -151,6 +153,9 @@ async function runExecutor(
const meta: Record<string, unknown>[] = [];
const logs: Array<{ stream: string; text: string }> = [];
const execute = createAcpxEngineExecutor({
...(options.prepareRemoteManagedHome
? { prepareRemoteManagedHome: options.prepareRemoteManagedHome }
: {}),
createRuntime: (options) => {
runtimeOptions.push(options as unknown as Record<string, unknown>);
return buildRuntime(
@ -1827,3 +1832,152 @@ describe("ACPX engine remote sandbox staging seam (PR 1: workspace + cwd)", () =
expect(runtimeOptions[0]?.cwd).toBe(localCwd);
});
});
describe("ACPX engine remote managed-home seam (PR 2: per-adapter home seed)", () => {
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 });
await fs.writeFile(path.join(localCwd, "hello.txt"), "hi", "utf8");
const executionTarget = {
kind: "remote",
transport: "sandbox",
providerKey: "fake-plugin",
remoteCwd,
runner: createLocalSandboxRunner(),
};
return { root, stateDir, localCwd, remoteCwd, executionTarget };
}
it("test_remote_seam_receives_adapter_agnostic_context", async () => {
const { stateDir, localCwd, remoteCwd, executionTarget } = await setupRemoteSandbox();
let captured: Record<string, unknown> | null = null;
const { sessionInputs } = await runExecutor(
{
agent: "custom",
agentCommand: "node ./fake-acp.js",
stateDir,
cwd: localCwd,
// A user/adapter-config env value proves the seam sees the resolved run env.
env: { SEAM_MARKER: "seam-marker-value" },
},
{
authToken: "real-run-jwt",
executionTarget,
prepareRemoteManagedHome: async (input) => {
captured = input as unknown as Record<string, unknown>;
const stagedRuntime = await input.stage([]);
return { stagedRuntime };
},
},
);
// The engine invoked the seam and used the runtime it staged (session/new
// binds to the in-sandbox workspace dir the seam returned).
expect(captured).not.toBeNull();
const context = captured as unknown as Record<string, unknown>;
// Only generic, adapter-agnostic inputs cross the boundary...
expect(context.acpxAgent).toBe("custom");
expect(context.companyId).toBe("company-1");
expect(context.runId).toBe("run-1");
expect(context.workspaceLocalDir).toBe(localCwd);
expect(context.executionTarget).toMatchObject({ kind: "remote", transport: "sandbox" });
expect(typeof context.stage).toBe("function");
expect(typeof context.timeoutSec).toBe("number");
// ...including the resolved run env (adapter config env folded in).
expect((context.env as Record<string, string>).SEAM_MARKER).toBe("seam-marker-value");
// ...and NOTHING scoped to a single adapter leaks across the seam. This locks
// the boundary: the engine must not hand a Gemini/Claude/Codex-specific field
// (e.g. the former `geminiSkillsHome`) to the generic seam context.
expect(context).not.toHaveProperty("geminiSkillsHome");
expect(Object.keys(context).some((key) => /gemini|claude|codex/i.test(key))).toBe(false);
expect(sessionInputs[0]?.cwd).toBe(remoteCwd);
});
it("test_remote_seam_stages_assets_and_env_remap_reaches_process", async () => {
const { root, stateDir, localCwd, remoteCwd, executionTarget } = await setupRemoteSandbox();
// A managed-home dir the seam ships as an asset (mirrors a per-adapter home).
const managedHomeDir = path.join(root, "managed-home");
await fs.mkdir(managedHomeDir, { recursive: true });
await fs.writeFile(path.join(managedHomeDir, "config.json"), "{}", "utf8");
const { meta } = await runExecutor(
{ agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd },
{
authToken: "real-run-jwt",
executionTarget,
prepareRemoteManagedHome: async (input) => {
const stagedRuntime = await input.stage([
{ key: "home", localDir: managedHomeDir, followSymlinks: true },
]);
// Repoint an adapter home env var onto the in-sandbox asset dir; the
// engine must forward this mutated run env to the spawned process.
input.env.MANAGED_HOME = stagedRuntime.assetDirs.home ?? "";
return { stagedRuntime };
},
},
);
// The seam's asset was threaded through the shared staging seam...
const stageArgs = vi.mocked(prepareAdapterExecutionTargetRuntime).mock.calls[0]![0];
expect(stageArgs.assets).toEqual([
{ key: "home", localDir: managedHomeDir, followSymlinks: true },
]);
// ...it really landed in the sandbox (local runner extracts to the asset dir)...
const remoteAssetDir = String((meta[0]?.env as Record<string, string>).MANAGED_HOME);
expect(remoteAssetDir).toBeTruthy();
await expect(fs.readFile(path.join(remoteAssetDir, "config.json"), "utf8")).resolves.toBe("{}");
// ...the staged asset dir resolves under the run's managed runtime root (an
// in-sandbox path), not the host managed-home dir.
expect(remoteAssetDir).toContain(".paperclip-runtime");
expect(remoteAssetDir).not.toBe(managedHomeDir);
expect(path.isAbsolute(remoteAssetDir)).toBe(true);
});
it("test_remote_seam_teardown_fires_once_on_exit", async () => {
const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox();
let teardownCalls = 0;
await runExecutor(
{ agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd },
{
authToken: "real-run-jwt",
executionTarget,
prepareRemoteManagedHome: async (input) => {
const stagedRuntime = await input.stage([]);
return {
stagedRuntime,
teardown: async () => {
teardownCalls += 1;
},
};
},
},
);
// The engine fires the seam's teardown exactly once on the exit/cleanup path
// (mirrors the codex auth copy-back + staged-temp cleanup finally).
expect(teardownCalls).toBe(1);
});
it("test_remote_seam_absent_stages_workspace_only", async () => {
// Without a seam (custom agents / adapters with no home seed), the remote lane
// stages the workspace with no home asset — byte-identical to PR-1 behavior.
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 },
);
expect(vi.mocked(prepareAdapterExecutionTargetRuntime)).toHaveBeenCalledTimes(1);
const stageArgs = vi.mocked(prepareAdapterExecutionTargetRuntime).mock.calls[0]![0];
expect(stageArgs.assets ?? []).toEqual([]);
expect(sessionInputs[0]?.cwd).toBe(remoteCwd);
});
});

View File

@ -26,6 +26,7 @@ import {
type AdapterExecutionTargetPaperclipBridgeHandle,
type AdapterExecutionTargetProcessSessionBridgeHandle,
type AdapterExecutionTargetTimeoutResolution,
type AdapterManagedRuntimeAsset,
type PreparedAdapterExecutionTargetRuntime,
} from "@paperclipai/adapter-utils/execution-target";
import {
@ -140,6 +141,73 @@ export interface AcpxEngineBillingIdentity {
billingType?: AdapterBillingType | null;
}
/**
* Per-adapter remote managed-home seed seam, injected by each adapter's ACP
* wiring ({codex,claude,gemini}-local `acp.ts`). The adapter-specific
* credential/home helpers (`copyBackCodexAuth`, `stageCodexHomeForSync`,
* `prepareClaudeConfigSeed`, the Gemini skills stager, ) live in the adapter
* packages, and the shared engine which lives *inside*
* `@paperclipai/adapter-utils`, a dependency of those packages cannot import
* them without a circular dependency. So the engine exposes this seam and each
* adapter supplies it, reusing the exact same vetted helpers (no duplication of
* the security-critical copy-back path).
*
* The seam mirrors the adapter's CLI lane: seed the managed home into the
* sandbox through the staging seam, repoint the adapter's home env var to the
* in-sandbox path, and codex only wire auth copy-back on teardown. It is
* invoked ONLY on the runner-backed remote sandbox lane
* (`useRemoteProcessSession`); when absent (custom agents, the shared-engine
* tests) the engine stages the workspace with no home asset, byte-identical to
* the PR-1 behavior and to the local / runner-less ACPCLI fallback.
*
* This context is deliberately adapter-agnostic: it carries only generic inputs
* (the resolved run `env`, the target, the host workspace dir, the `stage`
* callback, ) so that nothing adapter-specific leaks across the boundary. A
* seam derives every adapter-specific path it needs the Gemini skills dir, the
* Codex home, the Claude config dir from `config`/`env` on its own side, the
* same way the adapter's CLI lane does. No field here is named after or scoped
* to a single adapter.
*/
export interface AcpxRemoteManagedHomeContext {
acpxAgent: string;
companyId: string;
runId: string;
config: Record<string, unknown>;
/** The runner-backed remote sandbox target the workspace stages into. */
executionTarget: AdapterExecutionTarget;
/** Host workspace dir being staged (the local cwd). */
workspaceLocalDir: string;
timeoutSec: number;
/**
* The run env. The seam MUST repoint the adapter's home env var here onto the
* in-sandbox path (e.g. `env.CODEX_HOME = staged.assetDirs.home`). At call
* time it already carries the host managed-home paths the engine resolved
* notably `env.CODEX_HOME` is the host managed Codex home for the codex agent.
*/
env: Record<string, string>;
onLog: AdapterExecutionContext["onLog"];
onRuntimeProgress: AdapterExecutionContext["onRuntimeProgress"];
/**
* Runs the shared workspace+assets staging seam and returns the prepared
* runtime. The seam passes its per-adapter home `assets` here; the returned
* `assetDirs`/`runtimeRootDir` are what it remaps the home env var onto.
*/
stage: (assets: AdapterManagedRuntimeAsset[]) => Promise<PreparedAdapterExecutionTargetRuntime>;
}
export interface AcpxRemoteManagedHomeResult {
stagedRuntime: PreparedAdapterExecutionTargetRuntime;
/**
* Invoked once on every teardown/exit path (mirrors the CLI restore-hook +
* staged-temp cleanup finally). For codex this runs `restoreWorkspace()` the
* seam that fires the auth copy-back and removes the staged home temp dir.
* Failures are logged by the seam, never fatal to the run result (an
* unclean-teardown copy-back miss is the accepted `refresh_token_reused`
* residual, loud on the next host Codex use, never silent).
*/
teardown?: () => Promise<void>;
}
export interface AcpxEngineExecutorOptions {
createRuntime?: AcpxRuntimeFactory;
now?: () => number;
@ -155,6 +223,14 @@ export interface AcpxEngineExecutorOptions {
resolveBillingIdentity?: (
ctx: AdapterExecutionContext,
) => AcpxEngineBillingIdentity | null | Promise<AcpxEngineBillingIdentity | null>;
/**
* Per-adapter remote managed-home seed + remap (+ codex copy-back). See
* {@link AcpxRemoteManagedHomeContext}. Absent the remote lane stages the
* workspace with no home asset (PR-1 behavior).
*/
prepareRemoteManagedHome?: (
input: AcpxRemoteManagedHomeContext,
) => Promise<AcpxRemoteManagedHomeResult>;
}
interface AcpxPreparedRuntime {
@ -186,6 +262,11 @@ interface AcpxPreparedRuntime {
// are what PR 2 (managed-home seeding + codex copy-back) and PR 3 (session
// lifecycle re-staging) build on.
stagedRuntime: PreparedAdapterExecutionTargetRuntime | null;
// Teardown hook from the per-adapter remote managed-home seam: runs the
// codex auth copy-back (via `restoreWorkspace()`) and removes staged temp
// dirs. Invoked once on every exit path by `cleanupRemoteBridges`. Null for
// local runs, the runner-less fallback, and adapters with no seam.
remoteManagedHomeTeardown: (() => Promise<void>) | null;
remoteExecutionIdentity: Record<string, unknown> | null;
skillPromptInstructions: string;
skillsIdentity: Record<string, unknown>;
@ -974,19 +1055,23 @@ 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.
// workspace (and, in PR 2, the per-adapter managed-home `assets`) into the
// sandbox and obtain the in-sandbox `workspaceRemoteDir` plus the non-null
// `runtimeRootDir`/`assetDirs` the bridges and the home remap consume. This is
// the shared-engine mirror of the CLI lanes (codex/claude/gemini
// `*-local/execute.ts`). PR 1 shipped the workspace + cwd only; PR 2 threads
// the home `assets` (built by the per-adapter `prepareRemoteManagedHome` seam,
// carrying the codex `provision`/`restore` auth seams) through `assets` here so
// `assetDirs.<key>` resolves to the seeded in-sandbox home. The returned
// `restoreWorkspace` fires the per-asset `restore` (codex copy-back) at
// teardown.
async function stageAcpRemoteRuntime(input: {
runId: string;
target: AdapterExecutionTarget;
adapterKey: string;
workspaceLocalDir: string;
timeoutSec: number;
assets?: AdapterManagedRuntimeAsset[];
onLog: AdapterExecutionContext["onLog"];
onRuntimeProgress: AdapterExecutionContext["onRuntimeProgress"];
}): Promise<PreparedAdapterExecutionTargetRuntime> {
@ -1000,6 +1085,7 @@ async function stageAcpRemoteRuntime(input: {
adapterKey: input.adapterKey,
timeoutSec: input.timeoutSec,
workspaceLocalDir: input.workspaceLocalDir,
...(input.assets && input.assets.length > 0 ? { assets: input.assets } : {}),
onProgress: (line) => input.onLog("stdout", line),
onRuntimeProgress: input.onRuntimeProgress,
});
@ -1008,6 +1094,7 @@ async function stageAcpRemoteRuntime(input: {
async function buildRuntime(input: {
ctx: AdapterExecutionContext;
engine: AcpxEngineSettings;
deps: AcpxEngineExecutorOptions;
}): Promise<AcpxPreparedRuntime> {
const { runId, agent, config, context, authToken } = input.ctx;
const workspaceContext = parseObject(context.paperclipWorkspace);
@ -1250,21 +1337,49 @@ async function buildRuntime(input: {
// 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({
//
// PR 2: on the remote lane, delegate staging to the per-adapter
// `prepareRemoteManagedHome` seam when the adapter supplies one. The seam
// ships the adapter's managed home as an `assets` entry (through the `stage`
// callback = `stageAcpRemoteRuntime`), repoints the home env var (`env`) onto
// the in-sandbox `assetDirs.*` path, and returns a `teardown` that fires the
// codex auth copy-back (`restoreWorkspace()`) and removes staged temp dirs.
// Without a seam (custom agents / shared-engine tests) the engine stages the
// workspace with no home asset — identical to the PR-1 behavior.
let stagedRuntime: PreparedAdapterExecutionTargetRuntime | null = null;
let remoteManagedHomeTeardown: (() => Promise<void>) | null = null;
if (useRemoteProcessSession) {
const stage = (assets: AdapterManagedRuntimeAsset[]) =>
stageAcpRemoteRuntime({
runId,
target: executionTarget,
adapterKey: input.engine.adapterType,
workspaceLocalDir: cwd,
timeoutSec,
assets,
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.
});
if (input.deps.prepareRemoteManagedHome) {
const seeded = await input.deps.prepareRemoteManagedHome({
acpxAgent,
companyId: agent.companyId,
runId,
config,
executionTarget,
workspaceLocalDir: cwd,
timeoutSec,
env,
onLog: input.ctx.onLog,
onRuntimeProgress: input.ctx.onRuntimeProgress,
stage,
});
stagedRuntime = seeded.stagedRuntime;
remoteManagedHomeTeardown = seeded.teardown ?? null;
} else {
stagedRuntime = await stage([]);
}
}
// 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
@ -1311,6 +1426,11 @@ async function buildRuntime(input: {
: null;
} catch (err) {
await paperclipBridge?.stop().catch(() => {});
// The staged home / copy-back teardown must run even if a bridge fails to
// start after the workspace + managed home were already staged into the
// sandbox, so a refreshed credential is copied back and staged temp dirs
// are removed on this error path too.
await remoteManagedHomeTeardown?.().catch(() => {});
throw err;
}
const overrideCommand = processSessionBridge?.agentCommand ?? agentCommand;
@ -1382,6 +1502,7 @@ async function buildRuntime(input: {
processSessionBridge,
paperclipBridge,
stagedRuntime,
remoteManagedHomeTeardown,
remoteExecutionIdentity,
skillPromptInstructions,
skillsIdentity: {
@ -1454,6 +1575,15 @@ async function cleanupRemoteBridges(prepared: AcpxPreparedRuntime): Promise<void
prepared.processSessionBridge?.stop(),
prepared.paperclipBridge?.stop(),
]);
// Runs AFTER the bridges stop (mirrors the CLI finally: stop bridge → restore
// workspace). Fires the codex auth copy-back via `restoreWorkspace()` and
// removes staged temp dirs. The seam logs and swallows its own failures — an
// unclean-teardown copy-back miss is the accepted, loud `refresh_token_reused`
// residual on the next host Codex use, never silent HOST-credential corruption
// — so a teardown fault never masks or fails the run result here.
if (prepared.remoteManagedHomeTeardown) {
await prepared.remoteManagedHomeTeardown().catch(() => {});
}
}
function renderPaperclipEnvNote(env: Record<string, string>): string {
@ -1996,7 +2126,7 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) {
...(billingIdentity?.biller ? { biller: billingIdentity.biller } : {}),
billingType: billingIdentity?.billingType ?? ("unknown" as const),
};
const prepared = await buildRuntime({ ctx, engine });
const prepared = await buildRuntime({ ctx, engine, deps });
// State the effective wall-clock timeout and its source up front so a
// later timeout is diagnosable from the run log alone. Goes to stderr:
// the acpx stdout log stream carries JSON acpx.* event payloads and must

View File

@ -65,6 +65,11 @@ type FakeRuntimeTurn = {
const tempRoots: string[] = [];
const originalNodeVersion = process.version;
const originalEnv: Record<string, string | undefined> = {
PAPERCLIP_HOME: process.env.PAPERCLIP_HOME,
PAPERCLIP_INSTANCE_ID: process.env.PAPERCLIP_INSTANCE_ID,
CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR,
};
function setNodeVersion(version: string): void {
Object.defineProperty(process, "version", {
@ -76,6 +81,10 @@ function setNodeVersion(version: string): void {
afterEach(async () => {
setNodeVersion(originalNodeVersion);
for (const [key, value] of Object.entries(originalEnv)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
await Promise.all(tempRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })));
});
@ -536,6 +545,213 @@ describe("claude_local ACP lane", () => {
expect(runtimes[0]?.ensureInputs[0]?.cwd).not.toBe(localCwd);
});
it("seeds the managed Claude config into the sandbox and repoints CLAUDE_CONFIG_DIR to the in-sandbox path", async () => {
const root = await makeTempRoot("paperclip-claude-acp-home-seed-");
const localCwd = path.join(root, "worktree");
const remoteCwd = path.join(root, "remote-workspace");
const sharedClaudeConfig = path.join(root, "shared-claude-config");
await fs.mkdir(localCwd, { recursive: true });
await fs.mkdir(remoteCwd, { recursive: true });
await fs.mkdir(sharedClaudeConfig, { recursive: true });
// Host shared Claude config the seed is built from.
await fs.writeFile(
path.join(sharedClaudeConfig, "settings.json"),
JSON.stringify({ permissions: { defaultMode: "acceptEdits" } }),
"utf8",
);
await fs.writeFile(path.join(sharedClaudeConfig, "CLAUDE.md"), "# shared guidance\n", "utf8");
process.env.PAPERCLIP_HOME = path.join(root, "paperclip-home");
process.env.PAPERCLIP_INSTANCE_ID = "test";
process.env.CLAUDE_CONFIG_DIR = sharedClaudeConfig;
const meta: AdapterInvocationMeta[] = [];
const execute = createClaudeAcpExecutor({
createRuntime: (options: FakeRuntimeOptions) => new FakeRuntime(options) as never,
});
const result = await execute(
buildContext(localCwd, {
config: {
engine: "acp",
cwd: localCwd,
agentCommand: "node ./fake-acp.js",
stateDir: path.join(root, "state"),
promptTemplate: "Do the assigned work.",
},
context: {
issueId: "issue-1",
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",
onMeta: async (payload: AdapterInvocationMeta) => {
meta.push(payload);
},
}),
);
expect(result.exitCode).toBe(0);
const remappedConfigDir = String(meta[0]?.env?.CLAUDE_CONFIG_DIR ?? "");
// C2 — CLAUDE_CONFIG_DIR repointed onto an in-sandbox path, distinct from the
// host shared config dir.
expect(remappedConfigDir).not.toBe(sharedClaudeConfig);
expect(remappedConfigDir).toContain(".paperclip-runtime");
expect(remappedConfigDir.endsWith("/config")).toBe(true);
// Seeded: settings.json was materialized into the in-sandbox config dir (the
// local runner uses the host FS, so this is a real host path).
await expect(fs.readFile(path.join(remappedConfigDir, "settings.json"), "utf8")).resolves.toContain(
"permissions",
);
// C4 — no XDG_* variable is introduced for in-sandbox credential discovery.
expect(Object.keys(meta[0]?.env ?? {}).filter((key) => key.startsWith("XDG_"))).toEqual([]);
});
it("remaps a workspace-relative explicit CLAUDE_CONFIG_DIR onto the in-sandbox workspace path", async () => {
const root = await makeTempRoot("paperclip-claude-acp-explicit-inworkspace-");
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 });
// Operator pins a config dir that lives INSIDE the workspace cwd, so it is
// staged into the sandbox and its host prefix must be remapped onto the
// in-sandbox workspace dir (never forwarded as the host path).
const operatorConfigDir = path.join(localCwd, ".claude-config");
await fs.mkdir(operatorConfigDir, { recursive: true });
await fs.writeFile(
path.join(operatorConfigDir, "settings.json"),
JSON.stringify({ permissions: { defaultMode: "acceptEdits" } }),
"utf8",
);
process.env.PAPERCLIP_HOME = path.join(root, "paperclip-home");
process.env.PAPERCLIP_INSTANCE_ID = "test";
const meta: AdapterInvocationMeta[] = [];
const logs: string[] = [];
const execute = createClaudeAcpExecutor({
createRuntime: (options: FakeRuntimeOptions) => new FakeRuntime(options) as never,
});
const result = await execute(
buildContext(localCwd, {
config: {
engine: "acp",
cwd: localCwd,
agentCommand: "node ./fake-acp.js",
stateDir: path.join(root, "state"),
promptTemplate: "Do the assigned work.",
env: { CLAUDE_CONFIG_DIR: operatorConfigDir },
},
context: {
issueId: "issue-1",
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",
onLog: async (_stream: "stdout" | "stderr", chunk: string) => {
logs.push(chunk);
},
onMeta: async (payload: AdapterInvocationMeta) => {
meta.push(payload);
},
}),
);
expect(result.exitCode).toBe(0);
// Prefix remapped host→sandbox: same relative subpath, in-sandbox workspace root.
expect(meta[0]?.env?.CLAUDE_CONFIG_DIR).toBe(path.posix.join(remoteCwd, ".claude-config"));
expect(meta[0]?.env?.CLAUDE_CONFIG_DIR).not.toBe(operatorConfigDir);
// No managed config seed is materialized — the operator dir is authoritative.
expect(String(meta[0]?.env?.CLAUDE_CONFIG_DIR ?? "")).not.toContain(".paperclip-runtime");
expect(logs.join("")).toContain(
`Remapped operator CLAUDE_CONFIG_DIR from host path ${operatorConfigDir}`,
);
});
it("ignores a host-only explicit CLAUDE_CONFIG_DIR that cannot reach the sandbox and seeds the managed config instead", async () => {
const root = await makeTempRoot("paperclip-claude-acp-explicit-hostonly-");
const localCwd = path.join(root, "worktree");
const remoteCwd = path.join(root, "remote-workspace");
const sharedClaudeConfig = path.join(root, "shared-claude-config");
// An operator-pinned config dir OUTSIDE the workspace cwd: a host-only path the
// sandbox cannot reach, so it must not be forwarded verbatim.
const operatorConfigDir = path.join(root, "operator-claude-config");
await fs.mkdir(localCwd, { recursive: true });
await fs.mkdir(remoteCwd, { recursive: true });
await fs.mkdir(sharedClaudeConfig, { recursive: true });
// Host shared Claude config the managed seed is built from.
await fs.writeFile(
path.join(sharedClaudeConfig, "settings.json"),
JSON.stringify({ permissions: { defaultMode: "acceptEdits" } }),
"utf8",
);
await fs.writeFile(path.join(sharedClaudeConfig, "CLAUDE.md"), "# shared guidance\n", "utf8");
process.env.PAPERCLIP_HOME = path.join(root, "paperclip-home");
process.env.PAPERCLIP_INSTANCE_ID = "test";
process.env.CLAUDE_CONFIG_DIR = sharedClaudeConfig;
const meta: AdapterInvocationMeta[] = [];
const logs: string[] = [];
const execute = createClaudeAcpExecutor({
createRuntime: (options: FakeRuntimeOptions) => new FakeRuntime(options) as never,
});
const result = await execute(
buildContext(localCwd, {
config: {
engine: "acp",
cwd: localCwd,
agentCommand: "node ./fake-acp.js",
stateDir: path.join(root, "state"),
promptTemplate: "Do the assigned work.",
// Explicit user-managed CLAUDE_CONFIG_DIR (adapter config env, not a host
// env leak) pointing at a host-only path.
env: { CLAUDE_CONFIG_DIR: operatorConfigDir },
},
context: {
issueId: "issue-1",
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",
onLog: async (_stream: "stdout" | "stderr", chunk: string) => {
logs.push(chunk);
},
onMeta: async (payload: AdapterInvocationMeta) => {
meta.push(payload);
},
}),
);
expect(result.exitCode).toBe(0);
const remappedConfigDir = String(meta[0]?.env?.CLAUDE_CONFIG_DIR ?? "");
// The un-portable host path is dropped; managed config is seeded in-sandbox.
expect(remappedConfigDir).not.toBe(operatorConfigDir);
expect(remappedConfigDir).toContain(".paperclip-runtime");
expect(remappedConfigDir.endsWith("/config")).toBe(true);
await expect(fs.readFile(path.join(remappedConfigDir, "settings.json"), "utf8")).resolves.toContain(
"permissions",
);
// Observability: the un-portable override is flagged so the substitution is diagnosable.
expect(logs.join("")).toContain(
`operator-provided CLAUDE_CONFIG_DIR=${operatorConfigDir} is outside the staged workspace`,
);
});
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(

View File

@ -24,12 +24,20 @@ import {
DEFAULT_ACP_ENGINE_PERMISSION_MODE,
DEFAULT_ACP_ENGINE_WARM_HANDLE_IDLE_MS,
} from "@paperclipai/adapter-utils/acpx-engine/constants";
import type { AcpxEngineExecutorOptions } from "@paperclipai/adapter-utils/acpx-engine/execute";
import type {
AcpxEngineExecutorOptions,
AcpxRemoteManagedHomeContext,
AcpxRemoteManagedHomeResult,
} from "@paperclipai/adapter-utils/acpx-engine/execute";
import {
asNumber,
asString,
parseObject,
} from "@paperclipai/adapter-utils/server-utils";
import {
materializeRemoteClaudeConfig,
prepareClaudeConfigSeed,
} from "./claude-config.js";
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
const packageRootDir = path.resolve(moduleDir, "../..");
@ -166,9 +174,105 @@ export function resolveClaudeAcpBillingIdentity(
};
}
/**
* Claude remote managed-home seed for the runner-backed remote sandbox ACP lane.
* Mirrors the Claude CLI lane (`claude-local/execute.ts`): ship a sanitized
* config seed (settings.json + CLAUDE.md, no credentials) as the `config-seed`
* asset, materialize it into an in-sandbox config dir (copying the sandbox's own
* `$HOME/.claude` credentials in), then repoint `CLAUDE_CONFIG_DIR` onto that
* in-sandbox config dir. Claude has no credential copy-back (its CLI lane has
* none mirroring the CLI is the contract), so no teardown hook.
*
* An explicit `CLAUDE_CONFIG_DIR` (user-managed) is honored only if it can reach
* the remote sandbox; a host-only path cannot, so we do NOT forward it verbatim
* (that would start remote Claude with no config/credentials). See the branch
* below for the two portable dispositions. The engine's `useRemoteProcessSession`
* gate already guarantees the remote sandbox (managed-home) target.
*/
async function prepareClaudeRemoteManagedHome(
input: AcpxRemoteManagedHomeContext,
): Promise<AcpxRemoteManagedHomeResult> {
const { env, runId, onLog, executionTarget } = input;
const envConfig = parseObject(input.config.env);
const explicitClaudeConfigDir =
typeof envConfig.CLAUDE_CONFIG_DIR === "string" && envConfig.CLAUDE_CONFIG_DIR.trim().length > 0
? envConfig.CLAUDE_CONFIG_DIR.trim()
: "";
if (explicitClaudeConfigDir) {
// User-managed escape hatch. Unlike the Claude CLI lane
// (`claude-local/execute.ts`), which runs the process on the same host and can
// forward the operator's path verbatim, the remote ACP lane spawns Claude
// inside a sandbox that CANNOT see host paths. Forwarding an absolute host
// path unchanged would leave remote Claude without the requested config or
// credentials, so we choose one of two portable dispositions:
// 1. The path lives INSIDE the staged workspace → remap its prefix onto the
// in-sandbox workspace dir so it resolves against the copied files.
// 2. The path is host-only (outside the workspace) → it cannot cross into
// the sandbox, so ignore the un-portable override and seed the managed
// config instead (falling through below), which guarantees working
// config/credentials. Logged loudly so the substitution is diagnosable.
const relativeToWorkspace = path.relative(input.workspaceLocalDir, explicitClaudeConfigDir);
const isUnderWorkspace =
relativeToWorkspace.length > 0 &&
!relativeToWorkspace.startsWith("..") &&
!path.isAbsolute(relativeToWorkspace);
if (isUnderWorkspace) {
const stagedRuntime = await input.stage([]);
const remoteWorkspaceDir = stagedRuntime.workspaceRemoteDir ?? input.workspaceLocalDir;
const remappedConfigDir = path.posix.join(
remoteWorkspaceDir,
relativeToWorkspace.split(path.sep).join(path.posix.sep),
);
env.CLAUDE_CONFIG_DIR = remappedConfigDir;
await onLog(
"stdout",
`[paperclip] Remapped operator CLAUDE_CONFIG_DIR from host path ${explicitClaudeConfigDir} onto the in-sandbox workspace path ${remappedConfigDir} for the remote ACP run.\n`,
);
return { stagedRuntime };
}
await onLog(
"stderr",
`[paperclip] operator-provided CLAUDE_CONFIG_DIR=${explicitClaudeConfigDir} is outside the staged workspace and cannot reach the remote sandbox; ignoring the host-only path and seeding the managed Claude config instead.\n`,
);
}
// Content-addressed sanitized seed (managed cache under the instance root, not
// a temp dir — reused across runs, so no teardown cleanup).
const claudeConfigSeedDir = await prepareClaudeConfigSeed(process.env, onLog, input.companyId);
const stagedRuntime = await input.stage([
{ key: "config-seed", localDir: claudeConfigSeedDir, followSymlinks: true },
]);
const remoteClaudeRuntimeRoot =
stagedRuntime.runtimeRootDir ??
path.posix.join(stagedRuntime.workspaceRemoteDir ?? input.workspaceLocalDir, ".paperclip-runtime", "claude");
const remoteClaudeConfigSeedDir =
stagedRuntime.assetDirs["config-seed"] ?? path.posix.join(remoteClaudeRuntimeRoot, "config-seed");
const remoteClaudeConfigDir = path.posix.join(remoteClaudeRuntimeRoot, "config");
await onLog("stdout", `[paperclip] Materializing Claude auth/config into ${remoteClaudeConfigDir}.\n`);
await materializeRemoteClaudeConfig({
runId,
target: executionTarget,
remoteClaudeConfigDir,
remoteClaudeConfigSeedDir,
options: {
cwd: stagedRuntime.workspaceRemoteDir ?? input.workspaceLocalDir,
env,
timeoutSec: Math.max(input.timeoutSec, 15),
graceSec: 20,
onLog,
},
});
// Repoint CLAUDE_CONFIG_DIR onto the in-sandbox config dir.
env.CLAUDE_CONFIG_DIR = remoteClaudeConfigDir;
return { stagedRuntime };
}
function withClaudeAcpDefaults(options: ClaudeAcpExecutorOptions): AcpxEngineExecutorOptions {
return {
resolveBillingIdentity: resolveClaudeAcpBillingIdentity,
prepareRemoteManagedHome: prepareClaudeRemoteManagedHome,
...options,
adapterType: "claude_local",
moduleDir,

View File

@ -67,6 +67,30 @@ const tempRoots: string[] = [];
const originalNodeVersion = process.version;
const originalPaperclipHome = process.env.PAPERCLIP_HOME;
const originalPaperclipInstanceId = process.env.PAPERCLIP_INSTANCE_ID;
const originalCodexHome = process.env.CODEX_HOME;
// Older/newer ISO timestamps for the copy-back monotonic (strictly-newer)
// decision predicate, plus a subscription-shaped auth.json fixture matching the
// predicate's parseAuth contract (tokens.account_id + token material +
// last_refresh).
const OLDER_REFRESH = "2026-01-01T00:00:00.000Z";
const NEWER_REFRESH = "2026-06-01T00:00:00.000Z";
function subscriptionAuthJson(accountId: string, lastRefresh: string, marker: string): string {
return JSON.stringify(
{
tokens: {
id_token: `id-${marker}`,
access_token: `acc-${marker}`,
refresh_token: `ref-${marker}`,
account_id: accountId,
},
last_refresh: lastRefresh,
},
null,
2,
);
}
function setNodeVersion(version: string): void {
Object.defineProperty(process, "version", {
@ -82,6 +106,8 @@ afterEach(async () => {
else process.env.PAPERCLIP_HOME = originalPaperclipHome;
if (originalPaperclipInstanceId === undefined) delete process.env.PAPERCLIP_INSTANCE_ID;
else process.env.PAPERCLIP_INSTANCE_ID = originalPaperclipInstanceId;
if (originalCodexHome === undefined) delete process.env.CODEX_HOME;
else process.env.CODEX_HOME = originalCodexHome;
await Promise.all(tempRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })));
});
@ -571,6 +597,197 @@ describe("codex_local ACP lane", () => {
expect(runtimes[0]?.ensureInputs[0]?.cwd).not.toBe(localCwd);
});
it("seeds the managed Codex home into the sandbox and repoints CODEX_HOME to the in-sandbox path", async () => {
const root = await makeTempRoot("paperclip-codex-acp-home-seed-");
const localCwd = path.join(root, "worktree");
const remoteCwd = path.join(root, "remote-workspace");
const sourceHome = path.join(root, "codex-home");
// A separate shared host home with no auth.json so the teardown copy-back is
// a benign no-op here (this test asserts the inbound seed + remap only).
const sharedHostHome = path.join(root, "shared-codex-home");
await fs.mkdir(localCwd, { recursive: true });
await fs.mkdir(remoteCwd, { recursive: true });
await fs.mkdir(sourceHome, { recursive: true });
await fs.mkdir(sharedHostHome, { recursive: true });
await fs.writeFile(
path.join(sourceHome, "auth.json"),
subscriptionAuthJson("acct-seed", NEWER_REFRESH, "seed"),
{ mode: 0o600 },
);
process.env.CODEX_HOME = sharedHostHome;
const meta: AdapterInvocationMeta[] = [];
const execute = createCodexAcpExecutor({
createRuntime: (options: FakeRuntimeOptions) => new FakeRuntime(options) as never,
});
const result = await execute(
buildContext(localCwd, {
config: {
engine: "acp",
cwd: localCwd,
agentCommand: "node ./fake-acp.js",
stateDir: path.join(root, "state"),
env: { CODEX_HOME: sourceHome },
promptTemplate: "Do the assigned work.",
},
context: {
issueId: "issue-1",
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",
onMeta: async (payload: AdapterInvocationMeta) => {
meta.push(payload);
},
}),
);
expect(result.exitCode).toBe(0);
const remappedCodexHome = String(meta[0]?.env?.CODEX_HOME ?? "");
// C2 — the managed home was repointed onto an in-sandbox path, distinct from
// the host managed home; it is NOT the host CODEX_HOME.
expect(remappedCodexHome).not.toBe(sourceHome);
expect(remappedCodexHome).not.toBe(sharedHostHome);
expect(remappedCodexHome).toContain(".paperclip-runtime");
// Seeded: the credential materialized into the in-sandbox home (the local
// runner uses the host FS, so the in-sandbox path is a real host path).
await expect(fs.readFile(path.join(remappedCodexHome, "auth.json"), "utf8")).resolves.toContain(
"account_id",
);
// C4 — no XDG_* variable is introduced for in-sandbox credential discovery.
expect(Object.keys(meta[0]?.env ?? {}).filter((key) => key.startsWith("XDG_"))).toEqual([]);
});
it("copies a strictly-newer sandbox Codex auth back to the shared host on teardown", async () => {
const root = await makeTempRoot("paperclip-codex-acp-copyback-newer-");
const localCwd = path.join(root, "worktree");
const remoteCwd = path.join(root, "remote-workspace");
const sourceHome = path.join(root, "codex-home");
const sharedHostHome = path.join(root, "shared-codex-home");
await fs.mkdir(localCwd, { recursive: true });
await fs.mkdir(remoteCwd, { recursive: true });
await fs.mkdir(sourceHome, { recursive: true });
await fs.mkdir(sharedHostHome, { recursive: true });
// The home staged into the sandbox carries a strictly-newer, same-identity
// credential (simulating an in-sandbox token rotation); the shared host copy
// is older.
await fs.writeFile(
path.join(sourceHome, "auth.json"),
subscriptionAuthJson("acct-same", NEWER_REFRESH, "sandbox-newer"),
{ mode: 0o600 },
);
await fs.writeFile(
path.join(sharedHostHome, "auth.json"),
subscriptionAuthJson("acct-same", OLDER_REFRESH, "host-older"),
{ mode: 0o600 },
);
process.env.CODEX_HOME = sharedHostHome;
const execute = createCodexAcpExecutor({
createRuntime: (options: FakeRuntimeOptions) => new FakeRuntime(options) as never,
});
const result = await execute(
buildContext(localCwd, {
config: {
engine: "acp",
cwd: localCwd,
agentCommand: "node ./fake-acp.js",
stateDir: path.join(root, "state"),
env: { CODEX_HOME: sourceHome },
promptTemplate: "Do the assigned work.",
},
context: {
issueId: "issue-1",
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);
// C5 — copy-back fired on teardown and installed the strictly-newer sandbox
// credential onto the shared host under the merge-lock / monotonic guard.
const hostAuth = JSON.parse(await fs.readFile(path.join(sharedHostHome, "auth.json"), "utf8"));
expect(hostAuth.last_refresh).toBe(NEWER_REFRESH);
expect(hostAuth.tokens.refresh_token).toBe("ref-sandbox-newer");
// Mode preserved at 0600 by the atomic same-directory rename.
const mode = (await fs.stat(path.join(sharedHostHome, "auth.json"))).mode & 0o777;
expect(mode).toBe(0o600);
});
it("keeps the shared host Codex auth when the sandbox copy is not strictly newer", async () => {
const root = await makeTempRoot("paperclip-codex-acp-copyback-older-");
const localCwd = path.join(root, "worktree");
const remoteCwd = path.join(root, "remote-workspace");
const sourceHome = path.join(root, "codex-home");
const sharedHostHome = path.join(root, "shared-codex-home");
await fs.mkdir(localCwd, { recursive: true });
await fs.mkdir(remoteCwd, { recursive: true });
await fs.mkdir(sourceHome, { recursive: true });
await fs.mkdir(sharedHostHome, { recursive: true });
// The staged/sandbox credential is OLDER than the shared host copy: the
// strictly-newer guard must keep the host credential (never overwrite a good
// token with a spent one).
await fs.writeFile(
path.join(sourceHome, "auth.json"),
subscriptionAuthJson("acct-same", OLDER_REFRESH, "sandbox-older"),
{ mode: 0o600 },
);
await fs.writeFile(
path.join(sharedHostHome, "auth.json"),
subscriptionAuthJson("acct-same", NEWER_REFRESH, "host-newer"),
{ mode: 0o600 },
);
process.env.CODEX_HOME = sharedHostHome;
const execute = createCodexAcpExecutor({
createRuntime: (options: FakeRuntimeOptions) => new FakeRuntime(options) as never,
});
const result = await execute(
buildContext(localCwd, {
config: {
engine: "acp",
cwd: localCwd,
agentCommand: "node ./fake-acp.js",
stateDir: path.join(root, "state"),
env: { CODEX_HOME: sourceHome },
promptTemplate: "Do the assigned work.",
},
context: {
issueId: "issue-1",
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);
const hostAuth = JSON.parse(await fs.readFile(path.join(sharedHostHome, "auth.json"), "utf8"));
expect(hostAuth.last_refresh).toBe(NEWER_REFRESH);
expect(hostAuth.tokens.refresh_token).toBe("ref-host-newer");
});
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:

View File

@ -25,13 +25,23 @@ import {
DEFAULT_ACP_ENGINE_PERMISSION_MODE,
DEFAULT_ACP_ENGINE_WARM_HANDLE_IDLE_MS,
} from "@paperclipai/adapter-utils/acpx-engine/constants";
import type { AcpxEngineExecutorOptions } from "@paperclipai/adapter-utils/acpx-engine/execute";
import type {
AcpxEngineExecutorOptions,
AcpxRemoteManagedHomeContext,
AcpxRemoteManagedHomeResult,
} from "@paperclipai/adapter-utils/acpx-engine/execute";
import {
asNumber,
asString,
parseObject,
} from "@paperclipai/adapter-utils/server-utils";
import { classifyCodexAuthRefreshFailure } from "./parse.js";
import { copyBackCodexAuth } from "./codex-auth-copyback.js";
import { buildCodexAuthInboundProvision } from "./codex-auth-merge-scripts.js";
import {
resolveSharedCodexHomeDir,
stageCodexHomeForSync,
} from "./codex-home.js";
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
const packageRootDir = path.resolve(moduleDir, "../..");
@ -132,9 +142,104 @@ export function buildCodexAcpConfig(config: Record<string, unknown>): Record<str
};
}
/**
* Codex remote managed-home seed + auth copy-back for the runner-backed remote
* sandbox ACP lane. Mirrors the codex CLI lane (`codex-local/execute.ts`): stage
* the managed `CODEX_HOME` (auth.json + config.toml + skills) into the sandbox
* as the `home` asset carrying the inbound auth-merge `provision` and the
* outbound `restore` copy-back seams then repoint `CODEX_HOME` onto the
* in-sandbox `assetDirs.home` path. The copy-back rides the asset `restore`,
* which fires inside `restoreWorkspace()` at teardown.
*
* The engine already resolved+seeded the host managed Codex home and set
* `env.CODEX_HOME` to it (a HOST path) before this seam runs, so `env.CODEX_HOME`
* is exactly the home to stage. Seed inbound and copy-back outbound land together
* (never seed-without-copy-back): Codex refresh tokens are single-use, so a
* refreshed sandbox token that is never copied back would spend the host's token
* and corrupt the host credential.
*/
async function prepareCodexRemoteManagedHome(
input: AcpxRemoteManagedHomeContext,
): Promise<AcpxRemoteManagedHomeResult> {
const { env, runId, onLog } = input;
// The host managed Codex home the engine seeded and set on env.CODEX_HOME.
const effectiveCodexHome = env.CODEX_HOME;
if (!effectiveCodexHome) {
// No managed home resolved (e.g. custom CODEX_HOME cleared) — stage the
// workspace with no home asset, identical to the no-seam fallback.
return { stagedRuntime: await input.stage([]) };
}
// Curated allowlist temp dir (auth/config/skills only); caller owns cleanup.
const stagedCodexHomeDir = await stageCodexHomeForSync(effectiveCodexHome, { runId });
let stagedRuntime;
try {
stagedRuntime = await input.stage([
{
key: "home",
localDir: stagedCodexHomeDir,
followSymlinks: true,
// Inbound (host→sandbox) auth-merge: keeps whichever credential is newer
// when the sandbox image already carries a Codex auth.json.
provision: buildCodexAuthInboundProvision(),
// Outbound (sandbox→host) copy-back at teardown, under the same
// direction-agnostic decision predicate + directory merge-lock +
// atomic-rename + 0600 guard. Target is the SHARED host auth.json
// (the symlink source managed homes point at), never an in-sandbox copy.
restore: async ({ assetDir, readFile }) =>
void (await copyBackCodexAuth({
readSandboxAuth: () => readFile(path.posix.join(assetDir, "auth.json")),
hostAuthPath: path.join(resolveSharedCodexHomeDir(process.env), "auth.json"),
log: (line) => onLog("stdout", `${line}\n`),
})),
},
]);
} catch (err) {
await fs.rm(stagedCodexHomeDir, { recursive: true, force: true }).catch(() => {});
throw err;
}
// Repoint CODEX_HOME from the HOST path onto the seeded in-sandbox home.
env.CODEX_HOME =
stagedRuntime.assetDirs.home ??
path.posix.join(stagedRuntime.runtimeRootDir ?? "", "home");
return {
stagedRuntime,
teardown: async () => {
try {
await onLog(
"stdout",
"[paperclip] Restoring workspace changes and Codex auth from the sandbox.\n",
);
await stagedRuntime.restoreWorkspace((line) => onLog("stdout", line));
} catch (err) {
// Fail-soft: a teardown copy-back miss loses this rotation and surfaces
// loudly as refresh_token_reused on the next host Codex use (re-auth
// recovers) — never silent host-credential corruption, so it must not
// mask the run result.
await onLog(
"stderr",
`[paperclip] Codex ACP teardown restore/copy-back failed: ${
err instanceof Error ? err.message : String(err)
}\n`,
);
} finally {
await fs.rm(stagedCodexHomeDir, { recursive: true, force: true }).catch(async (error) => {
await onLog(
"stderr",
`[paperclip] Failed to remove staged Codex home "${stagedCodexHomeDir}": ${
error instanceof Error ? error.message : String(error)
}\n`,
);
});
}
},
};
}
function withCodexAcpDefaults(options: CodexAcpExecutorOptions): AcpxEngineExecutorOptions {
return {
resolveBillingIdentity: resolveCodexAcpBillingIdentity,
prepareRemoteManagedHome: prepareCodexRemoteManagedHome,
...options,
adapterType: "codex_local",
moduleDir,

View File

@ -467,6 +467,157 @@ describe("gemini_local ACP lane", () => {
expect(runtime.ensureInputs[0]?.cwd).not.toBe(localCwd);
});
it("seeds the managed Gemini home into the sandbox, repoints HOME, and keeps the key file-only", async () => {
const root = await makeTempRoot("paperclip-gemini-acp-home-seed-");
const localCwd = path.join(root, "worktree");
const remoteCwd = path.join(root, "remote-workspace");
const hostHome = path.join(root, "home");
await fs.mkdir(localCwd, { recursive: true });
await fs.mkdir(remoteCwd, { recursive: true });
// A selected skill so the shipped skills asset has content to seed.
const skillSource = path.join(root, "skills", "review");
await fs.mkdir(skillSource, { recursive: true });
await fs.writeFile(path.join(skillSource, "SKILL.md"), "---\n---\nUse the review skill.\n", "utf8");
// The credential is delivered through the adapter-config env — the run env the
// seam forwards into the sandbox — so pre-selecting api-key auth is backed by a
// credential that is actually available in-sandbox. A stray host-only key must
// NOT be relied on, so we clear it to prove the selection comes from the run env.
const SECRET_KEY = "AIza-secret-key-value-SENTINEL";
process.env.HOME = hostHome;
delete process.env.GEMINI_API_KEY;
const meta: AdapterInvocationMeta[] = [];
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,
agentCommand: "node ./fake-acp.js",
stateDir: path.join(root, "state"),
// Drive resolveGeminiSkillsHome to a temp home so the engine prepares
// skills off the real ~/.gemini, and deliver the key via config env.
env: { HOME: hostHome, GEMINI_API_KEY: SECRET_KEY },
promptTemplate: "Do the assigned work.",
paperclipRuntimeSkills: [{ key: "company/review", runtimeName: "review", source: skillSource }],
paperclipSkillSync: { desiredSkills: ["company/review"] },
},
context: {
issueId: "issue-1",
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",
onMeta: async (payload) => {
meta.push(payload);
},
}),
);
expect(result.exitCode).toBe(0);
const remappedHome = String(meta[0]?.env?.HOME ?? "");
// C2 — HOME repointed onto the in-sandbox managed runtime root, distinct from
// the host home.
expect(remappedHome).not.toBe(hostHome);
expect(remappedHome).toContain(".paperclip-runtime");
// Seeded: skills copied into $HOME/.gemini/skills (local runner = host FS).
await expect(
fs.readFile(path.join(remappedHome, ".gemini", "skills", "review", "SKILL.md"), "utf8"),
).resolves.toContain("review skill");
// settings.json pre-selects api-key auth but carries no key bytes.
const settingsRaw = await fs.readFile(path.join(remappedHome, ".gemini", "settings.json"), "utf8");
expect(settingsRaw).toContain("gemini-api-key");
expect(settingsRaw).not.toContain(SECRET_KEY);
// The selector is backed by a credential the sandbox actually receives: the key
// rides in the forwarded run env (that is how it reaches the sandbox). The
// invocation meta redacts the value for logging, so it is present but never the
// raw bytes; the settings.json selector above proves the seam saw it in-env.
expect(meta[0]?.env?.GEMINI_API_KEY).toBeDefined();
expect(meta[0]?.env?.GEMINI_API_KEY).not.toBe(SECRET_KEY);
// C4 — no XDG_* variable introduced for credential discovery.
expect(Object.keys(meta[0]?.env ?? {}).filter((key) => key.startsWith("XDG_"))).toEqual([]);
});
it("does not persist an api-key auth selector from a host-only credential", async () => {
const root = await makeTempRoot("paperclip-gemini-acp-hostkey-");
const localCwd = path.join(root, "worktree");
const remoteCwd = path.join(root, "remote-workspace");
const hostHome = path.join(root, "home");
await fs.mkdir(localCwd, { recursive: true });
await fs.mkdir(remoteCwd, { recursive: true });
// The key exists ONLY in the host process env — never in the adapter-config env
// — so the remote sandbox (which does not inherit the host environment) will not
// receive it. Selecting api-key auth off this host-only signal would start
// headless Gemini with a credential it cannot see and fail authentication, so
// the seam must NOT persist a selector here.
const SECRET_KEY = "AIza-host-only-key-SENTINEL";
process.env.HOME = hostHome;
process.env.GEMINI_API_KEY = SECRET_KEY;
const meta: AdapterInvocationMeta[] = [];
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,
agentCommand: "node ./fake-acp.js",
stateDir: path.join(root, "state"),
env: { HOME: hostHome },
promptTemplate: "Do the assigned work.",
},
context: {
issueId: "issue-1",
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",
onMeta: async (payload) => {
meta.push(payload);
},
}),
);
expect(result.exitCode).toBe(0);
const remappedHome = String(meta[0]?.env?.HOME ?? "");
expect(remappedHome).toContain(".paperclip-runtime");
// No settings.json auth selector is written, because a host-only key is not a
// reliable in-sandbox credential signal.
await expect(
fs.readFile(path.join(remappedHome, ".gemini", "settings.json"), "utf8"),
).rejects.toThrow();
// And the host-only key never leaks into the forwarded run env.
for (const value of Object.values(meta[0]?.env ?? {})) {
expect(String(value)).not.toContain(SECRET_KEY);
}
});
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(

View File

@ -1,4 +1,5 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type {
@ -12,6 +13,7 @@ import {
ensureAdapterExecutionTargetCommandResolvable,
readAdapterExecutionTarget,
resolveAdapterExecutionTargetCwd,
runAdapterExecutionTargetShellCommand,
} from "@paperclipai/adapter-utils/execution-target";
import {
DEFAULT_ACP_ENGINE_MODE,
@ -19,7 +21,11 @@ import {
DEFAULT_ACP_ENGINE_PERMISSION_MODE,
DEFAULT_ACP_ENGINE_WARM_HANDLE_IDLE_MS,
} from "@paperclipai/adapter-utils/acpx-engine/constants";
import type { AcpxEngineExecutorOptions } from "@paperclipai/adapter-utils/acpx-engine/execute";
import type {
AcpxEngineExecutorOptions,
AcpxRemoteManagedHomeContext,
AcpxRemoteManagedHomeResult,
} from "@paperclipai/adapter-utils/acpx-engine/execute";
import {
asNumber,
asString,
@ -117,8 +123,111 @@ export function buildGeminiAcpConfig(config: Record<string, unknown>): Record<st
return next;
}
/**
* Host skills dir the shared engine materializes this run's Gemini skills into.
* Derived here inside the adapter boundary from the same generic `config`
* the engine reads (`config.env.HOME` else the process home), so the remote seam
* ships exactly the dir the engine's `prepareGeminiSkillRuntime` prepared without
* the engine having to hand a Gemini-specific path across the seam.
*/
function resolveGeminiSkillsHome(config: Record<string, unknown>): string {
const envConfig = parseObject(config.env);
const configuredHome =
typeof envConfig.HOME === "string" && envConfig.HOME.trim().length > 0
? path.resolve(envConfig.HOME.trim())
: os.homedir();
return path.join(configuredHome, ".gemini", "skills");
}
/**
* Gemini remote managed-home seed for the runner-backed remote sandbox ACP lane.
* Mirrors the Gemini CLI lane (`gemini-local/execute.ts`): set `HOME` to the
* managed runtime root, ship the prepared skills dir as the `skills` asset,
* `cp -a` it into `$HOME/.gemini/skills` in-sandbox, and only when an API key
* is present pre-select the api-key auth in `$HOME/.gemini/settings.json`
* (Gemini refuses headless runs without a persisted auth selection).
*
* The seed never writes key bytes: the key is only read as a boolean signal to
* decide whether to persist the auth-method selector. Gemini has no credential
* copy-back, so no teardown hook.
*/
async function prepareGeminiRemoteManagedHome(
input: AcpxRemoteManagedHomeContext,
): Promise<AcpxRemoteManagedHomeResult> {
const { env, runId, onLog, executionTarget } = input;
const geminiSkillsHome = resolveGeminiSkillsHome(input.config);
const stagedRuntime = await input.stage(
geminiSkillsHome
? [{ key: "skills", localDir: geminiSkillsHome, followSymlinks: true }]
: [],
);
// Managed HOME = the per-run runtime root. `useRemoteProcessSession` already
// guarantees a sandbox (managed-home) target, so the runtime root replaces the
// image home for this run.
const managedRemoteHomeDir = stagedRuntime.runtimeRootDir;
if (!managedRemoteHomeDir) {
// No runtime root resolved — leave HOME as-is (host fallback) and skip the
// in-sandbox seed; nothing to remap onto.
return { stagedRuntime };
}
env.HOME = managedRemoteHomeDir;
const shellOptions = {
cwd: stagedRuntime.workspaceRemoteDir ?? input.workspaceLocalDir,
env,
timeoutSec: Math.max(input.timeoutSec, 15),
graceSec: 20,
onLog,
};
// Copy the shipped skills into $HOME/.gemini/skills so the CLI finds them under
// the managed home.
const remoteSkillsAssetDir = stagedRuntime.assetDirs.skills;
if (remoteSkillsAssetDir) {
const remoteSkillsDir = path.posix.join(managedRemoteHomeDir, ".gemini", "skills");
await runAdapterExecutionTargetShellCommand(
runId,
executionTarget,
`mkdir -p ${JSON.stringify(path.posix.dirname(remoteSkillsDir))} && rm -rf ${JSON.stringify(remoteSkillsDir)} && cp -a ${JSON.stringify(remoteSkillsAssetDir)} ${JSON.stringify(remoteSkillsDir)}`,
shellOptions,
);
}
// Pre-select api-key auth (file-only; no key bytes) so headless Gemini does not
// fail with "Invalid auth method selected". Only the credential's PRESENCE is
// used as a signal — no key bytes are written to settings.json.
//
// The presence check reads ONLY the resolved run `env` — the credential state
// this seam actually provisions into the sandbox (adapter-config env + resolved
// secret refs, repointed onto the in-sandbox HOME). A key that exists only in
// the host `process.env` is NOT a reliable signal: the remote sandbox does not
// inherit the host environment, so persisting a `gemini-api-key` selector off a
// host-only key would start headless Gemini with an auth method whose credential
// is unavailable in-sandbox and fail authentication. We therefore select api-key
// auth only when the key is present in the run env that reaches the sandbox. An
// existing settings.json (user-shipped via workspace) is left untouched.
const hasGeminiApiKey = Boolean(env.GEMINI_API_KEY || env.GOOGLE_API_KEY);
if (hasGeminiApiKey) {
const remoteSettingsPath = path.posix.join(managedRemoteHomeDir, ".gemini", "settings.json");
const authSettingsJson = JSON.stringify({
selectedAuthType: "gemini-api-key",
security: { auth: { selectedType: "gemini-api-key" } },
});
await runAdapterExecutionTargetShellCommand(
runId,
executionTarget,
`mkdir -p ${JSON.stringify(path.posix.dirname(remoteSettingsPath))} && { [ -f ${JSON.stringify(remoteSettingsPath)} ] || printf '%s' ${JSON.stringify(authSettingsJson)} > ${JSON.stringify(remoteSettingsPath)}; }`,
shellOptions,
);
}
return { stagedRuntime };
}
function withGeminiAcpDefaults(options: GeminiAcpExecutorOptions): AcpxEngineExecutorOptions {
return {
prepareRemoteManagedHome: prepareGeminiRemoteManagedHome,
...options,
adapterType: "gemini_local",
moduleDir,