mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 06:42:06 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - AI connections choose credentials and routes independently of the agent harness. > - OpenRouter assigns app credit from the final request headers. > - Harness defaults can assign Paperclip work to the underlying harness. > - This pull request applies one public Paperclip identity to managed OpenRouter requests. > - The benefit is consistent app credit across supported harnesses and internal model calls. ## Linked Issues or Issue Description **What existing behavior does this improve?** App attribution for existing OpenRouter connections and explicit provider configurations. **Current behavior** Requests can omit attribution or carry the harness identity. Paperclip does not receive consistent credit in OpenRouter rankings. **Proposed behavior** Use `https://paperclip.ing`, the title `Paperclip`, and the coding categories `cloud-agent,programming-app` for managed requests to OpenRouter. Keep credentials, routing, and unrelated providers intact. **Reason and benefit** The provider URL identifies the app across personal and shared API keys. A search of public issues and pull requests found no duplicate attribution work. This improves existing AI connections and does not add a separate roadmap feature. ## What Changed - Add a shared attribution policy and a parity-checked copy for the standalone Runner. - Apply it to managed Codex, Claude, OpenCode, and Hermes connections, plus native Pi and explicit legacy provider configs. - Preserve attribution across Codex config filtering, Claude environment filtering, OpenCode proxy requests, and model-level overrides. - Attribute direct fast-response and decision-model requests, including custom connections to the OpenRouter endpoint. - Add positive and negative regression tests and provider attribution requirements to the connector playbook. - Advance native Pi to profile 24, preserving historical identities and binding the attribution source in its declaration and release checks. ## Verification - Final head `6df282d2a58c2698e41127443903d6fc85b4b72f`: all CI/status checks passed or were intentionally skipped; Greptile is **5/5**, with zero unresolved review threads. [Final CI run](https://github.com/paperclipai/paperclip/actions/runs/39274938069). - Focused server/shared/config suites: 132 tests passed before the final edge-case additions. Expanded adapter suites pass 48 tests; the final OpenCode merge passes 15 tests; global/EU/US endpoint and policy-parity suites pass 26 tests. - Focused Runner suites: 85 tests passed, including model overrides and recovery/config boundaries. Profile installation/environment/config tests subsequently passed 72 tests (one optional distribution test skipped); historical decoding passed 11 tests and updated recovery coverage passed 27 tests. The Rust profile admission test and all five native Pi launch/recovery tests passed. - All 114 managed AI connection tests pass together. They include custom-header preservation for legacy and native Claude runs, both OpenRouter and other gateways, inherited headers, and unresolved secret references. - Actual requests from installed Codex 0.160.0, Claude Code 2.1.245, OpenCode 1.18.34, Pi 0.74.0 and 1.0.0, and the managed Hermes runtime carried all four Paperclip headers. A local endpoint used dummy credentials and returned an intentional 401 after capture. Codex and Claude retry requests retained attribution. This proves outgoing header precedence; it is not a successful model-inference test. The first Claude capture had an argument-separation error and sent no request; correcting the command produced the captured requests. - A live request through `fastResponseModel` completed with `attribution verified`. OpenRouter recorded generation `gen-1792420273-Ez1GUTCc7YuZ1FJMCGob` under app ID `3118063`, at a cost of $0.00000375. The public app record maps that ID to `https://paperclip.ing/`. - Full `pnpm -r typecheck` and `pnpm build` passed, including a repeat after the review fixes. The final server build stamp records commit `6df282d2a58c2698e41127443903d6fc85b4b72f`. `pnpm check:token-gates` passed. - CI first caught the stale Pi source attestation; it is now a new immutable profile. Greptile caught custom-header loss, now fixed and covered by the complete managed-connection suite. The next CI run caught the missing Docker allowlist entry for profile 24; the allowlist and release smoke pins are now synchronized. Local Docker verification cannot run because its daemon socket is absent; The exact Docker context check passed in CI on the final head. - The initial local `pnpm test:run` did **not** pass. It ran across source/build edits, reported chat/tool-access collection failures, three workspace-runtime failures, two new managed-header failures, one Dot Runner readiness failure, and two native-session-resume failures, and was interrupted after the final-head CI server coverage passed. Its secrets suite also reported 91 cases skipped. The isolated tool-access suite passed all 428 tests, and the complete managed-connection suite passed all 114 tests. These retries establish recovery, not the cause of the original local failures; no claim of a successful full local run is made. Final acceptance uses immutable final-head CI plus the focused local and live checks above. - Final-head CI encountered one unchanged UI assertion in `FileViewerSheet.copy.test.tsx` (missing “Copied link” feedback after clipboard completion). That shard passed on the earlier head with identical UI code, and all six clipboard tests passed locally. A timing race around the asynchronous React update is suspected but not conclusively established. The single CI retry passed, with no product/test code changed for it. The earlier failure remains part of this verification record. ## Risks - Native Pi profile 24 requires a fresh provider session when encountering a profile 23 identity; recovery tests cover that boundary. - Harness upgrades can change header precedence. The playbook requires actual request capture when changing provider paths. - Externally managed agents, unmanaged harness settings, and gateways that replace headers need separate qualification. This change does not intercept arbitrary model calls made by task scripts. - The existing OpenRouter entry for the Paperclip URL still displays an old title, “PrettyFly Opus Testing,” and no categories on the final public-page recheck. The live request received credit under that URL's app ID. Public title propagation and leaderboard placement remain controlled by OpenRouter. - Global, EU, and US OpenRouter hosts are recognized explicitly; other subdomains are not treated as OpenRouter. No database migration, credential ownership change, or Paperclip telemetry event is involved. Attribution contains public product constants only. ## Model Used OpenAI GPT-6 through Codex, with reasoning, repository editing, shell execution, web research, and live API verification. The exact deployment identifier and context-window size are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass — the focused suites listed above; the unsuccessful full local attempt is explicitly recorded, with full final-head coverage supplied by CI - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
440 lines
18 KiB
TypeScript
440 lines
18 KiB
TypeScript
import { isOpenRouterUrl, openRouterAttributionHeaders, OPENROUTER_ATTRIBUTION_HEADERS } from "@paperclipai/shared/openrouter-attribution";
|
|
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;
|
|
}
|
|
const provider = expandEnvPlaceholders(value, resolveEnv);
|
|
if (isOpenRouterUrl(provider.base_url)) {
|
|
provider.http_headers = openRouterAttributionHeaders(isPlainObject(provider.http_headers) ? provider.http_headers as Record<string, string> : {});
|
|
if (isPlainObject(provider.env_http_headers)) {
|
|
const attribution = new Set(Object.keys(OPENROUTER_ATTRIBUTION_HEADERS).map(name => name.toLowerCase()));
|
|
provider.env_http_headers = Object.fromEntries(Object.entries(provider.env_http_headers).filter(([name]) => !attribution.has(name.toLowerCase())));
|
|
}
|
|
}
|
|
providers[key] = provider;
|
|
}
|
|
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 });
|
|
},
|
|
};
|
|
}
|