diff --git a/packages/adapter-utils/src/acpx-engine/execute.test.ts b/packages/adapter-utils/src/acpx-engine/execute.test.ts index d79e26588b..76cd8f3304 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.test.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.test.ts @@ -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; runtimeMcp?: AdapterRuntimeMcpAccess; + prepareRemoteManagedHome?: AcpxEngineExecutorOptions["prepareRemoteManagedHome"]; } = {}, ) { const runtimeOptions: Record[] = []; @@ -151,6 +153,9 @@ async function runExecutor( const meta: Record[] = []; const logs: Array<{ stream: string; text: string }> = []; const execute = createAcpxEngineExecutor({ + ...(options.prepareRemoteManagedHome + ? { prepareRemoteManagedHome: options.prepareRemoteManagedHome } + : {}), createRuntime: (options) => { runtimeOptions.push(options as unknown as Record); 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 | 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; + 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; + // 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).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).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); + }); +}); diff --git a/packages/adapter-utils/src/acpx-engine/execute.ts b/packages/adapter-utils/src/acpx-engine/execute.ts index 1ed603cf3a..d244558897 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.ts @@ -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; + /** 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; + 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; +} + +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; +} + export interface AcpxEngineExecutorOptions { createRuntime?: AcpxRuntimeFactory; now?: () => number; @@ -155,6 +223,14 @@ export interface AcpxEngineExecutorOptions { resolveBillingIdentity?: ( ctx: AdapterExecutionContext, ) => AcpxEngineBillingIdentity | null | Promise; + /** + * 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; } 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) | null; remoteExecutionIdentity: Record | null; skillPromptInstructions: string; skillsIdentity: Record; @@ -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.` 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 { @@ -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 { 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) | 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 {}); + } } function renderPaperclipEnvNote(env: Record): 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 diff --git a/packages/adapters/claude-local/src/server/acp.test.ts b/packages/adapters/claude-local/src/server/acp.test.ts index 200f350241..466bf950e3 100644 --- a/packages/adapters/claude-local/src/server/acp.test.ts +++ b/packages/adapters/claude-local/src/server/acp.test.ts @@ -65,6 +65,11 @@ type FakeRuntimeTurn = { const tempRoots: string[] = []; const originalNodeVersion = process.version; +const originalEnv: Record = { + 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( diff --git a/packages/adapters/claude-local/src/server/acp.ts b/packages/adapters/claude-local/src/server/acp.ts index c6316084dd..1513b6c360 100644 --- a/packages/adapters/claude-local/src/server/acp.ts +++ b/packages/adapters/claude-local/src/server/acp.ts @@ -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 { + 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, diff --git a/packages/adapters/codex-local/src/server/acp.test.ts b/packages/adapters/codex-local/src/server/acp.test.ts index cee2ab0577..bc686bcaba 100644 --- a/packages/adapters/codex-local/src/server/acp.test.ts +++ b/packages/adapters/codex-local/src/server/acp.test.ts @@ -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: diff --git a/packages/adapters/codex-local/src/server/acp.ts b/packages/adapters/codex-local/src/server/acp.ts index eccb3717fe..e9bd0bdb5a 100644 --- a/packages/adapters/codex-local/src/server/acp.ts +++ b/packages/adapters/codex-local/src/server/acp.ts @@ -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): Record { + 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, diff --git a/packages/adapters/gemini-local/src/server/acp.test.ts b/packages/adapters/gemini-local/src/server/acp.test.ts index 9c67890a11..8ee4001a1d 100644 --- a/packages/adapters/gemini-local/src/server/acp.test.ts +++ b/packages/adapters/gemini-local/src/server/acp.test.ts @@ -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( diff --git a/packages/adapters/gemini-local/src/server/acp.ts b/packages/adapters/gemini-local/src/server/acp.ts index bdedd2df43..ea07c647e7 100644 --- a/packages/adapters/gemini-local/src/server/acp.ts +++ b/packages/adapters/gemini-local/src/server/acp.ts @@ -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): Record): 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 { + 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,