@koreanhole/opencodex 2.98.0 → 2.99.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS_INSTALL.md CHANGED
@@ -29,6 +29,10 @@ ocx start
29
29
  ocx codex sync
30
30
  ```
31
31
 
32
+ `ocx start` journals the native Codex config and injects the managed routing.
33
+ `ocx stop` restores it. Use `ocx restore`/`ocx eject` to restore while keeping
34
+ the proxy alive, and `ocx restore back` to re-enable routing.
35
+
32
36
  `ocx codex sync` refreshes the Codex app catalog with locally discovered Gemini
33
37
  models and reasoning levels. Restart the Codex app/app-server after refresh. The
34
38
  catalog file is written only when `model_catalog_json` names an opencodex catalog
@@ -57,6 +61,9 @@ or through the documented default path; unrelated Codex settings are not changed
57
61
  ```bash
58
62
  ocx status
59
63
  ocx stop
64
+ ocx restore [--json] [--remove-codex-provider-table]
65
+ ocx restore back [--json]
66
+ ocx eject
60
67
  ```
61
68
 
62
69
  The default listener is loopback-only. A non-loopback bind is refused unless a
@@ -73,6 +80,7 @@ bun test tests/e2e/cline-only.test.ts
73
80
  bun run test:e2e:gemini
74
81
  bun run test:e2e:gemini-tool
75
82
  bun run test:e2e:gemini-reasoning
83
+ bun run test:e2e:codex-restore
76
84
  ```
77
85
 
78
86
  Report whether each evidence path used a deterministic ACP fixture or a real
package/README.md CHANGED
@@ -55,7 +55,15 @@ Cline-only configs load unchanged and keep Cline as the default.
55
55
 
56
56
  ## Codex
57
57
 
58
- Use a Responses provider pointed at the proxy:
58
+ `ocx start` now records a mode-0600 pre-image under `$OPENCODEX_HOME/codex/`,
59
+ injects the loopback Responses provider, and points Codex at the live model
60
+ catalog. The managed block is marked and is the only block opencodex will touch.
61
+
62
+ ```bash
63
+ ocx start
64
+ ```
65
+
66
+ The resulting Codex config is equivalent to:
59
67
 
60
68
  ```toml
61
69
  model = "gemini-cli/gemini-3.5-flash-lite"
@@ -69,6 +77,19 @@ requires_openai_auth = false
69
77
  model_catalog_url = "http://127.0.0.1:10100/v1/model-catalog"
70
78
  ```
71
79
 
80
+ Switch back to native Codex without stopping the proxy:
81
+
82
+ ```bash
83
+ ocx restore # alias: ocx eject
84
+ ocx restore back # re-point Codex at the running proxy
85
+ ```
86
+
87
+ `ocx stop`, Ctrl-C/SIGTERM, and SIGHUP also restore native Codex. The restore
88
+ covers Codex config and the opencodex catalog only; resume-history metadata is
89
+ not changed. A pre-existing unmarked `[model_providers.opencodex]` block is
90
+ treated as user-owned and refused. Remove a stale manual block first with
91
+ `ocx restore --remove-codex-provider-table` if needed.
92
+
72
93
  The model list is live. Cline ACP models are exposed as `cline-cli/<encoded-id>`;
73
94
  Gemini ACP models are exposed as `gemini-cli/<encoded-id>`. Each provider keeps a
74
95
  `default` selector. A provider prefix routes only to that vendor; unprefixed
@@ -120,6 +141,9 @@ Use a `cline-cli/...` model to route the same Messages surface to Cline.
120
141
  telemetry, updates, and automatic approval disabled. The real vendor provider
121
142
  store is immutable; a mode-0600 copy inside the staged home lets the vendor CLI
122
143
  refresh auth state and is removed after the turn.
144
+ - Codex restore uses a mode-0600 journal under `$OPENCODEX_HOME/codex/`. The
145
+ journal contains the pre-image needed to reverse only opencodex-managed config
146
+ and catalog changes; it is never logged and is removed after a successful restore.
123
147
  - Gemini native tools are removed by a per-turn deny policy; permission requests
124
148
  are cancelled, and no ACP filesystem/terminal client capabilities are advertised.
125
149
  User MCP servers, extensions, skills, and hooks are disabled for the session; only
@@ -149,6 +173,9 @@ bun run src/cli/index.ts provider list [--json]
149
173
  bun run src/cli/index.ts start [--port 10100] [--host 127.0.0.1]
150
174
  bun run src/cli/index.ts status [--json]
151
175
  bun run src/cli/index.ts stop [--json]
176
+ bun run src/cli/index.ts restore [--json] [--remove-codex-provider-table]
177
+ bun run src/cli/index.ts restore back [--json]
178
+ bun run src/cli/index.ts eject
152
179
  ```
153
180
 
154
181
  Gemini has no proxy login command. Authenticate it through the official CLI
@@ -168,6 +195,7 @@ bun run test:e2e:gemini-previous-response
168
195
  bun run test:e2e:gemini-websocket
169
196
  bun run test:e2e:gemini-websocket-tool
170
197
  bun run test:e2e:gemini-reasoning
198
+ bun run test:e2e:codex-restore
171
199
  bun run privacy:scan
172
200
  bun run structure:check
173
201
  ```
@@ -223,6 +251,9 @@ Optional overrides are `CLINE_BIN_PATH`, `GEMINI_BIN_PATH`, and `CODEX_BIN_PATH`
223
251
  quota; the catalog stays accurate but that model cannot complete a live turn.
224
252
  - **Codex model picker is stale**: run `ocx codex sync`, then restart the Codex
225
253
  app/app-server.
254
+ - **Codex provider conflict**: an existing unmarked
255
+ `[model_providers.opencodex]` was preserved. Reconcile it manually or use
256
+ `ocx restore --remove-codex-provider-table`, then start again.
226
257
  - **Port in use**: choose another port with `ocx start --port <port>`.
227
258
  - **Stale runtime state**: run `ocx stop`; it removes stale state only when the
228
259
  recorded process and health endpoint cannot be verified.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@koreanhole/opencodex",
3
- "version": "2.98.0",
3
+ "version": "2.99.0",
4
4
  "description": "Cline and Gemini CLI provider proxy for OpenAI Codex and Claude Code",
5
5
  "type": "module",
6
6
  "main": "./bin/package-main.mjs",
@@ -37,6 +37,7 @@
37
37
  "test:e2e:gemini-websocket": "bun test ./tests/e2e/gemini-codex.live.ts -t \"runs a live Codex WebSocket turn\"",
38
38
  "test:e2e:gemini-websocket-tool": "bun test ./tests/e2e/gemini-codex.live.ts -t \"runs a live Codex WebSocket tool loop\"",
39
39
  "test:e2e:gemini-reasoning": "bun test ./tests/e2e/gemini-reasoning.live.ts",
40
+ "test:e2e:codex-restore": "bun test ./tests/e2e/codex-restore.live.ts",
40
41
  "typecheck": "bun check --no-script --noEmit",
41
42
  "privacy:scan": "bun scripts/privacy-scan.ts",
42
43
  "structure:check": "bun scripts/structure-check.ts",
package/src/cli/index.ts CHANGED
@@ -20,6 +20,7 @@ import { startServer } from "../server";
20
20
  import { loginClineAcp } from "../adapters/cli-agent/cline/acp";
21
21
  import { runCodingAgentMcpServer } from "../adapters/cli-agent/mcp-server";
22
22
  import { syncCodexCatalog } from "../lib/codex-catalog";
23
+ import { injectCodexConfig, recordCodexCatalogState, restoreCodexConfig } from "../lib/codex-inject";
23
24
 
24
25
  const here = dirname(fileURLToPath(import.meta.url));
25
26
 
@@ -61,7 +62,10 @@ function help(): string {
61
62
  " provider list [--json] Show supported providers",
62
63
  " start [--port <port>] [--host <host>] Start the proxy",
63
64
  " status [--json] Check the running proxy",
64
- " stop [--json] Stop the running proxy",
65
+ " stop [--json] Stop the proxy and restore native Codex",
66
+ " restore [--json] Restore native Codex without stopping",
67
+ " restore back [--json] Re-point Codex at the running proxy",
68
+ " eject Alias of restore",
65
69
  " codex sync [--json] Refresh the Codex model catalog",
66
70
  " version Print the package version",
67
71
  " help Show this help",
@@ -179,21 +183,27 @@ async function startCommand(args: string[]): Promise<number> {
179
183
  config.port = parsed;
180
184
  }
181
185
  if (host !== undefined) config.host = host;
186
+ const running = startServer(config, { env });
187
+ try {
188
+ await injectCodexConfig({ env, endpoint: { host: running.host, port: running.port }, config });
189
+ } catch (error) {
190
+ await running.stop(true).catch(() => undefined);
191
+ throw error;
192
+ }
182
193
  try {
183
194
  const catalog = await syncCodexCatalog({ env });
184
195
  if (catalog.ok && catalog.written) {
196
+ await recordCodexCatalogState(env).catch(error => {
197
+ console.warn(`[opencodex] Codex catalog state was not journaled: ${error instanceof Error ? error.message : String(error)}`);
198
+ });
185
199
  console.log(`Codex catalog refreshed: ${catalog.path}`);
186
200
  console.log("Restart the Codex app/app-server to reload the model picker.");
187
- if (!catalog.configured) {
188
- console.log(`Activate it with: model_catalog_json = "${catalog.path}"`);
189
- }
190
201
  } else if (!catalog.ok) {
191
202
  console.warn(`[opencodex] Codex catalog was not refreshed: ${catalog.message}`);
192
203
  }
193
204
  } catch (error) {
194
205
  console.warn(`[opencodex] Codex catalog refresh failed: ${error instanceof Error ? error.message : String(error)}`);
195
206
  }
196
- const running = startServer(config, { env });
197
207
  await writeRuntimeState({ pid: process.pid, port: running.port, host: running.host, startedAt: new Date().toISOString() }, env);
198
208
  console.log(`opencodex listening on http://${running.host}:${running.port}/v1 (default provider: ${config.defaultProvider})`);
199
209
  console.log(`Health: http://${running.host}:${running.port}/healthz`);
@@ -202,10 +212,22 @@ async function startCommand(args: string[]): Promise<number> {
202
212
  console.log(`\nStopping proxy (${signal})...`);
203
213
  await running.stop(true);
204
214
  await removeRuntimeStateIfPid(process.pid, env);
205
- process.exit(0);
215
+ let exitCode = 0;
216
+ try {
217
+ const restored = await restoreCodexConfig({ env });
218
+ if (!restored.success) {
219
+ exitCode = 1;
220
+ console.error(`ocx: Codex restore did not complete: ${restored.catalog.message}`);
221
+ }
222
+ } catch (error) {
223
+ exitCode = 1;
224
+ console.error(`ocx: Codex restore failed: ${error instanceof Error ? error.message : String(error)}`);
225
+ }
226
+ process.exit(exitCode);
206
227
  };
207
228
  process.once("SIGINT", () => void shutdown("SIGINT"));
208
229
  process.once("SIGTERM", () => void shutdown("SIGTERM"));
230
+ process.once("SIGHUP", () => void shutdown("SIGHUP"));
209
231
  await new Promise<never>(() => undefined);
210
232
  return 0;
211
233
  }
@@ -232,9 +254,42 @@ async function codexCommand(args: string[]): Promise<number> {
232
254
  }
233
255
  console.log("Codex reload required: restart the Codex app or app-server model picker.");
234
256
  }
257
+ if (result.ok) await recordCodexCatalogState(process.env).catch(() => undefined);
235
258
  return 0;
236
259
  }
237
260
 
261
+ async function restoreCommand(args: string[]): Promise<number> {
262
+ const env = process.env;
263
+ const back = args[0] === "back";
264
+ if (back) args.shift();
265
+ const json = takeFlag(args, "--json");
266
+ const removeProviderTable = takeFlag(args, "--remove-codex-provider-table");
267
+ if (args.length > 0) throw new ConfigError(`Unknown restore option: ${args[0]}`);
268
+ let result;
269
+ if (back) {
270
+ const state = await readRuntimeState(env);
271
+ if (!state) throw new ConfigError("Proxy is not running; start it before `ocx restore back`.");
272
+ const config = await loadConfig(env);
273
+ if (!isAlive(state.pid) || !await health(state.host, state.port, config, env)) {
274
+ throw new ConfigError("The recorded proxy is not healthy; refusing to restore Codex routing.");
275
+ }
276
+ result = await injectCodexConfig({
277
+ env,
278
+ endpoint: { host: state.host, port: state.port },
279
+ config,
280
+ });
281
+ result = { ...result, action: "restore-back" as const };
282
+ } else {
283
+ result = await restoreCodexConfig({ env, removeProviderTable });
284
+ }
285
+ if (json) console.log(JSON.stringify(result));
286
+ else {
287
+ console.log(result.config.message);
288
+ console.log(result.catalog.message);
289
+ }
290
+ return result.success ? 0 : 1;
291
+ }
292
+
238
293
  async function statusCommand(args: string[]): Promise<number> {
239
294
  const json = takeFlag(args, "--json");
240
295
  if (args.length > 0) throw new ConfigError(`Unknown status option: ${args[0]}`);
@@ -257,16 +312,24 @@ async function stopCommand(args: string[]): Promise<number> {
257
312
  if (args.length > 0) throw new ConfigError(`Unknown stop option: ${args[0]}`);
258
313
  const state = await readRuntimeState();
259
314
  if (!state) {
260
- if (json) console.log(JSON.stringify({ stopped: false, reason: "not-running" }));
261
- else console.log("Proxy is not running.");
315
+ const restored = await restoreCodexConfig({ env: process.env });
316
+ if (json) console.log(JSON.stringify({ stopped: false, reason: "not-running", restore: restored }));
317
+ else {
318
+ console.log("Proxy is not running.");
319
+ console.log(restored.config.message);
320
+ }
262
321
  return 1;
263
322
  }
264
323
  const config = await loadConfig();
265
324
  if (!isAlive(state.pid)) {
266
325
  await removeRuntimeStateIfPid(state.pid);
267
- if (json) console.log(JSON.stringify({ stopped: true, stale: true }));
268
- else console.log("Removed stale proxy state.");
269
- return 0;
326
+ const restored = await restoreCodexConfig({ env: process.env });
327
+ if (json) console.log(JSON.stringify({ stopped: true, stale: true, restore: restored }));
328
+ else {
329
+ console.log("Removed stale proxy state.");
330
+ console.log(restored.config.message);
331
+ }
332
+ return restored.success ? 0 : 1;
270
333
  }
271
334
  if (!await health(state.host, state.port, config, process.env)) {
272
335
  throw new ConfigError(`Refusing to stop pid ${state.pid}: it is not the opencodex proxy recorded by this home.`);
@@ -276,9 +339,13 @@ async function stopCommand(args: string[]): Promise<number> {
276
339
  while (isAlive(state.pid) && Date.now() < deadline) await Bun.sleep(50);
277
340
  if (isAlive(state.pid)) process.kill(state.pid, "SIGKILL");
278
341
  await removeRuntimeStateIfPid(state.pid);
279
- if (json) console.log(JSON.stringify({ stopped: true, pid: state.pid }));
280
- else console.log(`Stopped proxy (pid ${state.pid}).`);
281
- return 0;
342
+ const restored = await restoreCodexConfig({ env: process.env });
343
+ if (json) console.log(JSON.stringify({ stopped: true, pid: state.pid, restore: restored }));
344
+ else {
345
+ console.log(`Stopped proxy (pid ${state.pid}).`);
346
+ console.log(restored.config.message);
347
+ }
348
+ return restored.success ? 0 : 1;
282
349
  }
283
350
 
284
351
  async function main(argv: string[]): Promise<number> {
@@ -301,7 +368,8 @@ async function main(argv: string[]): Promise<number> {
301
368
  if (command === "start") return await startCommand(argv);
302
369
  if (command === "status") return await statusCommand(argv);
303
370
  if (command === "stop") return await stopCommand(argv);
304
- throw new ConfigError(`Unknown command: ${command}. Expected login, provider, codex, start, status, stop, version, or help.`);
371
+ if (command === "restore" || command === "eject") return await restoreCommand(argv);
372
+ throw new ConfigError(`Unknown command: ${command}. Expected login, provider, codex, start, status, stop, restore, eject, version, or help.`);
305
373
  }
306
374
 
307
375
  try {
@@ -257,6 +257,67 @@ function readRootTomlString(text: string, key: string): string | undefined {
257
257
  return match?.[1]?.trim() || undefined;
258
258
  }
259
259
 
260
+ export function codexHome(env: NodeJS.ProcessEnv = process.env): string {
261
+ return env.CODEX_HOME?.trim() || join(env.HOME?.trim() || homedir(), ".codex");
262
+ }
263
+
264
+ /** Resolve the catalog only when opencodex is allowed to manage that file. */
265
+ export async function resolveCodexCatalogPath(
266
+ env: NodeJS.ProcessEnv = process.env,
267
+ ): Promise<string | undefined> {
268
+ const home = codexHome(env);
269
+ let configured: string | undefined;
270
+ try {
271
+ configured = readRootTomlString(await readFile(join(home, "config.toml"), "utf8"), "model_catalog_json");
272
+ } catch {
273
+ configured = undefined;
274
+ }
275
+ if (configured && basename(configured) !== "opencodex-catalog.json") return undefined;
276
+ return configured ? resolve(home, configured) : join(home, "opencodex-catalog.json");
277
+ }
278
+
279
+ export function isManagedCodexCatalogSlug(slug: unknown): boolean {
280
+ return managedSlug(slug);
281
+ }
282
+
283
+ /** Remove only opencodex-routed rows; preserve native/user rows. */
284
+ export async function removeManagedCodexCatalogRows(path: string): Promise<{
285
+ changed: boolean;
286
+ removed: number;
287
+ message: string;
288
+ }> {
289
+ let raw: string;
290
+ try {
291
+ raw = await readFile(path, "utf8");
292
+ } catch (error) {
293
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
294
+ return { changed: false, removed: 0, message: "No opencodex catalog file was present." };
295
+ }
296
+ throw error;
297
+ }
298
+ let parsed: Record<string, unknown>;
299
+ try {
300
+ const value = asRecord(JSON.parse(raw));
301
+ if (!value) throw new Error("catalog is not an object");
302
+ parsed = value;
303
+ } catch {
304
+ throw new Error("The opencodex catalog is malformed; it was preserved.");
305
+ }
306
+ const models = Array.isArray(parsed.models) ? parsed.models : undefined;
307
+ if (!models || models.some(model => !asRecord(model))) {
308
+ throw new Error("The opencodex catalog has an invalid models field; it was preserved.");
309
+ }
310
+ const kept = models.filter(model => !managedSlug(asRecord(model)?.slug));
311
+ const removed = models.length - kept.length;
312
+ if (removed === 0) return { changed: false, removed: 0, message: "No managed catalog rows were present." };
313
+ if (kept.length === 0 && basename(path) === "opencodex-catalog.json") {
314
+ await rm(path, { force: true });
315
+ return { changed: true, removed, message: `Removed ${removed} managed catalog row(s) and the empty opencodex catalog.` };
316
+ }
317
+ await writeCodexCatalog(path, { ...parsed, models: kept } as CodexCatalogDocument);
318
+ return { changed: true, removed, message: `Removed ${removed} managed catalog row(s) from ${path}.` };
319
+ }
320
+
260
321
  export async function writeCodexCatalog(
261
322
  path: string,
262
323
  document: CodexCatalogDocument,
@@ -0,0 +1,467 @@
1
+ import { createHash } from "node:crypto";
2
+ import { chmod, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import { getConfigDir, isLoopbackHost, type RuntimeConfig } from "../config";
5
+ import {
6
+ codexHome,
7
+ removeManagedCodexCatalogRows,
8
+ resolveCodexCatalogPath,
9
+ } from "./codex-catalog";
10
+
11
+ const JOURNAL_VERSION = 1;
12
+ const MANAGED_MARKER = "# Managed by opencodex: provider routing";
13
+ const LOCK_STALE_MS = 30_000;
14
+
15
+ export class CodexInjectionError extends Error {
16
+ constructor(message: string, readonly code: string) {
17
+ super(message);
18
+ this.name = "CodexInjectionError";
19
+ }
20
+ }
21
+
22
+ export interface CodexInjectionEndpoint {
23
+ host: string;
24
+ port: number;
25
+ }
26
+
27
+ export interface CodexInjectionResult {
28
+ success: boolean;
29
+ action: "inject" | "restore" | "restore-back";
30
+ changed: boolean;
31
+ config: { state: "ok" | "skipped" | "failed"; changed: boolean; message: string };
32
+ catalog: { state: "ok" | "skipped" | "failed"; changed: boolean; removed: number; message: string };
33
+ }
34
+
35
+ interface CodexJournal {
36
+ version: 1;
37
+ codexHome: string;
38
+ configPath: string;
39
+ originalConfigBase64: string | null;
40
+ injectedConfigHash: string;
41
+ catalogPath: string | null;
42
+ originalCatalogBase64: string | null;
43
+ managedCatalogHash: string | null;
44
+ createdAt: string;
45
+ }
46
+
47
+ interface MaybeFile {
48
+ exists: boolean;
49
+ bytes: Buffer | null;
50
+ }
51
+
52
+ function configPath(env: NodeJS.ProcessEnv): string {
53
+ return join(codexHome(env), "config.toml");
54
+ }
55
+
56
+ function journalPath(env: NodeJS.ProcessEnv): string {
57
+ return join(getConfigDir(env), "codex", "inject-journal.json");
58
+ }
59
+
60
+ function lockPath(env: NodeJS.ProcessEnv): string {
61
+ return join(getConfigDir(env), "codex", "inject.lock");
62
+ }
63
+
64
+ function hash(bytes: string | Buffer): string {
65
+ return createHash("sha256").update(bytes).digest("hex");
66
+ }
67
+
68
+ function b64(bytes: Buffer | null): string | null {
69
+ return bytes ? bytes.toString("base64") : null;
70
+ }
71
+
72
+ function unb64(value: string | null): Buffer | null {
73
+ return value ? Buffer.from(value, "base64") : null;
74
+ }
75
+
76
+ async function readMaybe(path: string): Promise<MaybeFile> {
77
+ try {
78
+ return { exists: true, bytes: await readFile(path) };
79
+ } catch (error) {
80
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return { exists: false, bytes: null };
81
+ throw error;
82
+ }
83
+ }
84
+
85
+ async function writeAtomic(path: string, bytes: string | Buffer, mode = 0o600): Promise<void> {
86
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
87
+ const temporary = `${path}.${process.pid}.${crypto.randomUUID()}.tmp`;
88
+ try {
89
+ await writeFile(temporary, bytes, { mode, flag: "wx" });
90
+ await chmod(temporary, mode);
91
+ await rename(temporary, path);
92
+ await chmod(path, mode).catch(() => undefined);
93
+ } finally {
94
+ await rm(temporary, { force: true }).catch(() => undefined);
95
+ }
96
+ }
97
+
98
+ function alive(pid: number): boolean {
99
+ try { process.kill(pid, 0); return true; } catch { return false; }
100
+ }
101
+
102
+ async function withLock<T>(env: NodeJS.ProcessEnv, fn: () => Promise<T>): Promise<T> {
103
+ const path = lockPath(env);
104
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
105
+ const acquire = async (): Promise<boolean> => {
106
+ try {
107
+ await writeFile(path, JSON.stringify({ pid: process.pid, at: Date.now() }), { mode: 0o600, flag: "wx" });
108
+ return true;
109
+ } catch (error) {
110
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
111
+ let stale = false;
112
+ try {
113
+ const info = await stat(path);
114
+ let pid = 0;
115
+ try { pid = (JSON.parse(await readFile(path, "utf8")) as { pid?: number }).pid ?? 0; } catch { /* malformed */ }
116
+ stale = Date.now() - info.mtimeMs > LOCK_STALE_MS || (pid > 0 && !alive(pid));
117
+ } catch {
118
+ stale = true;
119
+ }
120
+ if (stale) {
121
+ await rm(path, { force: true }).catch(() => undefined);
122
+ return await acquire();
123
+ }
124
+ return false;
125
+ }
126
+ };
127
+ if (!await acquire()) throw new CodexInjectionError("Another Codex configuration change is in progress; retry after it finishes.", "codex_config_busy");
128
+ try {
129
+ return await fn();
130
+ } finally {
131
+ await rm(path, { force: true }).catch(() => undefined);
132
+ }
133
+ }
134
+
135
+ function isProviderHeader(line: string): boolean {
136
+ return /^\s*\[\s*model_providers\.opencodex(?:\s*\.|\s*\])/.test(line);
137
+ }
138
+
139
+ function isTableHeader(line: string): boolean {
140
+ return /^\s*\[/.test(line);
141
+ }
142
+
143
+ function rootEnd(lines: readonly string[]): number {
144
+ const index = lines.findIndex(isTableHeader);
145
+ return index < 0 ? lines.length : index;
146
+ }
147
+
148
+ function rootValue(text: string, key: string): string | undefined {
149
+ const lines = text.split(/\r?\n/);
150
+ const end = rootEnd(lines);
151
+ const re = new RegExp(`^\\s*${key}\\s*=\\s*"([^"]*)"`, "m");
152
+ for (let index = 0; index < end; index++) {
153
+ const match = re.exec(lines[index]!);
154
+ if (match) return match[1] ?? "";
155
+ }
156
+ return undefined;
157
+ }
158
+
159
+ function setRootValue(text: string, key: string, value: string | undefined): string {
160
+ const lines = text.split(/\r?\n/);
161
+ let end = rootEnd(lines);
162
+ let found = false;
163
+ const re = new RegExp(`^\\s*${key}\\s*=`);
164
+ for (let index = 0; index < end; index++) {
165
+ if (!re.test(lines[index]!)) continue;
166
+ found = true;
167
+ if (value === undefined) {
168
+ lines.splice(index, 1);
169
+ end -= 1;
170
+ } else {
171
+ lines[index] = `${key} = ${JSON.stringify(value)}`;
172
+ }
173
+ break;
174
+ }
175
+ if (!found && value !== undefined) {
176
+ lines.splice(end, 0, `${key} = ${JSON.stringify(value)}`);
177
+ end += 1;
178
+ }
179
+ return lines.join("\n");
180
+ }
181
+
182
+ function hasUnmarkedProviderTable(text: string): boolean {
183
+ const lines = text.split(/\r?\n/);
184
+ for (let index = 0; index < lines.length; index++) {
185
+ if (!isProviderHeader(lines[index]!)) continue;
186
+ let previous = index - 1;
187
+ while (previous >= 0 && lines[previous]!.trim() === "") previous--;
188
+ if (lines[previous]?.trim() !== MANAGED_MARKER) return true;
189
+ }
190
+ return false;
191
+ }
192
+
193
+ function stripProviderTables(text: string): string {
194
+ const lines = text.split(/\r?\n/);
195
+ const out: string[] = [];
196
+ let skipping = false;
197
+ for (const line of lines) {
198
+ if (isTableHeader(line)) {
199
+ if (isProviderHeader(line)) {
200
+ skipping = true;
201
+ while (out.length > 0 && out[out.length - 1]!.trim() === MANAGED_MARKER) out.pop();
202
+ continue;
203
+ }
204
+ skipping = false;
205
+ }
206
+ if (skipping) continue;
207
+ if (line.trim() === MANAGED_MARKER) continue;
208
+ out.push(line);
209
+ }
210
+ return out.join("\n");
211
+ }
212
+
213
+ function providerBlock(endpoint: CodexInjectionEndpoint): string {
214
+ const base = `http://${endpoint.host}:${endpoint.port}/v1`;
215
+ return [
216
+ MANAGED_MARKER,
217
+ "[model_providers.opencodex]",
218
+ 'name = "opencodex (Cline or Gemini CLI)"',
219
+ `base_url = ${JSON.stringify(base)}`,
220
+ 'wire_api = "responses"',
221
+ "requires_openai_auth = false",
222
+ `model_catalog_url = ${JSON.stringify(`${base}/model-catalog`)}`,
223
+ ].join("\n");
224
+ }
225
+
226
+ function routedModel(original: string, config: RuntimeConfig): string {
227
+ const current = rootValue(original, "model");
228
+ if (current?.startsWith("cline-cli/") || current?.startsWith("gemini-cli/")) return current;
229
+ return `${config.defaultProvider}/${config.providers[config.defaultProvider].defaultModel}`;
230
+ }
231
+
232
+ function injectedConfig(text: string, endpoint: CodexInjectionEndpoint, config: RuntimeConfig): string {
233
+ if (hasUnmarkedProviderTable(text)) {
234
+ throw new CodexInjectionError(
235
+ "Codex config already defines a different [model_providers.opencodex] table; refusing to overwrite it.",
236
+ "codex_provider_conflict",
237
+ );
238
+ }
239
+ let next = stripProviderTables(text);
240
+ next = setRootValue(next, "model_provider", "opencodex");
241
+ next = setRootValue(next, "model", routedModel(text, config));
242
+ const body = next.trimEnd();
243
+ return `${body}${body ? "\n\n" : ""}${providerBlock(endpoint)}\n`;
244
+ }
245
+
246
+ function restoreRoots(current: string, original: string | null): string {
247
+ let next = stripProviderTables(current);
248
+ for (const key of ["model_provider", "model"] as const) {
249
+ next = setRootValue(next, key, original === null ? undefined : rootValue(original, key));
250
+ }
251
+ return next;
252
+ }
253
+
254
+ async function readJournal(env: NodeJS.ProcessEnv): Promise<CodexJournal | null> {
255
+ let raw: string;
256
+ try {
257
+ raw = await readFile(journalPath(env), "utf8");
258
+ } catch (error) {
259
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
260
+ throw error;
261
+ }
262
+ let parsed: Partial<CodexJournal>;
263
+ try {
264
+ parsed = JSON.parse(raw) as Partial<CodexJournal>;
265
+ } catch {
266
+ throw new CodexInjectionError("The Codex restore journal is malformed; it was preserved.", "codex_journal_malformed");
267
+ }
268
+ if (parsed.version !== JOURNAL_VERSION || typeof parsed.configPath !== "string" || typeof parsed.injectedConfigHash !== "string") {
269
+ throw new CodexInjectionError("The Codex restore journal is invalid; it was preserved.", "codex_journal_invalid");
270
+ }
271
+ return parsed as CodexJournal;
272
+ }
273
+
274
+ async function writeJournal(env: NodeJS.ProcessEnv, journal: CodexJournal): Promise<void> {
275
+ await writeAtomic(journalPath(env), `${JSON.stringify(journal, null, 2)}\n`);
276
+ }
277
+
278
+ function endpointError(endpoint: CodexInjectionEndpoint): void {
279
+ if (!Number.isInteger(endpoint.port) || endpoint.port < 1 || endpoint.port > 65_535) {
280
+ throw new CodexInjectionError("The Codex proxy endpoint port is invalid.", "codex_endpoint_invalid");
281
+ }
282
+ if (!isLoopbackHost(endpoint.host)) {
283
+ throw new CodexInjectionError("Codex injection requires a loopback proxy endpoint.", "codex_endpoint_non_loopback");
284
+ }
285
+ }
286
+
287
+ function validateToml(text: string): void {
288
+ if (!text.trim()) return;
289
+ try {
290
+ Bun.TOML.parse(text);
291
+ } catch {
292
+ throw new CodexInjectionError("Codex config is not valid TOML; it was preserved.", "codex_config_malformed");
293
+ }
294
+ }
295
+
296
+ /** Inject routing and persist the pre-image before touching Codex config. */
297
+ export async function injectCodexConfig(options: {
298
+ env: NodeJS.ProcessEnv;
299
+ endpoint: CodexInjectionEndpoint;
300
+ config: RuntimeConfig;
301
+ }): Promise<CodexInjectionResult> {
302
+ endpointError(options.endpoint);
303
+ return await withLock(options.env, async () => {
304
+ const path = configPath(options.env);
305
+ const current = await readMaybe(path);
306
+ if (current.exists) validateToml(current.bytes!.toString("utf8"));
307
+ const existing = await readJournal(options.env);
308
+ const originalConfig = existing ? unb64(existing.originalConfigBase64) : current.bytes;
309
+ const originalCatalog = existing ? unb64(existing.originalCatalogBase64) : null;
310
+ const catalogPath = existing?.catalogPath ?? await resolveCodexCatalogPath(options.env) ?? null;
311
+ const catalog = catalogPath ? await readMaybe(catalogPath) : { exists: false, bytes: null };
312
+ const before = current.bytes?.toString("utf8") ?? "";
313
+ if (existing && !hasManagedProvider(before)) {
314
+ throw new CodexInjectionError(
315
+ "A Codex restore journal exists but Codex routing is already native; run `ocx restore` before starting again.",
316
+ "codex_journal_state_conflict",
317
+ );
318
+ }
319
+ const next = injectedConfig(before, options.endpoint, options.config);
320
+ const journal: CodexJournal = {
321
+ version: JOURNAL_VERSION,
322
+ codexHome: codexHome(options.env),
323
+ configPath: path,
324
+ originalConfigBase64: b64(originalConfig),
325
+ injectedConfigHash: hash(next),
326
+ catalogPath: existing?.catalogPath ?? catalogPath,
327
+ originalCatalogBase64: existing ? existing.originalCatalogBase64 : b64(catalog.bytes),
328
+ managedCatalogHash: existing?.managedCatalogHash ?? null,
329
+ createdAt: existing?.createdAt ?? new Date().toISOString(),
330
+ };
331
+ await writeJournal(options.env, journal);
332
+ try {
333
+ await writeAtomic(path, next);
334
+ } catch (error) {
335
+ if (!existing) await rm(journalPath(options.env), { force: true }).catch(() => undefined);
336
+ throw error;
337
+ }
338
+ return {
339
+ success: true,
340
+ action: "inject",
341
+ changed: true,
342
+ config: { state: "ok", changed: true, message: "Codex routing is pointed at the local proxy." },
343
+ catalog: { state: "ok", changed: false, removed: 0, message: originalCatalog ? "Original catalog is journaled." : "Catalog state is journaled." },
344
+ };
345
+ });
346
+ }
347
+
348
+ function hasManagedProvider(text: string): boolean {
349
+ return text.includes(MANAGED_MARKER);
350
+ }
351
+
352
+ async function restoreCatalog(journal: CodexJournal): Promise<CodexInjectionResult["catalog"]> {
353
+ const path = journal.catalogPath;
354
+ if (!path) return { state: "skipped", changed: false, removed: 0, message: "No managed catalog path was journaled." };
355
+ const current = await readMaybe(path);
356
+ if (!current.exists) return { state: "skipped", changed: false, removed: 0, message: "No managed catalog file was present." };
357
+ const currentHash = hash(current.bytes!);
358
+ const original = unb64(journal.originalCatalogBase64);
359
+ if (journal.managedCatalogHash && currentHash === journal.managedCatalogHash) {
360
+ if (original === null) {
361
+ await rm(path, { force: true });
362
+ return { state: "ok", changed: true, removed: 0, message: "Removed the opencodex-owned catalog file." };
363
+ }
364
+ await writeAtomic(path, original);
365
+ return { state: "ok", changed: true, removed: 0, message: "Restored the original Codex catalog." };
366
+ }
367
+ try {
368
+ const removed = await removeManagedCodexCatalogRows(path);
369
+ return { state: "ok", changed: removed.changed, removed: removed.removed, message: removed.message };
370
+ } catch (error) {
371
+ return { state: "failed", changed: false, removed: 0, message: error instanceof Error ? error.message : String(error) };
372
+ }
373
+ }
374
+
375
+ /** Restore native Codex config/catalog; no-op when nothing was managed. */
376
+ export async function restoreCodexConfig(options: {
377
+ env: NodeJS.ProcessEnv;
378
+ removeProviderTable?: boolean;
379
+ }): Promise<CodexInjectionResult> {
380
+ return await withLock(options.env, async () => {
381
+ const journal = await readJournal(options.env);
382
+ const path = configPath(options.env);
383
+ const current = await readMaybe(path);
384
+ if (current.exists) validateToml(current.bytes!.toString("utf8"));
385
+ if (!journal) {
386
+ let catalog: CodexInjectionResult["catalog"] = {
387
+ state: "skipped", changed: false, removed: 0, message: "No journal was present.",
388
+ };
389
+ const catalogPath = await resolveCodexCatalogPath(options.env);
390
+ if (catalogPath) {
391
+ try {
392
+ const removed = await removeManagedCodexCatalogRows(catalogPath);
393
+ catalog = { state: "ok", changed: removed.changed, removed: removed.removed, message: removed.message };
394
+ } catch (error) {
395
+ catalog = { state: "failed", changed: false, removed: 0, message: error instanceof Error ? error.message : String(error) };
396
+ }
397
+ }
398
+ if (!current.exists) {
399
+ return {
400
+ success: catalog.state !== "failed", action: "restore", changed: catalog.changed,
401
+ config: { state: "skipped", changed: false, message: "Codex config was not present." },
402
+ catalog,
403
+ };
404
+ }
405
+ const text = current.bytes!.toString("utf8");
406
+ if (hasUnmarkedProviderTable(text) && !options.removeProviderTable) {
407
+ throw new CodexInjectionError("Codex contains an unmarked opencodex provider table; refusing to remove it.", "codex_provider_conflict");
408
+ }
409
+ if (!hasManagedProvider(text) && !options.removeProviderTable) {
410
+ return {
411
+ success: catalog.state !== "failed", action: "restore", changed: catalog.changed,
412
+ config: { state: "skipped", changed: false, message: "Codex is already using its native configuration." },
413
+ catalog,
414
+ };
415
+ }
416
+ const restored = restoreRoots(text, null);
417
+ await writeAtomic(path, restored);
418
+ return {
419
+ success: catalog.state !== "failed", action: "restore", changed: true || catalog.changed,
420
+ config: { state: "ok", changed: true, message: "Removed managed Codex routing; no pre-image journal was available." },
421
+ catalog,
422
+ };
423
+ }
424
+ const original = unb64(journal.originalConfigBase64);
425
+ const text = current.bytes?.toString("utf8") ?? "";
426
+ let changed = false;
427
+ let message = "Codex config was already native.";
428
+ if (current.exists && hash(current.bytes!) === journal.injectedConfigHash) {
429
+ if (original === null) await rm(path, { force: true });
430
+ else await writeAtomic(path, original);
431
+ changed = true;
432
+ message = original === null ? "Removed the managed Codex config." : "Restored the original Codex config.";
433
+ } else if (hasManagedProvider(text)) {
434
+ if (hasUnmarkedProviderTable(text) && !options.removeProviderTable) {
435
+ throw new CodexInjectionError("Codex contains an unmarked opencodex provider table; refusing to remove it.", "codex_provider_conflict");
436
+ }
437
+ await writeAtomic(path, restoreRoots(text, original?.toString("utf8") ?? null));
438
+ changed = true;
439
+ message = "Removed managed Codex routing while preserving unrelated edits.";
440
+ }
441
+ const catalog = await restoreCatalog(journal);
442
+ if (catalog.state === "failed") {
443
+ return {
444
+ success: false, action: "restore", changed,
445
+ config: { state: "ok", changed, message },
446
+ catalog,
447
+ };
448
+ }
449
+ await rm(journalPath(options.env), { force: true });
450
+ return {
451
+ success: true, action: "restore", changed: changed || catalog.changed,
452
+ config: { state: changed ? "ok" : "skipped", changed, message },
453
+ catalog,
454
+ };
455
+ });
456
+ }
457
+
458
+ /** Record the post-sync catalog hash so a later exact restore can remove it. */
459
+ export async function recordCodexCatalogState(env: NodeJS.ProcessEnv): Promise<void> {
460
+ await withLock(env, async () => {
461
+ const journal = await readJournal(env);
462
+ if (!journal?.catalogPath) return;
463
+ const current = await readMaybe(journal.catalogPath);
464
+ journal.managedCatalogHash = current.exists ? hash(current.bytes!) : null;
465
+ await writeJournal(env, journal);
466
+ });
467
+ }