mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-06 10:48:12 +02:00
## Thinking Path > - Paperclip coordinates autonomous agent work across isolated company-scoped sessions > - The ACP remote lane stages workspaces and managed home state inside the sandbox so sessions can resume safely > - If a compatible resume re-staged everything every time, it would waste work and risk inconsistent session reuse behavior > - If an incompatible resume reused the wrong staged runtime, it could cross session boundaries or leak credentials > - This pull request keeps the session fingerprint as the scoping key and adds a staged-runtime cache keyed to that fingerprint > - Compatible resumes now reuse the already staged runtime while incompatible fingerprints stage fresh > - The benefit is faster safe resumes without weakening session isolation or credential separation ## Linked Issues or Issue Description ### Problem or motivation The ACP remote lane currently needs to preserve safe resume behavior without repeatedly re-staging work that is already valid for the same session. The failure mode to avoid is letting one session reuse another session's staged workspace or credentials. ### Proposed solution Keep the session fingerprint as the scoping key and add a staged-runtime cache keyed to that fingerprint. When the fingerprint matches, reuse the already staged workspace and managed home. When the fingerprint changes, stage fresh. ### Alternatives considered - Always restage on resume: safest mechanically, but wastes work and breaks the compatible-resume optimization. - Reuse without fingerprint scoping: too risky because it could cross session boundaries. ### Roadmap alignment This is a narrow implementation change for the ACP remote lane and does not duplicate any broader roadmap item I could find in `ROADMAP.md`. ### Additional context The change preserves the existing session fingerprint contents and codex auth copy-back cadence while adding tests for compatible reuse, incompatible fresh staging, no cross-session credential reuse, and failed-turn eviction. ## What Changed - Added a staged-runtime cache in the ACP remote execution path keyed by the session fingerprint. - Reused the existing staged workspace and managed home for compatible resumes. - Kept incompatible fingerprints on the fresh staging path. - Preserved the existing session fingerprint contents and codex auth copy-back cadence. - Added tests for compatible reuse, incompatible fresh staging, no cross-session credential reuse, and failed-turn eviction. ## Verification - `pnpm exec vitest run packages/adapter-utils/src/acpx-engine/execute.test.ts` (74/74 pass, including the active-turn lease regression) - `pnpm exec tsc -p packages/adapter-utils/tsconfig.json --noEmit` - Verified the PR touches only `packages/adapter-utils/src/acpx-engine/execute.ts` and `packages/adapter-utils/src/acpx-engine/execute.test.ts` ## Risks - A cache eviction bug could cause an unavailable or partially staged runtime to be reused. - If the fingerprint scoping regressed, a session could incorrectly reuse another session's state. - The change is isolated to the ACP remote lane, but it still affects resume behavior for that path. ## Model Used OpenAI Codex, GPT-5, reasoning-capable coding agent, tool-enabled 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 - [ ] 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>
604 lines
23 KiB
TypeScript
604 lines
23 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import type {
|
|
AdapterBillingType,
|
|
AdapterEnvironmentCheck,
|
|
AdapterEnvironmentTestContext,
|
|
AdapterEnvironmentTestResult,
|
|
AdapterExecutionContext,
|
|
AdapterExecutionResult,
|
|
} from "@paperclipai/adapter-utils";
|
|
import {
|
|
parseLocalProcessFilesystemScope,
|
|
parseLocalProcessNetworkScope,
|
|
} from "@paperclipai/adapter-utils/local-process-sandbox";
|
|
import { inferOpenAiCompatibleBiller } from "@paperclipai/adapter-utils";
|
|
import {
|
|
ensureAdapterExecutionTargetCommandResolvable,
|
|
readAdapterExecutionTarget,
|
|
resolveAdapterExecutionTargetCwd,
|
|
} from "@paperclipai/adapter-utils/execution-target";
|
|
import {
|
|
DEFAULT_ACP_ENGINE_MODE,
|
|
DEFAULT_ACP_ENGINE_NON_INTERACTIVE_PERMISSIONS,
|
|
DEFAULT_ACP_ENGINE_PERMISSION_MODE,
|
|
DEFAULT_ACP_ENGINE_WARM_HANDLE_IDLE_MS,
|
|
} from "@paperclipai/adapter-utils/acpx-engine/constants";
|
|
import type {
|
|
AcpxEngineExecutorOptions,
|
|
AcpxRemoteManagedHomeContext,
|
|
AcpxRemoteManagedHomeResult,
|
|
} from "@paperclipai/adapter-utils/acpx-engine/execute";
|
|
import {
|
|
asNumber,
|
|
asString,
|
|
parseObject,
|
|
} from "@paperclipai/adapter-utils/server-utils";
|
|
import { classifyCodexAuthRefreshFailure } from "./parse.js";
|
|
import { copyBackCodexAuth } from "./codex-auth-copyback.js";
|
|
import { buildCodexAuthInboundProvision } from "./codex-auth-merge-scripts.js";
|
|
import {
|
|
resolveSharedCodexHomeDir,
|
|
stageCodexHomeForSync,
|
|
} from "./codex-home.js";
|
|
|
|
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
|
const packageRootDir = path.resolve(moduleDir, "../..");
|
|
const MIN_ACP_NODE_VERSION = "22.13.0";
|
|
|
|
export type CodexExecutionEngine = "cli" | "acp";
|
|
|
|
export interface CodexEngineSelection {
|
|
engine: CodexExecutionEngine;
|
|
explicit: boolean;
|
|
fallbackReason?: string;
|
|
}
|
|
|
|
type CodexEngineResolutionInput =
|
|
Pick<AdapterExecutionContext, "config"> &
|
|
Partial<Pick<AdapterExecutionContext, "executionTarget" | "executionTransport">>;
|
|
|
|
type CodexAcpExecutorOptions = Omit<
|
|
AcpxEngineExecutorOptions,
|
|
"adapterType" | "moduleDir" | "packageRootDir"
|
|
>;
|
|
|
|
type CodexAcpExecutor = (ctx: AdapterExecutionContext) => Promise<AdapterExecutionResult>;
|
|
|
|
function normalizeEngine(value: unknown): CodexEngineSelection {
|
|
const raw = typeof value === "string" ? value.trim().toLowerCase() : "";
|
|
if (raw === "acp") return { engine: "acp", explicit: true };
|
|
if (raw === "cli") return { engine: "cli", explicit: true };
|
|
return { engine: "acp", explicit: false };
|
|
}
|
|
|
|
export function resolveCodexExecutionEngine(config: Record<string, unknown>): CodexEngineSelection {
|
|
return normalizeEngine(config.engine);
|
|
}
|
|
|
|
export async function resolveCodexExecutionEngineForRun(
|
|
input: CodexEngineResolutionInput,
|
|
): Promise<CodexEngineSelection> {
|
|
const selection = normalizeEngine(input.config.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 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." }
|
|
: {}),
|
|
};
|
|
}
|
|
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`;
|
|
}
|
|
|
|
function firstNonEmptyString(...values: unknown[]): string | undefined {
|
|
for (const value of values) {
|
|
if (typeof value !== "string") continue;
|
|
const trimmed = value.trim();
|
|
if (trimmed.length > 0) return trimmed;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
export function buildCodexAcpConfig(config: Record<string, unknown>): Record<string, unknown> {
|
|
const agentCommand = firstNonEmptyString(config.agentCommand, config.acpAgentCommand);
|
|
const stateDir = firstNonEmptyString(config.stateDir, config.acpStateDir);
|
|
const mode = firstNonEmptyString(config.mode, config.acpMode) ?? DEFAULT_ACP_ENGINE_MODE;
|
|
const permissionMode =
|
|
firstNonEmptyString(config.permissionMode, config.acpPermissionMode) ??
|
|
DEFAULT_ACP_ENGINE_PERMISSION_MODE;
|
|
const nonInteractivePermissions =
|
|
firstNonEmptyString(config.nonInteractivePermissions, config.acpNonInteractivePermissions) ??
|
|
DEFAULT_ACP_ENGINE_NON_INTERACTIVE_PERMISSIONS;
|
|
const warmHandleIdleMs =
|
|
config.warmHandleIdleMs ??
|
|
config.acpWarmHandleIdleMs ??
|
|
DEFAULT_ACP_ENGINE_WARM_HANDLE_IDLE_MS;
|
|
|
|
return {
|
|
...config,
|
|
agent: "codex",
|
|
mode,
|
|
permissionMode,
|
|
nonInteractivePermissions,
|
|
warmHandleIdleMs,
|
|
...(agentCommand ? { agentCommand } : {}),
|
|
...(stateDir ? { stateDir } : {}),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Codex remote managed-home seed + auth copy-back for the runner-backed remote
|
|
* sandbox ACP lane. Mirrors the codex CLI lane (`codex-local/execute.ts`): stage
|
|
* the managed `CODEX_HOME` (auth.json + config.toml + skills) into the sandbox
|
|
* as the `home` asset — carrying the inbound auth-merge `provision` and the
|
|
* outbound `restore` copy-back seams — then repoint `CODEX_HOME` onto the
|
|
* in-sandbox `assetDirs.home` path. The copy-back rides the asset `restore`,
|
|
* which fires inside `restoreWorkspace()` at teardown.
|
|
*
|
|
* The engine already resolved+seeded the host managed Codex home and set
|
|
* `env.CODEX_HOME` to it (a HOST path) before this seam runs, so `env.CODEX_HOME`
|
|
* is exactly the home to stage. Seed inbound and copy-back outbound land together
|
|
* (never seed-without-copy-back): Codex refresh tokens are single-use, so a
|
|
* refreshed sandbox token that is never copied back would spend the host's token
|
|
* and corrupt the host credential.
|
|
*/
|
|
async function prepareCodexRemoteManagedHome(
|
|
input: AcpxRemoteManagedHomeContext,
|
|
): Promise<AcpxRemoteManagedHomeResult> {
|
|
const { env, runId, onLog } = input;
|
|
// The host managed Codex home the engine seeded and set on env.CODEX_HOME.
|
|
const effectiveCodexHome = env.CODEX_HOME;
|
|
if (!effectiveCodexHome) {
|
|
// No managed home resolved (e.g. custom CODEX_HOME cleared) — stage the
|
|
// workspace with no home asset, identical to the no-seam fallback.
|
|
return { stagedRuntime: await input.stage([]) };
|
|
}
|
|
// Curated allowlist temp dir (auth/config/skills only); caller owns cleanup.
|
|
const stagedCodexHomeDir = await stageCodexHomeForSync(effectiveCodexHome, { runId });
|
|
let stagedRuntime;
|
|
try {
|
|
stagedRuntime = await input.stage([
|
|
{
|
|
key: "home",
|
|
localDir: stagedCodexHomeDir,
|
|
followSymlinks: true,
|
|
// Inbound (host→sandbox) auth-merge: keeps whichever credential is newer
|
|
// when the sandbox image already carries a Codex auth.json.
|
|
provision: buildCodexAuthInboundProvision(),
|
|
// Outbound (sandbox→host) copy-back at teardown, under the same
|
|
// direction-agnostic decision predicate + directory merge-lock +
|
|
// atomic-rename + 0600 guard. Target is the SHARED host auth.json
|
|
// (the symlink source managed homes point at), never an in-sandbox copy.
|
|
restore: async ({ assetDir, readFile }) =>
|
|
void (await copyBackCodexAuth({
|
|
readSandboxAuth: () => readFile(path.posix.join(assetDir, "auth.json")),
|
|
hostAuthPath: path.join(resolveSharedCodexHomeDir(process.env), "auth.json"),
|
|
log: (line) => onLog("stdout", `${line}\n`),
|
|
})),
|
|
},
|
|
]);
|
|
} catch (err) {
|
|
await fs.rm(stagedCodexHomeDir, { recursive: true, force: true }).catch(() => {});
|
|
throw err;
|
|
}
|
|
// Repoint CODEX_HOME from the HOST path onto the seeded in-sandbox home.
|
|
env.CODEX_HOME =
|
|
stagedRuntime.assetDirs.home ??
|
|
path.posix.join(stagedRuntime.runtimeRootDir ?? "", "home");
|
|
|
|
return {
|
|
stagedRuntime,
|
|
// Per-run copy-back: fires on EVERY run's teardown (including a compatible
|
|
// resume that reuses this staged runtime). It reads the sandbox auth.json /
|
|
// workspace live and copies back to the host; it does NOT remove the staged
|
|
// in-sandbox home, so re-running it across resumes can't leave a later run
|
|
// without its staged home. Host staged-temp removal is deliberately NOT here
|
|
// — see `disposeStaged` — so caching this runtime for reuse never destroys
|
|
// resources the next resume needs.
|
|
teardown: async () => {
|
|
try {
|
|
await onLog(
|
|
"stdout",
|
|
"[paperclip] Restoring workspace changes and Codex auth from the sandbox.\n",
|
|
);
|
|
await stagedRuntime.restoreWorkspace((line) => onLog("stdout", line));
|
|
} catch (err) {
|
|
// Fail-soft: a teardown copy-back miss loses this rotation and surfaces
|
|
// loudly as refresh_token_reused on the next host Codex use (re-auth
|
|
// recovers) — never silent host-credential corruption, so it must not
|
|
// mask the run result.
|
|
await onLog(
|
|
"stderr",
|
|
`[paperclip] Codex ACP teardown restore/copy-back failed: ${
|
|
err instanceof Error ? err.message : String(err)
|
|
}\n`,
|
|
);
|
|
}
|
|
},
|
|
// One-time cleanup of the HOST staged home temp dir. Fired ONLY when the
|
|
// staged runtime is dropped (failed/cancelled/timed-out turn, incompatible
|
|
// re-stage, idle eviction) — never on a clean turn that keeps the runtime
|
|
// warm — so it can't remove the staged home while a reuse still depends on
|
|
// it. Idempotent: `force: true` no-ops if it was already removed.
|
|
disposeStaged: async () => {
|
|
await fs.rm(stagedCodexHomeDir, { recursive: true, force: true }).catch(async (error) => {
|
|
await onLog(
|
|
"stderr",
|
|
`[paperclip] Failed to remove staged Codex home "${stagedCodexHomeDir}": ${
|
|
error instanceof Error ? error.message : String(error)
|
|
}\n`,
|
|
);
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
function withCodexAcpDefaults(options: CodexAcpExecutorOptions): AcpxEngineExecutorOptions {
|
|
return {
|
|
resolveBillingIdentity: resolveCodexAcpBillingIdentity,
|
|
prepareRemoteManagedHome: prepareCodexRemoteManagedHome,
|
|
...options,
|
|
adapterType: "codex_local",
|
|
moduleDir,
|
|
packageRootDir,
|
|
};
|
|
}
|
|
|
|
function withCodexAuthRefreshFailureClassification(result: AdapterExecutionResult): AdapterExecutionResult {
|
|
if ((result.exitCode ?? 0) === 0) return result;
|
|
const resultJson = parseObject(result.resultJson);
|
|
const stopReason = asString(resultJson.stopReason, "");
|
|
const authFailure = classifyCodexAuthRefreshFailure({
|
|
errorMessage: [result.errorMessage ?? "", result.summary ?? "", stopReason]
|
|
.map((line) => line.trim())
|
|
.filter(Boolean)
|
|
.join("\n"),
|
|
});
|
|
if (!authFailure) return result;
|
|
|
|
return {
|
|
...result,
|
|
errorCode: authFailure,
|
|
errorFamily: authFailure,
|
|
resultJson: {
|
|
...(result.resultJson ?? {}),
|
|
errorFamily: authFailure,
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Classify billing the same way the Codex CLI lane does so ACP runs land in
|
|
* the cost ledger with a real provider/billingType instead of acpx/unknown.
|
|
* Host env only counts for local execution targets; remote targets see just
|
|
* the adapter-config env.
|
|
*/
|
|
export function resolveCodexAcpBillingIdentity(
|
|
ctx: Pick<AdapterExecutionContext, "config"> &
|
|
Partial<Pick<AdapterExecutionContext, "executionTarget" | "executionTransport">>,
|
|
): { provider: string; biller: string; billingType: AdapterBillingType } {
|
|
const envConfig = parseObject(parseObject(ctx.config).env);
|
|
const target = readAdapterExecutionTarget({
|
|
executionTarget: ctx.executionTarget,
|
|
legacyRemoteExecution: ctx.executionTransport?.remoteExecution,
|
|
});
|
|
const considerHostEnv = target?.kind !== "remote";
|
|
const mergedEnv: NodeJS.ProcessEnv = {
|
|
...(considerHostEnv ? process.env : {}),
|
|
...Object.fromEntries(
|
|
Object.entries(envConfig).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
|
|
),
|
|
};
|
|
const apiKey = typeof mergedEnv.OPENAI_API_KEY === "string" && mergedEnv.OPENAI_API_KEY.trim().length > 0;
|
|
const billingType: AdapterBillingType = apiKey ? "api" : "subscription";
|
|
const openAiCompatibleBiller = inferOpenAiCompatibleBiller(mergedEnv, "openai");
|
|
const biller =
|
|
openAiCompatibleBiller === "openrouter"
|
|
? "openrouter"
|
|
: billingType === "subscription"
|
|
? "chatgpt"
|
|
: openAiCompatibleBiller ?? "openai";
|
|
return { provider: "openai", biller, billingType };
|
|
}
|
|
|
|
export function createCodexAcpExecutor(options: CodexAcpExecutorOptions = {}): CodexAcpExecutor {
|
|
let executor: CodexAcpExecutor | null = null;
|
|
return async (ctx) => {
|
|
let currentExecutor = executor;
|
|
if (!currentExecutor) {
|
|
const { createAcpxEngineExecutor } = await import("@paperclipai/adapter-utils/acpx-engine/execute");
|
|
currentExecutor = createAcpxEngineExecutor(withCodexAcpDefaults(options));
|
|
executor = currentExecutor;
|
|
}
|
|
const result = await currentExecutor({
|
|
...ctx,
|
|
config: buildCodexAcpConfig(ctx.config),
|
|
});
|
|
return withCodexAuthRefreshFailureClassification(result);
|
|
};
|
|
}
|
|
|
|
function parseVersion(version: string): [number, number, number] {
|
|
const match = version.match(/^v?(\d+)\.(\d+)\.(\d+)/);
|
|
if (!match) return [0, 0, 0];
|
|
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
}
|
|
|
|
export function nodeVersionMeetsCodexAcpMinimum(version = process.version): boolean {
|
|
const [major, minor, patch] = parseVersion(version);
|
|
const [minMajor, minMinor, minPatch] = parseVersion(MIN_ACP_NODE_VERSION);
|
|
if (major !== minMajor) return major > minMajor;
|
|
if (minor !== minMinor) return minor > minMinor;
|
|
return patch >= minPatch;
|
|
}
|
|
|
|
async function pathExists(candidate: string): Promise<boolean> {
|
|
return fs.access(candidate).then(() => true).catch(() => false);
|
|
}
|
|
|
|
function hasPathSeparator(command: string): boolean {
|
|
return command.includes("/") || command.includes("\\");
|
|
}
|
|
|
|
function looksLikeShellCommand(command: string): boolean {
|
|
return /\s/.test(command.trim());
|
|
}
|
|
|
|
async function findCommandOnPath(binName: string): Promise<string | null> {
|
|
const pathValue = process.env.PATH ?? "";
|
|
for (const segment of pathValue.split(path.delimiter)) {
|
|
if (!segment) continue;
|
|
const candidate = path.join(segment, binName);
|
|
if (await pathExists(candidate)) return candidate;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function findAncestorBin(startDir: string, binName: string): Promise<string | null> {
|
|
let current = path.resolve(startDir);
|
|
while (true) {
|
|
const candidate = path.join(current, "node_modules", ".bin", binName);
|
|
if (await pathExists(candidate)) return candidate;
|
|
const parent = path.dirname(current);
|
|
if (parent === current) return null;
|
|
current = parent;
|
|
}
|
|
}
|
|
|
|
async function commandIsResolvable(
|
|
command: string,
|
|
input?: CodexEngineResolutionInput,
|
|
): Promise<boolean> {
|
|
const trimmed = command.trim();
|
|
if (!trimmed) return false;
|
|
if (looksLikeShellCommand(trimmed)) return true;
|
|
const target = readAdapterExecutionTarget({
|
|
executionTarget: input?.executionTarget,
|
|
legacyRemoteExecution: input?.executionTransport?.remoteExecution,
|
|
});
|
|
if (target?.kind === "remote") {
|
|
try {
|
|
await ensureAdapterExecutionTargetCommandResolvable(
|
|
trimmed,
|
|
target,
|
|
resolveAdapterExecutionTargetCwd(target, asString(input?.config.cwd, ""), process.cwd()),
|
|
process.env,
|
|
);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
if (path.isAbsolute(trimmed) || hasPathSeparator(trimmed)) return pathExists(trimmed);
|
|
return (await findCommandOnPath(trimmed)) !== null;
|
|
}
|
|
|
|
async function resolveCodexAcpCommand(config: Record<string, unknown>): Promise<string> {
|
|
const configured = firstNonEmptyString(config.agentCommand, config.acpAgentCommand);
|
|
if (configured) return configured;
|
|
return (
|
|
(await findAncestorBin(packageRootDir, "codex-acp")) ??
|
|
(await findCommandOnPath("codex-acp")) ??
|
|
path.join(packageRootDir, "node_modules", ".bin", "codex-acp")
|
|
);
|
|
}
|
|
|
|
function sandboxTargetHasProcessSessionBridge(
|
|
target: ReturnType<typeof readAdapterExecutionTarget>,
|
|
): boolean {
|
|
return target?.kind === "remote" && target.transport === "sandbox" && Boolean(target.runner);
|
|
}
|
|
|
|
async function resolveCodexAcpCommandForTarget(
|
|
config: Record<string, unknown>,
|
|
target: ReturnType<typeof readAdapterExecutionTarget>,
|
|
): Promise<string> {
|
|
const configured = firstNonEmptyString(config.agentCommand, config.acpAgentCommand);
|
|
if (configured) return configured;
|
|
if (target?.kind === "remote") return "codex-acp";
|
|
return resolveCodexAcpCommand(config);
|
|
}
|
|
|
|
async function defaultCodexAcpFallbackReason(
|
|
input: CodexEngineResolutionInput,
|
|
): Promise<string | null> {
|
|
const target = readAdapterExecutionTarget({
|
|
executionTarget: input.executionTarget,
|
|
legacyRemoteExecution: input.executionTransport?.remoteExecution,
|
|
});
|
|
if (target?.kind === "remote" && !sandboxTargetHasProcessSessionBridge(target)) {
|
|
if (target.transport === "sandbox") {
|
|
return "Codex ACP requires a bidirectional remote process target; this sandbox exposes only one-shot command execution.";
|
|
}
|
|
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.`;
|
|
}
|
|
const command = await resolveCodexAcpCommandForTarget(input.config, target);
|
|
if (!(await commandIsResolvable(command, input))) {
|
|
return `Codex ACP server command is not available: ${command}.`;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
|
|
if (checks.some((check) => check.level === "error")) return "fail";
|
|
if (checks.some((check) => check.level === "warn")) return "warn";
|
|
return "pass";
|
|
}
|
|
|
|
function isNonEmpty(value: unknown): value is string {
|
|
return typeof value === "string" && value.trim().length > 0;
|
|
}
|
|
|
|
async function hasCodexNativeCredentials(codexHome: string): Promise<boolean> {
|
|
const raw = await fs.readFile(path.join(codexHome, "auth.json"), "utf8").catch(() => null);
|
|
if (!raw) return false;
|
|
try {
|
|
const parsed = JSON.parse(raw) as unknown;
|
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return false;
|
|
const record = parsed as Record<string, unknown>;
|
|
return isNonEmpty(record.OPENAI_API_KEY) || isNonEmpty(record.refresh_token);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export async function testCodexAcpEnvironment(
|
|
ctx: AdapterEnvironmentTestContext,
|
|
): Promise<AdapterEnvironmentTestResult> {
|
|
const checks: AdapterEnvironmentCheck[] = [];
|
|
const config = parseObject(ctx.config);
|
|
const target = ctx.executionTarget ?? null;
|
|
const targetIsRemote = target?.kind === "remote";
|
|
|
|
checks.push({
|
|
code: "codex_engine_selected",
|
|
level: "info",
|
|
message: "Execution engine selected: ACP.",
|
|
hint: "Set engine=cli to use the existing Codex CLI lane.",
|
|
});
|
|
|
|
if (targetIsRemote) {
|
|
checks.push({
|
|
code: "codex_acp_remote_target",
|
|
level: "info",
|
|
message: "Codex ACP will run against the remote execution environment.",
|
|
hint: "Remote ACP requires a bidirectional process target such as SSH or Paperclip's sandbox process-session bridge.",
|
|
});
|
|
}
|
|
|
|
const cwd = asString(config.cwd, process.cwd());
|
|
try {
|
|
await fs.mkdir(cwd, { recursive: true });
|
|
checks.push({
|
|
code: "codex_acp_cwd_valid",
|
|
level: "info",
|
|
message: `Working directory is valid: ${cwd}`,
|
|
});
|
|
} catch (err) {
|
|
checks.push({
|
|
code: "codex_acp_cwd_invalid",
|
|
level: "error",
|
|
message: err instanceof Error ? err.message : "Invalid working directory",
|
|
detail: cwd,
|
|
});
|
|
}
|
|
|
|
checks.push({
|
|
code: nodeVersionMeetsCodexAcpMinimum() ? "codex_acp_node_supported" : "codex_acp_node_unsupported",
|
|
level: nodeVersionMeetsCodexAcpMinimum() ? "info" : "error",
|
|
message: nodeVersionMeetsCodexAcpMinimum()
|
|
? `Node ${process.version} satisfies ACP runtime requirements.`
|
|
: `Node ${process.version} does not satisfy ACP runtime requirements.`,
|
|
hint: nodeVersionMeetsCodexAcpMinimum()
|
|
? undefined
|
|
: `Run Codex ACP with Node >=${MIN_ACP_NODE_VERSION} or switch engine=cli.`,
|
|
});
|
|
|
|
const command = await resolveCodexAcpCommandForTarget(config, target);
|
|
const commandResolvable = await commandIsResolvable(command, {
|
|
config,
|
|
executionTarget: ctx.executionTarget,
|
|
});
|
|
checks.push({
|
|
code: commandResolvable ? "codex_acp_command_resolvable" : "codex_acp_command_missing",
|
|
level: commandResolvable ? "info" : "error",
|
|
message: commandResolvable
|
|
? `Codex ACP server command is executable: ${command}`
|
|
: `Codex ACP server command is not available: ${command}`,
|
|
hint: commandResolvable
|
|
? undefined
|
|
: "Install dependencies so @agentclientprotocol/codex-acp is present, or set agentCommand to a valid Codex ACP server command.",
|
|
});
|
|
|
|
const envConfig = parseObject(config.env);
|
|
const considerHostEnv = !targetIsRemote;
|
|
const configApiKey = envConfig.OPENAI_API_KEY;
|
|
const hostApiKey = considerHostEnv ? process.env.OPENAI_API_KEY : undefined;
|
|
if (isNonEmpty(configApiKey) || isNonEmpty(hostApiKey)) {
|
|
const source = isNonEmpty(configApiKey) ? "adapter config env" : "server environment";
|
|
checks.push({
|
|
code: "codex_acp_openai_api_key_detected",
|
|
level: "info",
|
|
message: "OPENAI_API_KEY is set for Codex ACP authentication.",
|
|
detail: `Detected in ${source}.`,
|
|
});
|
|
} else if (!targetIsRemote) {
|
|
const codexHome = isNonEmpty(envConfig.CODEX_HOME)
|
|
? envConfig.CODEX_HOME
|
|
: path.join(process.env.HOME ?? "", ".codex");
|
|
if (codexHome && await hasCodexNativeCredentials(codexHome)) {
|
|
checks.push({
|
|
code: "codex_acp_native_auth_detected",
|
|
level: "info",
|
|
message: "Codex ACP can use Codex native authentication.",
|
|
detail: `Credentials found in ${path.join(codexHome, "auth.json")}.`,
|
|
});
|
|
} else {
|
|
checks.push({
|
|
code: "codex_acp_credentials_missing",
|
|
level: "warn",
|
|
message: "No Codex ACP credentials were detected.",
|
|
hint: "Set OPENAI_API_KEY or run `codex login` before starting a Codex ACP agent.",
|
|
});
|
|
}
|
|
}
|
|
|
|
const mode = firstNonEmptyString(config.mode, config.acpMode) ?? DEFAULT_ACP_ENGINE_MODE;
|
|
const warmHandleIdleMs = asNumber(
|
|
config.warmHandleIdleMs ?? config.acpWarmHandleIdleMs,
|
|
DEFAULT_ACP_ENGINE_WARM_HANDLE_IDLE_MS,
|
|
);
|
|
checks.push({
|
|
code: "codex_acp_runtime_scaffold",
|
|
level: "info",
|
|
message: "Codex ACP runtime execution is available through the shared ACP engine.",
|
|
detail: `mode=${mode}; warmHandleIdleMs=${warmHandleIdleMs}`,
|
|
});
|
|
|
|
return {
|
|
adapterType: ctx.adapterType,
|
|
status: summarizeStatus(checks),
|
|
checks,
|
|
testedAt: new Date().toISOString(),
|
|
};
|
|
}
|