9e750d3e92
## 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>
431 lines
17 KiB
TypeScript
431 lines
17 KiB
TypeScript
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 });
|
|
},
|
|
};
|
|
}
|