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:
parent
8a3cc86531
commit
e1cc63328a
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 ACP→CLI 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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue