@bitkyc08/opencodex 2.7.40 → 2.7.41

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.
Files changed (110) hide show
  1. package/README.md +63 -12
  2. package/assets/claude-code-models.gif +0 -0
  3. package/gui/dist/assets/index-B2J4t3te.css +1 -0
  4. package/gui/dist/assets/index-BmvM6wRb.js +65 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +3 -2
  7. package/src/adapters/google.ts +48 -9
  8. package/src/adapters/kiro-events.ts +15 -3
  9. package/src/adapters/kiro.ts +292 -28
  10. package/src/adapters/openai-chat.ts +63 -15
  11. package/src/adapters/openai-responses.ts +18 -0
  12. package/src/bridge.ts +76 -21
  13. package/src/chat/outbound.ts +66 -34
  14. package/src/claude/auth-detect.ts +229 -0
  15. package/src/claude/auth-mode-migration.ts +32 -0
  16. package/src/claude/auth-mode.ts +62 -0
  17. package/src/claude/desktop-3p-guard.ts +35 -0
  18. package/src/claude/desktop-3p.ts +121 -21
  19. package/src/claude/desktop-health.ts +26 -0
  20. package/src/claude/desktop-profile.ts +263 -0
  21. package/src/claude/inbound-debug.ts +4 -0
  22. package/src/claude/model-info.ts +9 -3
  23. package/src/cli/account-extended.ts +34 -0
  24. package/src/cli/account.ts +3 -1
  25. package/src/cli/claude-desktop.ts +152 -0
  26. package/src/cli/claude.ts +37 -3
  27. package/src/cli/doctor.ts +187 -6
  28. package/src/cli/help.ts +13 -1
  29. package/src/cli/index.ts +151 -57
  30. package/src/cli/status-oauth.ts +68 -0
  31. package/src/cli/status.ts +4 -0
  32. package/src/codex/account-lifecycle.ts +31 -0
  33. package/src/codex/auth-api.ts +133 -26
  34. package/src/codex/auth-collision.ts +55 -11
  35. package/src/codex/auth-context.ts +50 -6
  36. package/src/codex/catalog/provider-fetch.ts +6 -2
  37. package/src/codex/home.ts +61 -1
  38. package/src/codex/inject.ts +28 -66
  39. package/src/codex/injected-marker.ts +72 -0
  40. package/src/codex/journal.ts +39 -3
  41. package/src/codex/main-account-cache.ts +25 -0
  42. package/src/codex/model-cache.ts +20 -1
  43. package/src/codex/paths.ts +5 -0
  44. package/src/codex/routing.ts +138 -11
  45. package/src/codex/subagent-model-fallback.ts +455 -0
  46. package/src/codex/sync.ts +17 -0
  47. package/src/combos/failover.ts +10 -2
  48. package/src/combos/index.ts +1 -0
  49. package/src/combos/types.ts +9 -0
  50. package/src/config.ts +117 -0
  51. package/src/grok/inject.ts +339 -0
  52. package/src/grok/status.ts +88 -0
  53. package/src/grok/sync.ts +66 -0
  54. package/src/lib/destination-policy.ts +13 -0
  55. package/src/lib/errors.ts +59 -0
  56. package/src/lib/privacy.ts +9 -0
  57. package/src/lib/process-control.ts +57 -4
  58. package/src/oauth/health.ts +375 -0
  59. package/src/oauth/index.ts +69 -22
  60. package/src/oauth/kiro.ts +5 -3
  61. package/src/oauth/log.ts +48 -0
  62. package/src/oauth/store.ts +55 -6
  63. package/src/providers/alibaba-region-backup.ts +75 -0
  64. package/src/providers/alibaba-region-migration.ts +143 -0
  65. package/src/providers/alibaba-region-startup.ts +36 -0
  66. package/src/providers/api-keys.ts +5 -5
  67. package/src/providers/derive.ts +22 -1
  68. package/src/providers/free-directory.ts +181 -0
  69. package/src/providers/key-failover.ts +2 -2
  70. package/src/providers/kiro-models.ts +3 -2
  71. package/src/providers/openai-tiers.ts +1 -1
  72. package/src/providers/provider-id-rewrite.ts +150 -0
  73. package/src/providers/registry.ts +49 -2
  74. package/src/responses/parser.ts +49 -20
  75. package/src/responses/state.ts +156 -2
  76. package/src/router.ts +73 -5
  77. package/src/server/chat-completions.ts +78 -22
  78. package/src/server/claude-messages.ts +8 -0
  79. package/src/server/images.ts +2 -1
  80. package/src/server/index.ts +50 -30
  81. package/src/server/live.ts +2 -1
  82. package/src/server/management/agent-settings-routes.ts +337 -18
  83. package/src/server/management/api-access.ts +141 -0
  84. package/src/server/management/combo-routes.ts +3 -3
  85. package/src/server/management/config-routes.ts +4 -4
  86. package/src/server/management/logs-usage-routes.ts +64 -4
  87. package/src/server/management/model-routes.ts +114 -8
  88. package/src/server/management/oauth-account-routes.ts +31 -5
  89. package/src/server/management/provider-routes.ts +42 -11
  90. package/src/server/management/shared.ts +81 -2
  91. package/src/server/management/system-routes.ts +6 -1
  92. package/src/server/management-api.ts +24 -7
  93. package/src/server/port-reclaim.ts +261 -0
  94. package/src/server/request-log.ts +11 -5
  95. package/src/server/responses/collaboration.ts +11 -6
  96. package/src/server/responses/compact.ts +2 -1
  97. package/src/server/responses/core.ts +358 -155
  98. package/src/server/responses/passthrough-error.ts +53 -0
  99. package/src/server/search.ts +2 -1
  100. package/src/server/system-env.ts +23 -6
  101. package/src/server/windows-tcp-drop.ts +174 -0
  102. package/src/service.ts +43 -3
  103. package/src/types.ts +74 -6
  104. package/src/update/index.ts +30 -19
  105. package/src/update/job.ts +30 -15
  106. package/src/usage/log.ts +158 -2
  107. package/src/usage/summary.ts +8 -4
  108. package/src/web-search/loop.ts +4 -1
  109. package/gui/dist/assets/index-CMip1DzF.css +0 -1
  110. package/gui/dist/assets/index-cydcmbzC.js +0 -52
package/src/codex/home.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
- import { join, posix, resolve } from "node:path";
3
+ import { join, posix, resolve, win32 } from "node:path";
4
4
  import { expandUserPath } from "../config";
5
+ import { redactUserPath } from "../lib/redact";
5
6
 
6
7
  export type CodexHomeDeps = {
7
8
  env?: NodeJS.ProcessEnv;
@@ -144,3 +145,62 @@ export function resolveCodexHomeDir(deps: CodexHomeDeps = {}): string {
144
145
  if (raw) return resolve(expandUserPath(raw));
145
146
  return defaultCodexHome(deps);
146
147
  }
148
+
149
+ export type OrcaCodexHomeDiagnostic = {
150
+ applicable: boolean;
151
+ mismatch: boolean;
152
+ effectiveCodexHome: string;
153
+ appCodexHome: string;
154
+ orcaCodexHome: string | null;
155
+ warning: string | null;
156
+ action: string | null;
157
+ };
158
+
159
+ type OrcaCodexHomeDeps = CodexHomeDeps & {
160
+ effectiveCodexHome?: string;
161
+ appCodexHome?: string;
162
+ };
163
+
164
+ function normalizedWindowsPath(path: string): string {
165
+ return path.trim().replaceAll("/", "\\").replace(/\\+$/, "").toLowerCase();
166
+ }
167
+
168
+ /**
169
+ * High-confidence Orca/ChatGPT dual-home diagnosis. Explicit CODEX_HOME remains
170
+ * authoritative; this only explains when an Orca-owned shell targets a home the
171
+ * Windows ChatGPT/Codex app does not read.
172
+ */
173
+ export function collectOrcaCodexHomeDiagnostic(deps: OrcaCodexHomeDeps = {}): OrcaCodexHomeDiagnostic {
174
+ const platform = deps.platform ?? process.platform;
175
+ const env = deps.env ?? process.env;
176
+ const effectiveCodexHome = deps.effectiveCodexHome ?? resolveCodexHomeDir(deps);
177
+ const appCodexHome = deps.appCodexHome
178
+ ?? (platform === "win32" ? win32.join((deps.homedir ?? homedir)(), ".codex") : join((deps.homedir ?? homedir)(), ".codex"));
179
+ const explicitHome = env.CODEX_HOME?.trim() ?? "";
180
+ const orcaCodexHome = env.ORCA_CODEX_HOME?.trim() || null;
181
+ const normalizedEffective = normalizedWindowsPath(effectiveCodexHome);
182
+ const normalizedOrca = orcaCodexHome ? normalizedWindowsPath(orcaCodexHome) : "";
183
+ const normalizedApp = normalizedWindowsPath(appCodexHome);
184
+ const applicable = platform === "win32"
185
+ && !!explicitHome
186
+ && !!orcaCodexHome
187
+ && normalizedEffective === normalizedOrca
188
+ && /(?:^|\\)orca\\codex-runtime-home\\home$/i.test(normalizedOrca);
189
+ const mismatch = applicable && normalizedEffective !== normalizedApp;
190
+ const displayEffective = redactUserPath(effectiveCodexHome);
191
+ const displayApp = redactUserPath(appCodexHome);
192
+ const displayOrca = orcaCodexHome ? redactUserPath(orcaCodexHome) : null;
193
+ return {
194
+ applicable,
195
+ mismatch,
196
+ effectiveCodexHome: displayEffective,
197
+ appCodexHome: displayApp,
198
+ orcaCodexHome: displayOrca,
199
+ warning: mismatch
200
+ ? `CODEX_HOME targets Orca's runtime home (${displayEffective}), while the Windows ChatGPT/Codex app uses ${displayApp}; OpenCodex injection will not reach that app.`
201
+ : null,
202
+ action: mismatch
203
+ ? "If a service was installed from Orca, run 'ocx service uninstall' in that original Orca shell first. Then in Command Prompt run set \"ORCA_CODEX_HOME=\" and set \"CODEX_HOME=%USERPROFILE%\\.codex\"; or in PowerShell run Remove-Item Env:ORCA_CODEX_HOME -ErrorAction SilentlyContinue; $env:CODEX_HOME = Join-Path $env:USERPROFILE '.codex'. Rerun the command, then reinstall with 'ocx service install'."
204
+ : null,
205
+ };
206
+ }
@@ -3,11 +3,23 @@ import { atomicWriteFile, loadConfig, websocketsEnabled } from "../config";
3
3
  import { markJournalInjectedState, removeJournal, restoreJournalState, writeJournal } from "./journal";
4
4
  import { restoreCodexCatalog } from "./catalog";
5
5
  import { migrateHistoryToOpenai, syncCodexHistoryProvider } from "./history-provider";
6
+ import {
7
+ OCX_SECTION_MARKER,
8
+ hasInjectedCodexRouting,
9
+ hasInjectedOpenaiBaseUrl,
10
+ isRootOpenaiBaseUrlLine,
11
+ providerTableStart,
12
+ providerTableString,
13
+ rootTomlString,
14
+ tomlStringPattern,
15
+ } from "./injected-marker";
6
16
  import { CODEX_CONFIG_PATH, CODEX_PROFILE_PATH, DEFAULT_CATALOG_PATH, parseTomlString, readRootTomlString, resolveCodexConfigPath, tomlString } from "./paths";
7
17
  import { resolveEffectiveProjectModelProvider } from "./project-config-warnings";
8
18
  import type { OcxConfig } from "../types";
9
19
 
10
- const OCX_SECTION_MARKER = "# Auto-injected by opencodex";
20
+ // Ownership predicates live in `./injected-marker` so `journal.ts` can reach them
21
+ // without importing this module back. Re-exported for existing external callers.
22
+ export { hasInjectedCodexRouting, hasInjectedOpenaiBaseUrl };
11
23
 
12
24
  export function externalCodexModelProvider(content: string): string | null {
13
25
  const provider = resolveEffectiveProjectModelProvider(content).provider;
@@ -64,12 +76,18 @@ export interface InjectCodexOptions {
64
76
  * whatever `[table]` happened to be open last (e.g. `[plugins."chrome@openai-bundled"]`), so Codex
65
77
  * never saw a global model_provider and silently fell back to the `openai` (ChatGPT) provider.
66
78
  */
67
- function isLoopbackHostname(hostname: string | undefined): boolean {
79
+ /**
80
+ * True only for hostnames that bind loopback ONLY. Wildcard binds ("0.0.0.0", "::") are NOT
81
+ * loopback: they expose the proxy on every interface and therefore require the admission token.
82
+ * Do not use `providerBaseHost` for this decision — it folds wildcards to 127.0.0.1 because it
83
+ * answers "what address do I dial", which is a different question from "is this exposed".
84
+ */
85
+ export function isLoopbackHostname(hostname: string | undefined): boolean {
68
86
  const normalized = (hostname ?? "127.0.0.1").trim().toLowerCase();
69
87
  return normalized === "" || normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1" || normalized === "[::1]";
70
88
  }
71
89
 
72
- function providerBaseHost(hostname: string | undefined): string {
90
+ export function providerBaseHost(hostname: string | undefined): string {
73
91
  const trimmed = (hostname ?? "127.0.0.1").trim();
74
92
  const lower = trimmed.toLowerCase();
75
93
  // Match what the server actually binds. Writing "localhost" while binding IPv4-only
@@ -106,10 +124,6 @@ export function buildOpenaiBaseUrlLine(port: number, hostname?: string): string
106
124
  return `openai_base_url = "http://${providerBaseHost(hostname)}:${port}/v1"`;
107
125
  }
108
126
 
109
- function isRootOpenaiBaseUrlLine(line: string): boolean {
110
- return /^\s*openai_base_url\s*=/.test(line);
111
- }
112
-
113
127
  /**
114
128
  * Design B root-key injection: place `OCX_SECTION_MARKER` + `openai_base_url` at the document
115
129
  * ROOT (before the first table header). Idempotent: an existing marker-owned line is rewritten
@@ -162,55 +176,8 @@ export function stripInjectedOpenaiBaseUrl(content: string): string {
162
176
  return lines.filter((_, i) => !drop.has(i)).join("\n");
163
177
  }
164
178
 
165
- export function hasInjectedOpenaiBaseUrl(content: string): boolean {
166
- const lines = content.split("\n");
167
- const firstTable = lines.findIndex(l => /^\s*\[/.test(l));
168
- const rootEnd = firstTable === -1 ? lines.length : firstTable;
169
- for (let i = 1; i < rootEnd; i++) {
170
- if (isRootOpenaiBaseUrlLine(lines[i]) && lines[i - 1].includes(OCX_SECTION_MARKER)) return true;
171
- }
172
- return false;
173
- }
174
-
175
179
  export type CodexRoutingKind = "native" | "opencodex-local" | "custom-local" | "custom-remote" | "unknown";
176
180
 
177
- function tomlStringPattern(key: string): RegExp {
178
- const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
179
- const keyToken = `(?:${escaped}|\"${escaped}\"|'${escaped}')`;
180
- return new RegExp(`^\\s*${keyToken}\\s*=\\s*[\"']([^\"']+)[\"']\\s*(?:#.*)?$`);
181
- }
182
-
183
- function rootTomlString(content: string, key: string): string | null {
184
- const lines = content.split("\n");
185
- const firstTable = lines.findIndex(line => /^\s*\[/.test(line));
186
- const rootLines = lines.slice(0, firstTable === -1 ? lines.length : firstTable);
187
- const pattern = tomlStringPattern(key);
188
- for (const line of rootLines) {
189
- const match = pattern.exec(line);
190
- if (match?.[1]) return match[1].trim();
191
- }
192
- return null;
193
- }
194
-
195
- function providerTableStart(lines: string[], provider: string): number {
196
- const escapedProvider = provider.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
197
- const providerToken = `(?:${escapedProvider}|\"${escapedProvider}\"|'${escapedProvider}')`;
198
- const header = new RegExp(`^\\s*\\[\\s*(?:model_providers|\"model_providers\"|'model_providers')\\s*\\.\\s*${providerToken}\\s*\\]\\s*(?:#.*)?$`);
199
- return lines.findIndex(line => header.test(line));
200
- }
201
-
202
- function providerTableString(content: string, provider: string, key: string): string | null {
203
- const lines = content.split("\n");
204
- const start = providerTableStart(lines, provider);
205
- if (start === -1) return null;
206
- const pattern = tomlStringPattern(key);
207
- for (let index = start + 1; index < lines.length && !/^\s*\[/.test(lines[index]); index += 1) {
208
- const match = pattern.exec(lines[index]);
209
- if (match?.[1]) return match[1].trim();
210
- }
211
- return null;
212
- }
213
-
214
181
  type RoutingEndpointKind = "local" | "remote" | "unknown";
215
182
 
216
183
  function ipv4Octets(hostname: string): number[] | null {
@@ -271,17 +238,6 @@ export function classifyCodexRouting(content: string): CodexRoutingKind {
271
238
  return "native";
272
239
  }
273
240
 
274
- /**
275
- * True when the active Codex config is owned by opencodex routing. Covers the
276
- * loopback Design B root override and the legacy/non-loopback provider table.
277
- * A user-owned `openai_base_url` is intentionally not classified as injected.
278
- */
279
- export function hasInjectedCodexRouting(content: string): boolean {
280
- if (hasInjectedOpenaiBaseUrl(content)) return true;
281
- return rootTomlString(content, "model_provider") === "opencodex"
282
- && providerTableString(content, "opencodex", "base_url") !== null;
283
- }
284
-
285
241
  /** Read-only probe used by status, doctor, and the dashboard. */
286
242
  export function isCodexRoutingInjected(): boolean {
287
243
  const path = CODEX_CONFIG_PATH;
@@ -523,7 +479,13 @@ export async function injectCodexConfig(port: number, config?: OcxConfig, option
523
479
  };
524
480
  }
525
481
 
526
- writeJournal();
482
+ // Classify and journal the same bytes: a native config is a valid original and
483
+ // supersedes a stale snapshot (#477), while an injected one must never become
484
+ // one — that is how opencodex routing would survive `ocx stop`.
485
+ writeJournal({
486
+ currentStateIsNative: !hasInjectedCodexRouting(rawContent),
487
+ configContent: rawContent,
488
+ });
527
489
  // EOL boundary: transforms below are LF-pure; preserve the file's dominant ending on write.
528
490
  const eol = dominantEol(rawContent);
529
491
  let content = applyEol(rawContent, "\n");
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Ownership predicates for `~/.codex/config.toml`: does opencodex own the routing
3
+ * currently written there?
4
+ *
5
+ * These live in their own leaf module rather than in `inject.ts` because
6
+ * `journal.ts` needs them and `inject.ts` already imports `journal.ts`. Keeping
7
+ * them here breaks that cycle. `inject.ts` imports them back and re-exports the
8
+ * two public predicates, so external callers see no change.
9
+ */
10
+ export const OCX_SECTION_MARKER = "# Auto-injected by opencodex";
11
+
12
+ export function isRootOpenaiBaseUrlLine(line: string): boolean {
13
+ return /^\s*openai_base_url\s*=/.test(line);
14
+ }
15
+
16
+ export function tomlStringPattern(key: string): RegExp {
17
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
18
+ const keyToken = `(?:${escaped}|"${escaped}"|'${escaped}')`;
19
+ return new RegExp(`^\\s*${keyToken}\\s*=\\s*["']([^"']+)["']\\s*(?:#.*)?$`);
20
+ }
21
+
22
+ export function rootTomlString(content: string, key: string): string | null {
23
+ const lines = content.split("\n");
24
+ const firstTable = lines.findIndex(line => /^\s*\[/.test(line));
25
+ const rootLines = lines.slice(0, firstTable === -1 ? lines.length : firstTable);
26
+ const pattern = tomlStringPattern(key);
27
+ for (const line of rootLines) {
28
+ const match = pattern.exec(line);
29
+ if (match?.[1]) return match[1].trim();
30
+ }
31
+ return null;
32
+ }
33
+
34
+ export function providerTableStart(lines: string[], provider: string): number {
35
+ const escapedProvider = provider.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
36
+ const providerToken = `(?:${escapedProvider}|"${escapedProvider}"|'${escapedProvider}')`;
37
+ const header = new RegExp(`^\\s*\\[\\s*(?:model_providers|"model_providers"|'model_providers')\\s*\\.\\s*${providerToken}\\s*\\]\\s*(?:#.*)?$`);
38
+ return lines.findIndex(line => header.test(line));
39
+ }
40
+
41
+ export function providerTableString(content: string, provider: string, key: string): string | null {
42
+ const lines = content.split("\n");
43
+ const start = providerTableStart(lines, provider);
44
+ if (start === -1) return null;
45
+ const pattern = tomlStringPattern(key);
46
+ for (let index = start + 1; index < lines.length && !/^\s*\[/.test(lines[index]); index += 1) {
47
+ const match = pattern.exec(lines[index]);
48
+ if (match?.[1]) return match[1].trim();
49
+ }
50
+ return null;
51
+ }
52
+
53
+ export function hasInjectedOpenaiBaseUrl(content: string): boolean {
54
+ const lines = content.split("\n");
55
+ const firstTable = lines.findIndex(l => /^\s*\[/.test(l));
56
+ const rootEnd = firstTable === -1 ? lines.length : firstTable;
57
+ for (let i = 1; i < rootEnd; i++) {
58
+ if (isRootOpenaiBaseUrlLine(lines[i]!) && lines[i - 1]!.includes(OCX_SECTION_MARKER)) return true;
59
+ }
60
+ return false;
61
+ }
62
+
63
+ /**
64
+ * True when the active Codex config is owned by opencodex routing. Covers the
65
+ * loopback Design B root override and the legacy/non-loopback provider table.
66
+ * A user-owned `openai_base_url` is intentionally not classified as injected.
67
+ */
68
+ export function hasInjectedCodexRouting(content: string): boolean {
69
+ if (hasInjectedOpenaiBaseUrl(content)) return true;
70
+ return rootTomlString(content, "model_provider") === "opencodex"
71
+ && providerTableString(content, "opencodex", "base_url") !== null;
72
+ }
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { existsSync, readFileSync, unlinkSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { atomicWriteFile } from "../config";
5
+ import { hasInjectedCodexRouting } from "./injected-marker";
5
6
  import { CODEX_HOME, CODEX_CONFIG_PATH, CODEX_PROFILE_PATH } from "./paths";
6
7
 
7
8
  const JOURNAL_PATH = join(CODEX_HOME, "opencodex-journal.json");
@@ -28,10 +29,45 @@ function sha256(content: string | null): string | null {
28
29
  return content === null ? null : createHash("sha256").update(content).digest("hex");
29
30
  }
30
31
 
31
- export function writeJournal(): void {
32
- if (existsSync(JOURNAL_PATH) && readJournal()) return;
32
+ export interface WriteJournalOptions {
33
+ /**
34
+ * The caller's verdict on the config it is about to transform: false when
35
+ * `hasInjectedCodexRouting` matched. This does NOT decide whether the content
36
+ * may be journaled — that is checked below, from the bytes themselves. It only
37
+ * authorizes REPLACING an existing snapshot, which is why omitting it still
38
+ * allows a first snapshot but never an overwrite.
39
+ */
40
+ currentStateIsNative?: boolean;
41
+ /**
42
+ * The exact bytes the caller classified. Journaling these rather than re-reading
43
+ * the file keeps the snapshot and the verdict describing the same content when
44
+ * another process rewrites config.toml mid-flight.
45
+ */
46
+ configContent?: string;
47
+ }
48
+
49
+ /**
50
+ * Snapshot the pre-injection Codex state.
51
+ *
52
+ * Only native (non-opencodex-owned) config may be journaled, and native config
53
+ * always supersedes an older snapshot. The first half stops a re-inject from
54
+ * recording opencodex's own routing as the user's original — which would survive
55
+ * `ocx stop` and make the injection unremovable. The second half is the #477 fix:
56
+ * without it the first snapshot a machine ever takes is the only one it ever has,
57
+ * so an unclean shutdown days later replays a day-one config over the user's
58
+ * plugins, model choice, and trusted projects.
59
+ */
60
+ export function writeJournal(options: WriteJournalOptions = {}): void {
33
61
  if (!existsSync(CODEX_CONFIG_PATH)) return;
34
- const config = readFileSync(CODEX_CONFIG_PATH, "utf-8");
62
+ const config = options.configContent ?? readFileSync(CODEX_CONFIG_PATH, "utf-8");
63
+ // Ownership is decided HERE, from the bytes about to be journaled — never taken
64
+ // on the caller's word. A caller that says "native" about injected content would
65
+ // otherwise make opencodex's own routing the user's permanent "original".
66
+ if (hasInjectedCodexRouting(config)) return;
67
+ // The caller's verdict only authorizes REPLACEMENT. It is weaker evidence than
68
+ // the check above (it may describe bytes read a moment earlier), so an
69
+ // unclassified call creates a first snapshot but never overwrites one.
70
+ if (existsSync(JOURNAL_PATH) && readJournal() && options.currentStateIsNative !== true) return;
35
71
  const profile = existsSync(CODEX_PROFILE_PATH)
36
72
  ? readFileSync(CODEX_PROFILE_PATH, "utf-8")
37
73
  : null;
@@ -0,0 +1,25 @@
1
+ import type { StoredAccountQuota } from "./quota";
2
+
3
+ export interface MainAccountInfo {
4
+ email: string | null;
5
+ plan: string | null;
6
+ quota: Omit<StoredAccountQuota, "updatedAt"> | null;
7
+ }
8
+
9
+ export interface CachedMainAccountInfo extends MainAccountInfo {
10
+ ts: number;
11
+ }
12
+
13
+ let cachedMainAccountInfo: CachedMainAccountInfo | null = null;
14
+
15
+ export function getMainAccountInfoCache(): CachedMainAccountInfo | null {
16
+ return cachedMainAccountInfo;
17
+ }
18
+
19
+ export function setMainAccountInfoCache(value: CachedMainAccountInfo): void {
20
+ cachedMainAccountInfo = value;
21
+ }
22
+
23
+ export function clearMainAccountInfoCache(): void {
24
+ cachedMainAccountInfo = null;
25
+ }
@@ -47,13 +47,23 @@ export const MODELS_FETCH_FAILURE_COOLDOWN_MS = 30_000;
47
47
 
48
48
  const failureAt = new Map<string, number>();
49
49
  const discoveryStatus = new Map<string, ProviderModelDiscoveryStatus>();
50
+ /**
51
+ * How many models the last successful discovery actually returned, before any configured-alias
52
+ * augmentation or custom-model merge. Consumers cannot recover this by subtracting known custom
53
+ * ids: a custom id that later also appears upstream would make a real live catalog look
54
+ * custom-only, and the provider's configured fallback would wrongly stay authoritative.
55
+ */
56
+ const liveModelCounts = new Map<string, number>();
50
57
 
51
58
  export function markModelsFetchFailure(provider: string, now = Date.now()): void {
52
59
  failureAt.set(provider, now);
53
60
  }
54
61
 
55
- export function markProviderDiscoveryOk(provider: string): void {
62
+ /** `liveModelCount` is required so a caller that forgets to pass it fails typecheck instead of
63
+ * silently recording zero and misclassifying the provider as having no live catalog. */
64
+ export function markProviderDiscoveryOk(provider: string, liveModelCount: number): void {
56
65
  discoveryStatus.set(provider, { status: "ok" });
66
+ liveModelCounts.set(provider, Math.max(0, Math.floor(liveModelCount)));
57
67
  }
58
68
 
59
69
  export function markProviderDiscoveryFailed(
@@ -88,12 +98,19 @@ export function shouldLogDiscoveryFailure(
88
98
 
89
99
  export function clearProviderDiscoveryStatus(provider: string): void {
90
100
  discoveryStatus.delete(provider);
101
+ liveModelCounts.delete(provider);
91
102
  }
92
103
 
93
104
  export function getProviderDiscoveryStatus(provider: string): ProviderModelDiscoveryStatus | undefined {
94
105
  return discoveryStatus.get(provider);
95
106
  }
96
107
 
108
+ /** Undefined when discovery has never succeeded for this provider. A failed refresh keeps the
109
+ * last successful count so a stale catalog is still recognised as live-origin. */
110
+ export function getProviderLiveModelCount(provider: string): number | undefined {
111
+ return liveModelCounts.get(provider);
112
+ }
113
+
97
114
  export function isModelsFetchCoolingDown(provider: string, cooldownMs = MODELS_FETCH_FAILURE_COOLDOWN_MS, now = Date.now()): boolean {
98
115
  const at = failureAt.get(provider);
99
116
  return at !== undefined && now - at < cooldownMs;
@@ -121,9 +138,11 @@ export function clearModelCache(provider?: string): void {
121
138
  cache.delete(provider);
122
139
  failureAt.delete(provider);
123
140
  discoveryStatus.delete(provider);
141
+ liveModelCounts.delete(provider);
124
142
  } else {
125
143
  cache.clear();
126
144
  failureAt.clear();
127
145
  discoveryStatus.clear();
146
+ liveModelCounts.clear();
128
147
  }
129
148
  }
@@ -29,6 +29,11 @@ export const CODEX_PROFILE_PATH = join(CODEX_HOME, "opencodex.config.toml");
29
29
  export const DEFAULT_CATALOG_PATH = join(CODEX_HOME, "opencodex-catalog.json");
30
30
  export const CODEX_MODELS_CACHE_PATH = join(CODEX_HOME, "models_cache.json");
31
31
 
32
+ /** Runtime CODEX_HOME lookup (honors CODEX_HOME env changes after import). */
33
+ export function getCodexHome(): string {
34
+ return resolveCodexHome();
35
+ }
36
+
32
37
  export function tomlString(value: string): string {
33
38
  return JSON.stringify(value);
34
39
  }
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { saveConfig } from "../config";
2
+ import { saveConfigPreservingClaudeCode } from "../config";
3
3
  import { isCodexAccountGenerationLive, readCodexAccountRecord } from "./account-store";
4
4
  import { codexAccountLogLabel } from "./account-label";
5
5
  import { isCodexAccountUsable } from "./account-usability";
@@ -235,14 +235,8 @@ export function computeQuotaCooldownUntil(meta: CodexUpstreamOutcomeMeta = {}):
235
235
  * Returns the lease id, or null when no probe may go out right now.
236
236
  */
237
237
  export function tryAcquireCodexQuotaProbeLease(accountId: string, now = Date.now()): string | null {
238
- const health = upstreamHealth.get(accountId);
239
- if (!health) return null;
240
- const cooldownUntil = health.cooldownUntil;
241
- if (typeof cooldownUntil !== "number" || !Number.isFinite(cooldownUntil) || cooldownUntil <= now) return null;
242
- if (health.cooldownSource === "retry-after") return null;
243
- if (health.probeLeaseId !== undefined) return null;
244
- const origin = health.lastProbeAt ?? health.cooldownSince ?? cooldownUntil;
245
- if (now - origin < CODEX_QUOTA_PROBE_INTERVAL_MS) return null;
238
+ if (!canAcquireCodexQuotaProbeLease(accountId, now)) return null;
239
+ const health = upstreamHealth.get(accountId)!;
246
240
  const probeLeaseId = randomUUID();
247
241
  upstreamHealth.set(accountId, {
248
242
  ...health,
@@ -253,6 +247,18 @@ export function tryAcquireCodexQuotaProbeLease(accountId: string, now = Date.now
253
247
  return probeLeaseId;
254
248
  }
255
249
 
250
+ /** Side-effect-free check mirroring {@link tryAcquireCodexQuotaProbeLease} eligibility. */
251
+ export function canAcquireCodexQuotaProbeLease(accountId: string, now = Date.now()): boolean {
252
+ const health = upstreamHealth.get(accountId);
253
+ if (!health) return false;
254
+ const cooldownUntil = health.cooldownUntil;
255
+ if (typeof cooldownUntil !== "number" || !Number.isFinite(cooldownUntil) || cooldownUntil <= now) return false;
256
+ if (health.cooldownSource === "retry-after") return false;
257
+ if (health.probeLeaseId !== undefined) return false;
258
+ const origin = health.lastProbeAt ?? health.cooldownSince ?? cooldownUntil;
259
+ return now - origin >= CODEX_QUOTA_PROBE_INTERVAL_MS;
260
+ }
261
+
256
262
  /**
257
263
  * Hand a probe lease back without recording an upstream outcome. Used by paths
258
264
  * that take a lease and then fail before any request reaches upstream.
@@ -304,10 +310,63 @@ export function getCodexAccountCooldownUntil(accountId: string, now = Date.now()
304
310
  return typeof cooldownUntil === "number" && Number.isFinite(cooldownUntil) && cooldownUntil > now ? cooldownUntil : null;
305
311
  }
306
312
 
313
+ /** Read-only cooldown snapshot for shared OAuth health projection (no write side effects). */
314
+ export function getCodexAccountHealthSnapshot(accountId: string, now = Date.now()): {
315
+ cooldownUntil?: number;
316
+ cooldownSource?: CodexCooldownSource;
317
+ } | null {
318
+ const cooldownUntil = getCodexAccountCooldownUntil(accountId, now);
319
+ if (cooldownUntil === null) return null;
320
+ const source = upstreamHealth.get(accountId)?.cooldownSource;
321
+ return {
322
+ cooldownUntil,
323
+ ...(source ? { cooldownSource: source } : {}),
324
+ };
325
+ }
326
+
307
327
  export function isCodexAccountInCooldown(accountId: string, now = Date.now()): boolean {
308
328
  return getCodexAccountCooldownUntil(accountId, now) !== null;
309
329
  }
310
330
 
331
+ /**
332
+ * Manually lift a hard quota cooldown without touching failure history.
333
+ *
334
+ * Injected Codex routing makes this proxy the ONLY model path for Codex Desktop, so a
335
+ * cooldown that outlives the real upstream limit reads to the user as "the whole app is
336
+ * broken" with no escape but editing config.toml. This is that escape hatch.
337
+ *
338
+ * Deliberately narrow:
339
+ * - Failure counters and softAvoid survive. Clearing a cooldown says "the quota window
340
+ * moved", not "this account is healthy"; failover must keep its knowledge.
341
+ * - Dropping `probeLeaseId` is what stops a stale in-flight probe from later "proving"
342
+ * recovery against a NEWER cooldown: {@link ownsProbeLease} needs the id to match.
343
+ * `cooldownGeneration` is preserved and bumped as redundancy only — a fresh 429 already
344
+ * bumps it in {@link recordCodexUpstreamOutcome}, so the bump here is not load-bearing
345
+ * today and is kept so the invariant survives a future change that retains the lease.
346
+ *
347
+ * Returns false when the account carried no live cooldown (already expired or never set).
348
+ */
349
+ export function clearCodexAccountCooldown(accountId: string, now = Date.now()): boolean {
350
+ const health = upstreamHealth.get(accountId);
351
+ if (!health) return false;
352
+ const cooldownUntil = health.cooldownUntil;
353
+ if (typeof cooldownUntil !== "number" || !Number.isFinite(cooldownUntil) || cooldownUntil <= now) return false;
354
+ const {
355
+ cooldownUntil: _until,
356
+ cooldownSince: _since,
357
+ cooldownSource: _source,
358
+ probeLeaseId: _leaseId,
359
+ probeLeaseGeneration: _leaseGeneration,
360
+ ...rest
361
+ } = health;
362
+ upstreamHealth.set(accountId, {
363
+ ...rest,
364
+ cooldownGeneration: (health.cooldownGeneration ?? 0) + 1,
365
+ lastProbeAt: now,
366
+ });
367
+ return true;
368
+ }
369
+
311
370
  export function getCodexAccountSoftAvoidUntil(accountId: string, now = Date.now()): number | null {
312
371
  const softAvoidUntil = upstreamHealth.get(accountId)?.softAvoidUntil;
313
372
  return typeof softAvoidUntil === "number" && Number.isFinite(softAvoidUntil) && softAvoidUntil > now
@@ -391,7 +450,7 @@ function getEligiblePoolAccounts(config: OcxConfig, excludeId?: string, now = Da
391
450
  return ids;
392
451
  }
393
452
 
394
- function getPoolAccountPlan(config: OcxConfig, accountId: string): string | undefined {
453
+ export function getPoolAccountPlan(config: OcxConfig, accountId: string): string | undefined {
395
454
  if (accountId === MAIN_CODEX_ACCOUNT_ID) return getMainAccountPlan();
396
455
  return (config.codexAccounts ?? []).find(account => !account.isMain && account.id === accountId)?.plan;
397
456
  }
@@ -425,7 +484,7 @@ export function pickLowestUsageCodexAccount(config: OcxConfig, excludeId?: strin
425
484
  function setActiveCodexAccount(config: OcxConfig, accountId: string): void {
426
485
  if (config.activeCodexAccountId === accountId) return;
427
486
  config.activeCodexAccountId = accountId;
428
- saveConfig(config);
487
+ saveConfigPreservingClaudeCode(config);
429
488
  }
430
489
 
431
490
  function isUnknownUsage(usage: number): boolean {
@@ -499,6 +558,74 @@ export function resolveCodexAccountForThread(
499
558
  return resolution.status === "selected" ? resolution.accountId : null;
500
559
  }
501
560
 
561
+ /**
562
+ * Side-effect-free preview of the Codex pool account native routing would prefer.
563
+ * Used for subagent fallback quota decisions before final auth.
564
+ *
565
+ * Does not mutate activeCodexAccountId, thread affinity, config on disk, or probe leases.
566
+ * Mirrors {@link resolveCodexAccountForThreadDetailed} account choice, including returning a
567
+ * configured cooled account so callers can evaluate probe/quota availability.
568
+ */
569
+ export function previewCodexAccountForRequest(
570
+ threadId: string | null,
571
+ config: OcxConfig,
572
+ now = Date.now(),
573
+ ): string | null {
574
+ if (threadId && threadAccountMap.has(threadId)) {
575
+ const entry = threadAccountMap.get(threadId)!;
576
+ if (
577
+ !isThreadAffinityExpired(entry, now)
578
+ && isThreadAffinityGenerationLive(entry)
579
+ && isCodexAccountSelectable(config, entry.accountId, now)
580
+ && !shouldFailover(config, entry.accountId, now)
581
+ ) {
582
+ const threshold = config.autoSwitchThreshold ?? 80;
583
+ if (threshold > 0) {
584
+ const usage = computeCodexUsageScore(
585
+ getAccountQuota(entry.accountId),
586
+ getPoolAccountPlan(config, entry.accountId),
587
+ );
588
+ if (usage >= threshold) {
589
+ const best = pickLowerUsageAccount(config, entry.accountId, usage, now);
590
+ if (best !== entry.accountId) return best;
591
+ }
592
+ }
593
+ return entry.accountId;
594
+ }
595
+ // Stale/unusable affinity is ignored for preview (no map mutation).
596
+ }
597
+
598
+ let active = config.activeCodexAccountId ?? null;
599
+ if (!active) {
600
+ return pickLowestUsageCodexAccount(config, undefined, now);
601
+ }
602
+ if (!isCodexAccountSelectable(config, active, now)) {
603
+ const fallback = pickLowestUsageCodexAccount(config, active, now);
604
+ if (fallback) active = fallback;
605
+ else if (hasConfiguredPoolAccount(config, active)) return active;
606
+ else return null;
607
+ }
608
+
609
+ const threshold = config.autoSwitchThreshold ?? 80;
610
+ if (threshold > 0) {
611
+ const usage = computeCodexUsageScore(getAccountQuota(active), getPoolAccountPlan(config, active));
612
+ if (usage >= threshold) {
613
+ active = pickLowerUsageAccount(config, active, usage, now);
614
+ }
615
+ }
616
+ if (shouldFailover(config, active, now)) {
617
+ const best = pickLowestUsageCodexAccount(config, active, now);
618
+ if (best) active = best;
619
+ }
620
+ if (!isCodexAccountUsable(config, active)) {
621
+ return hasConfiguredPoolAccount(config, active) ? active : null;
622
+ }
623
+ if (isCodexAccountInCooldown(active, now)) {
624
+ return hasConfiguredPoolAccount(config, active) ? active : null;
625
+ }
626
+ return active;
627
+ }
628
+
502
629
  export function resolveCodexAccountForThreadDetailed(
503
630
  threadId: string | null,
504
631
  config: OcxConfig,