diff --git a/cli/src/__tests__/install-store.test.ts b/cli/src/__tests__/install-store.test.ts index eede05f47d..1efc9e892a 100644 --- a/cli/src/__tests__/install-store.test.ts +++ b/cli/src/__tests__/install-store.test.ts @@ -1,4 +1,5 @@ import fs from "node:fs"; +import { execFileSync } from "node:child_process"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; @@ -121,6 +122,29 @@ describe("managed install store", () => { expect(fs.statSync(rcPath).mode & 0o777).toBe(0o640); }); + it("uses the pinned Node for child tools even with an older node first on the service PATH", () => { + const entrypoint = path.join(paths.currentPath, "node_modules", "paperclipai", "dist", "index.js"); + fs.mkdirSync(path.dirname(entrypoint), { recursive: true }); + fs.writeFileSync(entrypoint, `console.log(require("node:child_process").execFileSync("node", ["-p", "process.execPath"], {encoding: "utf8"}).trim())`); + const oldBin = path.join(root, "old-bin"); + fs.mkdirSync(oldBin); + fs.writeFileSync(path.join(oldBin, "node"), "#!/bin/sh\nexit 42\n", { mode: 0o755 }); + writeManagedShim(paths); + const output = execFileSync(paths.shimPath, [], { env: { ...process.env, PATH: oldBin }, encoding: "utf8" }); + expect(fs.realpathSync(output.trim())).toBe(fs.realpathSync(process.execPath)); + expect(removeManagedShim(paths)).toBe(true); + }); + + it("upgrades and removes the original managed shim format", () => { + writeManagedShim(paths); + const original = fs.readFileSync(paths.shimPath, "utf8").split("\n").filter((line) => !line.startsWith("export PATH=")).join("\n"); + fs.writeFileSync(paths.shimPath, original); + writeManagedShim(paths); + expect(fs.readFileSync(paths.shimPath, "utf8")).toContain("export PATH="); + fs.writeFileSync(paths.shimPath, original); + expect(removeManagedShim(paths)).toBe(true); + }); + it("rejects marker substrings that are not the exact managed shim format", () => { fs.mkdirSync(path.dirname(paths.shimPath), { recursive: true }); fs.writeFileSync(paths.shimPath, `#!/bin/sh\necho '${MANAGED_SHIM_MARKER}'\n`); diff --git a/cli/src/__tests__/update-command.test.ts b/cli/src/__tests__/update-command.test.ts index 2b5c68f0c2..0ae687844a 100644 --- a/cli/src/__tests__/update-command.test.ts +++ b/cli/src/__tests__/update-command.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { flipCurrentAtomic, initializeInstallStore, payloadPathFor, readInstallManifest, resolveInstallStorePaths, writeInstallManifestAtomic, type InstallManifest, type InstallRecord } from "../install-store.js"; +import { writeManagedShim, flipCurrentAtomic, initializeInstallStore, payloadPathFor, readInstallManifest, resolveInstallStorePaths, writeInstallManifestAtomic, type InstallManifest, type InstallRecord } from "../install-store.js"; import type { CommandRunner } from "../commands/install.js"; import { compareVersions, detectInstallMode, resolveUpdateRequest, rollbackManagedInstall, updateCommand } from "../commands/update.js"; @@ -36,6 +36,56 @@ afterEach(() => { }); describe("update command", () => { + it.each(["npm", "git", "global-npm"] as const)("rejects %s updates on unsupported Node before any update work", async (source) => { + const paths = resolveInstallStorePaths(); initializeInstallStore(paths); + const payload = payloadPathFor(paths, "npm", "1.0.0"); + const entrypoint = createPayload(payload, "1.0.0"); + flipCurrentAtomic(payload, paths); + const manifest: InstallManifest = { schemaVersion: 1, ...record(payload, "1.0.0"), source: source === "git" ? "git" : "npm", previous: [] }; + writeInstallManifestAtomic(manifest, paths); + const runCommand = vi.fn(); + const backup = vi.fn(); + const restartActiveService = vi.fn(); + const nodeVersion = Object.getOwnPropertyDescriptor(process.versions, "node")!; + Object.defineProperty(process.versions, "node", { ...nodeVersion, value: "22.22.2" }); + try { + await expect(updateCommand({ yes: true }, { + paths, + executablePath: source === "global-npm" ? path.join(root, "lib", "node_modules", "paperclipai", "dist", "index.js") : entrypoint, + runCommand, backup, restartActiveService, + })).rejects.toThrow("npx paperclipai@latest install --yes"); + expect(runCommand).not.toHaveBeenCalled(); + expect(backup).not.toHaveBeenCalled(); + expect(restartActiveService).not.toHaveBeenCalled(); + expect(readInstallManifest(paths)).toEqual(manifest); + expect(fs.realpathSync(paths.currentPath)).toBe(fs.realpathSync(payload)); + } finally { + Object.defineProperty(process.versions, "node", nodeVersion); + } + }); + + it("keeps update checks, dry runs, and rollback available on unsupported Node", async () => { + const paths = resolveInstallStorePaths(); initializeInstallStore(paths); + const oldPayload = payloadPathFor(paths, "npm", "1.0.0"); createPayload(oldPayload, "1.0.0"); + const payload = payloadPathFor(paths, "npm", "2.0.0"); const executablePath = createPayload(payload, "2.0.0"); + flipCurrentAtomic(payload, paths); + writeInstallManifestAtomic({ schemaVersion: 1, ...record(payload, "2.0.0"), previous: [record(oldPayload, "1.0.0")] }, paths); + const runCommand = vi.fn(async () => ({ stdout: '\"3.0.0\"\n', stderr: "" })); + const restartActiveService = vi.fn(async () => false); + const nodeVersion = Object.getOwnPropertyDescriptor(process.versions, "node")!; + Object.defineProperty(process.versions, "node", { ...nodeVersion, value: "22.22.2" }); + try { + await updateCommand({ check: true }, { paths, executablePath, runCommand }); + await updateCommand({ dryRun: true }, { paths, executablePath, runCommand }); + expect(readInstallManifest(paths)?.version).toBe("2.0.0"); + await updateCommand({ rollback: true }, { paths, executablePath, restartActiveService }); + expect(readInstallManifest(paths)?.version).toBe("1.0.0"); + expect(restartActiveService).toHaveBeenCalledWith("1.0.0"); + } finally { + Object.defineProperty(process.versions, "node", nodeVersion); + } + }); + it("orders SemVer prerelease identifiers numerically", () => { expect(compareVersions("1.0.0-canary.10", "1.0.0-canary.2")).toBeGreaterThan(0); expect(compareVersions("1.0.0-1", "1.0.0-alpha")).toBeLessThan(0); @@ -72,12 +122,16 @@ describe("update command", () => { fs.writeFileSync(path.join(newPayload, "node_modules", "paperclipai", "package.json"), JSON.stringify({ version: "0.3.1" })); flipCurrentAtomic(oldPayload, paths); writeInstallManifestAtomic({ schemaVersion: 1, source: "git", version: "0.3.1", channel: "pinned", repo: "paperclipai/paperclip", ref: "master", sha: oldSha, payloadPath: oldPayload, installedAt: "2026-07-22T00:00:00.000Z", previous: [] }, paths); + writeManagedShim(paths); + // Simulate a launcher generated before child-runtime PATH pinning existed. + fs.writeFileSync(paths.shimPath, fs.readFileSync(paths.shimPath, "utf8").replace(/^export PATH=.*\n/m, "")); const backup = vi.fn(async () => undefined); const confirm = vi.fn(async () => true); const restartActiveService = vi.fn(async () => true); const runCommand = vi.fn(async (file: string) => file === "curl" ? { stdout: JSON.stringify({ sha: newSha }), stderr: "" } : { stdout: "0.3.1\n", stderr: "" }); await updateCommand({}, { paths, executablePath: executable, runCommand, backup, confirm, restartActiveService, hasInstanceData: () => true, now: () => new Date("2026-07-22T12:00:00Z") }); expect(confirm).toHaveBeenCalledWith(expect.stringContaining(`commit ${newSha.slice(0, 12)}`)); + expect(fs.readFileSync(paths.shimPath, "utf8")).toContain(`export PATH='${path.dirname(process.execPath)}'`); expect(backup).toHaveBeenCalledOnce(); expect(restartActiveService).toHaveBeenCalledWith("0.3.1"); expect(readInstallManifest(paths)?.sha).toBe(newSha); @@ -134,6 +188,9 @@ describe("update command", () => { const paths = resolveInstallStorePaths(); initializeInstallStore(paths); const oldPayload = payloadPathFor(paths, "npm", "1.0.0"); const executable = createPayload(oldPayload, "1.0.0"); flipCurrentAtomic(oldPayload, paths); writeInstallManifestAtomic({ schemaVersion: 1, ...record(oldPayload, "1.0.0"), previous: [] }, paths); + writeManagedShim(paths); + // Simulate a launcher generated before child-runtime PATH pinning existed. + fs.writeFileSync(paths.shimPath, fs.readFileSync(paths.shimPath, "utf8").replace(/^export PATH=.*\n/m, "")); const backup = vi.fn(async () => undefined); const restartActiveService = vi.fn(async () => true); const runCommand = vi.fn(async (file: string, args: string[]) => { @@ -142,6 +199,7 @@ describe("update command", () => { return { stdout: "2.0.0\n", stderr: "" }; }); await updateCommand({}, { paths, executablePath: executable, runCommand, backup, restartActiveService, hasInstanceData: () => true, now: () => new Date("2026-07-22T12:00:00Z") }); + expect(fs.readFileSync(paths.shimPath, "utf8")).toContain(`export PATH='${path.dirname(process.execPath)}'`); expect(backup).toHaveBeenCalledOnce(); expect(restartActiveService).toHaveBeenCalledWith("2.0.0"); expect(readInstallManifest(paths)?.version).toBe("2.0.0"); diff --git a/cli/src/commands/install.ts b/cli/src/commands/install.ts index 8559d83cb8..3fd4a5b198 100644 --- a/cli/src/commands/install.ts +++ b/cli/src/commands/install.ts @@ -84,9 +84,9 @@ export function resolveGitInstallWorkspacePackages(checkoutPath: string): Releas return ordered; } -function assertSupportedNodeVersion(): void { +export function assertSupportedNodeVersion(): void { if (!isSupportedNodeVersion(process.versions.node)) { - throw new Error(`Managed installs require Node.js ${MINIMUM_NODE_VERSION} or newer (found ${process.version}).`); + throw new Error(`Installing or updating Paperclip requires Node.js ${MINIMUM_NODE_VERSION} or newer (found ${process.version} at ${process.execPath}). Put a supported Node bin directory first on PATH and run 'npx paperclipai@latest install --yes' to re-pin an existing managed install.`); } } diff --git a/cli/src/commands/update.ts b/cli/src/commands/update.ts index 4c76fa3bd2..5ac0be9fb0 100644 --- a/cli/src/commands/update.ts +++ b/cli/src/commands/update.ts @@ -5,9 +5,9 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; import * as p from "@clack/prompts"; import pc from "picocolors"; -import { buildNextManifest, flipCurrentAtomic, isManagedExecutable, pruneInstallPayloads, readInstallManifest, resolveInstallStorePaths, withInstallStoreLock, writeInstallManifestAtomic, type InstallChannel, type InstallManifest, type InstallRecord, type InstallStorePaths } from "../install-store.js"; +import { assertManagedShimWritable, writeManagedShim, buildNextManifest, flipCurrentAtomic, isManagedExecutable, pruneInstallPayloads, readInstallManifest, resolveInstallStorePaths, withInstallStoreLock, writeInstallManifestAtomic, type InstallChannel, type InstallManifest, type InstallRecord, type InstallStorePaths } from "../install-store.js"; import { dbBackupCommand } from "./db-backup.js"; -import { installGitPayload, installNpmPayload, PUBLIC_NPM_REGISTRY, resolveGitHubRef, resolvePublishedVersion, type CommandRunner } from "./install.js"; +import { assertSupportedNodeVersion, installGitPayload, installNpmPayload, PUBLIC_NPM_REGISTRY, resolveGitHubRef, resolvePublishedVersion, type CommandRunner } from "./install.js"; import { resolvePaperclipInstanceId, resolvePaperclipInstanceRoot } from "../config/home.js"; import { resolveConfigPath } from "../config/store.js"; import { detectServiceManager } from "../services/service-manager.js"; @@ -180,6 +180,7 @@ export async function updateCommand(options: UpdateOptions, overrides: Partial dbBackupCommand({})), overrides.hasInstanceData); const installed = await withInstallStoreLock(async () => { + assertManagedShimWritable(paths); const payload = await installGitPayload(manifest.repo!, targetSha, runCommand, paths); + writeManagedShim(paths); const record: InstallRecord = { source: "git", version: payload.version, channel: "pinned", repo: manifest.repo, ref: manifest.ref, sha: targetSha, payloadPath: payload.payloadPath, installedAt: (overrides.now?.() ?? new Date()).toISOString() }; const next = buildNextManifest(record, manifest); const oldTarget = fs.readlinkSync(paths.currentPath); flipCurrentAtomic(payload.payloadPath, paths); try { writeInstallManifestAtomic(next, paths); } catch (error) { flipCurrentAtomic(path.resolve(paths.cliRoot, oldTarget), paths); throw error; } @@ -247,7 +250,9 @@ export async function updateCommand(options: UpdateOptions, overrides: Partial dbBackupCommand({})), overrides.hasInstanceData); const installed = await withInstallStoreLock(async () => { + assertManagedShimWritable(paths); const payload = await installNpmPayload(targetVersion, runCommand, paths); + writeManagedShim(paths); const record: InstallRecord = { source: "npm", version: targetVersion, channel: request.channel, payloadPath: payload.payloadPath, installedAt: (overrides.now?.() ?? new Date()).toISOString() }; const next = buildNextManifest(record, manifest); const oldTarget = fs.readlinkSync(paths.currentPath); flipCurrentAtomic(payload.payloadPath, paths); try { writeInstallManifestAtomic(next, paths); } catch (error) { flipCurrentAtomic(path.resolve(paths.cliRoot, oldTarget), paths); throw error; } diff --git a/cli/src/install-store.ts b/cli/src/install-store.ts index a89e867dc7..984d955b25 100644 --- a/cli/src/install-store.ts +++ b/cli/src/install-store.ts @@ -380,13 +380,17 @@ function shellQuote(value: string): string { function isManagedShimContents(contents: string): boolean { const lines = contents.split("\n"); + // Accept the original pinned-runtime shim so upgrades can replace it. + const withRuntimePath = lines.length === 6; + const execIndex = withRuntimePath ? 4 : 3; return ( - lines.length === 5 && + (lines.length === 5 || withRuntimePath) && lines[0] === "#!/bin/sh" && lines[1] === `# ${MANAGED_SHIM_MARKER}` && lines[2] === "set -eu" && - /^exec '(?:[^']|'"'"')+' '(?:[^']|'"'"')+' "\$@"$/.test(lines[3]) && - lines[4] === "" + (!withRuntimePath || /^export PATH='(?:[^']|'"'"')+':"\$\{PATH:-\/usr\/local\/bin:\/usr\/bin:\/bin\}"$/.test(lines[3])) && + /^exec '(?:[^']|'"'"')+' '(?:[^']|'"'"')+' "\$@"$/.test(lines[execIndex]) && + lines[execIndex + 1] === "" ); } @@ -399,7 +403,9 @@ export function writeManagedShim(paths = resolveInstallStorePaths()): void { fs.mkdirSync(path.dirname(paths.shimPath), { recursive: true, mode: 0o755 }); assertManagedShimWritable(paths); const entrypoint = path.join(paths.currentPath, "node_modules", "paperclipai", "dist", "index.js"); - const contents = `#!/bin/sh\n# ${MANAGED_SHIM_MARKER}\nset -eu\nexec ${shellQuote(process.execPath)} ${shellQuote(entrypoint)} "\$@"\n`; + // ACP servers and package-manager shims use /usr/bin/env node. Pin their + // runtime too, even when systemd/launchd supplies a different PATH. + const contents = `#!/bin/sh\n# ${MANAGED_SHIM_MARKER}\nset -eu\nexport PATH=${shellQuote(path.dirname(process.execPath))}:"\${PATH:-/usr/local/bin:/usr/bin:/bin}"\nexec ${shellQuote(process.execPath)} ${shellQuote(entrypoint)} "\$@"\n`; writeFileAtomic(paths.shimPath, contents, 0o755); } diff --git a/cli/src/node-version.test.ts b/cli/src/node-version.test.ts index ef1cde7359..aa53a6cf8f 100644 --- a/cli/src/node-version.test.ts +++ b/cli/src/node-version.test.ts @@ -29,6 +29,8 @@ describe("isSupportedNodeVersion", () => { expect(warning).toContain(NODE_VERSION_INSTALL_GUIDE_URL); expect(warning).toContain("piped install.sh form cannot upgrade"); expect(warning).toContain("Restart Paperclip after upgrading"); + expect(warning).toContain(process.execPath); + expect(warning).toContain("startup executable and PATH"); }); it("emits at most one warning when CLI and server boot in the same process", () => { diff --git a/doc/INSTALLING.md b/doc/INSTALLING.md index 26f0952845..3c0d80b17d 100644 --- a/doc/INSTALLING.md +++ b/doc/INSTALLING.md @@ -61,6 +61,45 @@ the same origin as the artifact as an independent trust anchor. Each installer flag also has a `PAPERCLIP_INSTALL_*` environment-variable equivalent. This helps where passing arguments through a pipe is awkward. +Codex ACP workspace sessions enable networking so agents can report task outcomes. +To disable it explicitly, set `extraArgs` to +`["-c", "sandbox_workspace_write.network_access=false"]`, or set +`env.PAPERCLIP_CODEX_ACP_NETWORK_ACCESS="false"`. Execution-target network denial +also remains enforced. Read-only ACP mode remains read-only. + +## Node runtime used by background services + +Check the Node executable used by the running service, not only `node --version` +in an interactive shell. Systemd and launchd do not load shell version-manager +configuration. A newer Node installed elsewhere does not upgrade a running +service or change a custom startup script's `PATH`. + +Managed installs pin the validated Node executable in the `paperclipai` shim +and prepend its directory to `PATH` for child tools, including ACP servers with +an `/usr/bin/env node` shebang. Re-run the installer using the supported +Node runtime after changing runtime installations, then restart the service. +For example, put the supported Node's bin directory first on `PATH` and run +`npx paperclipai@latest install --yes`. Do not use the old managed shim to +re-pin Node: it intentionally continues launching its previously pinned runtime. +Installs and updates refresh existing managed shims in place. Updates reject an +unsupported running Node before installing or activating a payload; read-only +update checks and rollback remain available for recovery. Global npm installs and +source checkout services must configure their own executable and child-process `PATH`. + +For custom service wrappers, use an absolute, supported Node executable and put +that executable's directory first on `PATH`. Keep required existing PATH entries. +On Linux, verify the running executable with `/proc//exe`; an +interactive shell version check alone is insufficient. Use the guarded restart +procedure in [DEVELOPING.md](DEVELOPING.md#hot-restart-deploys) when jobs are active. + +Legacy local adapters default to ACP, including configurations with no `engine` +field or the old `auto` value. An unavailable ACP runtime fails the run and the +agent environment test with a setup error; it never silently changes engines. +Repair the reported prerequisite or explicitly select `engine: cli`. Local +filesystem/network confinement and in-place Codex workspaces require explicit +CLI selection. CLI sandbox defaults and explicit restrictions are described in +the adapter configuration documentation. + ## Managed Install Layout Managed code is separate from instance data: diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 9aab2f402e..e660faf5e2 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -1176,6 +1176,17 @@ interface AgentAdapter { } ``` +### Local adapter engine availability + +For the legacy Codex, Claude, Gemini, and Kimi local adapters, an omitted engine +or legacy `auto` value selects ACP deterministically. Missing prerequisites or +ACP execution failures fail the run; they must not launch a different engine +with different session, permission, or sandbox semantics. CLI execution requires +explicit selection. Environment tests report the same engine availability error +as execution. Codex CLI defaults permit workspace writes and network access for +Paperclip coordination without disabling its sandbox; explicit operator +restrictions and execution-target network denials remain effective. + ## 11.2 Process Adapter Config shape: diff --git a/doc/SPEC.md b/doc/SPEC.md index df958cb507..b82ad05ca0 100644 --- a/doc/SPEC.md +++ b/doc/SPEC.md @@ -209,6 +209,12 @@ Agent configuration includes an **adapter** that defines how Paperclip invokes t The `process` and `http` adapters ship as generic defaults. Additional built-in adapters cover common local coding runtimes (see list above), and new adapter types can be registered via the plugin system (see Plugin / Extension Architecture). +An adapter's selected execution engine is part of its permission and session +contract. Missing prerequisites or engine failures must be surfaced without +silently launching a different engine. A default local engine must support +normal task work and control-plane coordination; explicit operator restrictions +remain authoritative. + ### Adapter Interface Every adapter implements three methods: diff --git a/docs/adapters/kimi-local.md b/docs/adapters/kimi-local.md index 78cf7cba81..794f94906e 100644 --- a/docs/adapters/kimi-local.md +++ b/docs/adapters/kimi-local.md @@ -3,7 +3,7 @@ title: Kimi Code CLI summary: Kimi Code CLI local adapter setup and configuration --- -The `kimi_local` adapter runs the Kimi Code CLI (`kimi`) locally. It has two execution engines: the default **ACP engine** (`kimi acp`, streaming transcript with live tool status, matching `claude_local`/`gemini_local`) and a **CLI lane** (`kimi -p --output-format stream-json`) used as an automatic fallback. It supports session persistence, per-run skill delivery via `--skills-dir`, thinking-effort control, and structured output parsing. +The `kimi_local` adapter runs the Kimi Code CLI (`kimi`) locally. It has two execution engines: the default **ACP engine** (`kimi acp`, streaming transcript with live tool status, matching `claude_local`/`gemini_local`) and a **CLI lane** (`kimi -p --output-format stream-json`) selected explicitly with `engine: cli`. It supports session persistence, per-run skill delivery via `--skills-dir`, thinking-effort control, and structured output parsing. ## Prerequisites @@ -17,7 +17,7 @@ The `kimi_local` adapter runs the Kimi Code CLI (`kimi`) locally. It has two exe | Field | Type | Required | Description | |-------|------|----------|-------------| -| `engine` | string | No | Execution engine: `acp` (default; streaming ACP lane via `kimi acp`), `cli` (headless `kimi -p` lane), or unset/`auto` (ACP with automatic CLI fallback when ACP prerequisites fail). | +| `engine` | string | No | Execution engine: `acp` (default; streaming ACP lane via `kimi acp`), `cli` (headless `kimi -p` lane), or unset/`auto` (ACP; unavailable prerequisites fail the run). | | `cwd` | string | Yes | Working directory for the agent process (absolute path; created automatically if missing when permissions allow) | | `model` | string | No | Kimi model alias (`provider/model`). Defaults to `kimi-code/kimi-for-coding`. When empty, Kimi uses `default_model` from its own `config.toml`. | | `promptTemplate` | string | No | Prompt used for all runs | @@ -35,7 +35,7 @@ By default the adapter runs Kimi through the **ACP engine** (`kimi acp`, an Agen Engine selection (`engine` config field): -- unset or `auto`: use ACP when its prerequisites pass (Node >= 20, resolvable `kimi acp` command, a bidirectional process target), otherwise fall back to the CLI lane with a diagnostic note. +- unset or `auto`: use ACP when its prerequisites pass (Node >= 20, resolvable `kimi acp` command, a bidirectional process target), otherwise fail with an actionable setup error. - `acp`: require ACP; startup failures surface as run errors rather than falling back. - `cli`: pin the headless CLI lane described below. @@ -51,7 +51,7 @@ When `instructionsFilePath` points at a managed instruction bundle, the entry fi ## Thinking Effort -The `effort` field applies to the **headless CLI lane only** (`engine: cli`, or the automatic fallback when ACP prerequisites fail). On the default ACP engine lane it is currently **not forwarded**: Kimi's ACP interface exposes a separate `thinking` config option that Paperclip does not wire yet, so an effort configured on an ACP-lane agent leaves Kimi's own default behavior in place. Pin `engine: cli` when thinking-effort control matters. On the CLI lane, `effort` is forwarded as the `KIMI_MODEL_THINKING_EFFORT` operational override, which applies to Kimi providers including managed OAuth models. Kimi has no per-invocation effort flag and no `medium` tier, so `medium` is mapped to `high`; `low`, `high`, and `max` pass through. Effort is only sent for models that advertise `support_efforts` (currently `kimi-code/k3`) to avoid provider rejections; extend `EFFORT_CAPABLE_MODELS` in the adapter as more models gain support. +The `effort` field applies to the **headless CLI lane only** (`engine: cli`). On the default ACP engine lane it is currently **not forwarded**: Kimi's ACP interface exposes a separate `thinking` config option that Paperclip does not wire yet, so an effort configured on an ACP-lane agent leaves Kimi's own default behavior in place. Pin `engine: cli` when thinking-effort control matters. On the CLI lane, `effort` is forwarded as the `KIMI_MODEL_THINKING_EFFORT` operational override, which applies to Kimi providers including managed OAuth models. Kimi has no per-invocation effort flag and no `medium` tier, so `medium` is mapped to `high`; `low`, `high`, and `max` pass through. Effort is only sent for models that advertise `support_efforts` (currently `kimi-code/k3`) to avoid provider rejections; extend `EFFORT_CAPABLE_MODELS` in the adapter as more models gain support. ## Session Persistence @@ -80,5 +80,5 @@ Use the "Test Environment" button in the UI to validate the adapter config. It c ## Notes -- Both execution engines are supported: the ACP engine (`kimi acp`, default) and the headless CLI lane (fallback / `engine=cli`). +- Both execution engines are supported: the ACP engine (`kimi acp`, default) and the headless CLI lane (`engine=cli`). - Available model aliases on a standard install: `kimi-code/kimi-for-coding` (K2.7 Coding), `kimi-code/kimi-for-coding-highspeed` (K2.7 Coding Highspeed), `kimi-code/k3` (K3). diff --git a/docs/adapters/overview.md b/docs/adapters/overview.md index 55b0dcc525..1f5717cffb 100644 --- a/docs/adapters/overview.md +++ b/docs/adapters/overview.md @@ -21,7 +21,7 @@ When a heartbeat fires, Paperclip: | [Claude Code](/adapters/claude-local) | `claude_local` | Runs Claude Code CLI locally, with a native ACP engine when available | | [Codex](/adapters/codex-local) | `codex_local` | Runs OpenAI Codex CLI locally, with a native ACP engine when available | | [Gemini CLI](/adapters/gemini-local) | `gemini_local` | Runs Gemini CLI locally (experimental — adapter package exists, not yet in stable type enum) | -| [Kimi Code CLI](/adapters/kimi-local) | `kimi_local` | Runs Kimi Code CLI locally through ACP, with headless `-p` mode as a fallback | +| [Kimi Code CLI](/adapters/kimi-local) | `kimi_local` | Runs Kimi Code CLI locally through ACP, with explicitly selectable headless `-p` mode | | OpenCode | `opencode_local` | Runs OpenCode CLI locally (multi-provider `provider/model`) | | Cursor | `cursor` | Runs Cursor in background mode | | Pi | `pi_local` | Runs an embedded Pi agent locally | diff --git a/packages/adapters/claude-local/src/index.ts b/packages/adapters/claude-local/src/index.ts index 1af3540bde..873b9eba56 100644 --- a/packages/adapters/claude-local/src/index.ts +++ b/packages/adapters/claude-local/src/index.ts @@ -45,7 +45,7 @@ export const agentConfigurationDoc = `# claude_local agent configuration Adapter: claude_local Core fields: -- engine (string, optional): execution engine. Leave unset/auto to use ACP when prerequisites pass and fall back to the Claude Code CLI with diagnostics. Use "cli" to pin the CLI lane or "acp" to require ACP. +- engine (string, optional): defaults to ACP, including legacy unset/"auto" values. Missing prerequisites and execution failures fail the run without changing engines. Set "cli" to explicitly select the CLI engine. - cwd (string, optional): default absolute working directory fallback for the agent process (created if missing when possible) - instructionsFilePath (string, optional): absolute path to a markdown instructions file injected at runtime - model (string, optional): Claude model id. Missing or blank defaults to ${DEFAULT_CLAUDE_LOCAL_MODEL} in both CLI and ACP, including existing agents. Explicit model IDs and ANTHROPIC_MODEL overrides are preserved. Bedrock/Vertex without an explicit model retain their provider default. @@ -77,8 +77,8 @@ Operational fields: - graceSec (number, optional): SIGTERM grace period in seconds Notes: -- filesystemScope and networkScope are spawn-level confinement and are orthogonal to Claude permission flags. Both require Bubblewrap on the host and select the CLI engine in auto mode; engine="acp" is rejected because ACP confinement is not yet supported. networkScope="allowlist" injects HTTP_PROXY/HTTPS_PROXY for the CLI while its private network namespace blocks direct sockets, so every required provider/API hostname must be listed explicitly. -- The Claude ACP lane requires Node >=24.11.0 and @agentclientprotocol/claude-agent-acp to be installed with this adapter package. Auto engine selection falls back to CLI when those prerequisites are unavailable; explicit engine="acp" fails loudly. +- filesystemScope and networkScope are spawn-level confinement and are orthogonal to Claude permission flags. Both require Bubblewrap on the host and explicit engine="cli"; default or explicit ACP is rejected because ACP confinement is not yet supported. networkScope="allowlist" injects HTTP_PROXY/HTTPS_PROXY for the CLI while its private network namespace blocks direct sockets, so every required provider/API hostname must be listed explicitly. +- The Claude ACP lane requires Node >=24.11.0 and @agentclientprotocol/claude-agent-acp to be installed with this adapter package. Missing prerequisites fail both default and explicit ACP runs with an actionable setup error; the adapter never switches engines automatically. - For ACP runs, model selection is passed through ANTHROPIC_MODEL at ACP server startup; Paperclip-managed Claude permissions and ephemeral skill materialization are handled by the shared ACP engine. - When Paperclip realizes a workspace/runtime for a run, it injects PAPERCLIP_WORKSPACE_* and PAPERCLIP_RUNTIME_* env vars for agent-side tooling. `; diff --git a/packages/adapters/claude-local/src/server/acp.test.ts b/packages/adapters/claude-local/src/server/acp.test.ts index d5c4aacb09..34ead8a952 100644 --- a/packages/adapters/claude-local/src/server/acp.test.ts +++ b/packages/adapters/claude-local/src/server/acp.test.ts @@ -287,7 +287,7 @@ describe("claude_local ACP lane", () => { expect(nodeVersionMeetsClaudeAcpMinimum()).toBe(true); }); - it("defaults to ACP when prerequisites pass and falls back to CLI only for auto resolution", async () => { + it("keeps ACP selected and reports unavailable prerequisites for default and explicit engines", async () => { const root = await makeTempRoot("paperclip-claude-acp-default-"); const commandPath = path.join(root, "bin", "claude-agent-acp"); await fs.mkdir(path.dirname(commandPath), { recursive: true }); @@ -315,44 +315,44 @@ describe("claude_local ACP lane", () => { executionTarget: null, }), ).resolves.toMatchObject({ - engine: "cli", + engine: "acp", explicit: false, - fallbackReason: expect.stringContaining("Node"), + unavailableReason: expect.stringContaining("Node"), }); await expect( resolveClaudeExecutionEngineForRun({ config: { engine: "acp", agentCommand: "/missing/claude-agent-acp" }, executionTarget: null, }), - ).resolves.toEqual({ engine: "acp", explicit: true }); + ).resolves.toMatchObject({ engine: "acp", explicit: true, unavailableReason: expect.stringContaining("Node") }); }); - it("selects the confined CLI lane for local filesystem or network scope", async () => { + it("requires explicit CLI selection for local filesystem or network scope", async () => { await expect( resolveClaudeExecutionEngineForRun({ config: { filesystemScope: "workspace" }, executionTarget: null, }), ).resolves.toMatchObject({ - engine: "cli", + engine: "acp", explicit: false, - fallbackReason: expect.stringContaining("spawn-level confinement"), + unavailableReason: expect.stringContaining("confinement"), }); await expect( resolveClaudeExecutionEngineForRun({ config: { engine: "acp", filesystemScope: "workspace" }, executionTarget: null, }), - ).rejects.toThrow("ACP confinement is not supported"); + ).resolves.toMatchObject({ engine: "acp", unavailableReason: expect.stringContaining("ACP confinement is not supported") }); await expect( resolveClaudeExecutionEngineForRun({ config: { networkScope: "deny" }, executionTarget: null, }), ).resolves.toMatchObject({ - engine: "cli", + engine: "acp", explicit: false, - fallbackReason: expect.stringContaining("network scope"), + unavailableReason: expect.stringContaining("confinement"), }); await expect( resolveClaudeExecutionEngineForRun({ @@ -388,7 +388,7 @@ describe("claude_local ACP lane", () => { ).resolves.toEqual({ engine: "acp", explicit: false }); }); - it("falls back to the CLI lane for one-shot sandbox auto runs", async () => { + it("reports unavailable ACP for one-shot sandbox auto runs", async () => { setNodeVersion("v24.11.0"); await expect( resolveClaudeExecutionEngineForRun({ @@ -401,13 +401,13 @@ describe("claude_local ACP lane", () => { }, }), ).resolves.toMatchObject({ - engine: "cli", + engine: "acp", explicit: false, - fallbackReason: expect.stringContaining("bidirectional remote process"), + unavailableReason: expect.stringContaining("bidirectional remote process"), }); }); - it("falls back to the CLI lane for non-sandbox remote auto runs", async () => { + it("reports unavailable ACP for non-sandbox remote auto runs", async () => { setNodeVersion("v24.11.0"); await expect( resolveClaudeExecutionEngineForRun({ @@ -429,9 +429,9 @@ describe("claude_local ACP lane", () => { }, }), ).resolves.toMatchObject({ - engine: "cli", + engine: "acp", explicit: false, - fallbackReason: expect.stringContaining("sandbox remote targets only"), + unavailableReason: expect.stringContaining("sandbox remote targets only"), }); }); @@ -976,7 +976,7 @@ describe("claude_local ACP lane", () => { ); }); - it("falls back to the CLI lane for a runner-less sandbox even when the ACP command is set", async () => { + it("reports unavailable ACP for a runner-less sandbox even when the ACP command is set", async () => { setNodeVersion("v24.11.0"); await expect( resolveClaudeExecutionEngineForRun({ @@ -989,9 +989,9 @@ describe("claude_local ACP lane", () => { }, }), ).resolves.toMatchObject({ - engine: "cli", + engine: "acp", explicit: false, - fallbackReason: expect.stringContaining("bidirectional remote process"), + unavailableReason: expect.stringContaining("bidirectional remote process"), }); }); diff --git a/packages/adapters/claude-local/src/server/acp.ts b/packages/adapters/claude-local/src/server/acp.ts index d26c48d7e4..b9f8724215 100644 --- a/packages/adapters/claude-local/src/server/acp.ts +++ b/packages/adapters/claude-local/src/server/acp.ts @@ -64,7 +64,7 @@ export type ClaudeExecutionEngine = "cli" | "acp"; export interface ClaudeEngineSelection { engine: ClaudeExecutionEngine; explicit: boolean; - fallbackReason?: string; + unavailableReason?: string; } type ClaudeEngineResolutionInput = @@ -93,29 +93,20 @@ export async function resolveClaudeExecutionEngineForRun( input: ClaudeEngineResolutionInput, ): Promise { const selection = normalizeEngine(input.config.engine); + // Engine availability must never change the agent's execution or permission contract. + if (selection.engine === "cli") return selection; + const unavailable = (reason: string): ClaudeEngineSelection => ({ + ...selection, + unavailableReason: `${reason} Repair the ACP setup, or explicitly set engine=cli to use the CLI engine.`, + }); const filesystemScope = parseLocalProcessFilesystemScope(input.config.filesystemScope); const networkScope = parseLocalProcessNetworkScope(input.config.networkScope); if (filesystemScope || networkScope) { - if (selection.explicit && selection.engine === "acp") { - throw new Error("Local filesystem/network confinement requires the Claude CLI engine; ACP confinement is not supported."); - } - return { - engine: "cli", - explicit: selection.explicit, - ...(!selection.explicit - ? { fallbackReason: "Local filesystem/network scope requires spawn-level confinement in the CLI lane." } - : {}), - }; + return unavailable("Local filesystem/network confinement requires the Claude CLI engine; ACP confinement is not supported."); } - if (selection.explicit || selection.engine !== "acp") return selection; - const fallbackReason = await defaultClaudeAcpFallbackReason(input); - if (!fallbackReason) return selection; - return { engine: "cli", explicit: false, fallbackReason }; -} - -export function formatClaudeAcpFallbackMessage(reason: string): string { - return `[paperclip] Claude ACP default unavailable; falling back to Claude CLI. ${reason} Set engine=acp to require ACP or engine=cli to silence this fallback.\n`; + const reason = await claudeAcpUnavailableReason(input); + return reason ? unavailable(reason) : selection; } function firstNonEmptyString(...values: unknown[]): string | undefined { @@ -470,7 +461,7 @@ async function resolveClaudeAcpCommandForTarget( return resolveClaudeAcpCommand(config); } -async function defaultClaudeAcpFallbackReason( +async function claudeAcpUnavailableReason( input: ClaudeEngineResolutionInput, ): Promise { const target = readAdapterExecutionTarget({ @@ -484,7 +475,7 @@ async function defaultClaudeAcpFallbackReason( return "Claude ACP supports sandbox remote targets only; this run targets a non-sandbox remote environment."; } if (!nodeVersionMeetsClaudeAcpMinimum()) { - return `Node ${process.version} does not satisfy Claude ACP's Node >=${MIN_ACP_NODE_VERSION} prerequisite.`; + return `Node ${process.version} (${process.execPath}) does not satisfy Claude ACP's Node >=${MIN_ACP_NODE_VERSION} prerequisite.`; } const command = await resolveClaudeAcpCommandForTarget(input.config, target); if (!(await commandIsResolvable(command, input))) { @@ -733,7 +724,7 @@ export async function testClaudeAcpEnvironment( level: nodeVersionMeetsClaudeAcpMinimum() ? "info" : "error", message: nodeVersionMeetsClaudeAcpMinimum() ? `Node ${process.version} satisfies Claude ACP runtime requirements.` - : `Node ${process.version} does not satisfy Claude ACP runtime requirements.`, + : `Node ${process.version} (${process.execPath}) does not satisfy Claude ACP runtime requirements.`, hint: nodeVersionMeetsClaudeAcpMinimum() ? undefined : `Run Claude ACP with Node >=${MIN_ACP_NODE_VERSION} or switch engine=cli.`, diff --git a/packages/adapters/claude-local/src/server/config-schema.ts b/packages/adapters/claude-local/src/server/config-schema.ts index 099bacf94c..d5b74ef0e5 100644 --- a/packages/adapters/claude-local/src/server/config-schema.ts +++ b/packages/adapters/claude-local/src/server/config-schema.ts @@ -16,11 +16,11 @@ export function getConfigSchema(): AdapterConfigSchema { type: "select", default: "auto", options: [ - { value: "auto", label: "Auto (ACP preferred)" }, + { value: "auto", label: "Default (ACP)" }, { value: "cli", label: "Claude CLI" }, { value: "acp", label: "ACP" }, ], - hint: "Auto uses ACP when prerequisites pass and falls back to Claude CLI with diagnostics.", + hint: "Default uses ACP. If ACP is unavailable, the run fails with a setup error. Choose CLI explicitly to use it.", }, { key: "agentCommand", diff --git a/packages/adapters/claude-local/src/server/engine-availability.test.ts b/packages/adapters/claude-local/src/server/engine-availability.test.ts new file mode 100644 index 0000000000..ed73a9d7e4 --- /dev/null +++ b/packages/adapters/claude-local/src/server/engine-availability.test.ts @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { resolveClaudeExecutionEngineForRun } from "./acp.js"; +import { execute } from "./execute.js"; +import { testEnvironment } from "./test.js"; + +const originalVersion = process.version; +afterEach(() => Object.defineProperty(process, "version", { value: originalVersion })); + +describe("claude engine availability", () => { + it.each([undefined, "auto", "acp"])("reports a setup failure for engine=%s without starting a process", async (engine) => { + Object.defineProperty(process, "version", { value: "v18.0.0" }); + const config = { engine }; + const onSpawn = vi.fn(); + const result = await execute({ config, onSpawn } as never); + expect(result).toMatchObject({ + exitCode: 1, + errorCode: "adapter_engine_unavailable", + errorMessage: expect.stringContaining("Node v18.0.0"), + resultJson: { executionRecovery: { kind: "bootstrap", providerWorkStarted: false } }, + }); + expect(result.errorMessage).toContain(process.execPath); + expect(onSpawn).not.toHaveBeenCalled(); + const diagnostic = await testEnvironment({ config } as never); + expect(diagnostic.status).toBe("fail"); + expect(diagnostic.checks).toContainEqual(expect.objectContaining({ + code: "adapter_engine_unavailable", level: "error", + })); + }); + + it("does not apply ACP prerequisites to explicitly selected CLI", async () => { + Object.defineProperty(process, "version", { value: "v18.0.0" }); + await expect(resolveClaudeExecutionEngineForRun({ config: { engine: "cli" } })) + .resolves.toEqual({ engine: "cli", explicit: true }); + }); + + it("keeps an unavailable ACP command as a failure, not a CLI selection", async () => { + Object.defineProperty(process, "version", { value: "v24.11.0" }); + const result = await resolveClaudeExecutionEngineForRun({ + config: { agentCommand: "/nonexistent/paperclip-test/acp", command: "/nonexistent/paperclip-test/acp" }, + }); + expect(result.engine).toBe("acp"); + expect(result.unavailableReason).toContain("not available"); + }); +}); diff --git a/packages/adapters/claude-local/src/server/execute.acp-fallback.test.ts b/packages/adapters/claude-local/src/server/execute.acp-fallback.test.ts index 772fc70302..64d8f04b22 100644 --- a/packages/adapters/claude-local/src/server/execute.acp-fallback.test.ts +++ b/packages/adapters/claude-local/src/server/execute.acp-fallback.test.ts @@ -39,10 +39,10 @@ const { vi.mock("./acp.js", () => ({ createClaudeAcpExecutor: () => executeClaudeAcp, - formatClaudeAcpFallbackMessage: (reason: string) => - `[paperclip] Claude ACP default unavailable; falling back to Claude CLI. ${reason} Set engine=acp to require ACP or engine=cli to silence this fallback.\n`, resolveClaudeExecutionEngineForRun: async (ctx: { config: Record }) => - ctx.config.engine === "acp" + ctx.config.engine === "cli" + ? { engine: "cli", explicit: true } + : ctx.config.engine === "acp" ? { engine: "acp", explicit: true } : { engine: "acp", explicit: false }, })); @@ -93,28 +93,17 @@ describe("claude_local ACP startup fallback", () => { vi.unstubAllEnvs(); }); - it("falls back to Claude CLI when auto-selected ACP fails before execution starts", async () => { + it("does not start CLI after default ACP fails", async () => { const ctx = buildContext(); - - const result = await execute(ctx as never); - - expect(result.exitCode).toBe(0); + await expect(execute(ctx as never)).rejects.toThrow('Unexpected "<<"'); expect(executeClaudeAcp).toHaveBeenCalledTimes(1); - expect(runAdapterExecutionTargetProcess).toHaveBeenCalledTimes(1); - expect(ctx.onLog).toHaveBeenCalledWith( - "stderr", - expect.stringContaining("Claude ACP startup failed"), - ); - expect(ctx.onLog).toHaveBeenCalledWith( - "stderr", - expect.stringContaining('Unexpected "<<"'), - ); + expect(runAdapterExecutionTargetProcess).not.toHaveBeenCalled(); }); it("trusts the Paperclip API URL when network access is allowlisted", async () => { const paperclipApiUrl = "http://127.0.0.1:4310"; vi.stubEnv("PAPERCLIP_API_URL", paperclipApiUrl); - const ctx = buildContext({ networkScope: "allowlist" }); + const ctx = buildContext({ engine: "cli", networkScope: "allowlist" }); await execute(ctx as never); diff --git a/packages/adapters/claude-local/src/server/execute.remote.test.ts b/packages/adapters/claude-local/src/server/execute.remote.test.ts index 5291aae67b..7f37595c90 100644 --- a/packages/adapters/claude-local/src/server/execute.remote.test.ts +++ b/packages/adapters/claude-local/src/server/execute.remote.test.ts @@ -123,6 +123,7 @@ describe("claude remote execution", () => { taskKey: null, }, config: { + engine: "cli", command: "claude", instructionsFilePath: instructionsPath, env: { @@ -257,6 +258,7 @@ describe("claude remote execution", () => { taskKey: null, }, config: { + engine: "cli", command: "claude", }, context: { @@ -318,6 +320,7 @@ describe("claude remote execution", () => { taskKey: null, }, config: { + engine: "cli", command: "claude", }, context: { @@ -385,6 +388,7 @@ describe("claude remote execution", () => { taskKey: null, }, config: { + engine: "cli", command: "claude", }, context: { @@ -434,6 +438,7 @@ describe("claude remote execution", () => { taskKey: null, }, config: { + engine: "cli", command: "claude", ...config, }, diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index e875157464..02230de87a 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -95,7 +95,6 @@ import { buildClaudeExecutionPermissionArgs } from "./permissions.js"; import { resolveClaudeModel, SANDBOX_INSTALL_COMMAND } from "../index.js"; import { createClaudeAcpExecutor, - formatClaudeAcpFallbackMessage, resolveClaudeExecutionEngineForRun, } from "./acp.js"; @@ -403,20 +402,20 @@ export async function runClaudeLogin(input: { export async function execute(ctx: AdapterExecutionContext): Promise { const engineSelection = await resolveClaudeExecutionEngineForRun(ctx); - if (engineSelection.engine === "acp") { - try { - return await executeClaudeAcp(ctx); - } catch (err) { - if (engineSelection.explicit) throw err; - const reason = err instanceof Error ? err.message : String(err); - await ctx.onLog( - "stderr", - formatClaudeAcpFallbackMessage(`Claude ACP startup failed: ${reason}`), - ); - } + if (engineSelection.unavailableReason) { + return { + exitCode: 1, + signal: null, + timedOut: false, + errorCode: "adapter_engine_unavailable", + errorMessage: engineSelection.unavailableReason, + resultJson: { + executionRecovery: { kind: "bootstrap", providerWorkStarted: false }, + }, + }; } - if (!engineSelection.explicit && engineSelection.fallbackReason) { - await ctx.onLog("stderr", formatClaudeAcpFallbackMessage(engineSelection.fallbackReason)); + if (engineSelection.engine === "acp") { + return executeClaudeAcp(ctx); } const { runId, agent, runtime, config, context, onLog, onMeta, onSpawn, authToken } = ctx; diff --git a/packages/adapters/claude-local/src/server/test.ts b/packages/adapters/claude-local/src/server/test.ts index 8cada6dfb5..cf5940d4f0 100644 --- a/packages/adapters/claude-local/src/server/test.ts +++ b/packages/adapters/claude-local/src/server/test.ts @@ -69,20 +69,23 @@ export async function testEnvironment( config: parseObject(ctx.config), executionTarget: ctx.executionTarget, }); + if (engineSelection.unavailableReason) { + return { + adapterType: "claude_local", + status: "fail", + checks: [{ + code: "adapter_engine_unavailable", + level: "error", + message: engineSelection.unavailableReason, + }], + testedAt: new Date().toISOString(), + }; + } if (engineSelection.engine === "acp") { return testClaudeAcpEnvironment(ctx); } const checks: AdapterEnvironmentCheck[] = []; - if (!engineSelection.explicit && engineSelection.fallbackReason) { - checks.push({ - code: "claude_acp_default_fallback", - level: "warn", - message: "Claude ACP default is unavailable; testing the Claude CLI fallback lane.", - detail: engineSelection.fallbackReason, - hint: "Fix the ACP prerequisite to use the default ACP lane, or set engine=cli to pin the CLI lane.", - }); - } const config = parseObject(ctx.config); const command = asString(config.command, "claude"); const target = ctx.executionTarget ?? null; diff --git a/packages/adapters/codex-local/src/index.ts b/packages/adapters/codex-local/src/index.ts index 537c276654..4bd62ddeff 100644 --- a/packages/adapters/codex-local/src/index.ts +++ b/packages/adapters/codex-local/src/index.ts @@ -112,7 +112,7 @@ export const agentConfigurationDoc = `# codex_local agent configuration Adapter: codex_local Core fields: -- engine (string, optional): leave unset/auto to use ACP when prerequisites pass and fall back to the Codex CLI with diagnostics. Use "cli" to pin the CLI lane or "acp" to require ACP. +- engine (string, optional): defaults to ACP, including legacy unset/"auto" values. Missing prerequisites and execution failures fail the run without changing engines. Set "cli" to explicitly select the CLI engine. - cwd (string, optional): default absolute working directory fallback for the agent process (created if missing when possible) - instructionsFilePath (string, optional): absolute path to a markdown instructions file prepended to stdin prompt at runtime - model (string, optional): Codex model id @@ -143,7 +143,7 @@ Operational fields: - warmHandleIdleMs (number, optional): warm ACP process idle timeout when engine="acp"; defaults to 0 Notes: -- filesystemScope and networkScope are spawn-level confinement and are orthogonal to Codex approval/sandbox flags. Both require Bubblewrap on the host and select the CLI engine in auto mode; engine="acp" is rejected because ACP confinement is not yet supported. networkScope="allowlist" injects HTTP_PROXY/HTTPS_PROXY for the CLI while its private network namespace blocks direct sockets, so every required provider/API hostname must be listed explicitly. +- filesystemScope and networkScope are spawn-level confinement and are orthogonal to Codex approval/sandbox flags. Both require Bubblewrap on the host and explicit engine="cli"; default or explicit ACP is rejected because ACP confinement is not yet supported. networkScope="allowlist" injects HTTP_PROXY/HTTPS_PROXY for the CLI while its private network namespace blocks direct sockets, so every required provider/API hostname must be listed explicitly. - Prompts are piped via stdin (Codex receives "-" prompt argument). - If instructionsFilePath is configured, Paperclip prepends that file's contents to the stdin prompt on every run. - Codex exec automatically applies repo-scoped AGENTS.md instructions from the active workspace. Paperclip cannot suppress that discovery in exec mode, so repo AGENTS.md files may still apply even when you only configured an explicit instructionsFilePath. @@ -152,5 +152,7 @@ Notes: - Some model/tool combinations reject certain effort levels (for example minimal with web search enabled). - Fast mode is supported on GPT-6 Astra, GPT-5.6 (sol/terra/luna), GPT-5.5, GPT-5.4 and manual model IDs. When enabled for those models, Paperclip applies \`service_tier="fast"\` and \`features.fast_mode=true\`. - When Paperclip realizes a workspace/runtime for a run, it injects PAPERCLIP_WORKSPACE_* and PAPERCLIP_RUNTIME_* env vars for agent-side tooling. -- Codex ACP is the preferred auto lane when Node >=24.11.0 and the Codex ACP server are available. It reuses shared ACP prompt/runtime guidance, selected skill materialization into CODEX_HOME/skills, model/reasoning/fast-mode session config, and existing quota-window reporting. Auto selection falls back to CLI when ACP prerequisites are unavailable; explicit engine="acp" fails loudly. +- The ACP engine keeps its workspace sandbox and enables network access on each turn. Explicit sandbox_workspace_write.network_access overrides in extraArgs (or env.PAPERCLIP_CODEX_ACP_NETWORK_ACCESS="false") disable it; execution-target network denial wins. The bundled ACP patch is needed because upstream mode presets override Codex config.toml on every turn. +- The CLI engine defaults to a writable workspace sandbox with network access for unattended work and Paperclip API calls. It does not enable the dangerous bypass flag. Explicit sandbox modes/profiles and network overrides in extraArgs retain their meaning. An execution-target network denial remains enforced. +- Codex ACP is the preferred auto lane when Node >=24.11.0 and the Codex ACP server are available. It reuses shared ACP prompt/runtime guidance, selected skill materialization into CODEX_HOME/skills, model/reasoning/fast-mode session config, and existing quota-window reporting. Missing ACP prerequisites fail both default and explicit ACP runs with an actionable setup error; the adapter never switches engines automatically. `; diff --git a/packages/adapters/codex-local/src/server/acp.test.ts b/packages/adapters/codex-local/src/server/acp.test.ts index b9f79d9d4f..e98a1881e4 100644 --- a/packages/adapters/codex-local/src/server/acp.test.ts +++ b/packages/adapters/codex-local/src/server/acp.test.ts @@ -1,4 +1,5 @@ import fs from "node:fs/promises"; +import { randomUUID } from "node:crypto"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -288,7 +289,7 @@ function buildContext(root: string, overrides: Partial } describe("codex_local ACP lane", () => { - it("defaults to ACP when prerequisites pass and falls back to CLI only for auto resolution", async () => { + it("keeps ACP selected and reports unavailable prerequisites for default and explicit engines", async () => { const root = await makeTempRoot("paperclip-codex-acp-default-"); const commandPath = path.join(root, "bin", "codex-acp"); await fs.mkdir(path.dirname(commandPath), { recursive: true }); @@ -320,44 +321,44 @@ describe("codex_local ACP lane", () => { executionTarget: null, }), ).resolves.toMatchObject({ - engine: "cli", + engine: "acp", explicit: false, - fallbackReason: expect.stringContaining("Node"), + unavailableReason: expect.stringContaining("Node"), }); await expect( resolveCodexExecutionEngineForRun({ config: { engine: "acp", agentCommand: "/missing/codex-acp" }, executionTarget: null, }), - ).resolves.toEqual({ engine: "acp", explicit: true }); + ).resolves.toMatchObject({ engine: "acp", explicit: true, unavailableReason: expect.stringContaining("Node") }); }); - it("selects the confined CLI lane for local filesystem or network scope", async () => { + it("requires explicit CLI selection for local filesystem or network scope", async () => { await expect( resolveCodexExecutionEngineForRun({ config: { filesystemScope: "workspace" }, executionTarget: null, }), ).resolves.toMatchObject({ - engine: "cli", + engine: "acp", explicit: false, - fallbackReason: expect.stringContaining("spawn-level confinement"), + unavailableReason: expect.stringContaining("confinement"), }); await expect( resolveCodexExecutionEngineForRun({ config: { engine: "acp", filesystemScope: "workspace" }, executionTarget: null, }), - ).rejects.toThrow("ACP confinement is not supported"); + ).resolves.toMatchObject({ engine: "acp", unavailableReason: expect.stringContaining("ACP confinement is not supported") }); await expect( resolveCodexExecutionEngineForRun({ config: { networkScope: "allowlist" }, executionTarget: null, }), ).resolves.toMatchObject({ - engine: "cli", + engine: "acp", explicit: false, - fallbackReason: expect.stringContaining("network scope"), + unavailableReason: expect.stringContaining("confinement"), }); await expect( resolveCodexExecutionEngineForRun({ @@ -367,7 +368,7 @@ describe("codex_local ACP lane", () => { ).rejects.toThrow('filesystemScope must be "workspace"'); }); - it("selects the CLI lane for in-place realization and rejects explicitly required ACP", async () => { + it("requires explicit CLI selection for in-place realization", async () => { const executionTarget = { kind: "remote" as const, transport: "sandbox" as const, @@ -382,13 +383,13 @@ describe("codex_local ACP lane", () => { await expect( resolveCodexExecutionEngineForRun({ config: {}, executionTarget }), ).resolves.toMatchObject({ - engine: "cli", + engine: "acp", explicit: false, - fallbackReason: expect.stringContaining("without ACP archive staging"), + unavailableReason: expect.stringContaining("ACP archive staging"), }); await expect( resolveCodexExecutionEngineForRun({ config: { engine: "acp" }, executionTarget }), - ).rejects.toThrow("In-place workspace realization requires the Codex CLI engine"); + ).resolves.toMatchObject({ engine: "acp", unavailableReason: expect.stringContaining("In-place workspace realization requires the Codex CLI engine") }); }); it("uses ACP for bridged sandbox auto runs when the ACP command is configured as a shell command", async () => { @@ -417,7 +418,7 @@ describe("codex_local ACP lane", () => { ).resolves.toEqual({ engine: "acp", explicit: false }); }); - it("falls back to the CLI lane for one-shot sandbox auto runs", async () => { + it("reports unavailable ACP for one-shot sandbox auto runs", async () => { setNodeVersion("v24.11.0"); await expect( resolveCodexExecutionEngineForRun({ @@ -430,13 +431,13 @@ describe("codex_local ACP lane", () => { }, }), ).resolves.toMatchObject({ - engine: "cli", + engine: "acp", explicit: false, - fallbackReason: expect.stringContaining("bidirectional remote process"), + unavailableReason: expect.stringContaining("bidirectional remote process"), }); }); - it("falls back to the CLI lane for non-sandbox remote auto runs", async () => { + it("reports unavailable ACP for non-sandbox remote auto runs", async () => { setNodeVersion("v24.11.0"); await expect( resolveCodexExecutionEngineForRun({ @@ -458,9 +459,27 @@ describe("codex_local ACP lane", () => { }, }), ).resolves.toMatchObject({ - engine: "cli", + engine: "acp", explicit: false, - fallbackReason: expect.stringContaining("sandbox remote targets only"), + unavailableReason: expect.stringContaining("sandbox remote targets only"), + }); + }); + + it("enables workspace networking for ACP without changing other env settings", () => { + expect(buildCodexAcpConfig({ env: { CUSTOM: "kept" } })).toMatchObject({ + env: { CUSTOM: "kept", PAPERCLIP_CODEX_ACP_NETWORK_ACCESS: "true" }, + }); + }); + + it.each([ + { env: { PAPERCLIP_CODEX_ACP_NETWORK_ACCESS: "false" } }, + { extraArgs: ["-c", "sandbox_workspace_write.network_access=false"] }, + { extraArgs: ["--config=sandbox_workspace_write.network_access=false"] }, + { args: ["-csandbox_workspace_write.network_access=false"] }, + { extraArgs: ["-c", "sandbox_workspace_write.network_access=true", "-c", "sandbox_workspace_write.network_access=false"] }, + ])("preserves explicit ACP network denial %j", (config) => { + expect(buildCodexAcpConfig(config)).toMatchObject({ + env: { PAPERCLIP_CODEX_ACP_NETWORK_ACCESS: "false" }, }); }); @@ -1019,7 +1038,7 @@ describe("codex_local ACP lane", () => { // `disposeStaged`, fired only when the runtime is dropped. So after a CLEAN // turn the engine caches the staged runtime warm and its host staged home is // still on disk for the next compatible resume to reuse. - const runId = "run-keep-staged-home"; + const runId = `run-keep-staged-home-${randomUUID()}`; const root = await makeTempRoot("paperclip-codex-acp-keep-staged-"); const localCwd = path.join(root, "worktree"); const remoteCwd = path.join(root, "remote-workspace"); @@ -1097,7 +1116,7 @@ describe("codex_local ACP lane", () => { // failed turn), the one-time `disposeStaged` fires and removes the host // staged-home temp dir — while the per-run copy-back (`teardown`) STILL fires // on the unclean exit path, so a rotated sandbox credential is never lost. - const runId = "run-drop-staged-home"; + const runId = `run-drop-staged-home-${randomUUID()}`; const root = await makeTempRoot("paperclip-codex-acp-drop-staged-"); const localCwd = path.join(root, "worktree"); const remoteCwd = path.join(root, "remote-workspace"); @@ -1218,7 +1237,7 @@ describe("codex_local ACP lane", () => { ); }); - it("falls back to the CLI lane for a runner-less sandbox even when the ACP command is set", async () => { + it("reports unavailable ACP for a runner-less sandbox even when the ACP command is set", async () => { setNodeVersion("v24.11.0"); // Isolate the missing bidirectional runner as the sole fallback cause: // provide a valid ACP command and Node version so the only difference from @@ -1234,9 +1253,9 @@ describe("codex_local ACP lane", () => { }, }), ).resolves.toMatchObject({ - engine: "cli", + engine: "acp", explicit: false, - fallbackReason: expect.stringContaining("bidirectional remote process"), + unavailableReason: expect.stringContaining("bidirectional remote process"), }); }); diff --git a/packages/adapters/codex-local/src/server/acp.ts b/packages/adapters/codex-local/src/server/acp.ts index 650fb04096..500d7a72f1 100644 --- a/packages/adapters/codex-local/src/server/acp.ts +++ b/packages/adapters/codex-local/src/server/acp.ts @@ -33,6 +33,7 @@ import type { import { asNumber, asString, + asStringArray, parseObject, } from "@paperclipai/adapter-utils/server-utils"; import { createWorkspaceRestoreTeardown } from "@paperclipai/adapter-utils/workspace-restore-teardown"; @@ -56,7 +57,7 @@ export type CodexExecutionEngine = "cli" | "acp"; export interface CodexEngineSelection { engine: CodexExecutionEngine; explicit: boolean; - fallbackReason?: string; + unavailableReason?: string; } type CodexEngineResolutionInput = @@ -85,45 +86,27 @@ export async function resolveCodexExecutionEngineForRun( input: CodexEngineResolutionInput, ): Promise { const selection = normalizeEngine(input.config.engine); + // Engine availability must never change the agent's execution or permission contract. + if (selection.engine === "cli") return selection; + const unavailable = (reason: string): CodexEngineSelection => ({ + ...selection, + unavailableReason: `${reason} Repair the ACP setup, or explicitly set engine=cli to use the CLI engine.`, + }); const target = readAdapterExecutionTarget({ executionTarget: input.executionTarget, legacyRemoteExecution: input.executionTransport?.remoteExecution, }); if (target?.workspaceRealization?.mode === "in_place") { - if (selection.explicit && selection.engine === "acp") { - throw new Error("In-place workspace realization requires the Codex CLI engine; ACP archive staging is not supported."); - } - return { - engine: "cli", - explicit: selection.explicit, - ...(!selection.explicit - ? { fallbackReason: "In-place workspace realization must run without ACP archive staging." } - : {}), - }; + return unavailable("In-place workspace realization requires the Codex CLI engine; ACP archive staging is not supported."); } const filesystemScope = parseLocalProcessFilesystemScope(input.config.filesystemScope); const networkScope = parseLocalProcessNetworkScope(input.config.networkScope); if (filesystemScope || networkScope) { - if (selection.explicit && selection.engine === "acp") { - throw new Error("Local filesystem/network confinement requires the Codex CLI engine; ACP confinement is not supported."); - } - return { - engine: "cli", - explicit: selection.explicit, - ...(!selection.explicit - ? { fallbackReason: "Local filesystem/network scope requires spawn-level confinement in the CLI lane." } - : {}), - }; + return unavailable("Local filesystem/network confinement requires the Codex CLI engine; ACP confinement is not supported."); } - if (selection.explicit || selection.engine !== "acp") return selection; - const fallbackReason = await defaultCodexAcpFallbackReason(input); - if (!fallbackReason) return selection; - return { engine: "cli", explicit: false, fallbackReason }; -} - -export function formatCodexAcpFallbackMessage(reason: string): string { - return `[paperclip] Codex ACP default unavailable; falling back to Codex CLI. ${reason} Set engine=acp to require ACP or engine=cli to silence this fallback.\n`; + const reason = await codexAcpUnavailableReason(input); + return reason ? unavailable(reason) : selection; } function firstNonEmptyString(...values: unknown[]): string | undefined { @@ -155,8 +138,17 @@ export function buildCodexAcpConfig(config: Record): Record 0 ? extraArgs : asStringArray(config.args)) { + const match = /^(?:(?:--config=|-c=?)\s*)?sandbox_workspace_write\.network_access\s*=\s*(true|false)\s*$/.exec(arg); + if (match) networkAccess = match[1] === "true"; + } + return { ...config, + env: { ...env, PAPERCLIP_CODEX_ACP_NETWORK_ACCESS: String(networkAccess) }, agent: "codex", mode, permissionMode, @@ -451,7 +443,7 @@ async function resolveCodexAcpCommandForTarget( return resolveCodexAcpCommand(config); } -async function defaultCodexAcpFallbackReason( +async function codexAcpUnavailableReason( input: CodexEngineResolutionInput, ): Promise { const target = readAdapterExecutionTarget({ @@ -465,7 +457,7 @@ async function defaultCodexAcpFallbackReason( return "Codex ACP supports sandbox remote targets only; this run targets a non-sandbox remote environment."; } if (!nodeVersionMeetsCodexAcpMinimum()) { - return `Node ${process.version} does not satisfy Codex ACP's Node >=${MIN_ACP_NODE_VERSION} prerequisite.`; + return `Node ${process.version} (${process.execPath}) does not satisfy Codex ACP's Node >=${MIN_ACP_NODE_VERSION} prerequisite.`; } const command = await resolveCodexAcpCommandForTarget(input.config, target); if (!(await commandIsResolvable(command, input))) { @@ -531,7 +523,7 @@ export async function testCodexAcpEnvironment( level: nodeVersionMeetsCodexAcpMinimum() ? "info" : "error", message: nodeVersionMeetsCodexAcpMinimum() ? `Node ${process.version} satisfies ACP runtime requirements.` - : `Node ${process.version} does not satisfy ACP runtime requirements.`, + : `Node ${process.version} (${process.execPath}) does not satisfy ACP runtime requirements.`, hint: nodeVersionMeetsCodexAcpMinimum() ? undefined : `Run Codex ACP with Node >=${MIN_ACP_NODE_VERSION} or switch engine=cli.`, diff --git a/packages/adapters/codex-local/src/server/codex-args.test.ts b/packages/adapters/codex-local/src/server/codex-args.test.ts index 3777f5e65e..2d1e4fe91f 100644 --- a/packages/adapters/codex-local/src/server/codex-args.test.ts +++ b/packages/adapters/codex-local/src/server/codex-args.test.ts @@ -15,6 +15,10 @@ describe("buildCodexExecArgs", () => { expect(result.args).toEqual([ "exec", "--json", + "-c", + 'sandbox_mode="workspace-write"', + "-c", + "sandbox_workspace_write.network_access=true", "--model", "gpt-6-astra", "-c", @@ -54,6 +58,10 @@ describe("buildCodexExecArgs", () => { "--search", "exec", "--json", + "-c", + 'sandbox_mode="workspace-write"', + "-c", + "sandbox_workspace_write.network_access=true", "--model", "gpt-5.4", "-c", @@ -76,6 +84,10 @@ describe("buildCodexExecArgs", () => { expect(result.args).toEqual([ "exec", "--json", + "-c", + 'sandbox_mode="workspace-write"', + "-c", + "sandbox_workspace_write.network_access=true", "--model", "gpt-5.5", "-c", @@ -98,6 +110,10 @@ describe("buildCodexExecArgs", () => { expect(result.args).toEqual([ "exec", "--json", + "-c", + 'sandbox_mode="workspace-write"', + "-c", + "sandbox_workspace_write.network_access=true", "--model", "future-codex-model", "-c", @@ -120,6 +136,10 @@ describe("buildCodexExecArgs", () => { "exec", "--json", "-c", + 'sandbox_mode="workspace-write"', + "-c", + "sandbox_workspace_write.network_access=true", + "-c", 'service_tier="fast"', "-c", "features.fast_mode=true", @@ -141,6 +161,10 @@ describe("buildCodexExecArgs", () => { expect(result.args).toEqual([ "exec", "--json", + "-c", + 'sandbox_mode="workspace-write"', + "-c", + "sandbox_workspace_write.network_access=true", "--model", "gpt-5", "-", @@ -158,6 +182,10 @@ describe("buildCodexExecArgs", () => { expect(result.args).toEqual([ "exec", "--json", + "-c", + 'sandbox_mode="workspace-write"', + "-c", + "sandbox_workspace_write.network_access=true", "--model", "gpt-5.4-mini", "-", @@ -175,6 +203,10 @@ describe("buildCodexExecArgs", () => { expect(result.args).toEqual([ "exec", "--json", + "-c", + 'sandbox_mode="workspace-write"', + "-c", + "sandbox_workspace_write.network_access=true", "--skip-git-repo-check", "--model", "gpt-5.5", @@ -195,6 +227,10 @@ describe("buildCodexExecArgs", () => { expect(result.args).toEqual([ "exec", "--json", + "-c", + 'sandbox_mode="workspace-write"', + "-c", + "sandbox_workspace_write.network_access=true", "--model", "gpt-5.5", "--skip-git-repo-check", @@ -222,4 +258,44 @@ describe("buildCodexExecArgs", () => { expect(result.args.filter((arg) => arg === "--skip-git-repo-check")).toHaveLength(1); }); + it.each([null, "existing-session"])("makes legacy settings operable for session %s", (resumeSessionId) => { + const { args } = buildCodexExecArgs({ + dangerouslyBypassApprovalsAndSandbox: false, + extraArgs: ["-c", "sandbox_workspace_write.network_access=true"], + }, { resumeSessionId }); + expect(args).toContain('sandbox_mode="workspace-write"'); + expect(args).toContain("sandbox_workspace_write.network_access=true"); + expect(args).not.toContain("--dangerously-bypass-approvals-and-sandbox"); + if (resumeSessionId) expect(args.slice(-3)).toEqual(["resume", resumeSessionId, "-"]); + }); + + it.each([ + ["--sandbox", "read-only"], ["--sandbox=read-only"], ["-s", "read-only"], + ["-sread-only"], ["-prestricted"], ["-c=sandbox_mode=read-only"], + ["-c", 'sandbox_mode="read-only"'], ["--config=sandbox_mode=read-only"], + ["--profile", "restricted"], ["-p", "restricted"], ["--full-auto"], + ["--dangerously-bypass-approvals-and-sandbox"], + ])("preserves explicit sandbox/profile arguments %j", (...extraArgs) => { + const { args } = buildCodexExecArgs({ extraArgs }); + expect(args).not.toContain('sandbox_mode="workspace-write"'); + expect(args).not.toContain("sandbox_workspace_write.network_access=true"); + expect(args).toEqual(["exec", "--json", ...extraArgs, "-"]); + }); + + it("preserves an explicit network denial after defaults", () => { + const { args } = buildCodexExecArgs({ extraArgs: ["-c", "sandbox_workspace_write.network_access=false"] }); + expect(args.lastIndexOf("sandbox_workspace_write.network_access=false")) + .toBeGreaterThan(args.indexOf("sandbox_workspace_write.network_access=true")); + }); + + it("honors a disabled execution-target network policy even with an agent override", () => { + const { args } = buildCodexExecArgs({ extraArgs: ["-c", "sandbox_workspace_write.network_access=true"] }, { networkAccess: false }); + expect(args.slice(-3)).toEqual(["-c", "sandbox_workspace_write.network_access=false", "-"]); + }); + + it("preserves the existing explicit bypass configuration", () => { + const { args } = buildCodexExecArgs({ dangerouslyBypassApprovalsAndSandbox: true }); + expect(args).toEqual(["exec", "--json", "--dangerously-bypass-approvals-and-sandbox", "-"]); + }); + }); diff --git a/packages/adapters/codex-local/src/server/codex-args.ts b/packages/adapters/codex-local/src/server/codex-args.ts index db9b349c0a..ca5b3fde2d 100644 --- a/packages/adapters/codex-local/src/server/codex-args.ts +++ b/packages/adapters/codex-local/src/server/codex-args.ts @@ -36,6 +36,7 @@ export function buildCodexExecArgs( options: { resumeSessionId?: string | null; skipGitRepoCheck?: boolean; + networkAccess?: boolean; } = {}, ): BuildCodexExecArgsResult { const record = asRecord(config); @@ -54,6 +55,17 @@ export function buildCodexExecArgs( const extraArgs = readExtraArgs(record); const args = ["exec", "--json"]; + // `codex exec` otherwise defaults to read-only/never, which cannot perform + // Paperclip work. Keep the sandbox, but make unattended workspace work and + // API calls possible. Explicit operator modes/profiles retain their meaning. + const explicitSandbox = extraArgs.some((arg) => + /^(--sandbox(?:=|$)|-s|--profile(?:=|$)|-p|--full-auto$|--yolo$|--dangerously-bypass-approvals-and-sandbox$)/.test(arg) + || /^(?:(?:--config=|-c=?)\s*)?(?:sandbox_mode|profile)\s*=/.test(arg), + ); + if (!bypass && !explicitSandbox) { + args.push("-c", 'sandbox_mode="workspace-write"'); + args.push("-c", `sandbox_workspace_write.network_access=${options.networkAccess !== false}`); + } // Codex rejects a repeated `--skip-git-repo-check` ("cannot be used multiple // times"). The adapter injects this flag for sandbox execution, so when an // operator's extraArgs already carry it the injection would abort the run @@ -72,6 +84,9 @@ export function buildCodexExecArgs( args.push("-c", 'service_tier="fast"', "-c", "features.fast_mode=true"); } if (extraArgs.length > 0) args.push(...extraArgs); + if (!bypass && options.networkAccess === false) { + args.push("-c", "sandbox_workspace_write.network_access=false"); + } if (options.resumeSessionId) args.push("resume", options.resumeSessionId, "-"); else args.push("-"); diff --git a/packages/adapters/codex-local/src/server/config-schema.ts b/packages/adapters/codex-local/src/server/config-schema.ts index 3e026b4c5a..4a32525631 100644 --- a/packages/adapters/codex-local/src/server/config-schema.ts +++ b/packages/adapters/codex-local/src/server/config-schema.ts @@ -16,11 +16,11 @@ export function getConfigSchema(): AdapterConfigSchema { type: "select", default: "auto", options: [ - { value: "auto", label: "Auto (ACP preferred)" }, + { value: "auto", label: "Default (ACP)" }, { value: "cli", label: "Codex CLI" }, { value: "acp", label: "ACP" }, ], - hint: "Auto uses ACP when prerequisites pass and falls back to Codex CLI with diagnostics.", + hint: "Default uses ACP. If ACP is unavailable, the run fails with a setup error. Choose CLI explicitly to use it.", }, { key: "agentCommand", diff --git a/packages/adapters/codex-local/src/server/engine-availability.test.ts b/packages/adapters/codex-local/src/server/engine-availability.test.ts new file mode 100644 index 0000000000..0d7d6a6bbe --- /dev/null +++ b/packages/adapters/codex-local/src/server/engine-availability.test.ts @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { resolveCodexExecutionEngineForRun } from "./acp.js"; +import { execute } from "./execute.js"; +import { testEnvironment } from "./test.js"; + +const originalVersion = process.version; +afterEach(() => Object.defineProperty(process, "version", { value: originalVersion })); + +describe("codex engine availability", () => { + it.each([undefined, "auto", "acp"])("reports a setup failure for engine=%s without starting a process", async (engine) => { + Object.defineProperty(process, "version", { value: "v18.0.0" }); + const config = { engine }; + const onSpawn = vi.fn(); + const result = await execute({ config, onSpawn } as never); + expect(result).toMatchObject({ + exitCode: 1, + errorCode: "adapter_engine_unavailable", + errorMessage: expect.stringContaining("Node v18.0.0"), + resultJson: { executionRecovery: { kind: "bootstrap", providerWorkStarted: false } }, + }); + expect(result.errorMessage).toContain(process.execPath); + expect(onSpawn).not.toHaveBeenCalled(); + const diagnostic = await testEnvironment({ config } as never); + expect(diagnostic.status).toBe("fail"); + expect(diagnostic.checks).toContainEqual(expect.objectContaining({ + code: "adapter_engine_unavailable", level: "error", + })); + }); + + it("does not apply ACP prerequisites to explicitly selected CLI", async () => { + Object.defineProperty(process, "version", { value: "v18.0.0" }); + await expect(resolveCodexExecutionEngineForRun({ config: { engine: "cli" } })) + .resolves.toEqual({ engine: "cli", explicit: true }); + }); + + it("keeps an unavailable ACP command as a failure, not a CLI selection", async () => { + Object.defineProperty(process, "version", { value: "v24.11.0" }); + const result = await resolveCodexExecutionEngineForRun({ + config: { agentCommand: "/nonexistent/paperclip-test/acp", command: "/nonexistent/paperclip-test/acp" }, + }); + expect(result.engine).toBe("acp"); + expect(result.unavailableReason).toContain("not available"); + }); +}); diff --git a/packages/adapters/codex-local/src/server/execute.acp-fallback.test.ts b/packages/adapters/codex-local/src/server/execute.acp-fallback.test.ts index 85203b0b51..34f413d947 100644 --- a/packages/adapters/codex-local/src/server/execute.acp-fallback.test.ts +++ b/packages/adapters/codex-local/src/server/execute.acp-fallback.test.ts @@ -42,10 +42,10 @@ const { vi.mock("./acp.js", () => ({ createCodexAcpExecutor: () => executeCodexAcp, - formatCodexAcpFallbackMessage: (reason: string) => - `[paperclip] Codex ACP default unavailable; falling back to Codex CLI. ${reason} Set engine=acp to require ACP or engine=cli to silence this fallback.\n`, resolveCodexExecutionEngineForRun: async (ctx: { config: Record }) => - ctx.config.engine === "acp" + ctx.config.engine === "cli" + ? { engine: "cli", explicit: true } + : ctx.config.engine === "acp" ? { engine: "acp", explicit: true } : { engine: "acp", explicit: false }, })); @@ -132,23 +132,11 @@ describe("codex_local ACP startup fallback", () => { vi.clearAllMocks(); }); - it("falls back to Codex CLI when auto-selected ACP fails before execution starts", async () => { + it("does not start CLI after default ACP fails", async () => { const ctx = buildContext(); - - const result = await execute(ctx as never); - - expect(result.exitCode).toBe(0); - expect(result.summary).toBe("hello"); + await expect(execute(ctx as never)).rejects.toThrow('Unexpected "<<"'); expect(executeCodexAcp).toHaveBeenCalledTimes(1); - expect(runAdapterExecutionTargetProcess).toHaveBeenCalledTimes(1); - expect(ctx.onLog).toHaveBeenCalledWith( - "stderr", - expect.stringContaining("Codex ACP startup failed"), - ); - expect(ctx.onLog).toHaveBeenCalledWith( - "stderr", - expect.stringContaining('Unexpected "<<"'), - ); + expect(runAdapterExecutionTargetProcess).not.toHaveBeenCalled(); }); it("keeps explicit ACP strict when startup fails", async () => { diff --git a/packages/adapters/codex-local/src/server/execute.remote.test.ts b/packages/adapters/codex-local/src/server/execute.remote.test.ts index 4be867d325..16345c8798 100644 --- a/packages/adapters/codex-local/src/server/execute.remote.test.ts +++ b/packages/adapters/codex-local/src/server/execute.remote.test.ts @@ -116,6 +116,7 @@ describe("codex remote execution", () => { taskKey: null, }, config: { + engine: "cli", command: "codex", env: { CODEX_HOME: codexHomeDir, @@ -279,6 +280,7 @@ describe("codex remote execution", () => { taskKey: null, }, config: { + engine: "cli", command: "codex", env: { CODEX_HOME: codexHomeDir, @@ -360,6 +362,7 @@ describe("codex remote execution", () => { taskKey: null, }, config: { + engine: "cli", command: "codex", env: { CODEX_HOME: codexHomeDir, @@ -391,6 +394,10 @@ describe("codex remote execution", () => { expect(call?.[2]).toEqual([ "exec", "--json", + "-c", + 'sandbox_mode="workspace-write"', + "-c", + "sandbox_workspace_write.network_access=true", "-", ]); }); @@ -431,6 +438,7 @@ describe("codex remote execution", () => { taskKey: null, }, config: { + engine: "cli", command: "codex", env: { CODEX_HOME: codexHomeDir, @@ -462,6 +470,10 @@ describe("codex remote execution", () => { expect(call?.[2]).toEqual([ "exec", "--json", + "-c", + 'sandbox_mode="workspace-write"', + "-c", + "sandbox_workspace_write.network_access=true", "resume", "session-123", "-", @@ -504,6 +516,7 @@ describe("codex remote execution", () => { taskKey: null, }, config: { + engine: "cli", command: "codex", env: { CODEX_HOME: codexHomeDir, @@ -541,6 +554,10 @@ describe("codex remote execution", () => { expect(call?.[2]).toEqual([ "exec", "--json", + "-c", + 'sandbox_mode="workspace-write"', + "-c", + "sandbox_workspace_write.network_access=true", "resume", "session-123", "-", @@ -568,7 +585,7 @@ describe("codex remote execution", () => { adapterConfig: {}, }, runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null }, - config: { command: "codex", env: { CODEX_HOME: codexHomeDir } }, + config: { engine: "cli", command: "codex", env: { CODEX_HOME: codexHomeDir } }, context: { paperclipWorkspace: { cwd: workspaceDir, diff --git a/packages/adapters/codex-local/src/server/execute.ts b/packages/adapters/codex-local/src/server/execute.ts index 436cd585b4..7a09fc0489 100644 --- a/packages/adapters/codex-local/src/server/execute.ts +++ b/packages/adapters/codex-local/src/server/execute.ts @@ -103,7 +103,6 @@ import { } from "./process-activity-monitor.js"; import { createCodexAcpExecutor, - formatCodexAcpFallbackMessage, resolveCodexExecutionEngineForRun, } from "./acp.js"; @@ -568,20 +567,20 @@ export async function ensureCodexSkillsInjected( export async function execute(ctx: AdapterExecutionContext): Promise { const engineSelection = await resolveCodexExecutionEngineForRun(ctx); - if (engineSelection.engine === "acp") { - try { - return await executeCodexAcp(ctx); - } catch (err) { - if (engineSelection.explicit) throw err; - const reason = err instanceof Error ? err.message : String(err); - await ctx.onLog( - "stderr", - formatCodexAcpFallbackMessage(`Codex ACP startup failed: ${reason}`), - ); - } + if (engineSelection.unavailableReason) { + return { + exitCode: 1, + signal: null, + timedOut: false, + errorCode: "adapter_engine_unavailable", + errorMessage: engineSelection.unavailableReason, + resultJson: { + executionRecovery: { kind: "bootstrap", providerWorkStarted: false }, + }, + }; } - if (!engineSelection.explicit && engineSelection.fallbackReason) { - await ctx.onLog("stderr", formatCodexAcpFallbackMessage(engineSelection.fallbackReason)); + if (engineSelection.engine === "acp") { + return executeCodexAcp(ctx); } const { runId, agent, runtime, config, context, onLog, onMeta, onEvent, onSpawn, authToken } = ctx; @@ -1206,6 +1205,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise=24.11.0 and the local Gemini CLI command is available. It runs Gemini CLI's native \`gemini --acp\` server through Paperclip's shared ACP engine, including selected skill links, Paperclip runtime prompt/env guidance, model config, and persistent ACP session state. Auto selection falls back to the CLI lane when ACP prerequisites are unavailable; explicit engine="acp" fails loudly. +- Gemini ACP is the preferred auto lane when Node >=24.11.0 and the local Gemini CLI command is available. It runs Gemini CLI's native \`gemini --acp\` server through Paperclip's shared ACP engine, including selected skill links, Paperclip runtime prompt/env guidance, model config, and persistent ACP session state. Missing prerequisites fail both default and explicit ACP runs with an actionable setup error; the adapter never switches engines automatically. - Runs use --prompt for non-interactive execution, not stdin. - The adapter sets a headless-safe terminal/browser environment for Gemini CLI child processes so unattended runs do not wait on browser auth or 256-color terminal prompts. - Sessions resume with --resume when stored session cwd matches the current cwd. diff --git a/packages/adapters/gemini-local/src/server/acp.test.ts b/packages/adapters/gemini-local/src/server/acp.test.ts index 53d369f3e9..44a21c0f73 100644 --- a/packages/adapters/gemini-local/src/server/acp.test.ts +++ b/packages/adapters/gemini-local/src/server/acp.test.ts @@ -267,7 +267,7 @@ describe("gemini_local ACP lane", () => { expect(nodeVersionMeetsGeminiAcpMinimum()).toBe(true); }); - it("defaults to ACP when prerequisites pass and falls back to CLI only for auto resolution", async () => { + it("keeps ACP selected and reports unavailable prerequisites for default and explicit engines", async () => { const root = await makeTempRoot("paperclip-gemini-acp-default-"); const commandPath = path.join(root, "bin", "gemini"); await fs.mkdir(path.dirname(commandPath), { recursive: true }); @@ -299,19 +299,19 @@ describe("gemini_local ACP lane", () => { executionTarget: null, }), ).resolves.toMatchObject({ - engine: "cli", + engine: "acp", explicit: false, - fallbackReason: expect.stringContaining("Node"), + unavailableReason: expect.stringContaining("Node"), }); await expect( resolveGeminiExecutionEngineForRun({ config: { engine: "acp", command: "/missing/gemini" }, executionTarget: null, }), - ).resolves.toEqual({ engine: "acp", explicit: true }); + ).resolves.toMatchObject({ engine: "acp", explicit: true, unavailableReason: expect.stringContaining("Node") }); }); - it("falls back to the CLI lane for non-sandbox remote auto runs", async () => { + it("reports unavailable ACP for non-sandbox remote auto runs", async () => { setNodeVersion("v24.11.0"); await expect( resolveGeminiExecutionEngineForRun({ @@ -333,13 +333,13 @@ describe("gemini_local ACP lane", () => { }, }), ).resolves.toMatchObject({ - engine: "cli", + engine: "acp", explicit: false, - fallbackReason: expect.stringContaining("sandbox remote targets only"), + unavailableReason: expect.stringContaining("sandbox remote targets only"), }); }); - it("falls back to the CLI lane for one-shot sandbox auto runs", async () => { + it("reports unavailable ACP for one-shot sandbox auto runs", async () => { setNodeVersion("v24.11.0"); await expect( resolveGeminiExecutionEngineForRun({ @@ -352,9 +352,9 @@ describe("gemini_local ACP lane", () => { }, }), ).resolves.toMatchObject({ - engine: "cli", + engine: "acp", explicit: false, - fallbackReason: expect.stringContaining("bidirectional remote process"), + unavailableReason: expect.stringContaining("bidirectional remote process"), }); }); @@ -747,7 +747,7 @@ describe("gemini_local ACP lane", () => { } }); - it("falls back to the CLI lane for a runner-less sandbox even when the ACP command is set", async () => { + it("reports unavailable ACP for a runner-less sandbox even when the ACP command is set", async () => { setNodeVersion("v24.11.0"); await expect( resolveGeminiExecutionEngineForRun({ @@ -760,9 +760,9 @@ describe("gemini_local ACP lane", () => { }, }), ).resolves.toMatchObject({ - engine: "cli", + engine: "acp", explicit: false, - fallbackReason: expect.stringContaining("bidirectional remote process"), + unavailableReason: expect.stringContaining("bidirectional remote process"), }); }); diff --git a/packages/adapters/gemini-local/src/server/acp.ts b/packages/adapters/gemini-local/src/server/acp.ts index 97ea0f584b..d5a06a1bef 100644 --- a/packages/adapters/gemini-local/src/server/acp.ts +++ b/packages/adapters/gemini-local/src/server/acp.ts @@ -43,7 +43,7 @@ export type GeminiExecutionEngine = "cli" | "acp"; export interface GeminiEngineSelection { engine: GeminiExecutionEngine; explicit: boolean; - fallbackReason?: string; + unavailableReason?: string; } type GeminiEngineResolutionInput = @@ -72,15 +72,15 @@ export async function resolveGeminiExecutionEngineForRun( input: GeminiEngineResolutionInput, ): Promise { const selection = normalizeEngine(input.config.engine); - if (selection.explicit || selection.engine !== "acp") return selection; + // Engine availability must never change the agent's execution or permission contract. + if (selection.engine === "cli") return selection; + const unavailable = (reason: string): GeminiEngineSelection => ({ + ...selection, + unavailableReason: `${reason} Repair the ACP setup, or explicitly set engine=cli to use the CLI engine.`, + }); - const fallbackReason = await defaultGeminiAcpFallbackReason(input); - if (!fallbackReason) return selection; - return { engine: "cli", explicit: false, fallbackReason }; -} - -export function formatGeminiAcpFallbackMessage(reason: string): string { - return `[paperclip] Gemini ACP default unavailable; falling back to Gemini CLI. ${reason} Set engine=acp to require ACP or engine=cli to silence this fallback.\n`; + const reason = await geminiAcpUnavailableReason(input); + return reason ? unavailable(reason) : selection; } function firstNonEmptyString(...values: unknown[]): string | undefined { @@ -353,7 +353,7 @@ function sandboxTargetHasProcessSessionBridge( return target?.kind === "remote" && target.transport === "sandbox" && Boolean(target.runner); } -async function defaultGeminiAcpFallbackReason( +async function geminiAcpUnavailableReason( input: GeminiEngineResolutionInput, ): Promise { const target = readAdapterExecutionTarget({ @@ -367,7 +367,7 @@ async function defaultGeminiAcpFallbackReason( return "Gemini ACP supports sandbox remote targets only; this run targets a non-sandbox remote environment."; } if (!nodeVersionMeetsGeminiAcpMinimum()) { - return `Node ${process.version} does not satisfy Gemini ACP's Node >=${MIN_ACP_NODE_VERSION} prerequisite.`; + return `Node ${process.version} (${process.execPath}) does not satisfy Gemini ACP's Node >=${MIN_ACP_NODE_VERSION} prerequisite.`; } const command = resolveGeminiAcpCommand(input.config); if (!(await commandIsResolvable(command, resolveConfigPath(input.config), input))) { @@ -432,7 +432,7 @@ export async function testGeminiAcpEnvironment( level: nodeVersionMeetsGeminiAcpMinimum() ? "info" : "error", message: nodeVersionMeetsGeminiAcpMinimum() ? `Node ${process.version} satisfies ACP runtime requirements.` - : `Node ${process.version} does not satisfy ACP runtime requirements.`, + : `Node ${process.version} (${process.execPath}) does not satisfy ACP runtime requirements.`, hint: nodeVersionMeetsGeminiAcpMinimum() ? undefined : `Run Gemini ACP with Node >=${MIN_ACP_NODE_VERSION} or switch engine=cli.`, diff --git a/packages/adapters/gemini-local/src/server/config-schema.ts b/packages/adapters/gemini-local/src/server/config-schema.ts index 8d1308c203..54e37a3ccb 100644 --- a/packages/adapters/gemini-local/src/server/config-schema.ts +++ b/packages/adapters/gemini-local/src/server/config-schema.ts @@ -16,11 +16,11 @@ export function getConfigSchema(): AdapterConfigSchema { type: "select", default: "auto", options: [ - { value: "auto", label: "Auto (ACP preferred)" }, + { value: "auto", label: "Default (ACP)" }, { value: "cli", label: "Gemini CLI" }, { value: "acp", label: "ACP" }, ], - hint: "Auto uses ACP when prerequisites pass and falls back to Gemini CLI with diagnostics.", + hint: "Default uses ACP. If ACP is unavailable, the run fails with a setup error. Choose CLI explicitly to use it.", }, { key: "agentCommand", diff --git a/packages/adapters/gemini-local/src/server/engine-availability.test.ts b/packages/adapters/gemini-local/src/server/engine-availability.test.ts new file mode 100644 index 0000000000..3440f874f4 --- /dev/null +++ b/packages/adapters/gemini-local/src/server/engine-availability.test.ts @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { resolveGeminiExecutionEngineForRun } from "./acp.js"; +import { execute } from "./execute.js"; +import { testEnvironment } from "./test.js"; + +const originalVersion = process.version; +afterEach(() => Object.defineProperty(process, "version", { value: originalVersion })); + +describe("gemini engine availability", () => { + it.each([undefined, "auto", "acp"])("reports a setup failure for engine=%s without starting a process", async (engine) => { + Object.defineProperty(process, "version", { value: "v18.0.0" }); + const config = { engine }; + const onSpawn = vi.fn(); + const result = await execute({ config, onSpawn } as never); + expect(result).toMatchObject({ + exitCode: 1, + errorCode: "adapter_engine_unavailable", + errorMessage: expect.stringContaining("Node v18.0.0"), + resultJson: { executionRecovery: { kind: "bootstrap", providerWorkStarted: false } }, + }); + expect(result.errorMessage).toContain(process.execPath); + expect(onSpawn).not.toHaveBeenCalled(); + const diagnostic = await testEnvironment({ config } as never); + expect(diagnostic.status).toBe("fail"); + expect(diagnostic.checks).toContainEqual(expect.objectContaining({ + code: "adapter_engine_unavailable", level: "error", + })); + }); + + it("does not apply ACP prerequisites to explicitly selected CLI", async () => { + Object.defineProperty(process, "version", { value: "v18.0.0" }); + await expect(resolveGeminiExecutionEngineForRun({ config: { engine: "cli" } })) + .resolves.toEqual({ engine: "cli", explicit: true }); + }); + + it("keeps an unavailable ACP command as a failure, not a CLI selection", async () => { + Object.defineProperty(process, "version", { value: "v24.11.0" }); + const result = await resolveGeminiExecutionEngineForRun({ + config: { agentCommand: "/nonexistent/paperclip-test/acp", command: "/nonexistent/paperclip-test/acp" }, + }); + expect(result.engine).toBe("acp"); + expect(result.unavailableReason).toContain("not available"); + }); +}); diff --git a/packages/adapters/gemini-local/src/server/execute.acp-fallback.test.ts b/packages/adapters/gemini-local/src/server/execute.acp-fallback.test.ts index 99329bbbce..435ad0226a 100644 --- a/packages/adapters/gemini-local/src/server/execute.acp-fallback.test.ts +++ b/packages/adapters/gemini-local/src/server/execute.acp-fallback.test.ts @@ -36,10 +36,10 @@ const { vi.mock("./acp.js", () => ({ createGeminiAcpExecutor: () => executeGeminiAcp, - formatGeminiAcpFallbackMessage: (reason: string) => - `[paperclip] Gemini ACP default unavailable; falling back to Gemini CLI. ${reason} Set engine=acp to require ACP or engine=cli to silence this fallback.\n`, resolveGeminiExecutionEngineForRun: async (ctx: { config: Record }) => - ctx.config.engine === "acp" + ctx.config.engine === "cli" + ? { engine: "cli", explicit: true } + : ctx.config.engine === "acp" ? { engine: "acp", explicit: true } : { engine: "acp", explicit: false }, })); @@ -99,23 +99,11 @@ describe("gemini_local ACP startup fallback", () => { vi.clearAllMocks(); }); - it("falls back to Gemini CLI when auto-selected ACP fails before execution starts", async () => { + it("does not start CLI after default ACP fails", async () => { const ctx = buildContext(); - - const result = await execute(ctx as never); - - expect(result.exitCode).toBe(0); - expect(result.summary).toBe("hello"); + await expect(execute(ctx as never)).rejects.toThrow('Unexpected "<<"'); expect(executeGeminiAcp).toHaveBeenCalledTimes(1); - expect(runAdapterExecutionTargetProcess).toHaveBeenCalledTimes(1); - expect(ctx.onLog).toHaveBeenCalledWith( - "stderr", - expect.stringContaining("Gemini ACP startup failed"), - ); - expect(ctx.onLog).toHaveBeenCalledWith( - "stderr", - expect.stringContaining('Unexpected "<<"'), - ); + expect(runAdapterExecutionTargetProcess).not.toHaveBeenCalled(); }); it("keeps explicit ACP strict when startup fails", async () => { diff --git a/packages/adapters/gemini-local/src/server/execute.remote.test.ts b/packages/adapters/gemini-local/src/server/execute.remote.test.ts index 2b307ae544..e681f5f18b 100644 --- a/packages/adapters/gemini-local/src/server/execute.remote.test.ts +++ b/packages/adapters/gemini-local/src/server/execute.remote.test.ts @@ -125,6 +125,7 @@ describe("gemini remote execution", () => { taskKey: null, }, config: { + engine: "cli", command: "gemini", env: { GEMINI_API_KEY: "test-key", @@ -246,11 +247,16 @@ describe("gemini remote execution", () => { stats: { input_tokens: 1, cached_input_tokens: 0, output_tokens: 1 }, }), ].join("\n"); + // A valid empty tar lets the real workspace restore finish after auth setup. + const emptyArchive = Buffer.alloc(1024); const runnerExecute = vi.fn(async (input: { command: string; args?: string[] }) => ({ exitCode: 0, signal: null, timedOut: false, - stdout: input.command === "gemini" ? geminiOutput : "", + stdout: input.command === "gemini" ? geminiOutput + : input.args?.some((arg) => arg.startsWith("wc -c < ")) ? String(emptyArchive.length) + : input.args?.some((arg) => arg.startsWith("dd if=")) ? emptyArchive.toString("base64") + : "", stderr: "", pid: 321, startedAt: new Date().toISOString(), @@ -337,6 +343,7 @@ describe("gemini remote execution", () => { taskKey: null, }, config: { + engine: "cli", command: "gemini", }, context: { @@ -388,6 +395,7 @@ describe("gemini remote execution", () => { taskKey: null, }, config: { + engine: "cli", command: "gemini", }, context: { diff --git a/packages/adapters/gemini-local/src/server/execute.ts b/packages/adapters/gemini-local/src/server/execute.ts index 1d267fdf2e..5ecfd95b43 100644 --- a/packages/adapters/gemini-local/src/server/execute.ts +++ b/packages/adapters/gemini-local/src/server/execute.ts @@ -64,7 +64,6 @@ import { import { firstNonEmptyLine } from "./utils.js"; import { createGeminiAcpExecutor, - formatGeminiAcpFallbackMessage, resolveGeminiExecutionEngineForRun, } from "./acp.js"; import { resolveGeminiSkillsHome } from "./skills.js"; @@ -206,20 +205,20 @@ async function buildGeminiSkillsDir( export async function execute(ctx: AdapterExecutionContext): Promise { const engineSelection = await resolveGeminiExecutionEngineForRun(ctx); - if (engineSelection.engine === "acp") { - try { - return await executeGeminiAcp(ctx); - } catch (err) { - if (engineSelection.explicit) throw err; - const reason = err instanceof Error ? err.message : String(err); - await ctx.onLog( - "stderr", - formatGeminiAcpFallbackMessage(`Gemini ACP startup failed: ${reason}`), - ); - } + if (engineSelection.unavailableReason) { + return { + exitCode: 1, + signal: null, + timedOut: false, + errorCode: "adapter_engine_unavailable", + errorMessage: engineSelection.unavailableReason, + resultJson: { + executionRecovery: { kind: "bootstrap", providerWorkStarted: false }, + }, + }; } - if (!engineSelection.explicit && engineSelection.fallbackReason) { - await ctx.onLog("stderr", formatGeminiAcpFallbackMessage(engineSelection.fallbackReason)); + if (engineSelection.engine === "acp") { + return executeGeminiAcp(ctx); } const { runId, agent, runtime, config, context, onLog, onMeta, onSpawn, authToken } = ctx; diff --git a/packages/adapters/gemini-local/src/server/test.ts b/packages/adapters/gemini-local/src/server/test.ts index 0ea2d8fd86..7e56862f8e 100644 --- a/packages/adapters/gemini-local/src/server/test.ts +++ b/packages/adapters/gemini-local/src/server/test.ts @@ -58,20 +58,23 @@ export async function testEnvironment( config: parseObject(ctx.config), executionTarget: ctx.executionTarget, }); + if (engineSelection.unavailableReason) { + return { + adapterType: "gemini_local", + status: "fail", + checks: [{ + code: "adapter_engine_unavailable", + level: "error", + message: engineSelection.unavailableReason, + }], + testedAt: new Date().toISOString(), + }; + } if (engineSelection.engine === "acp") { return testGeminiAcpEnvironment(ctx); } const checks: AdapterEnvironmentCheck[] = []; - if (!engineSelection.explicit && engineSelection.fallbackReason) { - checks.push({ - code: "gemini_acp_default_fallback", - level: "warn", - message: "Gemini ACP default is unavailable; testing the Gemini CLI fallback lane.", - detail: engineSelection.fallbackReason, - hint: "Fix the ACP prerequisite to use the default ACP lane, or set engine=cli to pin the CLI lane.", - }); - } const config = parseObject(ctx.config); const command = asString(config.command, "gemini"); const target = ctx.executionTarget ?? null; diff --git a/packages/adapters/kimi-local/src/index.ts b/packages/adapters/kimi-local/src/index.ts index a27aded28a..2ad83521ae 100644 --- a/packages/adapters/kimi-local/src/index.ts +++ b/packages/adapters/kimi-local/src/index.ts @@ -65,7 +65,7 @@ Core fields: - instructionsFilePath (string, optional): absolute path to a markdown instructions file prepended to the run prompt. Sibling files in the same directory (HEARTBEAT.md, SOUL.md, TOOLS.md) are made readable via --add-dir for local runs. - promptTemplate (string, optional): run prompt template - model (string, optional): Kimi model alias (provider/model). Defaults to kimi-code/kimi-for-coding. -- effort (string, optional): thinking effort (low | medium | high | max). CLI lane only (engine=cli or the automatic fallback): forwarded as KIMI_MODEL_THINKING_EFFORT for effort-capable models (currently kimi-code/k3); "medium" maps to "high" since Kimi has no medium tier. Ignored for models without support_efforts, and NOT forwarded on the default ACP engine lane (Kimi ACP exposes a separate "thinking" option that is not wired yet) — pin engine=cli when effort control matters. +- effort (string, optional): thinking effort (low | medium | high | max). CLI lane only (engine=cli): forwarded as KIMI_MODEL_THINKING_EFFORT for effort-capable models (currently kimi-code/k3); "medium" maps to "high" since Kimi has no medium tier. Ignored for models without support_efforts, and NOT forwarded on the default ACP engine lane (Kimi ACP exposes a separate "thinking" option that is not wired yet) — pin engine=cli when effort control matters. - command (string, optional): defaults to "kimi" - extraArgs (string[], optional): additional CLI args - env (object, optional): KEY=VALUE environment variables @@ -75,7 +75,7 @@ Operational fields: - graceSec (number, optional): SIGTERM grace period in seconds Notes: -- The adapter defaults to the ACP engine (\`kimi acp\`) and falls back to the headless CLI lane when ACP prerequisites are unavailable. Set \`engine\` to \`acp\` or \`cli\` to require a specific lane. +- The adapter defaults to the ACP engine (\`kimi acp\`) and fails with a setup error when ACP prerequisites are unavailable. Set \`engine\` to \`acp\` or \`cli\` to require a specific lane. - CLI-lane runs use \`kimi -p\` with \`--output-format stream-json\` for non-interactive headless execution; the prompt is passed as an argument, not stdin. - The adapter sets a headless-safe environment (CI=1, NO_COLOR=1, KIMI_CODE_NO_AUTO_UPDATE=1) so unattended runs never wait on interactive prompts or update preflight. - Sessions resume with \`-r \` when the stored session cwd matches the current cwd; the session id is captured from the trailing session.resume_hint meta event. diff --git a/packages/adapters/kimi-local/src/server/acp.ts b/packages/adapters/kimi-local/src/server/acp.ts index e6a8b8b523..d1bbaa41d4 100644 --- a/packages/adapters/kimi-local/src/server/acp.ts +++ b/packages/adapters/kimi-local/src/server/acp.ts @@ -36,7 +36,7 @@ export type KimiExecutionEngine = "cli" | "acp"; export interface KimiEngineSelection { engine: KimiExecutionEngine; explicit: boolean; - fallbackReason?: string; + unavailableReason?: string; } type KimiEngineResolutionInput = @@ -65,15 +65,15 @@ export async function resolveKimiExecutionEngineForRun( input: KimiEngineResolutionInput, ): Promise { const selection = normalizeEngine(input.config.engine); - if (selection.explicit || selection.engine !== "acp") return selection; + // Engine availability must never change the agent's execution or permission contract. + if (selection.engine === "cli") return selection; + const unavailable = (reason: string): KimiEngineSelection => ({ + ...selection, + unavailableReason: `${reason} Repair the ACP setup, or explicitly set engine=cli to use the CLI engine.`, + }); - const fallbackReason = await defaultKimiAcpFallbackReason(input); - if (!fallbackReason) return selection; - return { engine: "cli", explicit: false, fallbackReason }; -} - -export function formatKimiAcpFallbackMessage(reason: string): string { - return `[paperclip] Kimi ACP default unavailable; falling back to Kimi CLI. ${reason} Set engine=acp to require ACP or engine=cli to silence this fallback.\n`; + const reason = await kimiAcpUnavailableReason(input); + return reason ? unavailable(reason) : selection; } function firstNonEmptyString(...values: unknown[]): string | undefined { @@ -243,7 +243,7 @@ function sandboxTargetHasProcessSessionBridge( return target?.kind === "remote" && target.transport === "sandbox" && Boolean(target.runner); } -async function defaultKimiAcpFallbackReason( +async function kimiAcpUnavailableReason( input: KimiEngineResolutionInput, ): Promise { const target = readAdapterExecutionTarget({ @@ -257,7 +257,7 @@ async function defaultKimiAcpFallbackReason( return "Kimi ACP supports sandbox remote targets only; this run targets a non-sandbox remote environment."; } if (!nodeVersionMeetsKimiAcpMinimum()) { - return `Node ${process.version} does not satisfy Kimi ACP's Node >=${MIN_ACP_NODE_VERSION} prerequisite.`; + return `Node ${process.version} (${process.execPath}) does not satisfy Kimi ACP's Node >=${MIN_ACP_NODE_VERSION} prerequisite.`; } const command = resolveKimiAcpCommand(input.config); if (!(await commandIsResolvable(command, resolveConfigPath(input.config), input))) { @@ -322,7 +322,7 @@ export async function testKimiAcpEnvironment( level: nodeVersionMeetsKimiAcpMinimum() ? "info" : "error", message: nodeVersionMeetsKimiAcpMinimum() ? `Node ${process.version} satisfies ACP runtime requirements.` - : `Node ${process.version} does not satisfy ACP runtime requirements.`, + : `Node ${process.version} (${process.execPath}) does not satisfy ACP runtime requirements.`, hint: nodeVersionMeetsKimiAcpMinimum() ? undefined : `Run Kimi ACP with Node >=${MIN_ACP_NODE_VERSION} or switch engine=cli.`, diff --git a/packages/adapters/kimi-local/src/server/engine-availability.test.ts b/packages/adapters/kimi-local/src/server/engine-availability.test.ts new file mode 100644 index 0000000000..24685ed5c7 --- /dev/null +++ b/packages/adapters/kimi-local/src/server/engine-availability.test.ts @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { resolveKimiExecutionEngineForRun } from "./acp.js"; +import { execute } from "./execute.js"; +import { testEnvironment } from "./test.js"; + +const originalVersion = process.version; +afterEach(() => Object.defineProperty(process, "version", { value: originalVersion })); + +describe("kimi engine availability", () => { + it.each([undefined, "auto", "acp"])("reports a setup failure for engine=%s without starting a process", async (engine) => { + Object.defineProperty(process, "version", { value: "v18.0.0" }); + const config = { engine }; + const onSpawn = vi.fn(); + const result = await execute({ config, onSpawn } as never); + expect(result).toMatchObject({ + exitCode: 1, + errorCode: "adapter_engine_unavailable", + errorMessage: expect.stringContaining("Node v18.0.0"), + resultJson: { executionRecovery: { kind: "bootstrap", providerWorkStarted: false } }, + }); + expect(result.errorMessage).toContain(process.execPath); + expect(onSpawn).not.toHaveBeenCalled(); + const diagnostic = await testEnvironment({ config } as never); + expect(diagnostic.status).toBe("fail"); + expect(diagnostic.checks).toContainEqual(expect.objectContaining({ + code: "adapter_engine_unavailable", level: "error", + })); + }); + + it("does not apply ACP prerequisites to explicitly selected CLI", async () => { + Object.defineProperty(process, "version", { value: "v18.0.0" }); + await expect(resolveKimiExecutionEngineForRun({ config: { engine: "cli" } })) + .resolves.toEqual({ engine: "cli", explicit: true }); + }); + + it("keeps an unavailable ACP command as a failure, not a CLI selection", async () => { + Object.defineProperty(process, "version", { value: "v24.11.0" }); + const result = await resolveKimiExecutionEngineForRun({ + config: { agentCommand: "/nonexistent/paperclip-test/acp", command: "/nonexistent/paperclip-test/acp" }, + }); + expect(result.engine).toBe("acp"); + expect(result.unavailableReason).toContain("not available"); + }); +}); diff --git a/packages/adapters/kimi-local/src/server/execute.acp-failure.test.ts b/packages/adapters/kimi-local/src/server/execute.acp-failure.test.ts new file mode 100644 index 0000000000..07d8467b42 --- /dev/null +++ b/packages/adapters/kimi-local/src/server/execute.acp-failure.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it, vi } from "vitest"; + +const { executeAcp, runProcess } = vi.hoisted(() => ({ + executeAcp: vi.fn(async () => { throw new Error("ACP session startup failed"); }), + runProcess: vi.fn(), +})); + +vi.mock("./acp.js", () => ({ + createKimiAcpExecutor: () => executeAcp, + resolveKimiExecutionEngineForRun: async () => ({ engine: "acp", explicit: false }), +})); +vi.mock("@paperclipai/adapter-utils/execution-target", async (importOriginal) => ({ + ...await importOriginal(), + runAdapterExecutionTargetProcess: runProcess, +})); + +import { execute } from "./execute.js"; + +describe("Kimi ACP failure handling", () => { + it("does not replay a failed default ACP invocation through CLI", async () => { + await expect(execute({ config: {} } as never)).rejects.toThrow("ACP session startup failed"); + expect(executeAcp).toHaveBeenCalledTimes(1); + expect(runProcess).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/adapters/kimi-local/src/server/execute.test.ts b/packages/adapters/kimi-local/src/server/execute.test.ts index dafbc212b5..f005916d8a 100644 --- a/packages/adapters/kimi-local/src/server/execute.test.ts +++ b/packages/adapters/kimi-local/src/server/execute.test.ts @@ -397,7 +397,7 @@ describe("kimi_local execute", () => { expect(prompt).toContain("./TOOLS.md"); }); - it("does not pass --skills-dir when no skills are desired", async () => { + it("loads the operational skill when no optional skills are configured", async () => { const root = await makeTempRoot(); let seenArgs: string[] = []; runProcessMock.mockImplementation(async (_runId, _target, _command, args) => { @@ -407,6 +407,7 @@ describe("kimi_local execute", () => { await execute(makeContext(root, { config: { cwd: root, model: "kimi-code/k3" } })); - expect(seenArgs).not.toContain("--skills-dir"); + expect(seenArgs).toContain("--skills-dir"); + expect(seenArgs[seenArgs.indexOf("--skills-dir") + 1]).toContain("paperclip-kimi-skills-"); }); }); diff --git a/packages/adapters/kimi-local/src/server/execute.ts b/packages/adapters/kimi-local/src/server/execute.ts index 89a4ef08eb..f9be53cc95 100644 --- a/packages/adapters/kimi-local/src/server/execute.ts +++ b/packages/adapters/kimi-local/src/server/execute.ts @@ -58,7 +58,6 @@ import { } from "./parse.js"; import { createKimiAcpExecutor, - formatKimiAcpFallbackMessage, resolveKimiExecutionEngineForRun, } from "./acp.js"; import { firstNonEmptyLine } from "./utils.js"; @@ -186,18 +185,20 @@ async function buildKimiSkillsDir( export async function execute(ctx: AdapterExecutionContext): Promise { const engineSelection = await resolveKimiExecutionEngineForRun(ctx); + if (engineSelection.unavailableReason) { + return { + exitCode: 1, + signal: null, + timedOut: false, + errorCode: "adapter_engine_unavailable", + errorMessage: engineSelection.unavailableReason, + resultJson: { + executionRecovery: { kind: "bootstrap", providerWorkStarted: false }, + }, + }; + } if (engineSelection.engine === "acp") { - try { - return await executeKimiAcp(ctx); - } catch (err) { - // An explicitly requested ACP engine surfaces its failure; the default - // (auto) selection falls back to the CLI lane with a diagnostic note. - if (engineSelection.explicit) throw err; - const reason = err instanceof Error ? err.message : String(err); - await ctx.onLog("stderr", formatKimiAcpFallbackMessage(`Kimi ACP startup failed: ${reason}`)); - } - } else if (!engineSelection.explicit && engineSelection.fallbackReason) { - await ctx.onLog("stderr", formatKimiAcpFallbackMessage(engineSelection.fallbackReason)); + return executeKimiAcp(ctx); } const { runId, agent, runtime, config, context, onLog, onMeta, onEvent, onSpawn, authToken } = ctx; diff --git a/packages/adapters/kimi-local/src/server/test.ts b/packages/adapters/kimi-local/src/server/test.ts index 551194d655..5900f9d754 100644 --- a/packages/adapters/kimi-local/src/server/test.ts +++ b/packages/adapters/kimi-local/src/server/test.ts @@ -107,20 +107,23 @@ export async function testEnvironment( config: parseObject(ctx.config), executionTarget: ctx.executionTarget, }); + if (engineSelection.unavailableReason) { + return { + adapterType: "kimi_local", + status: "fail", + checks: [{ + code: "adapter_engine_unavailable", + level: "error", + message: engineSelection.unavailableReason, + }], + testedAt: new Date().toISOString(), + }; + } if (engineSelection.engine === "acp") { return testKimiAcpEnvironment(ctx); } const checks: AdapterEnvironmentCheck[] = []; - if (!engineSelection.explicit && engineSelection.fallbackReason) { - checks.push({ - code: "kimi_acp_default_fallback", - level: "warn", - message: "Kimi ACP default is unavailable; testing the Kimi CLI fallback lane.", - detail: engineSelection.fallbackReason, - hint: "Fix the ACP prerequisite to use the default ACP lane, or set engine=cli to pin the CLI lane.", - }); - } const config = parseObject(ctx.config); const command = asString(config.command, "kimi"); const target = ctx.executionTarget ?? null; diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs index 3e8c67df35..79fa00941c 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs @@ -156,7 +156,7 @@ impl AcpxProviderDescriptor { "1.6.2", Some("@openai/codex"), Some("0.153.4"), - "sha256:91d61bdfcb3c2830a5af690b13e355c669a483b562ce2f5d82d3e53b2378bb00", + "sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3", ), "pi" => return Err(DurableRunnerError::invalid( "ACPX agent pi is not executable through the verified runnerd provider boundary", @@ -1776,7 +1776,7 @@ mod tests { "1.6.2", json!("@openai/codex"), json!("0.153.4"), - "sha256:91d61bdfcb3c2830a5af690b13e355c669a483b562ce2f5d82d3e53b2378bb00", + "sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3", ) }; json!({ diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs index 447c6dbc99..8c965e0cc3 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs @@ -15,7 +15,7 @@ use serde_json::{json, Value}; use sha2::{Digest, Sha256}; const CODEX_ACPX_DIGEST: &str = - "sha256:91d61bdfcb3c2830a5af690b13e355c669a483b562ce2f5d82d3e53b2378bb00"; + "sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3"; fn temporary_directory(label: &str) -> PathBuf { let nonce = SystemTime::now() diff --git a/packages/paperclip-runner/scripts/build-provider-pack.mjs b/packages/paperclip-runner/scripts/build-provider-pack.mjs index 368d3d1780..396c44d01d 100644 --- a/packages/paperclip-runner/scripts/build-provider-pack.mjs +++ b/packages/paperclip-runner/scripts/build-provider-pack.mjs @@ -310,7 +310,7 @@ try { claude: "sha256:9d73d1f0f121fb96cc8badb28c22d5bff02d8582eb2e40360a81c189e1b9422a", codex: - "sha256:91d61bdfcb3c2830a5af690b13e355c669a483b562ce2f5d82d3e53b2378bb00", + "sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3", }, artifacts: { nodeCommand: { diff --git a/packages/paperclip-runner/src/backends/native-backend-factory.test.ts b/packages/paperclip-runner/src/backends/native-backend-factory.test.ts index 9446906077..83cd717ac4 100644 --- a/packages/paperclip-runner/src/backends/native-backend-factory.test.ts +++ b/packages/paperclip-runner/src/backends/native-backend-factory.test.ts @@ -104,7 +104,7 @@ function acpxExecution( agent === "pi" ? "0.84.2" : agent === "codex" ? "0.153.4" : "0.3.263", commandDigest: agent === "codex" - ? "sha256:91d61bdfcb3c2830a5af690b13e355c669a483b562ce2f5d82d3e53b2378bb00" + ? "sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3" : agent === "pi" ? "sha256:8c696f38296d53d0061fa11534570c5ddd951b63532aed30e0f1fcc676dc169f" : "sha256:9d73d1f0f121fb96cc8badb28c22d5bff02d8582eb2e40360a81c189e1b9422a", diff --git a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts index 753d5fbcf7..05f7bb2233 100644 --- a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts +++ b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts @@ -74,7 +74,7 @@ export const QUALIFIED_ACPX_PROFILES: Readonly< agentRuntimePackage: "@openai/codex", agentRuntimeVersion: "0.153.4", commandDigest: - "sha256:91d61bdfcb3c2830a5af690b13e355c669a483b562ce2f5d82d3e53b2378bb00", + "sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3", qualificationModel: "gpt-5.6-sol", reportedModelId: "gpt-5.6-sol", permissionPolicy: "interactive", diff --git a/packages/shared/src/node-version.ts b/packages/shared/src/node-version.ts index 0cd7ed5bbe..9a7766a832 100644 --- a/packages/shared/src/node-version.ts +++ b/packages/shared/src/node-version.ts @@ -26,10 +26,12 @@ export function formatNodeVersionWarning(version: string): string | null { const currentVersion = version.trim() || "unknown"; return [ `[paperclip] warning: Node.js ${currentVersion} is unsupported. Paperclip requires Node.js ${MINIMUM_NODE_VERSION} or newer.`, + `Running executable: ${process.execPath}`, "Upgrade Node.js with your version manager, or follow the recommended downloaded install.sh workflow:", ` ${NODE_VERSION_INSTALL_GUIDE_URL}`, "The piped install.sh form cannot upgrade an unsupported Node.js runtime.", "Restart Paperclip after upgrading.", + "For a background service, update its startup executable and PATH; installing a newer Node.js in your shell does not change the service runtime.", ].join("\n"); } diff --git a/patches/@agentclientprotocol__codex-acp@1.6.2.patch b/patches/@agentclientprotocol__codex-acp@1.6.2.patch index b352c3c7f4..8f6c17911e 100644 --- a/patches/@agentclientprotocol__codex-acp@1.6.2.patch +++ b/patches/@agentclientprotocol__codex-acp@1.6.2.patch @@ -32,7 +32,7 @@ diff --git a/dist/index.js b/dist/index.js // content: [messageContent], — omitted: already rendered via item/started - // rawInput: { ... } — omitted: same reason }, -@@ -26988,4 +26988,13 @@ +@@ -26988,4 +26988,18 @@ }; +function paperclipBaseInstructions(request) { + if (process.env.PAPERCLIP_ACPX_ISOLATED_CONTEXT !== "1") return void 0; @@ -42,6 +42,11 @@ diff --git a/dist/index.js b/dist/index.js + return prompt.append; + } + return void 0; ++} ++function paperclipSandboxPolicy(sandboxPolicy) { ++ const networkAccess = process.env.PAPERCLIP_CODEX_ACP_NETWORK_ACCESS; ++ if (sandboxPolicy.type !== "workspaceWrite" || !["true", "false"].includes(networkAccess)) return sandboxPolicy; ++ return { ...sandboxPolicy, networkAccess: networkAccess === "true" && process.env.PAPERCLIP_RUNNER_NETWORK_ACCESS !== "disabled" }; +} var CodexAcpClient = class { codexClient; @@ -109,6 +114,15 @@ diff --git a/dist/index.js b/dist/index.js forceReload: true }); } +@@ -27600,7 +27624,7 @@ + threadId: request.sessionId, + input, + approvalPolicy: agentMode.approvalPolicy, +- sandboxPolicy: addAdditionalDirectoriesToSandboxPolicy(agentMode.sandboxPolicy, additionalDirectories), ++ sandboxPolicy: addAdditionalDirectoriesToSandboxPolicy(paperclipSandboxPolicy(agentMode.sandboxPolicy), additionalDirectories), + summary: disableSummary ? "none" : "auto", + effort, + model: modelId.model, @@ -30595,6 +30614,9 @@ updatedGoal ); diff --git a/scripts/codex-acp-network-policy.test.mjs b/scripts/codex-acp-network-policy.test.mjs new file mode 100644 index 0000000000..cf4c9d9389 --- /dev/null +++ b/scripts/codex-acp-network-policy.test.mjs @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { runInNewContext } from "node:vm"; +import test from "node:test"; + +const require = createRequire(new URL("../packages/paperclip-runner/package.json", import.meta.url)); +const source = readFileSync(require.resolve("@agentclientprotocol/codex-acp"), "utf8"); +const helperStart = source.indexOf("function paperclipSandboxPolicy("); +const helperEnd = source.indexOf("\nvar CodexAcpClient", helperStart); +const methodStart = source.indexOf(" async sendPrompt("); +const methodEnd = source.indexOf("\n async runAgentFileChangeReport", methodStart); +assert.ok(helperStart >= 0 && helperEnd > helperStart, "installed Codex ACP must contain the network policy patch"); +assert.ok(methodStart >= 0 && methodEnd > methodStart, "installed Codex ACP must expose the patched turn boundary"); + +async function turnPolicy(policy, env) { + const context = { + process: { env }, + buildPromptItems: (prompt) => prompt, + addAdditionalDirectoriesToSandboxPolicy: (value) => value, + }; + const client = runInNewContext( + source.slice(helperStart, helperEnd) + "\n({" + source.slice(methodStart, methodEnd) + "})", + context, + ); + client.refreshSkills = async () => {}; + client.codexClient = { runTurn: async (params) => params }; + return client.sendPrompt( + { sessionId: "fresh-or-resumed-thread", prompt: [] }, + { approvalPolicy: "on-request", sandboxPolicy: policy }, + { model: "test-model", effort: "low" }, + null, false, "/workspace", [], undefined, undefined, + ); +} + +test("ACP forwards workspace networking to the actual Codex turn without changing confinement", async () => { + const policy = { type: "workspaceWrite", writableRoots: ["/workspace"], networkAccess: false }; + const result = await turnPolicy(policy, { PAPERCLIP_CODEX_ACP_NETWORK_ACCESS: "true" }); + assert.equal(result.sandboxPolicy.networkAccess, true); + assert.equal(result.sandboxPolicy.type, "workspaceWrite"); + assert.equal(result.sandboxPolicy.writableRoots, policy.writableRoots); + assert.equal(result.approvalPolicy, "on-request"); + assert.equal(policy.networkAccess, false, "must not mutate the shared mode preset"); +}); + +test("ACP preserves operator and execution-target network denials", async () => { + const policy = { type: "workspaceWrite", networkAccess: true }; + for (const env of [ + { PAPERCLIP_CODEX_ACP_NETWORK_ACCESS: "false" }, + { PAPERCLIP_CODEX_ACP_NETWORK_ACCESS: "true", PAPERCLIP_RUNNER_NETWORK_ACCESS: "disabled" }, + ]) { + assert.equal((await turnPolicy(policy, env)).sandboxPolicy.networkAccess, false); + } +}); + +test("ACP leaves other sandbox modes and callers without a valid override unchanged", async () => { + for (const policy of [{ type: "readOnly", networkAccess: false }, { type: "dangerFullAccess" }]) { + assert.equal((await turnPolicy(policy, { PAPERCLIP_CODEX_ACP_NETWORK_ACCESS: "true" })).sandboxPolicy, policy); + } + const policy = { type: "workspaceWrite", networkAccess: false }; + for (const value of [undefined, "", "1", "invalid"]) { + assert.equal((await turnPolicy(policy, { PAPERCLIP_CODEX_ACP_NETWORK_ACCESS: value })).sandboxPolicy, policy); + } +}); diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts index 8ad9d03380..046fce2c86 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -653,7 +653,7 @@ describe("remote provider pack manifest", () => { claude: "sha256:9d73d1f0f121fb96cc8badb28c22d5bff02d8582eb2e40360a81c189e1b9422a", codex: - "sha256:91d61bdfcb3c2830a5af690b13e355c669a483b562ce2f5d82d3e53b2378bb00", + "sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3", }, artifacts: { nodeCommand: { diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index 097f26221f..bbd921104e 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -5611,7 +5611,7 @@ const REMOTE_PROVIDER_PACK_PROFILE_DIGESTS = { claude: "sha256:9d73d1f0f121fb96cc8badb28c22d5bff02d8582eb2e40360a81c189e1b9422a", codex: - "sha256:91d61bdfcb3c2830a5af690b13e355c669a483b562ce2f5d82d3e53b2378bb00", + "sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3", } as const; const REMOTE_PROVIDER_PACK_ARTIFACT_PATHS = { nodeCommand: "node_modules/node/bin/node", diff --git a/server/src/services/recovery/provider-failure-classification.test.ts b/server/src/services/recovery/provider-failure-classification.test.ts index 307d38f4fe..cf38fe5c04 100644 --- a/server/src/services/recovery/provider-failure-classification.test.ts +++ b/server/src/services/recovery/provider-failure-classification.test.ts @@ -2,7 +2,9 @@ import { describe, expect, it } from "vitest"; import { PROVIDER_QUOTA_RECOVERY_DEFAULT_BACKOFF_MS, classifyAdapterFailureForRecovery, + classifyContinuationFailure, } from "./service.js"; +import { legacyExecutionNeedsReconciliation } from "../legacy-execution-recovery.js"; describe("classifyAdapterFailureForRecovery", () => { it("classifies usage-limit messages and parses the provider reset time", () => { @@ -101,6 +103,22 @@ describe("classifyAdapterFailureForRecovery", () => { })).toBeNull(); }); + it("routes unavailable engines to a configuration blocker instead of retrying", () => { + expect(classifyAdapterFailureForRecovery({ + errorCode: "adapter_engine_unavailable", + error: "Node v22.22.2 does not satisfy Codex ACP's Node >=24.11.0 prerequisite.", + resultJson: null, + })).toEqual({ kind: "configuration_incomplete" }); + expect(classifyContinuationFailure({ errorCode: "adapter_engine_unavailable" } as never)) + .toMatchObject({ kind: "non_retryable", maxAttempts: 0 }); + expect(legacyExecutionNeedsReconciliation({ + runtimeMode: "legacy", + status: "failed", + errorCode: "adapter_engine_unavailable", + resultJson: { executionRecovery: { kind: "bootstrap", providerWorkStarted: false } }, + })).toBe(false); + }); + it("does not treat a generic capacity limit as provider quota", () => { expect(classifyAdapterFailureForRecovery({ errorCode: "adapter_failed", diff --git a/server/src/services/recovery/service.ts b/server/src/services/recovery/service.ts index dc03cf8e03..924c15d58e 100644 --- a/server/src/services/recovery/service.ts +++ b/server/src/services/recovery/service.ts @@ -356,6 +356,7 @@ const TRANSIENT_INFRA_CONTINUATION_ERROR_CODES = new Set([ ]); const NON_RETRYABLE_CONTINUATION_ERROR_CODES = new Set([ + "adapter_engine_unavailable", "agent_not_invokable", "agent_not_found", "budget_blocked", @@ -458,6 +459,11 @@ export function classifyAdapterFailureForRecovery( latestRun: Pick, "error" | "errorCode" | "resultJson">, now = new Date(), ): AdapterFailureRecoveryClassification { + // An engine prerequisite cannot be repaired by asking the same unavailable + // engine to retry. Use the existing configuration-blocker path. + if (latestRun.errorCode === "adapter_engine_unavailable") { + return { kind: "configuration_incomplete" }; + } if ( latestRun.errorCode !== "adapter_failed" && latestRun.errorCode !== "provider_quota" && diff --git a/ui/src/adapters/claude-local/config-fields.tsx b/ui/src/adapters/claude-local/config-fields.tsx index 1bcb31a460..c3a9e4ae27 100644 --- a/ui/src/adapters/claude-local/config-fields.tsx +++ b/ui/src/adapters/claude-local/config-fields.tsx @@ -96,7 +96,7 @@ export function ClaudeLocalAdvancedFields({ environment owns both, so the managed-sandbox-only policy hides them, the same way `runnerManaged` hides them for the Paperclip Runner. */} - {!managedSandboxOnly && + {!managedSandboxOnly && diff --git a/ui/src/adapters/codex-local/config-fields.tsx b/ui/src/adapters/codex-local/config-fields.tsx index 476a90b610..5d569a1385 100644 --- a/ui/src/adapters/codex-local/config-fields.tsx +++ b/ui/src/adapters/codex-local/config-fields.tsx @@ -161,7 +161,7 @@ export function CodexLocalConfigFields({ {!hideEngineChoice && ( diff --git a/ui/src/adapters/gemini-local/config-fields.tsx b/ui/src/adapters/gemini-local/config-fields.tsx index 2a73593740..e46ea8cbdb 100644 --- a/ui/src/adapters/gemini-local/config-fields.tsx +++ b/ui/src/adapters/gemini-local/config-fields.tsx @@ -36,7 +36,7 @@ export function GeminiLocalConfigFields({ the ACP sub-fields below name host paths. The platform-managed environment owns both, so the managed-sandbox-only policy hides them. */} - {!managedSandboxOnly && + {!managedSandboxOnly &&