@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 +8 -0
- package/README.md +32 -1
- package/package.json +2 -1
- package/src/cli/index.ts +83 -15
- package/src/lib/codex-catalog.ts +61 -0
- package/src/lib/codex-inject.ts +467 -0
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
261
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
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 {
|
package/src/lib/codex-catalog.ts
CHANGED
|
@@ -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
|
+
}
|