feat(codex-local): env-driven gateway routing via PAPERCLIP_CODEX_PROVIDERS config.toml (#7919)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The `codex-local` adapter runs the OpenAI Codex CLI; Paperclip already maintains a managed `CODEX_HOME` per company and ships it to remote/sandboxed execution targets > - Deployments increasingly put an OpenAI-compatible LLM gateway between the harness and the model for cost, governance, or data-residency reasons: LiteLLM, OpenRouter, Portkey, Kong, a corporate proxy, self-hosted models (vLLM/Ollama), or region-pinned/sovereign endpoints. But Codex has no CLI flag or env var for a custom endpoint: its only mechanism is `[model_providers.<id>]` tables (with `base_url`, `env_key`, `wire_api`) in `$CODEX_HOME/config.toml`, selected by a root-level `model_provider` key > - Today there is no supported way to get such provider config into the managed `CODEX_HOME`, so gateway routing requires hand-editing files the adapter owns and regenerates > - This pull request adds the codex analogue of #7837's opencode mechanism: a `PAPERCLIP_CODEX_PROVIDERS` JSON env var whose shape maps 1:1 onto codex's TOML schema, merged into the managed `config.toml` so the existing asset-shipping + `env.CODEX_HOME` mechanics deliver it to local and sandboxed runs alike; nothing here is specific to one hosting setup > - The benefit is Codex works behind any OpenAI-compatible gateway with config only; with no env set, behavior is unchanged ## Linked Issues or Issue Description No existing issue; describing in-PR (feature / adapter enhancement). - **Gap:** there is no supported way to register a custom/gateway `[model_providers.*]` endpoint for `codex-local`. Codex's only custom-endpoint mechanism is `config.toml` (`base_url` + `env_key` + `wire_api`, selected via the root `model_provider` key), and the adapter owns/regenerates the managed `CODEX_HOME`, so operators cannot durably hand-edit it. - Related: #7837 (the opencode-local analogue of this change, same env-driven gateway-routing pattern). Searched for duplicate/related PRs: no existing codex-local gateway/provider-routing PR found. > Note on ROADMAP: this is adapter-level, opt-in config (defaults unchanged) that *enables* gateway routing for one harness; it is not the core "Cloud / Sandbox agents" platform work itself. ## What Changed - New `prepareCodexRuntimeConfig()` (`packages/adapters/codex-local/src/server/runtime-config.ts`): reads `PAPERCLIP_CODEX_PROVIDERS` (run env first, then `process.env`), shaped as `{"providers": {"<id>": {base_url, env_key, wire_api, ...}}, "model_provider": "<id>"}`, and merges it into the managed `CODEX_HOME`'s `config.toml`. No-op when unset or empty. - A malformed value (invalid JSON, not a JSON object, no `providers` object, no usable provider entries, or individual entries with empty names or non-object values, which are skipped by name) is never silently dropped: each case surfaces a distinct, user-visible note (via the prepare notes, which flow into command notes + `onLog`) and unusable input leaves `config.toml` untouched. - Merge is marker-delimited and TOML-correct: existing `config.toml` content is preserved between two managed blocks. Root keys (e.g. `model_provider`) are prepended **before the first table header** (TOML root-region rule), `[model_providers.*]` tables are appended. Pre-existing same-name provider sections and root `model_provider` keys are excised so the managed definitions win without duplicate-table parse errors. - `{env:VAR}` placeholders are expanded server-side for literal-credential fields; `env_key` indirection remains the preferred path. - Crash-safe restore: prepare writes a pre-run backup (`config.toml.paperclip-backup`) before the merged file; `cleanup()` restores the original in the execute `finally` and removes the backup. If a run never reaches `cleanup()` (a throw during the setup between prepare and execution, or SIGKILL), the next prepare restores the original from the backup with full fidelity, including user `[model_providers.*]` sections the merge excised (review feedback, P2); plain block-stripping remains the fallback for pre-backup state. - An explicit adapter-config `env.CODEX_HOME` override is treated as user-managed: no merge, surfaced as a command note. - Dependency-free hand-emitted TOML (strings/numbers/booleans, arrays of scalars, plain objects as inline tables); basic strings escape U+0000-U+001F and U+007F per TOML 1.0 (review feedback, P2). Merged output was additionally validated locally with python tomllib during development; the committed tests assert the structural invariants. - `execute.ts` wiring: `prepareCodexRuntimeConfig` runs after `prepareManagedCodexHome` (before the home ships to the remote target), notes surface via `onLog` + command notes, and the `finally` calls `cleanup()`. **Note for reviewers:** current codex removed `wire_api = "chat"` (openai/codex#10157, Feb 2026), so gateway provider configs must use `wire_api = "responses"`, i.e. the gateway must speak `/v1/responses`. The adapter passes the value through verbatim; this is a codex-side constraint worth knowing when configuring it. ## Verification - `pnpm --filter @paperclipai/adapter-codex-local build` and `typecheck`: tsc clean against current `master` - `pnpm exec vitest run packages/adapters/codex-local`: 45 passing (incl. 17 `runtime-config` tests: fresh-merge + cleanup restore, root-region placement, same-name provider override, inline tables/arrays, DEL escaping, `{env:}` expansion from run env + `process.env`, per-case malformed-input notes with `config.toml` untouched, skipped-entry notes alongside a successful merge, silent no-op when unset/empty, explicit-`CODEX_HOME` skip note, backup restore of excised user sections after an interrupted run, backup removal on cleanup, stale-block self-heal, re-run replacement) - Verified end-to-end: a codex agent in a hardened Kubernetes (gVisor) sandbox completed a real task routed through an OpenAI-compatible gateway's `/v1/responses`, with a billed usage row recorded on the gateway. That deployment supplies the verification evidence; the mechanism is gateway-agnostic. ## Risks Low. Entirely env-driven and opt-in; with `PAPERCLIP_CODEX_PROVIDERS` unset the adapter never touches `config.toml` and behavior is byte-identical to before. The merge preserves user content, restores the original file on cleanup, and survives interrupted runs via the pre-run backup; malformed input surfaces a visible note and is ignored without touching `config.toml`. No migration/UI impact. ## Model Used Claude Opus 4.8 (`claude-opus-4-8`, 1M context), extended thinking + tool use, via Claude Code. ## 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 (adapter-level opt-in config enabling gateway routing; not the core sandbox-platform work, noted above) - [x] I have searched GitHub for duplicate or related PRs and linked them above (#7837 is the opencode analogue; no codex-local duplicate found) - [x] I have either (a) linked existing issues OR (b) described the issue in-PR following the relevant issue template - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots (n/a, no UI) - [ ] I have updated relevant documentation to reflect my changes (env var documented inline; no central doc references the adapter env yet) - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green (green on the previous head; re-running on the final note-copy polish commit) - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups (both review P2s are fixed at head: the interrupted-run restore via the pre-run backup and the U+007F escaping; a re-review is requested for the note-copy polish) - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
6e4aca9c67
commit
9e750d3e92
@@ -45,6 +45,7 @@ import {
|
||||
isCodexUnknownSessionError,
|
||||
} from "./parse.js";
|
||||
import { pathExists, prepareManagedCodexHome, resolveManagedCodexHomeDir, resolveSharedCodexHomeDir } from "./codex-home.js";
|
||||
import { prepareCodexRuntimeConfig } from "./runtime-config.js";
|
||||
import { resolveCodexDesiredSkillNames } from "./skills.js";
|
||||
import { buildCodexExecArgs } from "./codex-args.js";
|
||||
import { SANDBOX_INSTALL_COMMAND } from "../index.js";
|
||||
@@ -348,276 +349,306 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
const defaultCodexHome = resolveManagedCodexHomeDir(process.env, agent.companyId);
|
||||
const effectiveCodexHome = configuredCodexHome ?? preparedManagedCodexHome ?? defaultCodexHome;
|
||||
await fs.mkdir(effectiveCodexHome, { recursive: true });
|
||||
// Inject skills into the same CODEX_HOME that Codex will actually run with
|
||||
// (managed home in the default case, or an explicit override from adapter config).
|
||||
const codexSkillsDir = resolveCodexSkillsDir(effectiveCodexHome);
|
||||
await ensureCodexSkillsInjected(
|
||||
onLog,
|
||||
{
|
||||
skillsHome: codexSkillsDir,
|
||||
skillsEntries: codexSkillEntries,
|
||||
desiredSkillNames,
|
||||
},
|
||||
// Merge custom model providers (PAPERCLIP_CODEX_PROVIDERS) into the managed
|
||||
// CODEX_HOME's config.toml BEFORE the home is shipped to a remote execution
|
||||
// target, so both local and sandboxed Codex processes pick up the routing.
|
||||
// An explicit env.CODEX_HOME override is treated as user-managed and skipped.
|
||||
const envConfigStrings = Object.fromEntries(
|
||||
Object.entries(envConfig).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === "string",
|
||||
),
|
||||
);
|
||||
const timeoutSec = resolveAdapterExecutionTargetTimeoutSec(
|
||||
executionTarget,
|
||||
asNumber(config.timeoutSec, 0),
|
||||
);
|
||||
const graceSec = asNumber(config.graceSec, 20);
|
||||
let effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd);
|
||||
const preparedExecutionTargetRuntime = executionTargetIsRemote
|
||||
? await (async () => {
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[paperclip] Syncing workspace and CODEX_HOME to ${describeAdapterExecutionTarget(executionTarget)}.\n`,
|
||||
);
|
||||
return await prepareAdapterExecutionTargetRuntime({
|
||||
runId,
|
||||
target: executionTarget,
|
||||
adapterKey: "codex",
|
||||
timeoutSec,
|
||||
workspaceLocalDir: cwd,
|
||||
installCommand: SANDBOX_INSTALL_COMMAND,
|
||||
detectCommand: command,
|
||||
assets: [
|
||||
{
|
||||
key: "home",
|
||||
localDir: effectiveCodexHome,
|
||||
followSymlinks: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
})()
|
||||
: null;
|
||||
if (preparedExecutionTargetRuntime?.workspaceRemoteDir) {
|
||||
effectiveExecutionCwd = preparedExecutionTargetRuntime.workspaceRemoteDir;
|
||||
}
|
||||
const runtimeExecutionTarget = overrideAdapterExecutionTargetRemoteCwd(executionTarget, effectiveExecutionCwd);
|
||||
const executionTargetIsSandbox =
|
||||
runtimeExecutionTarget?.kind === "remote" && runtimeExecutionTarget.transport === "sandbox";
|
||||
const restoreRemoteWorkspace = preparedExecutionTargetRuntime
|
||||
? () => preparedExecutionTargetRuntime.restoreWorkspace()
|
||||
: null;
|
||||
let paperclipBridge: Awaited<ReturnType<typeof startAdapterExecutionTargetPaperclipBridge>> = null;
|
||||
const remoteCodexHome = executionTargetIsRemote
|
||||
? preparedExecutionTargetRuntime?.assetDirs.home ??
|
||||
path.posix.join(effectiveExecutionCwd, ".paperclip-runtime", "codex", "home")
|
||||
: null;
|
||||
const hasExplicitApiKey =
|
||||
typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0;
|
||||
const env: Record<string, string> = { ...buildPaperclipEnv(agent) };
|
||||
env.PAPERCLIP_RUN_ID = runId;
|
||||
const wakeTaskId =
|
||||
(typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) ||
|
||||
(typeof context.issueId === "string" && context.issueId.trim().length > 0 && context.issueId.trim()) ||
|
||||
null;
|
||||
const wakeReason =
|
||||
typeof context.wakeReason === "string" && context.wakeReason.trim().length > 0
|
||||
? context.wakeReason.trim()
|
||||
: null;
|
||||
const wakeCommentId =
|
||||
(typeof context.wakeCommentId === "string" && context.wakeCommentId.trim().length > 0 && context.wakeCommentId.trim()) ||
|
||||
(typeof context.commentId === "string" && context.commentId.trim().length > 0 && context.commentId.trim()) ||
|
||||
null;
|
||||
const approvalId =
|
||||
typeof context.approvalId === "string" && context.approvalId.trim().length > 0
|
||||
? context.approvalId.trim()
|
||||
: null;
|
||||
const approvalStatus =
|
||||
typeof context.approvalStatus === "string" && context.approvalStatus.trim().length > 0
|
||||
? context.approvalStatus.trim()
|
||||
: null;
|
||||
const linkedIssueIds = Array.isArray(context.issueIds)
|
||||
? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
||||
: [];
|
||||
const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake);
|
||||
const issueWorkMode = readPaperclipIssueWorkModeFromContext(context);
|
||||
if (wakeTaskId) {
|
||||
env.PAPERCLIP_TASK_ID = wakeTaskId;
|
||||
}
|
||||
if (issueWorkMode) {
|
||||
env.PAPERCLIP_ISSUE_WORK_MODE = issueWorkMode;
|
||||
}
|
||||
if (wakeReason) {
|
||||
env.PAPERCLIP_WAKE_REASON = wakeReason;
|
||||
}
|
||||
if (wakeCommentId) {
|
||||
env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId;
|
||||
}
|
||||
if (approvalId) {
|
||||
env.PAPERCLIP_APPROVAL_ID = approvalId;
|
||||
}
|
||||
if (approvalStatus) {
|
||||
env.PAPERCLIP_APPROVAL_STATUS = approvalStatus;
|
||||
}
|
||||
if (linkedIssueIds.length > 0) {
|
||||
env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
|
||||
}
|
||||
if (wakePayloadJson) {
|
||||
env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson;
|
||||
}
|
||||
refreshPaperclipWorkspaceEnvForExecution({
|
||||
env,
|
||||
envConfig,
|
||||
workspaceCwd: effectiveWorkspaceCwd,
|
||||
workspaceSource,
|
||||
workspaceStrategy,
|
||||
workspaceId,
|
||||
workspaceRepoUrl,
|
||||
workspaceRepoRef,
|
||||
workspaceBranch,
|
||||
workspaceWorktreePath,
|
||||
workspaceHints,
|
||||
agentHome,
|
||||
executionTargetIsRemote,
|
||||
executionCwd: effectiveExecutionCwd,
|
||||
const preparedRuntimeConfig = await prepareCodexRuntimeConfig({
|
||||
env: envConfigStrings,
|
||||
codexHome: configuredCodexHome ? null : effectiveCodexHome,
|
||||
});
|
||||
if (runtimeServiceIntents.length > 0) {
|
||||
env.PAPERCLIP_RUNTIME_SERVICE_INTENTS_JSON = JSON.stringify(runtimeServiceIntents);
|
||||
}
|
||||
if (runtimeServices.length > 0) {
|
||||
env.PAPERCLIP_RUNTIME_SERVICES_JSON = JSON.stringify(runtimeServices);
|
||||
}
|
||||
if (runtimePrimaryUrl) {
|
||||
env.PAPERCLIP_RUNTIME_PRIMARY_URL = runtimePrimaryUrl;
|
||||
}
|
||||
env.CODEX_HOME = remoteCodexHome ?? effectiveCodexHome;
|
||||
if (!hasExplicitApiKey && authToken) {
|
||||
env.PAPERCLIP_API_KEY = authToken;
|
||||
}
|
||||
if (executionTargetIsRemote && adapterExecutionTargetUsesPaperclipBridge(runtimeExecutionTarget)) {
|
||||
paperclipBridge = await startAdapterExecutionTargetPaperclipBridge({
|
||||
try {
|
||||
for (const note of preparedRuntimeConfig.notes) {
|
||||
await onLog("stdout", `[paperclip] ${note}\n`);
|
||||
}
|
||||
// Inject skills into the same CODEX_HOME that Codex will actually run with
|
||||
// (managed home in the default case, or an explicit override from adapter config).
|
||||
const codexSkillsDir = resolveCodexSkillsDir(effectiveCodexHome);
|
||||
await ensureCodexSkillsInjected(
|
||||
onLog,
|
||||
{
|
||||
skillsHome: codexSkillsDir,
|
||||
skillsEntries: codexSkillEntries,
|
||||
desiredSkillNames,
|
||||
},
|
||||
);
|
||||
const timeoutSec = resolveAdapterExecutionTargetTimeoutSec(
|
||||
executionTarget,
|
||||
asNumber(config.timeoutSec, 0),
|
||||
);
|
||||
const graceSec = asNumber(config.graceSec, 20);
|
||||
let effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd);
|
||||
const preparedExecutionTargetRuntime = executionTargetIsRemote
|
||||
? await (async () => {
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[paperclip] Syncing workspace and CODEX_HOME to ${describeAdapterExecutionTarget(executionTarget)}.\n`,
|
||||
);
|
||||
return await prepareAdapterExecutionTargetRuntime({
|
||||
runId,
|
||||
target: executionTarget,
|
||||
adapterKey: "codex",
|
||||
timeoutSec,
|
||||
workspaceLocalDir: cwd,
|
||||
installCommand: SANDBOX_INSTALL_COMMAND,
|
||||
detectCommand: command,
|
||||
assets: [
|
||||
{
|
||||
key: "home",
|
||||
localDir: effectiveCodexHome,
|
||||
followSymlinks: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
})()
|
||||
: null;
|
||||
if (preparedExecutionTargetRuntime?.workspaceRemoteDir) {
|
||||
effectiveExecutionCwd = preparedExecutionTargetRuntime.workspaceRemoteDir;
|
||||
}
|
||||
const runtimeExecutionTarget = overrideAdapterExecutionTargetRemoteCwd(executionTarget, effectiveExecutionCwd);
|
||||
const executionTargetIsSandbox =
|
||||
runtimeExecutionTarget?.kind === "remote" && runtimeExecutionTarget.transport === "sandbox";
|
||||
const restoreRemoteWorkspace = preparedExecutionTargetRuntime
|
||||
? () => preparedExecutionTargetRuntime.restoreWorkspace()
|
||||
: null;
|
||||
let paperclipBridge: Awaited<ReturnType<typeof startAdapterExecutionTargetPaperclipBridge>> = null;
|
||||
const remoteCodexHome = executionTargetIsRemote
|
||||
? preparedExecutionTargetRuntime?.assetDirs.home ??
|
||||
path.posix.join(effectiveExecutionCwd, ".paperclip-runtime", "codex", "home")
|
||||
: null;
|
||||
const hasExplicitApiKey =
|
||||
typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0;
|
||||
const env: Record<string, string> = { ...buildPaperclipEnv(agent) };
|
||||
env.PAPERCLIP_RUN_ID = runId;
|
||||
const wakeTaskId =
|
||||
(typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) ||
|
||||
(typeof context.issueId === "string" && context.issueId.trim().length > 0 && context.issueId.trim()) ||
|
||||
null;
|
||||
const wakeReason =
|
||||
typeof context.wakeReason === "string" && context.wakeReason.trim().length > 0
|
||||
? context.wakeReason.trim()
|
||||
: null;
|
||||
const wakeCommentId =
|
||||
(typeof context.wakeCommentId === "string" && context.wakeCommentId.trim().length > 0 && context.wakeCommentId.trim()) ||
|
||||
(typeof context.commentId === "string" && context.commentId.trim().length > 0 && context.commentId.trim()) ||
|
||||
null;
|
||||
const approvalId =
|
||||
typeof context.approvalId === "string" && context.approvalId.trim().length > 0
|
||||
? context.approvalId.trim()
|
||||
: null;
|
||||
const approvalStatus =
|
||||
typeof context.approvalStatus === "string" && context.approvalStatus.trim().length > 0
|
||||
? context.approvalStatus.trim()
|
||||
: null;
|
||||
const linkedIssueIds = Array.isArray(context.issueIds)
|
||||
? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
||||
: [];
|
||||
const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake);
|
||||
const issueWorkMode = readPaperclipIssueWorkModeFromContext(context);
|
||||
if (wakeTaskId) {
|
||||
env.PAPERCLIP_TASK_ID = wakeTaskId;
|
||||
}
|
||||
if (issueWorkMode) {
|
||||
env.PAPERCLIP_ISSUE_WORK_MODE = issueWorkMode;
|
||||
}
|
||||
if (wakeReason) {
|
||||
env.PAPERCLIP_WAKE_REASON = wakeReason;
|
||||
}
|
||||
if (wakeCommentId) {
|
||||
env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId;
|
||||
}
|
||||
if (approvalId) {
|
||||
env.PAPERCLIP_APPROVAL_ID = approvalId;
|
||||
}
|
||||
if (approvalStatus) {
|
||||
env.PAPERCLIP_APPROVAL_STATUS = approvalStatus;
|
||||
}
|
||||
if (linkedIssueIds.length > 0) {
|
||||
env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
|
||||
}
|
||||
if (wakePayloadJson) {
|
||||
env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson;
|
||||
}
|
||||
refreshPaperclipWorkspaceEnvForExecution({
|
||||
env,
|
||||
envConfig,
|
||||
workspaceCwd: effectiveWorkspaceCwd,
|
||||
workspaceSource,
|
||||
workspaceStrategy,
|
||||
workspaceId,
|
||||
workspaceRepoUrl,
|
||||
workspaceRepoRef,
|
||||
workspaceBranch,
|
||||
workspaceWorktreePath,
|
||||
workspaceHints,
|
||||
agentHome,
|
||||
executionTargetIsRemote,
|
||||
executionCwd: effectiveExecutionCwd,
|
||||
});
|
||||
if (runtimeServiceIntents.length > 0) {
|
||||
env.PAPERCLIP_RUNTIME_SERVICE_INTENTS_JSON = JSON.stringify(runtimeServiceIntents);
|
||||
}
|
||||
if (runtimeServices.length > 0) {
|
||||
env.PAPERCLIP_RUNTIME_SERVICES_JSON = JSON.stringify(runtimeServices);
|
||||
}
|
||||
if (runtimePrimaryUrl) {
|
||||
env.PAPERCLIP_RUNTIME_PRIMARY_URL = runtimePrimaryUrl;
|
||||
}
|
||||
env.CODEX_HOME = remoteCodexHome ?? effectiveCodexHome;
|
||||
if (!hasExplicitApiKey && authToken) {
|
||||
env.PAPERCLIP_API_KEY = authToken;
|
||||
}
|
||||
if (executionTargetIsRemote && adapterExecutionTargetUsesPaperclipBridge(runtimeExecutionTarget)) {
|
||||
paperclipBridge = await startAdapterExecutionTargetPaperclipBridge({
|
||||
runId,
|
||||
target: runtimeExecutionTarget,
|
||||
runtimeRootDir: preparedExecutionTargetRuntime?.runtimeRootDir,
|
||||
adapterKey: "codex",
|
||||
timeoutSec,
|
||||
hostApiToken: env.PAPERCLIP_API_KEY,
|
||||
onLog,
|
||||
});
|
||||
if (paperclipBridge) {
|
||||
Object.assign(env, paperclipBridge.env);
|
||||
}
|
||||
}
|
||||
const effectiveEnv = Object.fromEntries(
|
||||
Object.entries({ ...process.env, ...env }).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === "string",
|
||||
),
|
||||
);
|
||||
const billingType = resolveCodexBillingType(effectiveEnv);
|
||||
const runtimeEnv = Object.fromEntries(
|
||||
Object.entries(ensurePathInEnv(effectiveEnv)).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === "string",
|
||||
),
|
||||
);
|
||||
await ensureAdapterExecutionTargetRuntimeCommandInstalled({
|
||||
runId,
|
||||
target: runtimeExecutionTarget,
|
||||
runtimeRootDir: preparedExecutionTargetRuntime?.runtimeRootDir,
|
||||
adapterKey: "codex",
|
||||
target: executionTarget,
|
||||
installCommand: ctx.runtimeCommandSpec?.installCommand,
|
||||
detectCommand: ctx.runtimeCommandSpec?.detectCommand,
|
||||
cwd,
|
||||
env: runtimeEnv,
|
||||
timeoutSec,
|
||||
hostApiToken: env.PAPERCLIP_API_KEY,
|
||||
graceSec,
|
||||
onLog,
|
||||
});
|
||||
if (paperclipBridge) {
|
||||
Object.assign(env, paperclipBridge.env);
|
||||
}
|
||||
}
|
||||
const effectiveEnv = Object.fromEntries(
|
||||
Object.entries({ ...process.env, ...env }).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === "string",
|
||||
),
|
||||
);
|
||||
const billingType = resolveCodexBillingType(effectiveEnv);
|
||||
const runtimeEnv = Object.fromEntries(
|
||||
Object.entries(ensurePathInEnv(effectiveEnv)).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === "string",
|
||||
),
|
||||
);
|
||||
await ensureAdapterExecutionTargetRuntimeCommandInstalled({
|
||||
runId,
|
||||
target: executionTarget,
|
||||
installCommand: ctx.runtimeCommandSpec?.installCommand,
|
||||
detectCommand: ctx.runtimeCommandSpec?.detectCommand,
|
||||
cwd,
|
||||
env: runtimeEnv,
|
||||
timeoutSec,
|
||||
graceSec,
|
||||
onLog,
|
||||
});
|
||||
await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv);
|
||||
const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs(command, executionTarget, cwd, runtimeEnv);
|
||||
const loggedEnv = buildInvocationEnvForLogs(env, {
|
||||
runtimeEnv,
|
||||
includeRuntimeKeys: ["HOME"],
|
||||
resolvedCommand,
|
||||
});
|
||||
await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv);
|
||||
const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs(command, executionTarget, cwd, runtimeEnv);
|
||||
const loggedEnv = buildInvocationEnvForLogs(env, {
|
||||
runtimeEnv,
|
||||
includeRuntimeKeys: ["HOME"],
|
||||
resolvedCommand,
|
||||
});
|
||||
|
||||
const runtimeSessionParams = parseObject(runtime.sessionParams);
|
||||
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
|
||||
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
|
||||
const runtimeRemoteExecution = parseObject(runtimeSessionParams.remoteExecution);
|
||||
const canResumeSession =
|
||||
runtimeSessionId.length > 0 &&
|
||||
(runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(effectiveExecutionCwd)) &&
|
||||
adapterExecutionTargetSessionMatches(runtimeRemoteExecution, runtimeExecutionTarget);
|
||||
const codexTransientFallbackMode = readCodexTransientFallbackMode(context);
|
||||
const forceSaferInvocation = fallbackModeUsesSaferInvocation(codexTransientFallbackMode);
|
||||
const forceFreshSession = fallbackModeUsesFreshSession(codexTransientFallbackMode);
|
||||
const sessionId = canResumeSession && !forceFreshSession ? runtimeSessionId : null;
|
||||
if (executionTargetIsRemote && runtimeSessionId && !canResumeSession) {
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[paperclip] Codex session "${runtimeSessionId}" does not match the current remote execution identity and will not be resumed in "${effectiveExecutionCwd}". Starting a fresh remote session.\n`,
|
||||
);
|
||||
} else if (runtimeSessionId && !canResumeSession) {
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[paperclip] Codex session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${effectiveExecutionCwd}".\n`,
|
||||
);
|
||||
}
|
||||
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
|
||||
const instructionsDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : "";
|
||||
let instructionsPrefix = "";
|
||||
let instructionsChars = 0;
|
||||
if (instructionsFilePath) {
|
||||
try {
|
||||
const instructionsContents = await fs.readFile(instructionsFilePath, "utf8");
|
||||
instructionsPrefix =
|
||||
`${instructionsContents}\n\n` +
|
||||
`The above agent instructions were loaded from ${instructionsFilePath}. ` +
|
||||
`Resolve any relative file references from ${instructionsDir}.\n\n`;
|
||||
instructionsChars = instructionsPrefix.length;
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
const runtimeSessionParams = parseObject(runtime.sessionParams);
|
||||
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
|
||||
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
|
||||
const runtimeRemoteExecution = parseObject(runtimeSessionParams.remoteExecution);
|
||||
const canResumeSession =
|
||||
runtimeSessionId.length > 0 &&
|
||||
(runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(effectiveExecutionCwd)) &&
|
||||
adapterExecutionTargetSessionMatches(runtimeRemoteExecution, runtimeExecutionTarget);
|
||||
const codexTransientFallbackMode = readCodexTransientFallbackMode(context);
|
||||
const forceSaferInvocation = fallbackModeUsesSaferInvocation(codexTransientFallbackMode);
|
||||
const forceFreshSession = fallbackModeUsesFreshSession(codexTransientFallbackMode);
|
||||
const sessionId = canResumeSession && !forceFreshSession ? runtimeSessionId : null;
|
||||
if (executionTargetIsRemote && runtimeSessionId && !canResumeSession) {
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[paperclip] Warning: could not read agent instructions file "${instructionsFilePath}": ${reason}\n`,
|
||||
`[paperclip] Codex session "${runtimeSessionId}" does not match the current remote execution identity and will not be resumed in "${effectiveExecutionCwd}". Starting a fresh remote session.\n`,
|
||||
);
|
||||
} else if (runtimeSessionId && !canResumeSession) {
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[paperclip] Codex session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${effectiveExecutionCwd}".\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const repoAgentsNote =
|
||||
"Codex exec automatically applies repo-scoped AGENTS.md instructions from the current workspace; Paperclip does not currently suppress that discovery.";
|
||||
const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, "");
|
||||
const templateData = {
|
||||
agentId: agent.id,
|
||||
companyId: agent.companyId,
|
||||
runId,
|
||||
company: { id: agent.companyId },
|
||||
agent,
|
||||
run: { id: runId, source: "on_demand" },
|
||||
context,
|
||||
};
|
||||
const renderedBootstrapPrompt =
|
||||
!sessionId && bootstrapPromptTemplate.trim().length > 0
|
||||
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
|
||||
: "";
|
||||
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: Boolean(sessionId) });
|
||||
const shouldUseResumeDeltaPrompt = Boolean(sessionId) && wakePrompt.length > 0;
|
||||
const promptInstructionsPrefix = shouldUseResumeDeltaPrompt ? "" : instructionsPrefix;
|
||||
instructionsChars = promptInstructionsPrefix.length;
|
||||
const continuationSummary = parseObject(context.paperclipContinuationSummary);
|
||||
const continuationSummaryBody = asString(continuationSummary.body, "").trim() || null;
|
||||
const codexFallbackHandoffNote =
|
||||
forceFreshSession
|
||||
? buildCodexTransientHandoffNote({
|
||||
previousSessionId: runtimeSessionId || runtime.sessionId || null,
|
||||
fallbackMode: codexTransientFallbackMode ?? "fresh_session",
|
||||
continuationSummaryBody,
|
||||
})
|
||||
: "";
|
||||
const commandNotes = (() => {
|
||||
if (!instructionsFilePath) {
|
||||
const notes = [repoAgentsNote];
|
||||
if (forceSaferInvocation) {
|
||||
notes.push("Codex transient fallback requested safer invocation settings for this retry.");
|
||||
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
|
||||
const instructionsDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : "";
|
||||
let instructionsPrefix = "";
|
||||
let instructionsChars = 0;
|
||||
if (instructionsFilePath) {
|
||||
try {
|
||||
const instructionsContents = await fs.readFile(instructionsFilePath, "utf8");
|
||||
instructionsPrefix =
|
||||
`${instructionsContents}\n\n` +
|
||||
`The above agent instructions were loaded from ${instructionsFilePath}. ` +
|
||||
`Resolve any relative file references from ${instructionsDir}.\n\n`;
|
||||
instructionsChars = instructionsPrefix.length;
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[paperclip] Warning: could not read agent instructions file "${instructionsFilePath}": ${reason}\n`,
|
||||
);
|
||||
}
|
||||
if (forceFreshSession) {
|
||||
notes.push("Codex transient fallback forced a fresh session with a continuation handoff.");
|
||||
}
|
||||
return notes;
|
||||
}
|
||||
if (instructionsPrefix.length > 0) {
|
||||
if (shouldUseResumeDeltaPrompt) {
|
||||
const repoAgentsNote =
|
||||
"Codex exec automatically applies repo-scoped AGENTS.md instructions from the current workspace; Paperclip does not currently suppress that discovery.";
|
||||
const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, "");
|
||||
const templateData = {
|
||||
agentId: agent.id,
|
||||
companyId: agent.companyId,
|
||||
runId,
|
||||
company: { id: agent.companyId },
|
||||
agent,
|
||||
run: { id: runId, source: "on_demand" },
|
||||
context,
|
||||
};
|
||||
const renderedBootstrapPrompt =
|
||||
!sessionId && bootstrapPromptTemplate.trim().length > 0
|
||||
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
|
||||
: "";
|
||||
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: Boolean(sessionId) });
|
||||
const shouldUseResumeDeltaPrompt = Boolean(sessionId) && wakePrompt.length > 0;
|
||||
const promptInstructionsPrefix = shouldUseResumeDeltaPrompt ? "" : instructionsPrefix;
|
||||
instructionsChars = promptInstructionsPrefix.length;
|
||||
const continuationSummary = parseObject(context.paperclipContinuationSummary);
|
||||
const continuationSummaryBody = asString(continuationSummary.body, "").trim() || null;
|
||||
const codexFallbackHandoffNote =
|
||||
forceFreshSession
|
||||
? buildCodexTransientHandoffNote({
|
||||
previousSessionId: runtimeSessionId || runtime.sessionId || null,
|
||||
fallbackMode: codexTransientFallbackMode ?? "fresh_session",
|
||||
continuationSummaryBody,
|
||||
})
|
||||
: "";
|
||||
const commandNotes = (() => {
|
||||
if (!instructionsFilePath) {
|
||||
const notes = [repoAgentsNote];
|
||||
if (forceSaferInvocation) {
|
||||
notes.push("Codex transient fallback requested safer invocation settings for this retry.");
|
||||
}
|
||||
if (forceFreshSession) {
|
||||
notes.push("Codex transient fallback forced a fresh session with a continuation handoff.");
|
||||
}
|
||||
return notes;
|
||||
}
|
||||
if (instructionsPrefix.length > 0) {
|
||||
if (shouldUseResumeDeltaPrompt) {
|
||||
const notes = [
|
||||
`Loaded agent instructions from ${instructionsFilePath}`,
|
||||
"Skipped stdin instruction reinjection because an existing Codex session is being resumed with a wake delta.",
|
||||
repoAgentsNote,
|
||||
];
|
||||
if (forceSaferInvocation) {
|
||||
notes.push("Codex transient fallback requested safer invocation settings for this retry.");
|
||||
}
|
||||
if (forceFreshSession) {
|
||||
notes.push("Codex transient fallback forced a fresh session with a continuation handoff.");
|
||||
}
|
||||
return notes;
|
||||
}
|
||||
const notes = [
|
||||
`Loaded agent instructions from ${instructionsFilePath}`,
|
||||
"Skipped stdin instruction reinjection because an existing Codex session is being resumed with a wake delta.",
|
||||
`Prepended instructions + path directive to stdin prompt (relative references from ${instructionsDir}).`,
|
||||
repoAgentsNote,
|
||||
];
|
||||
if (forceSaferInvocation) {
|
||||
@@ -629,8 +660,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
return notes;
|
||||
}
|
||||
const notes = [
|
||||
`Loaded agent instructions from ${instructionsFilePath}`,
|
||||
`Prepended instructions + path directive to stdin prompt (relative references from ${instructionsDir}).`,
|
||||
`Configured instructionsFilePath ${instructionsFilePath}, but file could not be read; continuing without injected instructions.`,
|
||||
repoAgentsNote,
|
||||
];
|
||||
if (forceSaferInvocation) {
|
||||
@@ -640,218 +670,220 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
notes.push("Codex transient fallback forced a fresh session with a continuation handoff.");
|
||||
}
|
||||
return notes;
|
||||
})();
|
||||
if (executionTargetIsSandbox) {
|
||||
commandNotes.push(
|
||||
"Added --skip-git-repo-check for sandbox execution because Codex requires an explicit trust bypass in headless remote workspaces.",
|
||||
);
|
||||
}
|
||||
const notes = [
|
||||
`Configured instructionsFilePath ${instructionsFilePath}, but file could not be read; continuing without injected instructions.`,
|
||||
repoAgentsNote,
|
||||
];
|
||||
if (forceSaferInvocation) {
|
||||
notes.push("Codex transient fallback requested safer invocation settings for this retry.");
|
||||
if (preparedRuntimeConfig.notes.length > 0) {
|
||||
commandNotes.unshift(...preparedRuntimeConfig.notes);
|
||||
}
|
||||
if (forceFreshSession) {
|
||||
notes.push("Codex transient fallback forced a fresh session with a continuation handoff.");
|
||||
}
|
||||
return notes;
|
||||
})();
|
||||
if (executionTargetIsSandbox) {
|
||||
commandNotes.push(
|
||||
"Added --skip-git-repo-check for sandbox execution because Codex requires an explicit trust bypass in headless remote workspaces.",
|
||||
);
|
||||
}
|
||||
const renderedPrompt = shouldUseResumeDeltaPrompt ? "" : renderTemplate(promptTemplate, templateData);
|
||||
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
|
||||
const prompt = joinPromptSections([
|
||||
promptInstructionsPrefix,
|
||||
renderedBootstrapPrompt,
|
||||
wakePrompt,
|
||||
codexFallbackHandoffNote,
|
||||
sessionHandoffNote,
|
||||
renderedPrompt,
|
||||
]);
|
||||
const promptMetrics = {
|
||||
promptChars: prompt.length,
|
||||
instructionsChars,
|
||||
bootstrapPromptChars: renderedBootstrapPrompt.length,
|
||||
wakePromptChars: wakePrompt.length,
|
||||
sessionHandoffChars: sessionHandoffNote.length,
|
||||
heartbeatPromptChars: renderedPrompt.length,
|
||||
};
|
||||
|
||||
const runAttempt = async (resumeSessionId: string | null) => {
|
||||
const execArgs = buildCodexExecArgs(
|
||||
forceSaferInvocation ? { ...config, fastMode: false } : config,
|
||||
{
|
||||
resumeSessionId,
|
||||
skipGitRepoCheck: executionTargetIsSandbox,
|
||||
},
|
||||
);
|
||||
const args = execArgs.args;
|
||||
const commandNotesWithFastMode =
|
||||
execArgs.fastModeIgnoredReason == null
|
||||
? commandNotes
|
||||
: [...commandNotes, execArgs.fastModeIgnoredReason];
|
||||
if (onMeta) {
|
||||
await onMeta({
|
||||
adapterType: "codex_local",
|
||||
command: resolvedCommand,
|
||||
cwd: effectiveExecutionCwd,
|
||||
commandNotes: commandNotesWithFastMode,
|
||||
commandArgs: args.map((value, idx) => {
|
||||
if (idx === args.length - 1 && value !== "-") return `<prompt ${prompt.length} chars>`;
|
||||
return value;
|
||||
}),
|
||||
env: loggedEnv,
|
||||
prompt,
|
||||
promptMetrics,
|
||||
context,
|
||||
});
|
||||
}
|
||||
|
||||
const proc = await runAdapterExecutionTargetProcess(runId, runtimeExecutionTarget, command, args, {
|
||||
cwd,
|
||||
env,
|
||||
stdin: prompt,
|
||||
timeoutSec,
|
||||
graceSec,
|
||||
onSpawn,
|
||||
onLog: async (stream, chunk) => {
|
||||
if (stream !== "stderr") {
|
||||
await onLog(stream, chunk);
|
||||
return;
|
||||
}
|
||||
const cleaned = stripCodexRolloutNoise(chunk);
|
||||
if (!cleaned.trim()) return;
|
||||
await onLog(stream, cleaned);
|
||||
},
|
||||
});
|
||||
const cleanedStderr = stripCodexRolloutNoise(proc.stderr);
|
||||
return {
|
||||
proc: {
|
||||
...proc,
|
||||
stderr: cleanedStderr,
|
||||
},
|
||||
rawStderr: proc.stderr,
|
||||
parsed: parseCodexJsonl(proc.stdout),
|
||||
const renderedPrompt = shouldUseResumeDeltaPrompt ? "" : renderTemplate(promptTemplate, templateData);
|
||||
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
|
||||
const prompt = joinPromptSections([
|
||||
promptInstructionsPrefix,
|
||||
renderedBootstrapPrompt,
|
||||
wakePrompt,
|
||||
codexFallbackHandoffNote,
|
||||
sessionHandoffNote,
|
||||
renderedPrompt,
|
||||
]);
|
||||
const promptMetrics = {
|
||||
promptChars: prompt.length,
|
||||
instructionsChars,
|
||||
bootstrapPromptChars: renderedBootstrapPrompt.length,
|
||||
wakePromptChars: wakePrompt.length,
|
||||
sessionHandoffChars: sessionHandoffNote.length,
|
||||
heartbeatPromptChars: renderedPrompt.length,
|
||||
};
|
||||
};
|
||||
|
||||
const toResult = (
|
||||
attempt: { proc: { exitCode: number | null; signal: string | null; timedOut: boolean; stdout: string; stderr: string }; rawStderr: string; parsed: ReturnType<typeof parseCodexJsonl> },
|
||||
clearSessionOnMissingSession = false,
|
||||
isRetry = false,
|
||||
): AdapterExecutionResult => {
|
||||
if (attempt.proc.timedOut) {
|
||||
const runAttempt = async (resumeSessionId: string | null) => {
|
||||
const execArgs = buildCodexExecArgs(
|
||||
forceSaferInvocation ? { ...config, fastMode: false } : config,
|
||||
{
|
||||
resumeSessionId,
|
||||
skipGitRepoCheck: executionTargetIsSandbox,
|
||||
},
|
||||
);
|
||||
const args = execArgs.args;
|
||||
const commandNotesWithFastMode =
|
||||
execArgs.fastModeIgnoredReason == null
|
||||
? commandNotes
|
||||
: [...commandNotes, execArgs.fastModeIgnoredReason];
|
||||
if (onMeta) {
|
||||
await onMeta({
|
||||
adapterType: "codex_local",
|
||||
command: resolvedCommand,
|
||||
cwd: effectiveExecutionCwd,
|
||||
commandNotes: commandNotesWithFastMode,
|
||||
commandArgs: args.map((value, idx) => {
|
||||
if (idx === args.length - 1 && value !== "-") return `<prompt ${prompt.length} chars>`;
|
||||
return value;
|
||||
}),
|
||||
env: loggedEnv,
|
||||
prompt,
|
||||
promptMetrics,
|
||||
context,
|
||||
});
|
||||
}
|
||||
|
||||
const proc = await runAdapterExecutionTargetProcess(runId, runtimeExecutionTarget, command, args, {
|
||||
cwd,
|
||||
env,
|
||||
stdin: prompt,
|
||||
timeoutSec,
|
||||
graceSec,
|
||||
onSpawn,
|
||||
onLog: async (stream, chunk) => {
|
||||
if (stream !== "stderr") {
|
||||
await onLog(stream, chunk);
|
||||
return;
|
||||
}
|
||||
const cleaned = stripCodexRolloutNoise(chunk);
|
||||
if (!cleaned.trim()) return;
|
||||
await onLog(stream, cleaned);
|
||||
},
|
||||
});
|
||||
const cleanedStderr = stripCodexRolloutNoise(proc.stderr);
|
||||
return {
|
||||
proc: {
|
||||
...proc,
|
||||
stderr: cleanedStderr,
|
||||
},
|
||||
rawStderr: proc.stderr,
|
||||
parsed: parseCodexJsonl(proc.stdout),
|
||||
};
|
||||
};
|
||||
|
||||
const toResult = (
|
||||
attempt: { proc: { exitCode: number | null; signal: string | null; timedOut: boolean; stdout: string; stderr: string }; rawStderr: string; parsed: ReturnType<typeof parseCodexJsonl> },
|
||||
clearSessionOnMissingSession = false,
|
||||
isRetry = false,
|
||||
): AdapterExecutionResult => {
|
||||
if (attempt.proc.timedOut) {
|
||||
return {
|
||||
exitCode: attempt.proc.exitCode,
|
||||
signal: attempt.proc.signal,
|
||||
timedOut: true,
|
||||
errorMessage: `Timed out after ${timeoutSec}s`,
|
||||
clearSession: clearSessionOnMissingSession,
|
||||
};
|
||||
}
|
||||
|
||||
const canFallbackToRuntimeSession = !isRetry && !forceFreshSession;
|
||||
const resolvedSessionId =
|
||||
attempt.parsed.sessionId ??
|
||||
(canFallbackToRuntimeSession ? (runtimeSessionId ?? runtime.sessionId ?? null) : null);
|
||||
const resolvedSessionParams = resolvedSessionId
|
||||
? ({
|
||||
sessionId: resolvedSessionId,
|
||||
cwd: effectiveExecutionCwd,
|
||||
...(executionTargetIsRemote
|
||||
? {
|
||||
remoteExecution: adapterExecutionTargetSessionIdentity(runtimeExecutionTarget),
|
||||
}
|
||||
: {}),
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}),
|
||||
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
|
||||
} as Record<string, unknown>)
|
||||
: null;
|
||||
const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : "";
|
||||
const stderrLine = firstNonEmptyLine(attempt.proc.stderr);
|
||||
const fallbackErrorMessage =
|
||||
parsedError ||
|
||||
stderrLine ||
|
||||
`Codex exited with code ${attempt.proc.exitCode ?? -1}`;
|
||||
const transientRetryNotBefore =
|
||||
(attempt.proc.exitCode ?? 0) !== 0
|
||||
? extractCodexRetryNotBefore({
|
||||
stdout: attempt.proc.stdout,
|
||||
stderr: attempt.proc.stderr,
|
||||
errorMessage: fallbackErrorMessage,
|
||||
})
|
||||
: null;
|
||||
const transientUpstream =
|
||||
(attempt.proc.exitCode ?? 0) !== 0 &&
|
||||
isCodexTransientUpstreamError({
|
||||
stdout: attempt.proc.stdout,
|
||||
stderr: attempt.proc.stderr,
|
||||
errorMessage: fallbackErrorMessage,
|
||||
});
|
||||
|
||||
return {
|
||||
exitCode: attempt.proc.exitCode,
|
||||
signal: attempt.proc.signal,
|
||||
timedOut: true,
|
||||
errorMessage: `Timed out after ${timeoutSec}s`,
|
||||
clearSession: clearSessionOnMissingSession,
|
||||
};
|
||||
}
|
||||
|
||||
const canFallbackToRuntimeSession = !isRetry && !forceFreshSession;
|
||||
const resolvedSessionId =
|
||||
attempt.parsed.sessionId ??
|
||||
(canFallbackToRuntimeSession ? (runtimeSessionId ?? runtime.sessionId ?? null) : null);
|
||||
const resolvedSessionParams = resolvedSessionId
|
||||
? ({
|
||||
timedOut: false,
|
||||
errorMessage:
|
||||
(attempt.proc.exitCode ?? 0) === 0
|
||||
? null
|
||||
: fallbackErrorMessage,
|
||||
errorCode:
|
||||
transientUpstream
|
||||
? "codex_transient_upstream"
|
||||
: null,
|
||||
errorFamily: transientUpstream ? "transient_upstream" : null,
|
||||
retryNotBefore: transientRetryNotBefore ? transientRetryNotBefore.toISOString() : null,
|
||||
usage: attempt.parsed.usage,
|
||||
sessionId: resolvedSessionId,
|
||||
cwd: effectiveExecutionCwd,
|
||||
...(executionTargetIsRemote
|
||||
? {
|
||||
remoteExecution: adapterExecutionTargetSessionIdentity(runtimeExecutionTarget),
|
||||
}
|
||||
: {}),
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}),
|
||||
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
|
||||
} as Record<string, unknown>)
|
||||
: null;
|
||||
const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : "";
|
||||
const stderrLine = firstNonEmptyLine(attempt.proc.stderr);
|
||||
const fallbackErrorMessage =
|
||||
parsedError ||
|
||||
stderrLine ||
|
||||
`Codex exited with code ${attempt.proc.exitCode ?? -1}`;
|
||||
const transientRetryNotBefore =
|
||||
(attempt.proc.exitCode ?? 0) !== 0
|
||||
? extractCodexRetryNotBefore({
|
||||
stdout: attempt.proc.stdout,
|
||||
stderr: attempt.proc.stderr,
|
||||
errorMessage: fallbackErrorMessage,
|
||||
})
|
||||
: null;
|
||||
const transientUpstream =
|
||||
(attempt.proc.exitCode ?? 0) !== 0 &&
|
||||
isCodexTransientUpstreamError({
|
||||
stdout: attempt.proc.stdout,
|
||||
stderr: attempt.proc.stderr,
|
||||
errorMessage: fallbackErrorMessage,
|
||||
});
|
||||
|
||||
return {
|
||||
exitCode: attempt.proc.exitCode,
|
||||
signal: attempt.proc.signal,
|
||||
timedOut: false,
|
||||
errorMessage:
|
||||
(attempt.proc.exitCode ?? 0) === 0
|
||||
? null
|
||||
: fallbackErrorMessage,
|
||||
errorCode:
|
||||
transientUpstream
|
||||
? "codex_transient_upstream"
|
||||
: null,
|
||||
errorFamily: transientUpstream ? "transient_upstream" : null,
|
||||
retryNotBefore: transientRetryNotBefore ? transientRetryNotBefore.toISOString() : null,
|
||||
usage: attempt.parsed.usage,
|
||||
sessionId: resolvedSessionId,
|
||||
sessionParams: resolvedSessionParams,
|
||||
sessionDisplayId: resolvedSessionId,
|
||||
provider: "openai",
|
||||
biller: resolveCodexBiller(effectiveEnv, billingType),
|
||||
model,
|
||||
billingType,
|
||||
costUsd: null,
|
||||
resultJson: {
|
||||
stdout: attempt.proc.stdout,
|
||||
stderr: attempt.proc.stderr,
|
||||
...(transientUpstream ? { errorFamily: "transient_upstream" } : {}),
|
||||
...(transientRetryNotBefore ? { retryNotBefore: transientRetryNotBefore.toISOString() } : {}),
|
||||
...(transientRetryNotBefore ? { transientRetryNotBefore: transientRetryNotBefore.toISOString() } : {}),
|
||||
},
|
||||
summary: attempt.parsed.summary,
|
||||
clearSession: Boolean((clearSessionOnMissingSession || forceFreshSession) && !resolvedSessionId),
|
||||
sessionParams: resolvedSessionParams,
|
||||
sessionDisplayId: resolvedSessionId,
|
||||
provider: "openai",
|
||||
biller: resolveCodexBiller(effectiveEnv, billingType),
|
||||
model,
|
||||
billingType,
|
||||
costUsd: null,
|
||||
resultJson: {
|
||||
stdout: attempt.proc.stdout,
|
||||
stderr: attempt.proc.stderr,
|
||||
...(transientUpstream ? { errorFamily: "transient_upstream" } : {}),
|
||||
...(transientRetryNotBefore ? { retryNotBefore: transientRetryNotBefore.toISOString() } : {}),
|
||||
...(transientRetryNotBefore ? { transientRetryNotBefore: transientRetryNotBefore.toISOString() } : {}),
|
||||
},
|
||||
summary: attempt.parsed.summary,
|
||||
clearSession: Boolean((clearSessionOnMissingSession || forceFreshSession) && !resolvedSessionId),
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
try {
|
||||
const initial = await runAttempt(sessionId);
|
||||
if (
|
||||
sessionId &&
|
||||
!initial.proc.timedOut &&
|
||||
(initial.proc.exitCode ?? 0) !== 0 &&
|
||||
isCodexUnknownSessionError(initial.proc.stdout, initial.rawStderr)
|
||||
) {
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[paperclip] Codex resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
|
||||
);
|
||||
const retry = await runAttempt(null);
|
||||
return toResult(retry, true, true);
|
||||
try {
|
||||
const initial = await runAttempt(sessionId);
|
||||
if (
|
||||
sessionId &&
|
||||
!initial.proc.timedOut &&
|
||||
(initial.proc.exitCode ?? 0) !== 0 &&
|
||||
isCodexUnknownSessionError(initial.proc.stdout, initial.rawStderr)
|
||||
) {
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[paperclip] Codex resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
|
||||
);
|
||||
const retry = await runAttempt(null);
|
||||
return toResult(retry, true, true);
|
||||
}
|
||||
|
||||
return toResult(initial, false, false);
|
||||
} finally {
|
||||
if (paperclipBridge) {
|
||||
await paperclipBridge.stop();
|
||||
}
|
||||
if (restoreRemoteWorkspace) {
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[paperclip] Restoring workspace changes from ${describeAdapterExecutionTarget(executionTarget)}.\n`,
|
||||
);
|
||||
await restoreRemoteWorkspace();
|
||||
}
|
||||
}
|
||||
|
||||
return toResult(initial, false, false);
|
||||
} finally {
|
||||
if (paperclipBridge) {
|
||||
await paperclipBridge.stop();
|
||||
}
|
||||
if (restoreRemoteWorkspace) {
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[paperclip] Restoring workspace changes from ${describeAdapterExecutionTarget(executionTarget)}.\n`,
|
||||
);
|
||||
await restoreRemoteWorkspace();
|
||||
}
|
||||
// Restore the managed config.toml so PAPERCLIP_CODEX_PROVIDERS changes
|
||||
// (or removal) between runs never leave stale provider routing behind. This
|
||||
// finally starts the moment prepareCodexRuntimeConfig returns, so a throw
|
||||
// anywhere in the remaining setup (skill injection, remote runtime
|
||||
// preparation, command building) restores the original config.toml too.
|
||||
// If the process dies before reaching this, the next
|
||||
// prepareCodexRuntimeConfig restores the original from the pre-run backup
|
||||
// written at prepare time.
|
||||
await preparedRuntimeConfig.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { prepareCodexRuntimeConfig } from "./runtime-config.js";
|
||||
|
||||
const cleanupPaths = new Set<string>();
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
[...cleanupPaths].map(async (filepath) => {
|
||||
await fs.rm(filepath, { recursive: true, force: true });
|
||||
cleanupPaths.delete(filepath);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
async function makeCodexHome(configToml?: string): Promise<string> {
|
||||
const home = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-home-"));
|
||||
cleanupPaths.add(home);
|
||||
if (configToml !== undefined) {
|
||||
await fs.writeFile(path.join(home, "config.toml"), configToml, "utf8");
|
||||
}
|
||||
return home;
|
||||
}
|
||||
|
||||
async function readConfigToml(home: string): Promise<string> {
|
||||
return fs.readFile(path.join(home, "config.toml"), "utf8");
|
||||
}
|
||||
|
||||
const BIFROST_PROVIDERS = {
|
||||
providers: {
|
||||
bifrost: {
|
||||
name: "bifrost",
|
||||
base_url: "http://gateway.example.svc.cluster.local:8080/v1",
|
||||
env_key: "OPENAI_API_KEY",
|
||||
wire_api: "responses",
|
||||
},
|
||||
},
|
||||
model_provider: "bifrost",
|
||||
};
|
||||
|
||||
describe("prepareCodexRuntimeConfig", () => {
|
||||
it("is a no-op when PAPERCLIP_CODEX_PROVIDERS is unset", async () => {
|
||||
const home = await makeCodexHome("model = \"gpt-5.1-codex\"\n");
|
||||
const prepared = await prepareCodexRuntimeConfig({ env: { FOO: "bar" }, codexHome: home });
|
||||
|
||||
expect(prepared.notes).toEqual([]);
|
||||
expect(await readConfigToml(home)).toBe("model = \"gpt-5.1-codex\"\n");
|
||||
await prepared.cleanup();
|
||||
});
|
||||
|
||||
it("is a no-op when the home has no config.toml and the env is unset", async () => {
|
||||
const home = await makeCodexHome();
|
||||
const prepared = await prepareCodexRuntimeConfig({ env: {}, codexHome: home });
|
||||
|
||||
expect(prepared.notes).toEqual([]);
|
||||
await expect(fs.access(path.join(home, "config.toml"))).rejects.toThrow();
|
||||
await prepared.cleanup();
|
||||
});
|
||||
|
||||
it("merges providers + model_provider into a fresh config.toml and cleans it up", async () => {
|
||||
const home = await makeCodexHome();
|
||||
const prepared = await prepareCodexRuntimeConfig({
|
||||
env: { PAPERCLIP_CODEX_PROVIDERS: JSON.stringify(BIFROST_PROVIDERS) },
|
||||
codexHome: home,
|
||||
});
|
||||
|
||||
const content = await readConfigToml(home);
|
||||
expect(content).toContain('model_provider = "bifrost"');
|
||||
expect(content).toContain("[model_providers.bifrost]");
|
||||
expect(content).toContain('base_url = "http://gateway.example.svc.cluster.local:8080/v1"');
|
||||
expect(content).toContain('env_key = "OPENAI_API_KEY"');
|
||||
expect(content).toContain('wire_api = "responses"');
|
||||
expect(prepared.notes.some((n) => n.includes("bifrost"))).toBe(true);
|
||||
|
||||
await prepared.cleanup();
|
||||
await expect(fs.access(path.join(home, "config.toml"))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("preserves existing config.toml content, keeps model_provider in the root region, and restores on cleanup", async () => {
|
||||
const original = [
|
||||
'model = "gpt-5.1-codex"',
|
||||
"",
|
||||
"[profiles.dev]",
|
||||
'model = "gpt-5.1-codex-mini"',
|
||||
"",
|
||||
].join("\n");
|
||||
const home = await makeCodexHome(original);
|
||||
const prepared = await prepareCodexRuntimeConfig({
|
||||
env: { PAPERCLIP_CODEX_PROVIDERS: JSON.stringify(BIFROST_PROVIDERS) },
|
||||
codexHome: home,
|
||||
});
|
||||
|
||||
const content = await readConfigToml(home);
|
||||
// Existing content survives.
|
||||
expect(content).toContain('model = "gpt-5.1-codex"');
|
||||
expect(content).toContain("[profiles.dev]");
|
||||
// model_provider must precede the first table header (TOML root region),
|
||||
// and the provider tables must come after the user's tables.
|
||||
expect(content.indexOf('model_provider = "bifrost"')).toBeLessThan(
|
||||
content.indexOf("[profiles.dev]"),
|
||||
);
|
||||
expect(content.indexOf("[model_providers.bifrost]")).toBeGreaterThan(
|
||||
content.indexOf("[profiles.dev]"),
|
||||
);
|
||||
|
||||
await prepared.cleanup();
|
||||
expect(await readConfigToml(home)).toBe(original);
|
||||
});
|
||||
|
||||
it("wins over a pre-existing same-name [model_providers.*] section and root model_provider key", async () => {
|
||||
const original = [
|
||||
'model_provider = "stale"',
|
||||
"",
|
||||
"[model_providers.bifrost]",
|
||||
'base_url = "http://old.example/v1"',
|
||||
'env_key = "OLD_KEY"',
|
||||
"",
|
||||
"[model_providers.bifrost.http_headers]",
|
||||
'"X-Old" = "1"',
|
||||
"",
|
||||
"[model_providers.other]",
|
||||
'base_url = "http://other.example/v1"',
|
||||
"",
|
||||
].join("\n");
|
||||
const home = await makeCodexHome(original);
|
||||
const prepared = await prepareCodexRuntimeConfig({
|
||||
env: { PAPERCLIP_CODEX_PROVIDERS: JSON.stringify(BIFROST_PROVIDERS) },
|
||||
codexHome: home,
|
||||
});
|
||||
|
||||
const content = await readConfigToml(home);
|
||||
expect(content).not.toContain('model_provider = "stale"');
|
||||
expect(content).not.toContain("http://old.example/v1");
|
||||
expect(content).not.toContain("X-Old");
|
||||
// Unrelated provider sections survive.
|
||||
expect(content).toContain("[model_providers.other]");
|
||||
expect(content).toContain("http://other.example/v1");
|
||||
// Exactly one bifrost section: ours.
|
||||
expect(content.split("[model_providers.bifrost]").length).toBe(2);
|
||||
expect(content).toContain('base_url = "http://gateway.example.svc.cluster.local:8080/v1"');
|
||||
|
||||
await prepared.cleanup();
|
||||
expect(await readConfigToml(home)).toBe(original);
|
||||
});
|
||||
|
||||
it("emits plain-object fields as inline tables and arrays as TOML arrays", async () => {
|
||||
const home = await makeCodexHome();
|
||||
const prepared = await prepareCodexRuntimeConfig({
|
||||
env: {
|
||||
PAPERCLIP_CODEX_PROVIDERS: JSON.stringify({
|
||||
providers: {
|
||||
gw: {
|
||||
base_url: "http://gw.example/v1",
|
||||
query_params: { "api-version": "2026-01-01" },
|
||||
http_headers: { "X Team": "agents" },
|
||||
request_max_retries: 4,
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
codexHome: home,
|
||||
});
|
||||
|
||||
const content = await readConfigToml(home);
|
||||
expect(content).toContain("[model_providers.gw]");
|
||||
// Bare keys (incl. hyphens) stay bare; keys with other characters get quoted.
|
||||
expect(content).toContain('query_params = { api-version = "2026-01-01" }');
|
||||
expect(content).toContain('http_headers = { "X Team" = "agents" }');
|
||||
expect(content).toContain("request_max_retries = 4");
|
||||
// No model_provider was requested, so none is emitted.
|
||||
expect(content).not.toContain("model_provider =");
|
||||
|
||||
await prepared.cleanup();
|
||||
});
|
||||
|
||||
it("expands {env:VAR} placeholders from the run env and process.env, leaving unresolvable ones intact", async () => {
|
||||
const home = await makeCodexHome();
|
||||
process.env.PAPERCLIP_CODEX_TEST_PROCESS_KEY = "from-process-env";
|
||||
try {
|
||||
const prepared = await prepareCodexRuntimeConfig({
|
||||
env: {
|
||||
PAPERCLIP_CODEX_PROVIDERS: JSON.stringify({
|
||||
providers: {
|
||||
gw: {
|
||||
base_url: "http://gw.example/v1",
|
||||
http_headers: {
|
||||
"X-Run": "{env:PAPERCLIP_CODEX_TEST_RUN_KEY}",
|
||||
"X-Process": "{env:PAPERCLIP_CODEX_TEST_PROCESS_KEY}",
|
||||
"X-Missing": "{env:DEFINITELY_UNSET_VAR_XYZ}",
|
||||
},
|
||||
},
|
||||
},
|
||||
model_provider: "gw",
|
||||
}),
|
||||
PAPERCLIP_CODEX_TEST_RUN_KEY: "from-run-env",
|
||||
},
|
||||
codexHome: home,
|
||||
});
|
||||
|
||||
const content = await readConfigToml(home);
|
||||
expect(content).toContain('X-Run = "from-run-env"');
|
||||
expect(content).toContain('X-Process = "from-process-env"');
|
||||
expect(content).toContain('X-Missing = "{env:DEFINITELY_UNSET_VAR_XYZ}"');
|
||||
|
||||
await prepared.cleanup();
|
||||
} finally {
|
||||
delete process.env.PAPERCLIP_CODEX_TEST_PROCESS_KEY;
|
||||
}
|
||||
});
|
||||
|
||||
it("reads PAPERCLIP_CODEX_PROVIDERS from process.env when absent from the run env", async () => {
|
||||
const home = await makeCodexHome();
|
||||
process.env.PAPERCLIP_CODEX_PROVIDERS = JSON.stringify(BIFROST_PROVIDERS);
|
||||
try {
|
||||
const prepared = await prepareCodexRuntimeConfig({ env: {}, codexHome: home });
|
||||
const content = await readConfigToml(home);
|
||||
expect(content).toContain("[model_providers.bifrost]");
|
||||
await prepared.cleanup();
|
||||
} finally {
|
||||
delete process.env.PAPERCLIP_CODEX_PROVIDERS;
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces a note for malformed PAPERCLIP_CODEX_PROVIDERS without touching config.toml", async () => {
|
||||
const home = await makeCodexHome("model = \"gpt-5.1-codex\"\n");
|
||||
const cases: Array<[raw: string, noteFragment: string]> = [
|
||||
["not json", "contains invalid JSON"],
|
||||
[JSON.stringify(["providers"]), "is not a JSON object"],
|
||||
[JSON.stringify({ no_providers: true }), 'has no "providers" object'],
|
||||
[JSON.stringify({ providers: {} }), "contains no usable entries"],
|
||||
[JSON.stringify({ providers: { gw: "nope" } }), "skipped provider(s) with empty names or non-object values: gw"],
|
||||
];
|
||||
for (const [raw, noteFragment] of cases) {
|
||||
const prepared = await prepareCodexRuntimeConfig({
|
||||
env: { PAPERCLIP_CODEX_PROVIDERS: raw },
|
||||
codexHome: home,
|
||||
});
|
||||
expect(prepared.notes).toHaveLength(1);
|
||||
expect(prepared.notes[0]).toContain(noteFragment);
|
||||
expect(prepared.notes[0]).toContain("custom providers ignored");
|
||||
expect(await readConfigToml(home)).toBe("model = \"gpt-5.1-codex\"\n");
|
||||
await prepared.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("stays silent when PAPERCLIP_CODEX_PROVIDERS is unset or empty", async () => {
|
||||
const home = await makeCodexHome("model = \"gpt-5.1-codex\"\n");
|
||||
const envs: Array<Record<string, string>> = [
|
||||
{},
|
||||
{ PAPERCLIP_CODEX_PROVIDERS: "" },
|
||||
{ PAPERCLIP_CODEX_PROVIDERS: " " },
|
||||
];
|
||||
for (const env of envs) {
|
||||
const prepared = await prepareCodexRuntimeConfig({ env, codexHome: home });
|
||||
expect(prepared.notes).toEqual([]);
|
||||
expect(await readConfigToml(home)).toBe("model = \"gpt-5.1-codex\"\n");
|
||||
await prepared.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces skipped non-object provider entries while merging the usable ones", async () => {
|
||||
const home = await makeCodexHome();
|
||||
const prepared = await prepareCodexRuntimeConfig({
|
||||
env: {
|
||||
PAPERCLIP_CODEX_PROVIDERS: JSON.stringify({
|
||||
providers: {
|
||||
bad: "http://gateway.example/v1",
|
||||
good: { base_url: "http://gateway.example/v1", env_key: "OPENAI_API_KEY" },
|
||||
},
|
||||
model_provider: "good",
|
||||
}),
|
||||
},
|
||||
codexHome: home,
|
||||
});
|
||||
|
||||
const content = await readConfigToml(home);
|
||||
expect(content).toContain("[model_providers.good]");
|
||||
expect(content).not.toContain("[model_providers.bad]");
|
||||
expect(
|
||||
prepared.notes.some((n) => n.includes("skipped provider(s) with empty names or non-object values: bad")),
|
||||
).toBe(true);
|
||||
expect(prepared.notes.some((n) => n.includes("Merged 1 custom Codex model provider(s)"))).toBe(true);
|
||||
await prepared.cleanup();
|
||||
});
|
||||
|
||||
it("escapes DEL (U+007F) in emitted TOML strings", async () => {
|
||||
const del = String.fromCharCode(0x7f);
|
||||
const home = await makeCodexHome();
|
||||
const prepared = await prepareCodexRuntimeConfig({
|
||||
env: {
|
||||
PAPERCLIP_CODEX_PROVIDERS: JSON.stringify({
|
||||
providers: { gw: { base_url: "http://gw.example/v1", name: `del${del}name` } },
|
||||
}),
|
||||
},
|
||||
codexHome: home,
|
||||
});
|
||||
|
||||
const content = await readConfigToml(home);
|
||||
expect(content).not.toContain(del);
|
||||
expect(content).toContain("u007fname");
|
||||
await prepared.cleanup();
|
||||
});
|
||||
|
||||
it("restores user provider sections from the pre-run backup after an interrupted run", async () => {
|
||||
const userConfig = [
|
||||
'model = "gpt-5.1-codex"',
|
||||
"",
|
||||
"[model_providers.bifrost]",
|
||||
'base_url = "http://user.example/v1"',
|
||||
"",
|
||||
].join("\n");
|
||||
const home = await makeCodexHome(userConfig);
|
||||
// Simulate a crash: the merge excises the user's same-name provider
|
||||
// section (the managed definition must win), and cleanup() never runs.
|
||||
await prepareCodexRuntimeConfig({
|
||||
env: { PAPERCLIP_CODEX_PROVIDERS: JSON.stringify(BIFROST_PROVIDERS) },
|
||||
codexHome: home,
|
||||
});
|
||||
expect(await readConfigToml(home)).toContain('env_key = "OPENAI_API_KEY"');
|
||||
|
||||
const prepared = await prepareCodexRuntimeConfig({ env: {}, codexHome: home });
|
||||
expect(prepared.notes.some((n) => n.includes("backup"))).toBe(true);
|
||||
expect(await readConfigToml(home)).toBe(userConfig);
|
||||
await expect(fs.access(path.join(home, "config.toml.paperclip-backup"))).rejects.toThrow();
|
||||
await prepared.cleanup();
|
||||
});
|
||||
|
||||
it("removes the pre-run backup on cleanup", async () => {
|
||||
const home = await makeCodexHome('approval_policy = "never"\n');
|
||||
const prepared = await prepareCodexRuntimeConfig({
|
||||
env: { PAPERCLIP_CODEX_PROVIDERS: JSON.stringify(BIFROST_PROVIDERS) },
|
||||
codexHome: home,
|
||||
});
|
||||
const backupPath = path.join(home, "config.toml.paperclip-backup");
|
||||
expect(await fs.readFile(backupPath, "utf8")).toBe('approval_policy = "never"\n');
|
||||
|
||||
await prepared.cleanup();
|
||||
expect(await readConfigToml(home)).toBe('approval_policy = "never"\n');
|
||||
await expect(fs.access(backupPath)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("skips the merge and surfaces a note when CODEX_HOME is explicitly configured", async () => {
|
||||
const prepared = await prepareCodexRuntimeConfig({
|
||||
env: { PAPERCLIP_CODEX_PROVIDERS: JSON.stringify(BIFROST_PROVIDERS) },
|
||||
codexHome: null,
|
||||
});
|
||||
|
||||
expect(prepared.notes).toHaveLength(1);
|
||||
expect(prepared.notes[0]).toContain("CODEX_HOME");
|
||||
await prepared.cleanup();
|
||||
});
|
||||
|
||||
it("self-heals stale managed blocks when the env is no longer set", async () => {
|
||||
const home = await makeCodexHome();
|
||||
const crashed = await prepareCodexRuntimeConfig({
|
||||
env: { PAPERCLIP_CODEX_PROVIDERS: JSON.stringify(BIFROST_PROVIDERS) },
|
||||
codexHome: home,
|
||||
});
|
||||
// Simulate a crash: cleanup never runs, the managed blocks persist.
|
||||
void crashed;
|
||||
expect(await readConfigToml(home)).toContain("[model_providers.bifrost]");
|
||||
|
||||
const prepared = await prepareCodexRuntimeConfig({ env: {}, codexHome: home });
|
||||
expect(prepared.notes.some((n) => n.includes("stale"))).toBe(true);
|
||||
const content = await readConfigToml(home);
|
||||
expect(content).not.toContain("model_provider");
|
||||
expect(content).not.toContain("bifrost");
|
||||
await prepared.cleanup();
|
||||
});
|
||||
|
||||
it("re-running with changed providers replaces the previous managed blocks", async () => {
|
||||
const home = await makeCodexHome("approval_policy = \"never\"\n");
|
||||
const first = await prepareCodexRuntimeConfig({
|
||||
env: { PAPERCLIP_CODEX_PROVIDERS: JSON.stringify(BIFROST_PROVIDERS) },
|
||||
codexHome: home,
|
||||
});
|
||||
void first; // simulate crash: no cleanup
|
||||
const second = await prepareCodexRuntimeConfig({
|
||||
env: {
|
||||
PAPERCLIP_CODEX_PROVIDERS: JSON.stringify({
|
||||
providers: { gw2: { base_url: "http://gw2.example/v1", env_key: "OPENAI_API_KEY" } },
|
||||
model_provider: "gw2",
|
||||
}),
|
||||
},
|
||||
codexHome: home,
|
||||
});
|
||||
|
||||
const content = await readConfigToml(home);
|
||||
expect(content).toContain('approval_policy = "never"');
|
||||
expect(content).toContain("[model_providers.gw2]");
|
||||
expect(content).toContain('model_provider = "gw2"');
|
||||
expect(content).not.toContain("bifrost");
|
||||
expect(content.split("model_provider =").length).toBe(2);
|
||||
await second.cleanup();
|
||||
});
|
||||
|
||||
it("rejects the block when model_provider names a filtered or missing provider", async () => {
|
||||
const home = await makeCodexHome("model = \"gpt-5.1-codex\"\n");
|
||||
const prepared = await prepareCodexRuntimeConfig({
|
||||
env: {
|
||||
PAPERCLIP_CODEX_PROVIDERS: JSON.stringify({
|
||||
providers: { good: { base_url: "https://gateway.example/v1" }, bad: "not-an-object" },
|
||||
model_provider: "bad",
|
||||
}),
|
||||
},
|
||||
codexHome: home,
|
||||
});
|
||||
expect(prepared.notes).toContain(
|
||||
'PAPERCLIP_CODEX_PROVIDERS: model_provider "bad" does not match any usable provider entry; custom providers ignored.',
|
||||
);
|
||||
const content = await readConfigToml(home);
|
||||
expect(content).toBe("model = \"gpt-5.1-codex\"\n");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,430 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
type PreparedCodexRuntimeConfig = {
|
||||
notes: string[];
|
||||
cleanup: () => Promise<void>;
|
||||
};
|
||||
|
||||
type ParsedCodexProvidersConfig = {
|
||||
providers: Record<string, Record<string, unknown>>;
|
||||
modelProvider: string | null;
|
||||
};
|
||||
|
||||
// Marker comments delimiting the Paperclip-managed regions of config.toml.
|
||||
// TOML requires root-level keys (model_provider) to appear before the first
|
||||
// table header, while [model_providers.*] tables must not swallow the user's
|
||||
// root keys, so the managed content is split into a root block prepended to
|
||||
// the file and a tables block appended to it.
|
||||
const MANAGED_ROOT_BEGIN = "# >>> paperclip codex providers (root) -- managed, do not edit >>>";
|
||||
const MANAGED_ROOT_END = "# <<< paperclip codex providers (root) <<<";
|
||||
const MANAGED_TABLES_BEGIN = "# >>> paperclip codex providers (tables) -- managed, do not edit >>>";
|
||||
const MANAGED_TABLES_END = "# <<< paperclip codex providers (tables) <<<";
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
// Recursively replace {env:VAR} placeholders with the resolved value. Used to bake
|
||||
// gateway provider secrets into config.toml SERVER-SIDE, where the value is
|
||||
// reliably present. Prefer codex's own `env_key` indirection (codex reads the
|
||||
// named env var at request time); placeholder expansion exists for fields that
|
||||
// must carry a literal value (e.g. http_headers). Unresolvable placeholders are
|
||||
// left intact.
|
||||
function expandEnvPlaceholders<T>(value: T, resolve: (name: string) => string | undefined): T {
|
||||
if (typeof value === "string") {
|
||||
return value.replace(/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g, (match, name: string) => {
|
||||
const resolved = resolve(name);
|
||||
return resolved !== undefined && resolved.length > 0 ? resolved : match;
|
||||
}) as unknown as T;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((entry) => expandEnvPlaceholders(entry, resolve)) as unknown as T;
|
||||
}
|
||||
if (isPlainObject(value)) {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
out[key] = expandEnvPlaceholders(entry, resolve);
|
||||
}
|
||||
return out as unknown as T;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// PAPERCLIP_CODEX_PROVIDERS is a JSON object that maps 1:1 onto codex's
|
||||
// config.toml schema:
|
||||
//
|
||||
// {
|
||||
// "providers": {
|
||||
// "<id>": { // -> [model_providers.<id>]
|
||||
// "name": "My gateway", // optional display name
|
||||
// "base_url": "http://...", // OpenAI-compatible endpoint
|
||||
// "env_key": "OPENAI_API_KEY", // env var codex reads the bearer key from
|
||||
// "wire_api": "responses", // protocol codex speaks to the provider
|
||||
// ... // any other field codex supports
|
||||
// // (query_params, http_headers,
|
||||
// // env_http_headers, request_max_retries, ...)
|
||||
// }
|
||||
// },
|
||||
// "model_provider": "<id>" // optional: top-level provider selection
|
||||
// }
|
||||
//
|
||||
// Scalar fields are emitted verbatim as TOML key = value pairs; plain-object
|
||||
// fields (query_params, http_headers, ...) are emitted as inline tables and
|
||||
// arrays of scalars as TOML arrays. String values may use {env:VAR}
|
||||
// placeholders, expanded server-side against the run env and process.env.
|
||||
function parseCodexProvidersConfig(
|
||||
raw: unknown,
|
||||
resolveEnv: (name: string) => string | undefined,
|
||||
notes: string[],
|
||||
): ParsedCodexProvidersConfig | null {
|
||||
if (typeof raw !== "string" || raw.trim().length === 0) return null;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
// Surface the misconfiguration instead of silently dropping the provider
|
||||
// config; an unparseable value would otherwise be undiagnosable.
|
||||
notes.push("PAPERCLIP_CODEX_PROVIDERS contains invalid JSON; custom providers ignored.");
|
||||
return null;
|
||||
}
|
||||
if (!isPlainObject(parsed)) {
|
||||
notes.push("PAPERCLIP_CODEX_PROVIDERS is set but is not a JSON object; custom providers ignored.");
|
||||
return null;
|
||||
}
|
||||
const rawProviders = parsed.providers;
|
||||
if (!isPlainObject(rawProviders)) {
|
||||
notes.push(
|
||||
'PAPERCLIP_CODEX_PROVIDERS has no "providers" object; custom providers ignored.',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
// Only keep provider entries with non-empty names and object values; surface
|
||||
// the ones we drop so a malformed entry is just as diagnosable as malformed JSON.
|
||||
const providers: Record<string, Record<string, unknown>> = {};
|
||||
const skipped: string[] = [];
|
||||
for (const [key, value] of Object.entries(rawProviders)) {
|
||||
if (key.trim().length === 0 || !isPlainObject(value)) {
|
||||
skipped.push(key.trim().length === 0 ? "(empty name)" : key);
|
||||
continue;
|
||||
}
|
||||
providers[key] = expandEnvPlaceholders(value, resolveEnv);
|
||||
}
|
||||
if (Object.keys(providers).length === 0) {
|
||||
notes.push(
|
||||
`PAPERCLIP_CODEX_PROVIDERS "providers" contains no usable entries${
|
||||
skipped.length > 0
|
||||
? ` (skipped provider(s) with empty names or non-object values: ${skipped.join(", ")})`
|
||||
: ""
|
||||
}; custom providers ignored.`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
if (skipped.length > 0) {
|
||||
notes.push(
|
||||
`PAPERCLIP_CODEX_PROVIDERS: skipped provider(s) with empty names or non-object values: ${skipped.join(", ")}.`,
|
||||
);
|
||||
}
|
||||
const modelProvider =
|
||||
typeof parsed.model_provider === "string" && parsed.model_provider.trim().length > 0
|
||||
? parsed.model_provider.trim()
|
||||
: null;
|
||||
// A selector pointing at a provider that did not survive filtering (or was
|
||||
// never defined) would emit model_provider = "x" with no [model_providers.x]
|
||||
// table, which codex rejects at runtime with an error that points nowhere
|
||||
// near the env var. Treat it as the same class of misconfiguration as
|
||||
// malformed JSON: reject the whole block with a visible note.
|
||||
if (modelProvider !== null && !(modelProvider in providers)) {
|
||||
notes.push(
|
||||
`PAPERCLIP_CODEX_PROVIDERS: model_provider "${modelProvider}" does not match any usable provider entry; custom providers ignored.`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return { providers, modelProvider };
|
||||
}
|
||||
|
||||
function escapeTomlString(value: string): string {
|
||||
// TOML 1.0 basic strings require escaping U+0000-U+001F and U+007F (DEL).
|
||||
return value.replace(/[\\"\u0000-\u001f\u007f]/g, (char) => {
|
||||
switch (char) {
|
||||
case "\\":
|
||||
return "\\\\";
|
||||
case '"':
|
||||
return '\\"';
|
||||
case "\n":
|
||||
return "\\n";
|
||||
case "\r":
|
||||
return "\\r";
|
||||
case "\t":
|
||||
return "\\t";
|
||||
default:
|
||||
return `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const BARE_TOML_KEY_RE = /^[A-Za-z0-9_-]+$/;
|
||||
|
||||
function tomlKey(key: string): string {
|
||||
return BARE_TOML_KEY_RE.test(key) ? key : `"${escapeTomlString(key)}"`;
|
||||
}
|
||||
|
||||
// Hand-emitted TOML for a constrained value space (strings, numbers, booleans,
|
||||
// arrays of scalars, plain objects as inline tables). Returns null for values
|
||||
// that cannot be represented, which are then skipped.
|
||||
function tomlValue(value: unknown): string | null {
|
||||
if (typeof value === "string") return `"${escapeTomlString(value)}"`;
|
||||
if (typeof value === "boolean") return value ? "true" : "false";
|
||||
if (typeof value === "number") return Number.isFinite(value) ? String(value) : null;
|
||||
if (Array.isArray(value)) {
|
||||
const entries = value.map((entry) => tomlValue(entry));
|
||||
if (entries.some((entry) => entry === null)) return null;
|
||||
return `[${entries.join(", ")}]`;
|
||||
}
|
||||
if (isPlainObject(value)) {
|
||||
const pairs: string[] = [];
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
const emitted = tomlValue(entry);
|
||||
if (emitted === null) continue;
|
||||
pairs.push(`${tomlKey(key)} = ${emitted}`);
|
||||
}
|
||||
return `{ ${pairs.join(", ")} }`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function emitProviderTable(name: string, fields: Record<string, unknown>): string[] {
|
||||
const lines = [`[model_providers.${tomlKey(name)}]`];
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
const emitted = tomlValue(value);
|
||||
if (emitted === null) continue;
|
||||
lines.push(`${tomlKey(key)} = ${emitted}`);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function stripManagedBlock(lines: string[], begin: string, end: string): string[] {
|
||||
const out: string[] = [];
|
||||
let inBlock = false;
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!inBlock && trimmed === begin) {
|
||||
inBlock = true;
|
||||
continue;
|
||||
}
|
||||
if (inBlock) {
|
||||
if (trimmed === end) inBlock = false;
|
||||
continue;
|
||||
}
|
||||
out.push(line);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function stripManagedCodexProviderBlocks(content: string): string {
|
||||
let lines = content.split("\n");
|
||||
lines = stripManagedBlock(lines, MANAGED_ROOT_BEGIN, MANAGED_ROOT_END);
|
||||
lines = stripManagedBlock(lines, MANAGED_TABLES_BEGIN, MANAGED_TABLES_END);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
const TABLE_HEADER_RE = /^\s*\[\s*([^\]]*?)\s*\]\s*(?:#.*)?$/;
|
||||
|
||||
// Best-effort parse of a TOML table header into its dotted path segments,
|
||||
// stripping surrounding quotes per segment. Dotted quoted segment names are
|
||||
// out of scope for this merge (codex provider ids are simple identifiers).
|
||||
function parseTableHeaderPath(line: string): string[] | null {
|
||||
const match = TABLE_HEADER_RE.exec(line);
|
||||
if (!match) return null;
|
||||
return match[1]
|
||||
.split(".")
|
||||
.map((segment) => segment.trim())
|
||||
.map((segment) => segment.replace(/^"(.*)"$/, "$1").replace(/^'(.*)'$/, "$1"));
|
||||
}
|
||||
|
||||
// Remove pre-existing definitions that would conflict with (or override) the
|
||||
// managed content: [model_providers.<name>] tables (and their subtables) for
|
||||
// names we are about to define, and the root-level `model_provider` key when
|
||||
// we set one. Duplicate TOML tables/keys are parse errors in codex, so the
|
||||
// managed definitions must win by excising the originals.
|
||||
function stripConflictingDefinitions(
|
||||
content: string,
|
||||
providerNames: string[],
|
||||
removeRootModelProvider: boolean,
|
||||
): string {
|
||||
const names = new Set(providerNames);
|
||||
const lines = content.split("\n");
|
||||
const out: string[] = [];
|
||||
let inRootRegion = true;
|
||||
let skippingSection = false;
|
||||
for (const line of lines) {
|
||||
const headerPath = parseTableHeaderPath(line);
|
||||
if (headerPath) {
|
||||
inRootRegion = false;
|
||||
skippingSection =
|
||||
headerPath.length >= 2 &&
|
||||
headerPath[0] === "model_providers" &&
|
||||
names.has(headerPath[1]);
|
||||
if (skippingSection) continue;
|
||||
} else if (skippingSection) {
|
||||
continue;
|
||||
}
|
||||
if (inRootRegion && removeRootModelProvider && /^\s*model_provider\s*=/.test(line)) {
|
||||
continue;
|
||||
}
|
||||
out.push(line);
|
||||
}
|
||||
return out.join("\n");
|
||||
}
|
||||
|
||||
function buildMergedConfigToml(base: string, parsed: ParsedCodexProvidersConfig): string {
|
||||
const sections: string[] = [];
|
||||
if (parsed.modelProvider) {
|
||||
sections.push(
|
||||
[
|
||||
MANAGED_ROOT_BEGIN,
|
||||
`model_provider = "${escapeTomlString(parsed.modelProvider)}"`,
|
||||
MANAGED_ROOT_END,
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
const trimmedBase = base.replace(/^\n+/, "").replace(/\n+$/, "");
|
||||
if (trimmedBase.length > 0) sections.push(trimmedBase);
|
||||
const tableLines: string[] = [MANAGED_TABLES_BEGIN];
|
||||
for (const [name, fields] of Object.entries(parsed.providers)) {
|
||||
tableLines.push(...emitProviderTable(name, fields), "");
|
||||
}
|
||||
while (tableLines[tableLines.length - 1] === "") tableLines.pop();
|
||||
tableLines.push(MANAGED_TABLES_END);
|
||||
sections.push(tableLines.join("\n"));
|
||||
return `${sections.join("\n\n")}\n`;
|
||||
}
|
||||
|
||||
async function readFileOrNull(filePath: string): Promise<string | null> {
|
||||
return fs.readFile(filePath, "utf8").catch(() => null);
|
||||
}
|
||||
|
||||
// Pre-run backup of the original config.toml, written before the merged file.
|
||||
// If a run dies without reaching cleanup() (a setup throw between prepare and
|
||||
// execution, SIGKILL, ...), the next prepare restores the original from this
|
||||
// backup with full fidelity -- including user [model_providers.*] sections the
|
||||
// merge excised, which block-stripping alone cannot bring back.
|
||||
function configTomlBackupPath(configTomlPath: string): string {
|
||||
return `${configTomlPath}.paperclip-backup`;
|
||||
}
|
||||
|
||||
// Merge custom Codex model providers supplied via PAPERCLIP_CODEX_PROVIDERS
|
||||
// into the managed CODEX_HOME's config.toml.
|
||||
//
|
||||
// Codex has no CLI flag or env var for pointing at a custom OpenAI-compatible
|
||||
// endpoint: custom endpoints are `[model_providers.<id>]` tables in
|
||||
// $CODEX_HOME/config.toml, selected by a top-level `model_provider = "<id>"`
|
||||
// key (the `--model` CLI flag picks the model WITHIN the selected provider).
|
||||
// We accept the providers as config (not hard-coded) so the gateway URL, key
|
||||
// indirection, and wire protocol stay declarative.
|
||||
//
|
||||
// The merge preserves any existing config.toml content (seeded from the shared
|
||||
// ~/.codex by prepareManagedCodexHome): managed content lives between marker
|
||||
// comments and conflicting pre-existing definitions are excised so the managed
|
||||
// definitions win. cleanup() restores the original file; if a run dies before
|
||||
// cleanup, the next prepare restores the original from the pre-run backup file
|
||||
// written alongside config.toml (including when PAPERCLIP_CODEX_PROVIDERS is
|
||||
// no longer set), falling back to stripping the stale managed blocks.
|
||||
//
|
||||
// When the adapter config explicitly sets env.CODEX_HOME (a user-managed home),
|
||||
// pass codexHome: null -- the file is left untouched and a note is surfaced.
|
||||
export async function prepareCodexRuntimeConfig(input: {
|
||||
env: Record<string, string>;
|
||||
codexHome: string | null;
|
||||
}): Promise<PreparedCodexRuntimeConfig> {
|
||||
const resolveEnv = (name: string): string | undefined => input.env[name] ?? process.env[name];
|
||||
const notes: string[] = [];
|
||||
const parsed = parseCodexProvidersConfig(
|
||||
input.env.PAPERCLIP_CODEX_PROVIDERS ?? process.env.PAPERCLIP_CODEX_PROVIDERS,
|
||||
resolveEnv,
|
||||
notes,
|
||||
);
|
||||
|
||||
if (!parsed) {
|
||||
// Self-heal state left behind by a crashed run (cleanup() never ran).
|
||||
if (input.codexHome) {
|
||||
const configTomlPath = path.join(input.codexHome, "config.toml");
|
||||
const reason = notes.length === 0 ? " (PAPERCLIP_CODEX_PROVIDERS is no longer set)" : "";
|
||||
const backupPath = configTomlBackupPath(configTomlPath);
|
||||
const backup = await readFileOrNull(backupPath);
|
||||
if (backup !== null) {
|
||||
// Full-fidelity restore: the backup is the pre-run original, including
|
||||
// any user provider sections the crashed run's merge excised.
|
||||
await fs.writeFile(configTomlPath, backup, "utf8");
|
||||
await fs.rm(backupPath, { force: true });
|
||||
return {
|
||||
notes: [
|
||||
...notes,
|
||||
`Restored "${configTomlPath}" from its pre-run backup, removing stale Paperclip-managed model providers left by an interrupted run${reason}.`,
|
||||
],
|
||||
cleanup: async () => {},
|
||||
};
|
||||
}
|
||||
// Fallback for pre-backup stale state: strip the managed blocks.
|
||||
const existing = await readFileOrNull(configTomlPath);
|
||||
if (existing !== null) {
|
||||
const stripped = stripManagedCodexProviderBlocks(existing);
|
||||
if (stripped !== existing) {
|
||||
await fs.writeFile(configTomlPath, stripped, "utf8");
|
||||
return {
|
||||
notes: [
|
||||
...notes,
|
||||
`Removed stale Paperclip-managed model provider blocks from "${configTomlPath}"${reason}.`,
|
||||
],
|
||||
cleanup: async () => {},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return { notes, cleanup: async () => {} };
|
||||
}
|
||||
|
||||
if (!input.codexHome) {
|
||||
return {
|
||||
notes: [
|
||||
...notes,
|
||||
"PAPERCLIP_CODEX_PROVIDERS is set but the adapter config explicitly sets env.CODEX_HOME; leaving the user-managed Codex home untouched (no model provider merge).",
|
||||
],
|
||||
cleanup: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
const configTomlPath = path.join(input.codexHome, "config.toml");
|
||||
const backupPath = configTomlBackupPath(configTomlPath);
|
||||
// A surviving backup from an interrupted run is the true pre-run content;
|
||||
// the current config.toml would still carry that run's managed blocks.
|
||||
const original = (await readFileOrNull(backupPath)) ?? (await readFileOrNull(configTomlPath));
|
||||
const providerNames = Object.keys(parsed.providers);
|
||||
const base = stripConflictingDefinitions(
|
||||
stripManagedCodexProviderBlocks(original ?? ""),
|
||||
providerNames,
|
||||
parsed.modelProvider !== null,
|
||||
);
|
||||
await fs.mkdir(input.codexHome, { recursive: true });
|
||||
// Persist the original BEFORE writing the merged file so a run that never
|
||||
// reaches cleanup() can be restored by the next prepare.
|
||||
await fs.writeFile(backupPath, original ?? "", "utf8");
|
||||
await fs.writeFile(configTomlPath, buildMergedConfigToml(base, parsed), "utf8");
|
||||
|
||||
return {
|
||||
notes: [
|
||||
...notes,
|
||||
`Merged ${providerNames.length} custom Codex model provider(s) from PAPERCLIP_CODEX_PROVIDERS into "${configTomlPath}": ${providerNames.join(", ")}${
|
||||
parsed.modelProvider ? `; selected model_provider "${parsed.modelProvider}"` : ""
|
||||
}.`,
|
||||
],
|
||||
cleanup: async () => {
|
||||
if (original === null) {
|
||||
await fs.rm(configTomlPath, { force: true });
|
||||
} else {
|
||||
await fs.writeFile(configTomlPath, original, "utf8");
|
||||
}
|
||||
await fs.rm(backupPath, { force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user