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:
parent
8cfd30fb07
commit
2991a59b17
|
|
@ -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`);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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.`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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.`,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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.`,
|
||||
|
|
|
|||
|
|
@ -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", "-"]);
|
||||
});
|
||||
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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("-");
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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.`,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.`,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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-");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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!({
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -310,7 +310,7 @@ try {
|
|||
claude:
|
||||
"sha256:9d73d1f0f121fb96cc8badb28c22d5bff02d8582eb2e40360a81c189e1b9422a",
|
||||
codex:
|
||||
"sha256:91d61bdfcb3c2830a5af690b13e355c669a483b562ce2f5d82d3e53b2378bb00",
|
||||
"sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3",
|
||||
},
|
||||
artifacts: {
|
||||
nodeCommand: {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
@ -653,7 +653,7 @@ describe("remote provider pack manifest", () => {
|
|||
claude:
|
||||
"sha256:9d73d1f0f121fb96cc8badb28c22d5bff02d8582eb2e40360a81c189e1b9422a",
|
||||
codex:
|
||||
"sha256:91d61bdfcb3c2830a5af690b13e355c669a483b562ce2f5d82d3e53b2378bb00",
|
||||
"sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3",
|
||||
},
|
||||
artifacts: {
|
||||
nodeCommand: {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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" &&
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue