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:
Jannes Stubbemann
2026-06-11 06:13:31 +02:00
committed by GitHub
parent 6e4aca9c67
commit 9e750d3e92
3 changed files with 1336 additions and 458 deletions
@@ -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 });
},
};
}