fix(adapters): prevent engine fallback and preserve usable runtime defaults (#13105)

## Thinking Path

> - Paperclip manages agents that must write work and report task
outcomes through its API.
> - Local adapters select an execution engine and its permission
settings.
> - A higher ACP Node requirement can make an unchanged installation
lose access to its default engine.
> - The adapter then silently selects CLI, which can change permissions
and block API access.
> - This pull request keeps the engine choice fixed and reports missing
prerequisites before work starts.
> - It also gives explicit Codex CLI runs usable defaults and keeps
managed services on a supported Node runtime.

## Linked Issues or Issue Description

Refs #12215. Related changes: #11792 raised the Node requirement; #13094
addressed separate runner networking behavior. This change fixes the
engine-selection and managed-launcher paths.

**What happened?**

An unchanged agent could switch from ACP to CLI after an upgrade. Codex
CLI then used read-only permissions with networking disabled. The run
could finish without updating its task. Repeated recovery attempts used
the same unavailable setup. Managed updates also skipped the Node check
and did not refresh old launchers.

**Expected behavior**

An unavailable engine must fail with a clear setup error. It must not
silently select another engine. Explicit CLI runs must be able to write
workspace files and call the API unless the operator configures stricter
settings. Managed updates must validate Node and keep child tools on
that runtime.

**Steps to reproduce**

1. Run an ACP-default agent under Node 22 after the ACP minimum rises to
24.11.
2. Leave the engine unset and disable the approval/sandbox bypass.
3. Observe the old adapter select CLI and fail to write task disposition
through the API.
4. Start a managed service with an old launcher and a supervisor PATH
that selects a different Node for child tools.

## What Changed

- Remove automatic engine fallback for Codex, Claude, Gemini, and Kimi.
Check prerequisites for default and explicit ACP selections.
- Return a configuration error with proof that provider work did not
start. Stop automatic continuation retries for this error.
- Enable Codex ACP workspace networking at the actual turn boundary.
Upstream mode presets otherwise force it off even when config.toml
enables it. Preserve explicit network denial and read-only mode.
- Set workspace-write and network access defaults for explicit Codex CLI
runs. Preserve explicit sandbox modes, profiles, and network
restrictions.
- Pin the validated Node directory in managed launcher PATH. Refresh
legacy launchers during installs and npm/Git updates.
- Reject updates on unsupported Node. Keep update checks, dry runs, and
rollback available.
- Synchronize the qualified Codex ACP executable identity across server,
TypeScript runner, Rust runner, and provider-pack launch paths.
- Add regression tests and update engine and installation documentation.

## Verification

- [Full CI passed on the final
head](https://github.com/paperclipai/paperclip/actions/runs/34387099695):
typecheck, build/native runner verification, all general and serialized
test shards, all browser shards, release registry, canary dry run, and
policy checks.
- Greptile: 5/5 on `2c1d6e2815830a5cd39e36c8a082cc0c4441b6c0`, with no
unresolved review findings. Security gates are green.
- Full workspace typecheck and build also passed locally. The final
deployed Linux build passed.
- Full Codex, Claude, Gemini, and Kimi source test suites: 804 passed, 2
skipped. Installer, updater, and launcher tests: 47 passed. Installed
ACP turn-boundary tests: 3 passed. ACP packaging tests: 14 passed.
Focused recovery classification tests also passed.
- Real Linux Codex CLI runs, both fresh and resumed, wrote a workspace
file and reached the control-plane health API with the new defaults.
- Explicit read-only and network-disabled control probes retained those
restrictions.
- A real ACP run on the final deployed Linux build wrote a file and
reached the control-plane API with HTTP 200, without engine fallback.
The same probe failed DNS before the turn-policy patch.
- Executable-identity and installed-policy contracts: 12 passed.
Affected native server tests: 197 passed. Runner factory tests: 21
passed. Rust qualification and native provider integration tests: 11
passed.
- Deployed the production changes to a Linux service on Node 24.20 after
a verified database backup. Health, bootstrap readiness, static UI,
executable/cwd identity, and guarded restart checks passed. The restart
lost no runs.
- Corrected stale Kimi skill-default and Gemini remote-archive fixtures;
both suites pass.

## Risks

- Default or legacy auto engine settings now fail when ACP is
unavailable. Operators who intend to use CLI must select it explicitly.
- Codex CLI now permits workspace writes and networking by default, and
ACP workspace-write turns permit networking by default. Explicit
operator sandbox settings remain authoritative.
- Old managed launchers keep their pinned Node until they are
reinstalled under a supported runtime. An old updater cannot repair
itself; the documentation gives the current installer command.
- Custom service wrappers and global/source installations must configure
their runtime PATH. No database migration is required.

## Model Used

OpenAI Codex, based on GPT-6, with reasoning, repository inspection,
shell execution, and test tools. The exact serving model identifier and
context-window size are not exposed in this session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-09 13:27:24 -05:00 committed by GitHub
parent 8cfd30fb07
commit 2991a59b17
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
62 changed files with 887 additions and 329 deletions

View File

@ -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`);

View File

@ -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<CommandRunner>();
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");

View File

@ -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.`);
}
}

View File

@ -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<D
}
if (mode === "npx") { emit(options, { mode, action: "install" }, "This is an ephemeral npx install. Run `paperclipai install`, then use `paperclipai update` from the managed shim."); return; }
if (mode === "source" || mode === "unknown") { emit(options, { mode, action: "manual" }, "This appears to be a source checkout. Update it with `git pull` followed by `pnpm install`; Paperclip will not mutate the repository."); return; }
if (!options.check && !options.dryRun) assertSupportedNodeVersion();
const request = resolveUpdateRequest(mode === "managed" ? manifest : null, options);
if (mode === "managed" && manifest?.source === "git") {
if (!manifest.repo || !manifest.ref || !manifest.sha) throw new Error("Managed git install metadata is incomplete.");
@ -193,7 +194,9 @@ export async function updateCommand(options: UpdateOptions, overrides: Partial<D
}
if (options.backup !== false) await runPreUpdateBackup(options, overrides.backup ?? (() => 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<D
if (options.dryRun) { emit(options, { mode, currentVersion, targetVersion, action: comparison < 0 ? "downgrade" : "update", backup: options.backup !== false, dryRun: true }, `Would ${comparison < 0 ? "downgrade" : "update"} paperclipai ${currentVersion} → ${targetVersion}${options.backup === false ? " without a backup" : " after a database backup"}.`); return; }
if (options.backup !== false) await runPreUpdateBackup(options, overrides.backup ?? (() => 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; }

View File

@ -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);
}

View File

@ -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", () => {

View File

@ -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/<server-pid>/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:

View File

@ -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:

View File

@ -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:

View File

@ -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).

View File

@ -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 |

View File

@ -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.
`;

View File

@ -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"),
});
});

View File

@ -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<ClaudeEngineSelection> {
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<string | null> {
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.`,

View File

@ -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",

View File

@ -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");
});
});

View File

@ -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<string, unknown> }) =>
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);

View File

@ -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,
},

View File

@ -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<AdapterExecutionResult> {
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;

View File

@ -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;

View File

@ -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.
`;

View File

@ -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<AdapterExecutionContext>
}
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"),
});
});

View File

@ -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<CodexEngineSelection> {
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<string, unknown>): Record<str
typeof config.model === "string" ? config.model : "",
);
const env = parseObject(config.env);
let networkAccess = env.PAPERCLIP_CODEX_ACP_NETWORK_ACCESS !== "false";
const extraArgs = asStringArray(config.extraArgs);
for (const arg of extraArgs.length > 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<string | null> {
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.`,

View File

@ -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", "-"]);
});
});

View File

@ -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("-");

View File

@ -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",

View File

@ -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");
});
});

View File

@ -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<string, unknown> }) =>
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 () => {

View File

@ -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,

View File

@ -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<AdapterExecutionResult> {
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<AdapterExec
{
resumeSessionId,
skipGitRepoCheck: executionTargetIsSandbox,
networkAccess: env.PAPERCLIP_RUNNER_NETWORK_ACCESS !== "disabled",
},
);
const args = execArgs.args;

View File

@ -245,20 +245,23 @@ export async function testEnvironment(
config: parseObject(ctx.config),
executionTarget: ctx.executionTarget,
});
if (engineSelection.unavailableReason) {
return {
adapterType: "codex_local",
status: "fail",
checks: [{
code: "adapter_engine_unavailable",
level: "error",
message: engineSelection.unavailableReason,
}],
testedAt: new Date().toISOString(),
};
}
if (engineSelection.engine === "acp") {
return testCodexAcpEnvironment(ctx);
}
const checks: AdapterEnvironmentCheck[] = [];
if (!engineSelection.explicit && engineSelection.fallbackReason) {
checks.push({
code: "codex_acp_default_fallback",
level: "warn",
message: "Codex ACP default is unavailable; testing the Codex 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, "codex");
const target = ctx.executionTarget ?? null;

View File

@ -39,7 +39,7 @@ Core fields:
- instructionsFilePath (string, optional): absolute path to a markdown instructions file prepended to the run prompt
- promptTemplate (string, optional): run prompt template
- model (string, optional): Gemini model id. Defaults to auto.
- engine (string, optional): leave unset/auto to use ACP when prerequisites pass and fall back to the Gemini 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.
- sandbox (boolean, optional): run in sandbox mode (default: false, passes --sandbox=none)
- command (string, optional): defaults to "gemini"
- extraArgs (string[], optional): additional CLI args
@ -55,7 +55,7 @@ Operational fields:
- graceSec (number, optional): SIGTERM grace period in seconds
Notes:
- 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. 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.

View File

@ -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"),
});
});

View File

@ -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<GeminiEngineSelection> {
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<string | null> {
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.`,

View File

@ -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",

View File

@ -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");
});
});

View File

@ -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<string, unknown> }) =>
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 () => {

View File

@ -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: {

View File

@ -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<AdapterExecutionResult> {
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;

View File

@ -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;

View File

@ -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 <session_id>\` when the stored session cwd matches the current cwd; the session id is captured from the trailing session.resume_hint meta event.

View File

@ -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<KimiEngineSelection> {
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<string | null> {
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.`,

View File

@ -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");
});
});

View File

@ -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<typeof import("@paperclipai/adapter-utils/execution-target")>(),
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();
});
});

View File

@ -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-");
});
});

View File

@ -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<AdapterExecutionResult> {
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;

View File

@ -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;

View File

@ -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!({

View File

@ -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()

View File

@ -310,7 +310,7 @@ try {
claude:
"sha256:9d73d1f0f121fb96cc8badb28c22d5bff02d8582eb2e40360a81c189e1b9422a",
codex:
"sha256:91d61bdfcb3c2830a5af690b13e355c669a483b562ce2f5d82d3e53b2378bb00",
"sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3",
},
artifacts: {
nodeCommand: {

View File

@ -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",

View File

@ -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",

View File

@ -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");
}

View File

@ -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
);

View File

@ -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);
}
});

View File

@ -653,7 +653,7 @@ describe("remote provider pack manifest", () => {
claude:
"sha256:9d73d1f0f121fb96cc8badb28c22d5bff02d8582eb2e40360a81c189e1b9422a",
codex:
"sha256:91d61bdfcb3c2830a5af690b13e355c669a483b562ce2f5d82d3e53b2378bb00",
"sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3",
},
artifacts: {
nodeCommand: {

View File

@ -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",

View File

@ -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",

View File

@ -356,6 +356,7 @@ const TRANSIENT_INFRA_CONTINUATION_ERROR_CODES = new Set<string>([
]);
const NON_RETRYABLE_CONTINUATION_ERROR_CODES = new Set<string>([
"adapter_engine_unavailable",
"agent_not_invokable",
"agent_not_found",
"budget_blocked",
@ -458,6 +459,11 @@ export function classifyAdapterFailureForRecovery(
latestRun: Pick<NonNullable<LatestIssueRun>, "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" &&

View File

@ -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 && <Field label="Execution engine" hint="Auto uses ACP when prerequisites pass and falls back to Claude CLI with diagnostics.">
{!managedSandboxOnly && <Field label="Execution engine" hint="Default uses ACP. If ACP is unavailable, the run fails with a setup error. Choose CLI explicitly to use it.">
<select
className={inputClass}
value={engine}
@ -107,7 +107,7 @@ export function ClaudeLocalAdvancedFields({
: mark("adapterConfig", "engine", value === "auto" ? undefined : value);
}}
>
<option value="auto">Auto (ACP preferred)</option>
<option value="auto">Default (ACP)</option>
<option value="cli">Claude CLI</option>
<option value="acp">ACP</option>
</select>

View File

@ -161,7 +161,7 @@ export function CodexLocalConfigFields({
{!hideEngineChoice && (
<Field
label="Execution engine"
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."
>
<select
className={inputClass}
@ -182,7 +182,7 @@ export function CodexLocalConfigFields({
);
}}
>
<option value="auto">Auto (ACP preferred)</option>
<option value="auto">Default (ACP)</option>
<option value="cli">Codex CLI</option>
<option value="acp">ACP</option>
</select>

View File

@ -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 && <Field label="Execution engine" hint="Auto uses ACP when prerequisites pass and falls back to Gemini CLI with diagnostics.">
{!managedSandboxOnly && <Field label="Execution engine" hint="Default uses ACP. If ACP is unavailable, the run fails with a setup error. Choose CLI explicitly to use it.">
<select
className={inputClass}
value={engine}
@ -47,7 +47,7 @@ export function GeminiLocalConfigFields({
: mark("adapterConfig", "engine", value === "auto" ? undefined : value);
}}
>
<option value="auto">Auto (ACP preferred)</option>
<option value="auto">Default (ACP)</option>
<option value="cli">Gemini CLI</option>
<option value="acp">ACP</option>
</select>