@bitkyc08/opencodex 2.25.0 → 2.26.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.
Files changed (99) hide show
  1. package/bin/ocx.mjs +59 -7
  2. package/gui/dist/assets/index-RL6b1bTV.js +102 -0
  3. package/gui/dist/index.html +1 -1
  4. package/package.json +1 -1
  5. package/src/adapters/base.ts +18 -0
  6. package/src/adapters/client-fingerprint.ts +0 -2
  7. package/src/adapters/cursor/http1-bidi.ts +361 -0
  8. package/src/adapters/cursor/live-models.ts +117 -30
  9. package/src/adapters/cursor/live-transport.ts +137 -56
  10. package/src/adapters/cursor/native-exec-fs.ts +1 -1
  11. package/src/adapters/cursor/native-exec-network.ts +1 -1
  12. package/src/adapters/cursor/native-exec-shell.ts +0 -1
  13. package/src/adapters/cursor/protobuf-request.ts +89 -21
  14. package/src/adapters/cursor/tool-definitions.ts +18 -7
  15. package/src/adapters/cursor/tool-result-normalize.ts +92 -0
  16. package/src/adapters/cursor/transport.ts +7 -0
  17. package/src/adapters/cursor.ts +2 -0
  18. package/src/adapters/google-http.ts +38 -10
  19. package/src/adapters/google.ts +23 -3
  20. package/src/adapters/openai-chat.ts +38 -13
  21. package/src/adapters/openai-responses.ts +129 -9
  22. package/src/adapters/registry.ts +30 -1
  23. package/src/bridge.ts +50 -12
  24. package/src/chat/inbound.ts +1 -0
  25. package/src/claude/agents-inject.ts +3 -2
  26. package/src/cli/dispatch.ts +19 -6
  27. package/src/cli/help.ts +2 -1
  28. package/src/cli/integrations.ts +35 -0
  29. package/src/cli/minimax.ts +8 -2
  30. package/src/cli/registry.ts +13 -2
  31. package/src/clients/config-export.ts +120 -1
  32. package/src/codex/account-store.ts +17 -1
  33. package/src/codex/auth-api.ts +51 -17
  34. package/src/codex/catalog/effort.ts +8 -5
  35. package/src/codex/catalog/provider-fetch.ts +39 -27
  36. package/src/codex/catalog/sync.ts +30 -8
  37. package/src/codex/inject.ts +39 -13
  38. package/src/codex/main-account.ts +29 -1
  39. package/src/codex/plan-from-token.ts +140 -0
  40. package/src/codex/plan.ts +25 -0
  41. package/src/codex/prompt-journal.ts +6 -2
  42. package/src/codex/quota-rejection.ts +21 -7
  43. package/src/codex/refresh.ts +4 -2
  44. package/src/codex/sync.ts +106 -3
  45. package/src/codex/warmup.ts +187 -81
  46. package/src/config.ts +85 -46
  47. package/src/generated/compatibility-version.json +127 -83
  48. package/src/grok/inject.ts +1 -1
  49. package/src/images/loop.ts +1 -0
  50. package/src/integrations/registry.ts +7 -0
  51. package/src/lab/automation/config-persistence.ts +2 -2
  52. package/src/lab/automation/persistence.ts +2 -2
  53. package/src/lab/ledger/purge.ts +2 -2
  54. package/src/lab/subject/behavior-fingerprint.ts +2 -2
  55. package/src/lib/config-ownership.ts +5 -2
  56. package/src/lib/destination-policy.ts +18 -1
  57. package/src/lib/provider-outbound.ts +7 -0
  58. package/src/lib/redact.ts +11 -0
  59. package/src/lib/tool-argument-integers.ts +50 -6
  60. package/src/lib/upstream-http-version.ts +57 -0
  61. package/src/lib/upstream-retry.ts +2 -1
  62. package/src/lib/windows-atomic-replace.ts +155 -0
  63. package/src/lib/windows-service-wrappers.ts +72 -0
  64. package/src/oauth/google-antigravity.ts +2 -2
  65. package/src/oauth/index.ts +60 -8
  66. package/src/providers/antigravity-models.ts +58 -4
  67. package/src/providers/command-code-efforts.ts +36 -9
  68. package/src/providers/context-cap.ts +9 -0
  69. package/src/providers/derive.ts +4 -0
  70. package/src/providers/fastwire.ts +453 -0
  71. package/src/providers/registry.ts +21 -1
  72. package/src/providers/service-tier.ts +173 -89
  73. package/src/responses/parser.ts +25 -16
  74. package/src/responses/reasoning-replay-cache.ts +30 -0
  75. package/src/responses/thought-signature-replay.ts +74 -4
  76. package/src/router.ts +6 -0
  77. package/src/routing/compatibility/behavior.ts +27 -16
  78. package/src/server/index.ts +10 -1
  79. package/src/server/management/oauth-account-routes.ts +10 -2
  80. package/src/server/management/shared.ts +1 -1
  81. package/src/server/management/system-routes.ts +15 -0
  82. package/src/server/request-log.ts +57 -3
  83. package/src/server/responses/agent-task-recovery.ts +6 -1
  84. package/src/server/responses/core.ts +209 -40
  85. package/src/server/responses/fetch-helpers.ts +15 -36
  86. package/src/server/responses/responses-field-backfill.ts +173 -0
  87. package/src/server/responses-reasoning-summary-rewrite.ts +171 -0
  88. package/src/service.ts +38 -36
  89. package/src/storage/cleanup.ts +2 -2
  90. package/src/tray/windows.ts +3 -2
  91. package/src/types.ts +101 -14
  92. package/src/update/job.ts +9 -18
  93. package/src/update/transactional-install.d.mts +22 -0
  94. package/src/update/transactional-install.mjs +259 -0
  95. package/src/usage/cost.ts +34 -5
  96. package/src/usage/log.ts +74 -4
  97. package/src/vision/anthropic-describe.ts +10 -6
  98. package/src/web-search/anthropic-executor.ts +8 -6
  99. package/gui/dist/assets/index-DxJ7kXj9.js +0 -102
@@ -227,9 +227,13 @@ export function buildProviderTableBlock(
227
227
  "requires_openai_auth = true",
228
228
  ];
229
229
  if (includeApiAuthHeader) {
230
- lines.push(
231
- 'env_http_headers = { "x-opencodex-api-key" = "OPENCODEX_API_AUTH_TOKEN" }',
232
- );
230
+ // codex-cli 0.146+ contract (#2073): env_key sends Authorization: Bearer $VAR and
231
+ // hard-errors on a missing/empty variable instead of silently omitting auth. It
232
+ // coexists with requires_openai_auth (env_key wins wire auth; the flag keeps the
233
+ // login/account UX), and the server substitutes stored main auth for our admission
234
+ // bearer (#1686), so the modern form is strictly better than the legacy
235
+ // env_http_headers table this line used to emit.
236
+ lines.push('env_key = "OPENCODEX_API_AUTH_TOKEN"');
233
237
  }
234
238
  if (supportsWebsockets) lines.push("supports_websockets = true");
235
239
  return lines.join("\n") + "\n";
@@ -735,7 +739,7 @@ export async function injectCodexConfig(
735
739
  // Design B form FIRST: removeOcxSection also keys on the marker line, so a root-level
736
740
  // marker + openai_base_url pair must be gone before it scans or it would swallow root keys.
737
741
  content = stripInjectedOpenaiBaseUrl(content);
738
- if (content.includes("[model_providers.opencodex]")) {
742
+ if (hasOcxProviderTable(content)) {
739
743
  content = removeOcxSection(content);
740
744
  }
741
745
  content = removeProfileSection(content);
@@ -1095,6 +1099,30 @@ export async function injectCodexConfig(
1095
1099
  };
1096
1100
  }
1097
1101
 
1102
+ /**
1103
+ * Sub-table headers like `[model_providers.opencodex.env_http_headers]` appear when a Codex app
1104
+ * config rewrite re-serializes the provider's inline `env_http_headers` table. They define the
1105
+ * same `model_providers.opencodex` provider, so cleanup must remove them too — otherwise the
1106
+ * provider survives with no `name` and Codex rejects the whole config
1107
+ * ("provider name must not be empty"). The dot terminator keeps a user's
1108
+ * `[model_providers.opencodex_backup]`-style tables out of scope.
1109
+ */
1110
+ function isOcxProviderHeaderLine(trimmedLine: string): boolean {
1111
+ // Root form matched by regex, not equality: TOML v1.0 allows a trailing comment
1112
+ // (`[model_providers.opencodex] # comment`), and an exact compare would miss that form.
1113
+ // The sub-table prefix check already tolerates trailing comments by construction.
1114
+ return (
1115
+ /^\[model_providers\.opencodex\]\s*(?:#.*)?$/.test(trimmedLine) ||
1116
+ trimmedLine.startsWith("[model_providers.opencodex.")
1117
+ );
1118
+ }
1119
+
1120
+ function hasOcxProviderTable(content: string): boolean {
1121
+ return content
1122
+ .split("\n")
1123
+ .some((line) => isOcxProviderHeaderLine(line.trim()));
1124
+ }
1125
+
1098
1126
  function removeOcxSection(content: string): string {
1099
1127
  const lines = content.split("\n");
1100
1128
  const filtered: string[] = [];
@@ -1102,18 +1130,16 @@ function removeOcxSection(content: string): string {
1102
1130
  for (const line of lines) {
1103
1131
  if (
1104
1132
  line.includes(OCX_SECTION_MARKER) ||
1105
- line.trim() === "[model_providers.opencodex]"
1133
+ isOcxProviderHeaderLine(line.trim())
1106
1134
  ) {
1107
1135
  inOcxSection = true;
1108
1136
  continue;
1109
1137
  }
1110
1138
  if (inOcxSection) {
1111
- // End the injected section at the next table header that ISN'T our own exact match so a
1112
- // user's "[model_providers.opencodex_backup]" (or similar) is preserved, not swallowed.
1113
- if (
1114
- /^\s*\[/.test(line) &&
1115
- line.trim() !== "[model_providers.opencodex]"
1116
- ) {
1139
+ // End the injected section at the next table header that ISN'T our own. Exact match on the
1140
+ // provider name (plus our own sub-tables) so a user's
1141
+ // "[model_providers.opencodex_backup]" (or similar) is preserved, not swallowed.
1142
+ if (/^\s*\[/.test(line) && !isOcxProviderHeaderLine(line.trim())) {
1117
1143
  inOcxSection = false;
1118
1144
  filtered.push(line);
1119
1145
  }
@@ -1153,7 +1179,7 @@ function stripOpencodexConfigResult(
1153
1179
  || (journaledBaseUrl !== null && rootTomlString(out, "openai_base_url") === journaledBaseUrl);
1154
1180
  out = stripInjectedOpenaiBaseUrl(out); // before removeOcxSection — it keys on the marker line too
1155
1181
  out = stripJournaledOpenaiBaseUrl(out, journaledBaseUrl);
1156
- if (out.includes("[model_providers.opencodex]")) {
1182
+ if (hasOcxProviderTable(out)) {
1157
1183
  out = removeOcxSection(out);
1158
1184
  }
1159
1185
  out = removeProfileSection(out);
@@ -1182,7 +1208,7 @@ export function stripOpencodexConfig(content: string): string {
1182
1208
 
1183
1209
  function hasOpencodexRouting(content: string): boolean {
1184
1210
  return (
1185
- content.includes("[model_providers.opencodex]") ||
1211
+ hasOcxProviderTable(content) ||
1186
1212
  /^\s*model_provider\s*=\s*"opencodex"/m.test(content) ||
1187
1213
  hasInjectedOpenaiBaseUrl(content)
1188
1214
  );
@@ -1,5 +1,6 @@
1
1
  import { readCodexTokens } from "./auth-collision";
2
2
  import { decodeJwtPayload } from "../oauth/chatgpt";
3
+ import { extractChatgptPlanType } from "./plan";
3
4
  import { MAIN_CODEX_ACCOUNT_ID } from "./account-id";
4
5
 
5
6
  export { MAIN_CODEX_ACCOUNT_ID } from "./account-id";
@@ -10,13 +11,23 @@ export { MAIN_CODEX_ACCOUNT_ID } from "./account-id";
10
11
  * percent, matching pool-account behavior.
11
12
  */
12
13
  let mainAccountPlan: string | null = null;
14
+ let jwtPlanAttempted = false;
13
15
 
14
16
  export function setMainAccountPlan(plan: string | null): void {
15
17
  mainAccountPlan = plan;
18
+ if (plan === null) jwtPlanAttempted = false;
16
19
  }
17
20
 
18
21
  export function getMainAccountPlan(): string | undefined {
19
- return mainAccountPlan ?? undefined;
22
+ if (mainAccountPlan) return mainAccountPlan;
23
+ if (jwtPlanAttempted) return undefined;
24
+ jwtPlanAttempted = true;
25
+ const tokens = readCodexTokens();
26
+ const jwtPlan = tokens
27
+ ? extractChatgptPlanType(tokens.id_token, tokens.access_token)
28
+ : undefined;
29
+ if (jwtPlan) mainAccountPlan = jwtPlan;
30
+ return jwtPlan;
20
31
  }
21
32
 
22
33
  /** Read-only main account token from ~/.codex/auth.json, or null when not logged in. */
@@ -38,3 +49,20 @@ export function isMainAccountTokenLive(now = Date.now()): boolean {
38
49
  const exp = typeof payload?.exp === "number" ? payload.exp * 1000 : undefined;
39
50
  return exp === undefined || exp > now;
40
51
  }
52
+
53
+ /**
54
+ * Strict liveness for auth-terminality decisions: true only when the access-token JWT
55
+ * carries a decodable `exp` that is still in the future.
56
+ *
57
+ * Unlike {@link isMainAccountTokenLive}, an undecodable `exp` counts as NOT live here.
58
+ * This gate decides whether a bare WHAM 401 is downgraded to a transient failure; if an
59
+ * undecodable token could vouch for itself, a genuinely dead credential would keep every
60
+ * 401 "transient" and needsReauth could never flip.
61
+ */
62
+ export function isMainAccountTokenVerifiablyLive(now = Date.now()): boolean {
63
+ const tokens = readCodexTokens();
64
+ if (!tokens?.access_token) return false;
65
+ const payload = decodeJwtPayload(tokens.access_token);
66
+ const exp = typeof payload?.exp === "number" ? payload.exp * 1000 : undefined;
67
+ return exp !== undefined && exp > now;
68
+ }
@@ -0,0 +1,140 @@
1
+ import {
2
+ ConfigMutationLockError,
3
+ loadConfig,
4
+ mutatePersistedConfig,
5
+ } from "../config";
6
+ import type { CodexAccount, OcxConfig } from "../types";
7
+ import { isSelectableCodexPoolAccount, isValidCodexAccountId } from "./account-id";
8
+ import {
9
+ getCodexAccountCredential,
10
+ isCodexAccountGenerationLive,
11
+ readCodexAccountRecord,
12
+ } from "./account-store";
13
+ import { extractChatgptPlanType, codexPlanValue } from "./plan";
14
+
15
+ interface FreshPoolPlanUpdate {
16
+ accountId: string;
17
+ plan: string;
18
+ credentialGeneration: number;
19
+ }
20
+
21
+ function configuredPoolAccount(config: OcxConfig, accountId: string): CodexAccount | null {
22
+ if (!isValidCodexAccountId(accountId)) return null;
23
+ return (config.codexAccounts ?? [])
24
+ .find(account => account.id === accountId && isSelectableCodexPoolAccount(account)) ?? null;
25
+ }
26
+
27
+ function jwtPlanFromPoolCredential(accountId: string): string | undefined {
28
+ const cred = getCodexAccountCredential(accountId);
29
+ return cred ? extractChatgptPlanType(undefined, cred.accessToken) : undefined;
30
+ }
31
+
32
+ /**
33
+ * WHAM-wins gate (release-audit fix). A JWT-derived plan may be persisted only when no
34
+ * WHAM-sourced plan exists for the CURRENT credential generation. A token refresh bumps the
35
+ * generation, and the refreshed JWT is then genuinely newer information than the previous
36
+ * generation's WHAM read, so it may write again until WHAM re-observes. Records without
37
+ * provenance (legacy) stay writable so the original #1989 recovery still works.
38
+ */
39
+ function jwtMayWritePlan(account: CodexAccount, generation: number): boolean {
40
+ if (account.planSource !== "wham") return true;
41
+ const whamGeneration = account.planCredentialGeneration;
42
+ return whamGeneration !== undefined && generation > whamGeneration;
43
+ }
44
+
45
+ function collectJwtPoolPlanUpdates(runtimeConfig: OcxConfig): FreshPoolPlanUpdate[] {
46
+ const updates: FreshPoolPlanUpdate[] = [];
47
+ for (const account of (runtimeConfig.codexAccounts ?? []).filter(isSelectableCodexPoolAccount)) {
48
+ const jwtPlan = jwtPlanFromPoolCredential(account.id);
49
+ if (!jwtPlan || codexPlanValue(account.plan) === jwtPlan) continue;
50
+ const generation = readCodexAccountRecord(account.id)?.generation;
51
+ if (generation === undefined) continue;
52
+ if (!jwtMayWritePlan(account, generation)) continue;
53
+ updates.push({ accountId: account.id, plan: jwtPlan, credentialGeneration: generation });
54
+ }
55
+ return updates;
56
+ }
57
+
58
+ const appliedJwtPlans = new Map<string, string>();
59
+
60
+ function persistJwtPlanUpdates(runtimeConfig: OcxConfig, updates: FreshPoolPlanUpdate[]): void {
61
+ if (updates.length === 0) return;
62
+ let outcome: ReturnType<typeof mutatePersistedConfig<FreshPoolPlanUpdate[]>>;
63
+ try {
64
+ outcome = mutatePersistedConfig(persistedConfig => {
65
+ const accepted: FreshPoolPlanUpdate[] = [];
66
+ let changed = false;
67
+ for (const update of updates) {
68
+ if (!isCodexAccountGenerationLive(update.accountId, update.credentialGeneration)) continue;
69
+ const liveAccount = configuredPoolAccount(runtimeConfig, update.accountId);
70
+ const persistedAccount = configuredPoolAccount(persistedConfig, update.accountId);
71
+ if (!liveAccount || !persistedAccount) continue;
72
+ // Re-check against the PERSISTED row: another process may have landed a WHAM
73
+ // observation between collect and this mutation.
74
+ if (!jwtMayWritePlan(persistedAccount, update.credentialGeneration)) continue;
75
+ accepted.push(update);
76
+ if (persistedAccount.plan !== update.plan) {
77
+ persistedAccount.plan = update.plan;
78
+ changed = true;
79
+ }
80
+ if (persistedAccount.planSource !== "jwt" || persistedAccount.planCredentialGeneration !== update.credentialGeneration) {
81
+ persistedAccount.planSource = "jwt";
82
+ persistedAccount.planCredentialGeneration = update.credentialGeneration;
83
+ changed = true;
84
+ }
85
+ }
86
+ return { changed, value: accepted };
87
+ });
88
+ } catch (error) {
89
+ if (error instanceof ConfigMutationLockError) return;
90
+ throw error;
91
+ }
92
+ if (outcome.status === "unavailable") return;
93
+ for (const update of outcome.value) {
94
+ if (!isCodexAccountGenerationLive(update.accountId, update.credentialGeneration)) continue;
95
+ const liveAccount = configuredPoolAccount(runtimeConfig, update.accountId);
96
+ if (liveAccount) {
97
+ liveAccount.plan = update.plan;
98
+ liveAccount.planSource = "jwt";
99
+ liveAccount.planCredentialGeneration = update.credentialGeneration;
100
+ appliedJwtPlans.set(update.accountId, update.plan);
101
+ }
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Persist JWT `chatgpt_plan_type` onto `codexAccounts[].plan` when it contradicts the stored
107
+ * label. Generation-gated, same fail-closed lock policy as the WHAM plan patch. Does not
108
+ * overwrite a plan that already matches the token.
109
+ */
110
+ export function reconcileCodexPlansFromTokens(runtimeConfig: OcxConfig = loadConfig()): void {
111
+ persistJwtPlanUpdates(runtimeConfig, collectJwtPoolPlanUpdates(runtimeConfig));
112
+ }
113
+
114
+ export function resetJwtPlanNotesForTests(): void {
115
+ appliedJwtPlans.clear();
116
+ }
117
+
118
+ /** Apply one account's live token claim without blocking the credential read path. */
119
+ export function noteCodexAccountAccessToken(
120
+ accountId: string,
121
+ accessToken: string,
122
+ credentialGeneration: number,
123
+ ): void {
124
+ const jwtPlan = extractChatgptPlanType(undefined, accessToken);
125
+ if (!jwtPlan) return;
126
+ if (appliedJwtPlans.get(accountId) === jwtPlan) return;
127
+ try {
128
+ const runtimeConfig = loadConfig();
129
+ const live = configuredPoolAccount(runtimeConfig, accountId);
130
+ if (!live || codexPlanValue(live.plan) === jwtPlan) {
131
+ appliedJwtPlans.set(accountId, jwtPlan);
132
+ return;
133
+ }
134
+ if (!jwtMayWritePlan(live, credentialGeneration)) return;
135
+ persistJwtPlanUpdates(runtimeConfig, [{ accountId, plan: jwtPlan, credentialGeneration }]);
136
+ if (codexPlanValue(live.plan) === jwtPlan) appliedJwtPlans.set(accountId, jwtPlan);
137
+ } catch {
138
+ appliedJwtPlans.delete(accountId);
139
+ }
140
+ }
package/src/codex/plan.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { decodeJwtPayload } from "../oauth/chatgpt";
2
+
1
3
  /** Preserve user/provider plan labels only when they are usable strings. */
2
4
  export function codexPlanValue(value: unknown): string | undefined {
3
5
  if (typeof value !== "string") return undefined;
@@ -13,3 +15,26 @@ export function isThirtyDayOnlyCodexPlan(value: unknown): boolean {
13
15
  const key = codexPlanKey(value);
14
16
  return key === "go" || key === "free";
15
17
  }
18
+
19
+ /**
20
+ * ChatGPT plan label from a live access/id token (`chatgpt_plan_type`).
21
+ * Used when WHAM has not run yet so a stale stored `free` cannot outrank the token (#1989).
22
+ */
23
+ export function extractChatgptPlanType(idToken?: string, accessToken?: string): string | undefined {
24
+ for (const token of [idToken, accessToken]) {
25
+ if (!token) continue;
26
+ const payload = decodeJwtPayload(token);
27
+ if (!payload) continue;
28
+ if (typeof payload.chatgpt_plan_type === "string") {
29
+ const plan = codexPlanValue(payload.chatgpt_plan_type);
30
+ if (plan) return plan;
31
+ }
32
+ const ns = payload["https://api.openai.com/auth"];
33
+ if (ns && typeof ns === "object") {
34
+ const nested = (ns as Record<string, unknown>).chatgpt_plan_type;
35
+ const plan = codexPlanValue(nested);
36
+ if (plan) return plan;
37
+ }
38
+ }
39
+ return undefined;
40
+ }
@@ -19,10 +19,11 @@
19
19
  * concrete: crash after writing config.toml, user or Codex then edits it,
20
20
  * recovery sees a mismatch and overwrites their work with a stale image.
21
21
  */
22
- import { existsSync, mkdirSync, openSync, closeSync, fsyncSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
22
+ import { existsSync, mkdirSync, openSync, closeSync, fsyncSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
23
23
  import { dirname, resolve } from "node:path";
24
24
  import { createHash, randomBytes } from "node:crypto";
25
25
  import { forgetEphemeralSecretPath, hardenSecretPath, windowsSecretAclApplies } from "../lib/windows-secret-acl";
26
+ import { renameAtomicFile } from "../lib/windows-atomic-replace";
26
27
 
27
28
  const FILE_MODE = 0o600;
28
29
  const DIR_MODE = 0o700;
@@ -79,7 +80,10 @@ export function durableWrite(path: string, content: string): void {
79
80
  fsyncSync(fd);
80
81
  closeSync(fd);
81
82
  fd = undefined;
82
- renameSync(tmp, path);
83
+ // Windows can refuse the replace with EBUSY/EPERM/EACCES while a scanner
84
+ // still holds the target; the shared helper retries that briefly. Losing
85
+ // this publish breaks journal restore, so it should not fail on a blink.
86
+ renameAtomicFile(tmp, path, undefined, "prompt-journal");
83
87
  // The temp is renamed away: proven absent — release its ACL memos.
84
88
  forgetEphemeralSecretPath(tmp);
85
89
  fsyncDir(path);
@@ -71,15 +71,29 @@ async function denialFromResponse(
71
71
  }
72
72
  }
73
73
 
74
- /** Own-property `code` lookup at the top level or under `error`. No coercion, no accessors. */
74
+ function ownStringField(container: Record<string, unknown>, field: string): string | undefined {
75
+ const descriptor = Object.getOwnPropertyDescriptor(container, field);
76
+ return descriptor && "value" in descriptor && typeof descriptor.value === "string"
77
+ ? descriptor.value
78
+ : undefined;
79
+ }
80
+
81
+ /** Own-property `code` lookup at the top level or under `error` / `detail`. No coercion or accessors. */
75
82
  function structuredDenialCode(payload: unknown): string | undefined {
76
83
  if (payload === null || typeof payload !== "object" || Array.isArray(payload)) return undefined;
77
- const direct = (payload as Record<string, unknown>).code;
78
- if (typeof direct === "string") return direct;
79
- const error = (payload as Record<string, unknown>).error;
80
- if (error === null || typeof error !== "object" || Array.isArray(error)) return undefined;
81
- const nested = (error as Record<string, unknown>).code;
82
- return typeof nested === "string" ? nested : undefined;
84
+ const record = payload as Record<string, unknown>;
85
+ const direct = ownStringField(record, "code");
86
+ if (direct !== undefined) return direct;
87
+
88
+ for (const field of ["error", "detail"] as const) {
89
+ const descriptor = Object.getOwnPropertyDescriptor(record, field);
90
+ if (!descriptor || !("value" in descriptor)) continue;
91
+ const nested = descriptor.value;
92
+ if (nested === null || typeof nested !== "object" || Array.isArray(nested)) continue;
93
+ const code = ownStringField(nested as Record<string, unknown>, "code");
94
+ if (code !== undefined) return code;
95
+ }
96
+ return undefined;
83
97
  }
84
98
 
85
99
  const RESET_ELIGIBLE_CODES: ReadonlySet<string> = new Set(RESET_ELIGIBLE_CODE_VALUES);
@@ -4,6 +4,7 @@ import type { ComboCatalogOmission } from "./catalog/aggregation";
4
4
  import { CODEX_MODELS_CACHE_PATH } from "./paths";
5
5
  import { atomicWriteFile } from "../config";
6
6
  import type { OcxConfig } from "../types";
7
+ import type { CodexCatalogSyncOptions } from "./catalog/sync";
7
8
 
8
9
  export interface CodexCatalogRefreshResult {
9
10
  added: number;
@@ -42,8 +43,9 @@ export function syncCodexModelsCacheFromCatalog(catalogPath: string): void {
42
43
  export async function refreshCodexModelCatalog(
43
44
  config: OcxConfig,
44
45
  deps: RefreshDeps = defaultDeps,
46
+ options?: CodexCatalogSyncOptions,
45
47
  ): Promise<CodexCatalogRefreshResult> {
46
- const result = await deps.syncCatalogModels(config);
48
+ const result = await deps.syncCatalogModels(config, options);
47
49
  const catalogExists = deps.existsSync(result.path);
48
50
  const catalogWritten = result.catalogWritten === true;
49
51
  const comboOmissions = result.comboOmissions ?? [];
@@ -55,6 +57,6 @@ export async function refreshCodexModelCatalog(
55
57
  if (!catalogExists) {
56
58
  return { ...result, catalogExists, catalogWritten: false, cacheSynced: false, comboOmissions };
57
59
  }
58
- const cacheSynced = deps.invalidateCodexModelsCache();
60
+ const cacheSynced = deps.invalidateCodexModelsCache(options);
59
61
  return { ...result, catalogExists, catalogWritten, cacheSynced, comboOmissions };
60
62
  }
package/src/codex/sync.ts CHANGED
@@ -7,10 +7,15 @@ import { collectOrcaCodexHomeDiagnostic } from "./home";
7
7
  import { summarizeComboCatalogOmissions, type ComboCatalogOmission } from "./catalog/aggregation";
8
8
  import { shouldSyncCodexOnStart } from "./desired-state";
9
9
  import { admitCodexWrite, type CodexAdmission } from "./admission";
10
+ import type { CodexCatalogSyncOptions } from "./catalog/sync";
10
11
 
11
12
  export interface CodexSyncResult {
12
- /** `skipped` is policy truth, never evidence that Codex was written. */
13
- status: "applied" | "skipped" | "refused";
13
+ /**
14
+ * `skipped` is policy truth, never evidence that Codex was written.
15
+ * `catalog-only` means an explicit sync refreshed the catalog/cache while
16
+ * Codex injection stayed OFF; config and history were not touched.
17
+ */
18
+ status: "applied" | "skipped" | "catalog-only" | "refused";
14
19
  ok: boolean;
15
20
  skippedReason?: "desired_disabled";
16
21
  /** Present when unattended convergence refused another service's native home. */
@@ -28,6 +33,17 @@ export interface CodexSyncResult {
28
33
  projectConfigGrouped?: { path: string; issues: string[]; bypass: string }[];
29
34
  }
30
35
 
36
+ export interface CodexSyncOptions {
37
+ /**
38
+ * Explicit `ocx sync` is also the refresh path for side profiles that consume
39
+ * the OpenCodex catalog without injection. When set, the sync still refreshes
40
+ * the catalog and models cache even if the Codex integration toggle is OFF or
41
+ * an external `model_provider` owns config.toml. Config/history injection is
42
+ * skipped in those cases, so the behavior is harmless to a native home.
43
+ */
44
+ catalogEvenWhenNotInjected?: boolean;
45
+ }
46
+
31
47
  type CodexSyncAdmission = Extract<CodexAdmission, { kind: "refused" }> | { readonly kind: "admitted" };
32
48
 
33
49
  interface CodexSyncDeps {
@@ -62,12 +78,15 @@ export async function syncModelsToCodex(
62
78
  config: OcxConfig = loadConfig(),
63
79
  log: Pick<Console, "log" | "error"> | null = console,
64
80
  deps: CodexSyncDeps = defaultDeps,
81
+ options: CodexSyncOptions = {},
65
82
  ): Promise<CodexSyncResult> {
66
83
  // `config` can be the server's startup object. The decision, however, is a
67
84
  // durable user switch and must be read again at this production boundary: a
68
85
  // PUT OFF while provider discovery is in flight cannot be allowed to commit
69
86
  // through an older captured object.
70
- if (!shouldSyncCodexOnStart(loadConfig())) {
87
+ const desiredDisabled = !shouldSyncCodexOnStart(loadConfig());
88
+ const catalogEvenWhenNotInjected = options.catalogEvenWhenNotInjected === true;
89
+ if (desiredDisabled && !catalogEvenWhenNotInjected) {
71
90
  return {
72
91
  status: "skipped",
73
92
  skippedReason: "desired_disabled",
@@ -99,7 +118,44 @@ export async function syncModelsToCodex(
99
118
  }
100
119
  const p = port ?? config.port ?? 10100;
101
120
  const externalProvider = (deps.currentExternalCodexModelProvider ?? currentExternalCodexModelProvider)();
121
+
122
+ if (desiredDisabled && catalogEvenWhenNotInjected) {
123
+ // Explicit `ocx sync` with the integration OFF: refresh the catalog/cache so
124
+ // side profiles that route to the proxy keep their model list current, but
125
+ // never touch config, journal, or history.
126
+ applyProxyEnv(config);
127
+ const refreshed = await refreshCatalogForSync(config, deps, { allowWhenDesiredDisabled: true }, log);
128
+ const message = refreshed.catalogWritten || refreshed.cacheSynced
129
+ ? "Codex integration is OFF; catalog and models cache refreshed, Codex config untouched."
130
+ : "Codex integration is OFF; catalog refresh skipped, Codex config untouched.";
131
+ return {
132
+ status: "catalog-only",
133
+ ok: true,
134
+ ...refreshed,
135
+ message,
136
+ ...(refreshed.comboOmissions.length > 0 ? { comboOmissions: refreshed.comboOmissions } : {}),
137
+ };
138
+ }
139
+
102
140
  if (externalProvider) {
141
+ if (catalogEvenWhenNotInjected) {
142
+ // External providers own config.toml, and the injector removes the OpenCodex
143
+ // journal for external providers (inject.ts). This explicit catalog-only sync
144
+ // must not touch config, journal, or history, so refresh the catalog/cache and
145
+ // return without injection.
146
+ applyProxyEnv(config);
147
+ const refreshed = await refreshCatalogForSync(config, deps, undefined, log);
148
+ const message = refreshed.catalogWritten || refreshed.cacheSynced
149
+ ? "External provider owns config.toml; catalog and models cache refreshed, Codex config/journal untouched."
150
+ : "External provider owns config.toml; catalog refresh skipped, Codex config/journal untouched.";
151
+ return {
152
+ status: "catalog-only",
153
+ ok: true,
154
+ ...refreshed,
155
+ message,
156
+ ...(refreshed.comboOmissions.length > 0 ? { comboOmissions: refreshed.comboOmissions } : {}),
157
+ };
158
+ }
103
159
  const result = await deps.injectCodexConfig(p, config, {});
104
160
  if (result.success) log?.log(result.message);
105
161
  else log?.error(result.message);
@@ -214,3 +270,50 @@ export async function syncModelsToCodex(
214
270
  } : {}),
215
271
  };
216
272
  }
273
+
274
+ async function refreshCatalogForSync(
275
+ config: OcxConfig,
276
+ deps: CodexSyncDeps,
277
+ catalogOptions: CodexCatalogSyncOptions | undefined,
278
+ log: Pick<Console, "log" | "error"> | null,
279
+ ): Promise<{
280
+ added: number;
281
+ catalogPath: string | null;
282
+ catalogExists: boolean;
283
+ catalogWritten: boolean;
284
+ cacheSynced: boolean;
285
+ comboOmissions: ComboCatalogOmission[];
286
+ warning?: string;
287
+ }> {
288
+ let added = 0;
289
+ let catalogPath: string | null = null;
290
+ let catalogExists = false;
291
+ let catalogWritten = false;
292
+ let cacheSynced = false;
293
+ let warning: string | undefined;
294
+ let comboOmissions: ComboCatalogOmission[] = [];
295
+ try {
296
+ const cat = await deps.refreshCodexModelCatalog(config, undefined, catalogOptions);
297
+ added = cat.added;
298
+ catalogExists = cat.catalogExists;
299
+ catalogWritten = cat.catalogWritten;
300
+ cacheSynced = cat.cacheSynced;
301
+ catalogPath = cat.catalogExists ? cat.path : null;
302
+ comboOmissions = cat.comboOmissions ?? [];
303
+ if (cat.added > 0) {
304
+ log?.log(` + ${cat.added} models appended to Codex catalog (${cat.path})`);
305
+ } else if (!cat.catalogExists) {
306
+ warning = "catalog sync skipped: no Codex catalog source found; keeping Codex's native catalog.";
307
+ log?.error(warning);
308
+ }
309
+ if (comboOmissions.length > 0) {
310
+ const summary = summarizeComboCatalogOmissions(comboOmissions);
311
+ log?.error(summary);
312
+ warning = warning ? `${warning} ${summary}` : summary;
313
+ }
314
+ } catch (e) {
315
+ warning = `catalog sync skipped: ${e instanceof Error ? e.message : String(e)}`;
316
+ log?.error(warning);
317
+ }
318
+ return { added, catalogPath, catalogExists, catalogWritten, cacheSynced, comboOmissions, ...(warning ? { warning } : {}) };
319
+ }