@bitkyc08/opencodex 2.77.0 → 2.78.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 (222) hide show
  1. package/bin/ocx.mjs +11 -10
  2. package/gui/dist/assets/App-BtfG5id-.js +54 -0
  3. package/gui/dist/assets/App-lCIslZ1L.css +1 -0
  4. package/gui/dist/assets/{Tray-gloIYNaW.js → Tray-DiJpSub0.js} +1 -1
  5. package/gui/dist/assets/index-Btc3Ogw4.js +94 -0
  6. package/gui/dist/assets/index-DmQB8D_R.css +1 -0
  7. package/gui/dist/assets/quota-summary-B_a96nXj.js +1 -0
  8. package/gui/dist/index.html +2 -2
  9. package/package.json +1 -1
  10. package/src/adapters/anthropic/beta-allowlist.ts +19 -2
  11. package/src/adapters/anthropic/client-identity.ts +13 -4
  12. package/src/adapters/anthropic/native-client-preamble.ts +31 -0
  13. package/src/adapters/anthropic/passthrough.ts +103 -25
  14. package/src/adapters/devin/cloud-direct/chat.ts +7 -2
  15. package/src/adapters/devin/trajectory.ts +49 -0
  16. package/src/adapters/devin.ts +13 -0
  17. package/src/adapters/ollama-native.ts +35 -19
  18. package/src/claude/intercept/client-hello.ts +103 -0
  19. package/src/claude/intercept/model-bindings.ts +3 -2
  20. package/src/claude/intercept/picker-listener.ts +279 -66
  21. package/src/cli/access-audio-input.ts +84 -0
  22. package/src/cli/access-audio-live.ts +99 -0
  23. package/src/cli/access-audio.ts +123 -0
  24. package/src/cli/access-data-client.ts +127 -0
  25. package/src/cli/access-data-plane.ts +104 -0
  26. package/src/cli/access-data-response.ts +76 -0
  27. package/src/cli/access.ts +65 -2
  28. package/src/cli/account-api.ts +30 -6
  29. package/src/cli/account-auth.ts +111 -42
  30. package/src/cli/account-key-quota.ts +118 -0
  31. package/src/cli/account-main.ts +11 -4
  32. package/src/cli/account-next-actions.ts +63 -0
  33. package/src/cli/account-policy-dto.ts +68 -0
  34. package/src/cli/account-policy.ts +167 -0
  35. package/src/cli/account-target.ts +8 -0
  36. package/src/cli/account.ts +34 -8
  37. package/src/cli/agent-runtime-settings.ts +130 -0
  38. package/src/cli/agent-settings.ts +101 -0
  39. package/src/cli/agent.ts +19 -2
  40. package/src/cli/alias.ts +7 -2
  41. package/src/cli/aside-profiles.ts +2 -2
  42. package/src/cli/capabilities-access-remote.ts +147 -0
  43. package/src/cli/capabilities-accounts.ts +195 -0
  44. package/src/cli/capabilities-agents-routing.ts +414 -0
  45. package/src/cli/capabilities-base.ts +783 -0
  46. package/src/cli/capabilities-command.ts +1 -0
  47. package/src/cli/capabilities-integrations.ts +264 -0
  48. package/src/cli/capabilities-lab.ts +214 -0
  49. package/src/cli/capabilities-observe-system.ts +662 -0
  50. package/src/cli/capabilities-provider-models.ts +302 -0
  51. package/src/cli/capabilities.ts +30 -1199
  52. package/src/cli/capability-types.ts +54 -0
  53. package/src/cli/catalog-command-result.ts +57 -0
  54. package/src/cli/chatgpt-command.ts +25 -2
  55. package/src/cli/claude-agent-startup-sync.ts +17 -0
  56. package/src/cli/claude-desktop-profile.ts +100 -0
  57. package/src/cli/claude-desktop.ts +39 -6
  58. package/src/cli/codex-shim-autorestore.ts +2 -1
  59. package/src/cli/codex-shim-readiness.ts +23 -3
  60. package/src/cli/combo-input.ts +88 -0
  61. package/src/cli/combo-result.ts +52 -0
  62. package/src/cli/combo-stats.ts +123 -0
  63. package/src/cli/combo.ts +37 -14
  64. package/src/cli/companion-timeline.ts +142 -0
  65. package/src/cli/companion-usage.ts +146 -0
  66. package/src/cli/companion.ts +12 -3
  67. package/src/cli/config-command.ts +24 -8
  68. package/src/cli/dispatch.ts +52 -79
  69. package/src/cli/doctor.ts +22 -4
  70. package/src/cli/help-catalog.ts +31 -14
  71. package/src/cli/help-recovery.ts +2 -2
  72. package/src/cli/help.ts +25 -11
  73. package/src/cli/index.ts +38 -27
  74. package/src/cli/injection-follow.ts +35 -0
  75. package/src/cli/inspect.ts +5 -0
  76. package/src/cli/integration-aside-sync.ts +69 -0
  77. package/src/cli/integration-cursor.ts +106 -0
  78. package/src/cli/integration-input.ts +60 -0
  79. package/src/cli/integration-journal.ts +49 -0
  80. package/src/cli/integration-plan-dto.ts +156 -0
  81. package/src/cli/integration-preview.ts +215 -0
  82. package/src/cli/integrations.ts +24 -18
  83. package/src/cli/json-input.ts +134 -0
  84. package/src/cli/link.ts +32 -4
  85. package/src/cli/local-sync-result.ts +41 -0
  86. package/src/cli/log-follow.ts +84 -0
  87. package/src/cli/log-view-filter.ts +146 -0
  88. package/src/cli/logout-command.ts +44 -0
  89. package/src/cli/model-picker-ordering.ts +137 -0
  90. package/src/cli/models-custom-input.ts +107 -0
  91. package/src/cli/models-custom-runtime.ts +112 -0
  92. package/src/cli/models-order.ts +194 -0
  93. package/src/cli/models-runtime-subcommands.ts +2 -0
  94. package/src/cli/models-runtime.ts +7 -0
  95. package/src/cli/models.ts +66 -81
  96. package/src/cli/observe-stream.ts +142 -0
  97. package/src/cli/observe.ts +75 -34
  98. package/src/cli/provider-batch.ts +77 -0
  99. package/src/cli/provider-lifecycle-runtime.ts +170 -0
  100. package/src/cli/provider-result.ts +124 -0
  101. package/src/cli/provider-runtime.ts +15 -12
  102. package/src/cli/provider-settings.ts +143 -0
  103. package/src/cli/provider.ts +112 -34
  104. package/src/cli/registry.ts +62 -20
  105. package/src/cli/remote-workspace-hub.ts +83 -0
  106. package/src/cli/remote-workspace.ts +6 -1
  107. package/src/cli/restart-failure.ts +27 -0
  108. package/src/cli/root.ts +12 -0
  109. package/src/cli/route-policy-write.ts +137 -0
  110. package/src/cli/route-policy.ts +8 -0
  111. package/src/cli/runtime-api.ts +56 -79
  112. package/src/cli/secret-args.ts +149 -0
  113. package/src/cli/settings-result.ts +30 -0
  114. package/src/cli/storage.ts +77 -7
  115. package/src/cli/system-command.ts +60 -2
  116. package/src/cli/system-restart-client.ts +20 -3
  117. package/src/cli/system-settings-parity.ts +83 -0
  118. package/src/cli/update-restart-candidate.ts +21 -0
  119. package/src/cli/update-restart-child.ts +81 -0
  120. package/src/cli/update-restart-home.ts +38 -0
  121. package/src/cli/update-restart-transport.ts +193 -0
  122. package/src/cli/update-restart.ts +260 -0
  123. package/src/cli/usage-model-search.ts +58 -0
  124. package/src/cli/usage-report.ts +13 -5
  125. package/src/cli/v2-input.ts +66 -0
  126. package/src/cli/v2-local-output.ts +37 -0
  127. package/src/cli/v2-runtime.ts +90 -0
  128. package/src/cli/v2.ts +102 -144
  129. package/src/clients/config-export/dsh.ts +21 -0
  130. package/src/clients/config-export.ts +7 -1
  131. package/src/codex/catalog/build-entries.ts +1 -1
  132. package/src/codex/catalog/bundled.ts +9 -2
  133. package/src/codex/catalog/heal-observation.ts +66 -0
  134. package/src/codex/catalog/publication-observer.ts +19 -0
  135. package/src/codex/catalog/remote.ts +13 -15
  136. package/src/codex/catalog/restore.ts +19 -6
  137. package/src/codex/catalog/retained-sync.ts +58 -10
  138. package/src/codex/catalog/routed-removal.ts +116 -0
  139. package/src/codex/catalog/write-audit-contract.ts +21 -0
  140. package/src/codex/catalog/write-audit.ts +171 -0
  141. package/src/codex/catalog-self-heal.ts +284 -0
  142. package/src/codex/catalog-write-serialization.ts +71 -7
  143. package/src/codex/codex-home-owner.ts +124 -0
  144. package/src/codex/convergence.ts +55 -15
  145. package/src/codex/features.ts +8 -1
  146. package/src/codex/inject/restore.ts +46 -4
  147. package/src/codex/inject.ts +11 -1
  148. package/src/codex/internal/catalog-writer.ts +120 -13
  149. package/src/codex/journal.ts +49 -13
  150. package/src/codex/management-convergence.ts +3 -0
  151. package/src/codex/quota-types.ts +3 -0
  152. package/src/codex/quota.ts +13 -3
  153. package/src/codex/refresh.ts +20 -9
  154. package/src/codex/runtime.ts +7 -1
  155. package/src/codex/shim-diagnostics.ts +80 -0
  156. package/src/codex/shim-fingerprint.ts +7 -2
  157. package/src/codex/shim-migration.ts +153 -0
  158. package/src/codex/shim-overlay.ts +509 -0
  159. package/src/codex/shim-path-resolution.ts +14 -2
  160. package/src/codex/shim-probe.ts +4 -3
  161. package/src/codex/shim-state-file.ts +93 -7
  162. package/src/codex/shim.ts +15 -68
  163. package/src/codex/sync.ts +24 -4
  164. package/src/combos/failover.ts +19 -0
  165. package/src/combos/index.ts +1 -0
  166. package/src/combos/resolve.ts +12 -0
  167. package/src/config/atomic-write.ts +13 -8
  168. package/src/config/diagnostics.ts +19 -1
  169. package/src/config/load-degrade.ts +3 -0
  170. package/src/config/schema/chatgpt-desktop.ts +22 -0
  171. package/src/config/schema/config-schema.ts +32 -14
  172. package/src/config/schema/leaf-validators.ts +3 -2
  173. package/src/generated/compatibility-version.json +445 -129
  174. package/src/integrations/merge.ts +13 -5
  175. package/src/integrations/mutation-plan.ts +46 -3
  176. package/src/integrations/omp-yaml-source.ts +181 -1
  177. package/src/integrations/ownership-policy.ts +66 -0
  178. package/src/integrations/registry.ts +50 -1
  179. package/src/integrations/state.ts +46 -21
  180. package/src/integrations/target.ts +25 -2
  181. package/src/integrations/writer.ts +89 -31
  182. package/src/lib/package-tree-integrity.ts +42 -1
  183. package/src/lib/proxy-env.ts +3 -0
  184. package/src/lib/test-home-guard.ts +2 -1
  185. package/src/oauth/anthropic-account-refusal.ts +19 -8
  186. package/src/oauth/anthropic-routing.ts +17 -12
  187. package/src/oauth/anthropic.ts +27 -2
  188. package/src/oauth/index.ts +6 -8
  189. package/src/oauth/pool-settings-capability.ts +9 -2
  190. package/src/protocols/plan-snapshot.ts +1 -1
  191. package/src/protocols/settings.ts +28 -4
  192. package/src/server/caller-session-identity.ts +32 -0
  193. package/src/server/claude-messages.ts +11 -4
  194. package/src/server/index/serve-options.ts +12 -7
  195. package/src/server/index.ts +1 -1
  196. package/src/server/management/agent-settings-routes.ts +6 -5
  197. package/src/server/management/context.ts +8 -1
  198. package/src/server/management/cursor-integration-routes.ts +3 -3
  199. package/src/server/management/integration-routes.ts +5 -5
  200. package/src/server/management/model-routes.ts +3 -3
  201. package/src/server/management/native-integration-routes.ts +2 -2
  202. package/src/server/management/oauth-account-routes.ts +96 -30
  203. package/src/server/management/route-registry.ts +25 -24
  204. package/src/server/messages-native-eligibility.ts +3 -6
  205. package/src/server/messages-native-oauth.ts +82 -35
  206. package/src/server/messages-native.ts +78 -16
  207. package/src/server/responses/adapter-dispatch.ts +53 -28
  208. package/src/server/responses/core-auth.ts +10 -4
  209. package/src/server/responses/core-combo.ts +46 -6
  210. package/src/service/cli.ts +6 -1
  211. package/src/service/ownership-mutation-lease.d.mts +26 -0
  212. package/src/service/ownership-mutation-lease.mjs +76 -1
  213. package/src/types/config.ts +2 -0
  214. package/src/types/provider.ts +10 -10
  215. package/src/update/index.ts +2 -1
  216. package/src/update/update-failure-guidance.d.mts +6 -0
  217. package/src/update/update-failure-guidance.mjs +30 -5
  218. package/gui/dist/assets/App-COdEeqSj.css +0 -1
  219. package/gui/dist/assets/App-DEpCJEP_.js +0 -54
  220. package/gui/dist/assets/index-Cd7ittMv.css +0 -1
  221. package/gui/dist/assets/index-DwJKFJm5.js +0 -94
  222. package/gui/dist/assets/quota-summary-DGcEJU3h.js +0 -1
@@ -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 { assertCodexHomeOwner, CODEX_HOME_JOURNAL_FILE, CodexHomeOwnerRefusal, opencodexHomeForInjection, readCodexHomeJournal, type CodexHomeOwnerRefusalReason } from "./codex-home-owner";
5
6
  import { hasInjectedCodexRouting } from "./injected-marker";
6
7
  import { CODEX_HOME, CODEX_CONFIG_PATH, CODEX_PROFILE_PATH } from "./paths";
7
8
 
@@ -14,7 +15,7 @@ import { CODEX_HOME, CODEX_CONFIG_PATH, CODEX_PROFILE_PATH } from "./paths";
14
15
  * agreed with the producer, and the pair stayed green. One exported constant
15
16
  * removes the opportunity.
16
17
  */
17
- export const JOURNAL_PATH = join(CODEX_HOME, "opencodex-journal.json");
18
+ export const JOURNAL_PATH = join(CODEX_HOME, CODEX_HOME_JOURNAL_FILE);
18
19
 
19
20
  export type JournalOwner =
20
21
  | { kind: "process"; pid: number }
@@ -71,12 +72,17 @@ interface Journal {
71
72
  * file we actually touched.
72
73
  */
73
74
  injectedCatalogPath?: string | null;
75
+ /** Injection binding survives native snapshot replacement and is released by native restore. */
76
+ opencodexHome?: string;
74
77
  pid: number;
75
78
  owner?: JournalOwner;
76
79
  timestamp: string;
77
80
  }
78
81
 
79
82
  export interface RestoreJournalResult {
83
+ /** An unchanged generated profile could not be restored; distinct from a preserved user edit. */
84
+ profileRestoreFailed?: true;
85
+ ownershipRefusal?: CodexHomeOwnerRefusalReason;
80
86
  configRestored: boolean;
81
87
  profileRestored: boolean;
82
88
  configChanged: boolean;
@@ -146,6 +152,7 @@ export interface WriteJournalOptions {
146
152
  * plugins, model choice, and trusted projects.
147
153
  */
148
154
  export function writeJournal(options: WriteJournalOptions = {}): void {
155
+ assertCodexHomeOwner(CODEX_HOME);
149
156
  if (!existsSync(CODEX_CONFIG_PATH)) return;
150
157
  const config = options.configContent ?? readFileSync(CODEX_CONFIG_PATH, "utf-8");
151
158
  // Ownership is decided HERE, from the bytes about to be journaled — never taken
@@ -155,7 +162,12 @@ export function writeJournal(options: WriteJournalOptions = {}): void {
155
162
  // The caller's verdict only authorizes REPLACEMENT. It is weaker evidence than
156
163
  // the check above (it may describe bytes read a moment earlier), so an
157
164
  // unclassified call creates a first snapshot but never overwrites one.
158
- if (existsSync(JOURNAL_PATH) && readJournal() && options.currentStateIsNative !== true) return;
165
+ const inspection = readCodexHomeJournal(JOURNAL_PATH);
166
+ if (inspection.kind === "unknown") {
167
+ throw new CodexHomeOwnerRefusal("owner-unknown");
168
+ }
169
+ const previous = inspection.kind === "read" ? inspection.journal as unknown as Journal : null;
170
+ if (previous && options.currentStateIsNative !== true) return;
159
171
  const profile = existsSync(CODEX_PROFILE_PATH)
160
172
  ? readFileSync(CODEX_PROFILE_PATH, "utf-8")
161
173
  : null;
@@ -163,6 +175,7 @@ export function writeJournal(options: WriteJournalOptions = {}): void {
163
175
  version: 1,
164
176
  originalConfig: Buffer.from(config).toString("base64"),
165
177
  originalProfile: profile !== null ? Buffer.from(profile).toString("base64") : null,
178
+ opencodexHome: opencodexHomeForInjection(previous?.opencodexHome),
166
179
  pid: process.pid,
167
180
  owner: options.owner?.kind === "client"
168
181
  ? { kind: "client", apiKeyId: options.owner.apiKeyId }
@@ -187,7 +200,10 @@ export function markJournalInjectedState(
187
200
  profile: string | null,
188
201
  ownership: InjectedJournalOwnership,
189
202
  ): void {
190
- const journal = readJournal();
203
+ assertCodexHomeOwner(CODEX_HOME);
204
+ const inspection = readCodexHomeJournal(JOURNAL_PATH);
205
+ if (inspection.kind === "unknown") throw new CodexHomeOwnerRefusal("owner-unknown");
206
+ const journal = inspection.kind === "read" ? inspection.journal as unknown as Journal : null;
191
207
  if (!journal) return;
192
208
  // The first exact injected config is the only safe whole-snapshot restore boundary for
193
209
  // the first native snapshot. A later reinjection may preserve user edits made while routed;
@@ -204,6 +220,7 @@ export function markJournalInjectedState(
204
220
  journal.injectedRootWebSearch = ownership.injectedRootWebSearch ?? null;
205
221
  journal.replacedRootWebSearch = ownership.replacedRootWebSearch ?? null;
206
222
  journal.injectedCatalogPath = ownership.injectedCatalogPath;
223
+ journal.opencodexHome = opencodexHomeForInjection(journal.opencodexHome);
207
224
  atomicWriteFile(JOURNAL_PATH, JSON.stringify(journal));
208
225
  }
209
226
 
@@ -240,19 +257,24 @@ export function journaledInjectedCatalogPath(): string | null {
240
257
  }
241
258
 
242
259
  export function removeJournal(): void {
260
+ assertCodexHomeOwner(CODEX_HOME);
243
261
  try { unlinkSync(JOURNAL_PATH); } catch { /* ignore */ }
244
262
  }
245
263
 
246
- function readJournal(cleanInvalid = true): Journal | null {
247
- if (!existsSync(JOURNAL_PATH)) return null;
248
- try {
249
- const journal = JSON.parse(readFileSync(JOURNAL_PATH, "utf-8")) as Journal;
250
- if (journal.version !== 1) throw new Error("unknown version");
251
- return journal;
252
- } catch {
253
- if (cleanInvalid) removeJournal();
254
- return null;
255
- }
264
+ /** Successful field-level native restore releases ownership while retaining recovery evidence. */
265
+ export function releaseJournalHomeBinding(): void {
266
+ assertCodexHomeOwner(CODEX_HOME);
267
+ const inspection = readCodexHomeJournal(JOURNAL_PATH);
268
+ if (inspection.kind === "unknown") throw new CodexHomeOwnerRefusal("owner-unknown");
269
+ if (inspection.kind === "missing" || inspection.journal.opencodexHome === undefined) return;
270
+ delete inspection.journal.opencodexHome;
271
+ atomicWriteFile(JOURNAL_PATH, JSON.stringify(inspection.journal));
272
+ }
273
+
274
+ // Kept compatible with existing read-only callers; invalid evidence is never cleanup authority.
275
+ function readJournal(_cleanInvalid = true): Journal | null {
276
+ const inspected = readCodexHomeJournal(JOURNAL_PATH);
277
+ return inspected.kind === "read" ? inspected.journal as unknown as Journal : null;
256
278
  }
257
279
 
258
280
  /**
@@ -274,6 +296,12 @@ export function journalOwner(options: { readOnly?: boolean } = {}): JournalOwner
274
296
  }
275
297
 
276
298
  export function restoreJournalState(): RestoreJournalResult {
299
+ try { assertCodexHomeOwner(CODEX_HOME); }
300
+ catch (error) {
301
+ if (!(error instanceof CodexHomeOwnerRefusal)) throw error;
302
+ return { configRestored: false, profileRestored: false, configChanged: false, profileChanged: false,
303
+ complete: false, unverified: true, ownershipRefusal: error.reason };
304
+ }
277
305
  const journal = readJournal();
278
306
  if (!journal) {
279
307
  return { configRestored: false, profileRestored: false, configChanged: false, profileChanged: false, complete: false, unverified: false };
@@ -295,6 +323,7 @@ export function restoreJournalState(): RestoreJournalResult {
295
323
  };
296
324
  }
297
325
 
326
+ assertCodexHomeOwner(CODEX_HOME);
298
327
  let configRestored = comparison.configAlreadyOriginal;
299
328
  let profileRestored = comparison.profileAlreadyOriginal;
300
329
  if (configUnchanged && !configRestored) {
@@ -330,6 +359,7 @@ export function restoreJournalState(): RestoreJournalResult {
330
359
  profileChanged: !profileUnchanged,
331
360
  complete,
332
361
  unverified: false,
362
+ ...(profileUnchanged && !profileRestored ? { profileRestoreFailed: true as const } : {}),
333
363
  };
334
364
  }
335
365
 
@@ -342,6 +372,12 @@ export interface ReconcileJournalOptions {
342
372
  }
343
373
 
344
374
  export function reconcileJournal(options: ReconcileJournalOptions = {}): boolean {
375
+ try { assertCodexHomeOwner(CODEX_HOME); }
376
+ catch (error) {
377
+ if (!(error instanceof CodexHomeOwnerRefusal)) throw error;
378
+ console.error(error.message);
379
+ return false;
380
+ }
345
381
  const journal = readJournal();
346
382
  if (!journal) return false;
347
383
  const owner = journalOwner();
@@ -143,6 +143,7 @@ export function projectCatalogOnlyOutcome({
143
143
  */
144
144
  export function createManagementConvergeCodex(
145
145
  config: Readonly<OcxConfig>,
146
+ options: Readonly<{ beforeCommit?: () => boolean; expectedCatalogPath?: string }> = {},
146
147
  ): ConvergeCodex {
147
148
  const retainedConfig = config;
148
149
  return async request => {
@@ -167,6 +168,8 @@ export function createManagementConvergeCodex(
167
168
  const snapshot = captureCatalogAdmissionSnapshot(retainedConfig);
168
169
  const result = await convergeCodexCatalog(snapshot, request, {
169
170
  onCommitBegin: () => { commitBegan = true; },
171
+ beforeCommit: options.beforeCommit,
172
+ expectedCatalogPath: options.expectedCatalogPath,
170
173
  });
171
174
  return projectCatalogOnlyOutcome(result);
172
175
  } catch (error) {
@@ -30,6 +30,7 @@ export interface CodexSpendableCredits {
30
30
  unlimited?: boolean;
31
31
  balance?: number;
32
32
  overageLimitReached?: boolean;
33
+ /** Credit spending control, never the included-plan rate_limit.allowed verdict. */
33
34
  allowed?: boolean;
34
35
  observedAt: number;
35
36
  }
@@ -147,6 +148,8 @@ export type WhamUsageResponse = {
147
148
  balance?: unknown;
148
149
  overage_limit_reached?: unknown;
149
150
  } | null;
151
+ /** Absent/null imposes no veto; present raw controls require an object with reached:false. */
152
+ spend_control?: unknown;
150
153
  rate_limit_reset_credits?: { available_count: number } | null;
151
154
  additional_rate_limits?: WhamAdditionalRateLimit[] | null;
152
155
  };
@@ -904,10 +904,17 @@ function isMeasuredLongWindow(window: WhamUsageWindow | null | undefined): boole
904
904
  return isExplicitLongWindow(window) && normalizeUsagePercent(window?.used_percent) !== undefined;
905
905
  }
906
906
 
907
+ /** Absent/null controls impose no veto; a present control requires reached:false. */
908
+ function spendControlAllowsCredits(control: unknown): boolean | undefined {
909
+ if (control === undefined || control === null) return undefined;
910
+ return typeof control === "object" && !Array.isArray(control)
911
+ && "reached" in control && control.reached === false;
912
+ }
913
+
907
914
  /**
908
915
  * Normalize WHAM windows into the display snapshot, preserving declared short-window shape.
909
916
  * Finite percentages are clamped for compatibility; policy callers must validate raw readings
910
- * separately. Return null when neither a quota value/window nor reset credits are available.
917
+ * separately. Return null when no usage window or credit observation/retraction is available.
911
918
  */
912
919
  export function parseUsageQuota(data: WhamUsageResponse): Omit<StoredAccountQuota, "updatedAt"> | null {
913
920
  const resetCredits = typeof data.rate_limit_reset_credits?.available_count === "number"
@@ -915,6 +922,7 @@ export function parseUsageQuota(data: WhamUsageResponse): Omit<StoredAccountQuot
915
922
  : undefined;
916
923
 
917
924
  const quota: Omit<StoredAccountQuota, "updatedAt"> = {};
925
+ const creditsAllowed = spendControlAllowsCredits(data.spend_control);
918
926
  if (resetCredits !== undefined) quota.resetCredits = resetCredits;
919
927
  if (data.credits !== undefined) {
920
928
  quota.credits = null;
@@ -926,11 +934,13 @@ export function parseUsageQuota(data: WhamUsageResponse): Omit<StoredAccountQuot
926
934
  ...(parsed.hasCredits !== undefined ? { hasCredits: parsed.hasCredits } : {}),
927
935
  ...(parsed.unlimited !== undefined ? { unlimited: parsed.unlimited } : {}),
928
936
  ...(parsed.overageLimitReached !== undefined ? { overageLimitReached: parsed.overageLimitReached } : {}),
929
- ...(typeof data.rate_limit?.allowed === "boolean" ? { allowed: data.rate_limit.allowed } : {}),
937
+ // Included-plan refusal is precisely when opted-in credits take over, not a credit veto.
938
+ // A declared spending control permits credits only with an explicit unreached verdict.
939
+ ...(creditsAllowed !== undefined ? { allowed: creditsAllowed } : {}),
930
940
  ...(balance !== undefined && Number.isFinite(balance) ? { balance } : {}),
931
941
  };
932
942
  }
933
- } else if (data.rate_limit?.allowed === false) quota.credits = null;
943
+ } else if (data.rate_limit?.allowed === false || creditsAllowed === false) quota.credits = null;
934
944
  if (!data.rate_limit) return snapshotHasCredits(quota) ? quota : null;
935
945
  const thirtyDayOnly = codexQuotaWindowForPlan(data.plan_type) === "monthly";
936
946
  const primaryWindow = data.rate_limit.primary_window;
@@ -1,8 +1,10 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
2
3
  import { invalidateCodexModelsCache, syncCatalogModels } from "./catalog";
3
4
  import type { ComboCatalogOmission } from "./catalog/aggregation";
4
- import { CODEX_MODELS_CACHE_PATH } from "./paths";
5
- import { atomicWriteFile } from "../config";
5
+ import { getCodexHome } from "./paths";
6
+ import { withCatalogWriteSerialization, CatalogWritePermitRefusal } from "./catalog-write-serialization";
7
+ import { replaceCodexModelsCache } from "./internal/catalog-writer";
6
8
  import type { OcxConfig } from "../types";
7
9
  import type { CodexCatalogSyncOptions } from "./catalog/sync";
8
10
 
@@ -14,8 +16,12 @@ export interface CodexCatalogRefreshResult {
14
16
  cacheSynced: boolean;
15
17
  comboOmissions: ComboCatalogOmission[];
16
18
  refreshOutcome?: "committed" | "refused";
17
- /** Desired OFF observed under K during the catalog commit; no cache write either. */
18
- skippedReason?: "desired_disabled";
19
+ /**
20
+ * Desired OFF observed under K during the catalog commit, or a write refused because it would
21
+ * empty routed namespaces config.json does not back (#6529); no cache write either way.
22
+ */
23
+ skippedReason?: "desired_disabled" | "unbacked_routed_removal" | "foreign_owner" | "owner_unknown";
24
+ protectedRoutedNamespaces?: number;
19
25
  }
20
26
 
21
27
  interface RefreshDeps {
@@ -31,8 +37,13 @@ const defaultDeps: RefreshDeps = {
31
37
  };
32
38
 
33
39
  export function syncCodexModelsCacheFromCatalog(catalogPath: string): void {
34
- const content = readFileSync(catalogPath, "utf8");
35
- atomicWriteFile(CODEX_MODELS_CACHE_PATH, content);
40
+ const owningCodexHome = getCodexHome();
41
+ const outcome = withCatalogWriteSerialization(owningCodexHome, permit =>
42
+ replaceCodexModelsCache(permit, owningCodexHome, {
43
+ path: join(owningCodexHome, "models_cache.json"),
44
+ content: readFileSync(catalogPath, "utf8"),
45
+ }), { intent: "cache", writer: "cache-from-catalog" });
46
+ if (outcome.kind === "unavailable") throw new CatalogWritePermitRefusal(`Catalog cache synchronization unavailable (${outcome.reason}).`);
36
47
  }
37
48
 
38
49
  /**
@@ -50,9 +61,9 @@ export async function refreshCodexModelCatalog(
50
61
  const catalogExists = deps.existsSync(result.path);
51
62
  const catalogWritten = result.catalogWritten === true;
52
63
  const comboOmissions = result.comboOmissions ?? [];
53
- if (result.skippedReason === "desired_disabled" || result.refreshOutcome === "refused") {
54
- // The commit path observed OFF under K. Invalidate nothing: rewriting the
55
- // models cache here would be exactly the routed-cache write the skip refused.
64
+ if (result.skippedReason !== undefined || result.refreshOutcome === "refused") {
65
+ // The commit path observed OFF, or an unbacked routed removal, under K. Invalidate
66
+ // nothing: rewriting the models cache here would be exactly the write the skip refused.
56
67
  return { ...result, catalogExists, catalogWritten: false, cacheSynced: false, comboOmissions };
57
68
  }
58
69
  if (!catalogExists) {
@@ -5,6 +5,7 @@ import { delimiter, join } from "node:path";
5
5
  import { atomicWriteFile, getConfigDir } from "../config";
6
6
  import { codexExecInvocation, isSpawnableCodexCandidate } from "./exec-invocation";
7
7
  import { resolveCodexHomeDir } from "./home";
8
+ import { decodeOverlayState } from "./shim-state-file";
8
9
  import { redactSecretString, redactUserPath } from "../lib/redact";
9
10
 
10
11
  export type CodexRuntimeSource =
@@ -516,13 +517,18 @@ function shimCandidates(deps: ResolveCodexRuntimeDeps): string[] {
516
517
  const platform = deps.platform ?? process.platform;
517
518
  try {
518
519
  const state = JSON.parse(read(join(configDir, "codex-shim.json"), "utf8")) as {
520
+ schema?: unknown;
521
+ mode?: unknown;
519
522
  wrapperPath?: unknown;
520
523
  originalPath?: unknown;
521
524
  backupPath?: unknown;
522
525
  wrappers?: Array<{ wrapperPath?: unknown; originalPath?: unknown; backupPath?: unknown }>;
523
526
  };
527
+ const overlay = decodeOverlayState(state, configDir);
528
+ if ((state.schema !== undefined || state.mode !== undefined) && !overlay) return [];
524
529
  const files = Array.isArray(state.wrappers) && state.wrappers.length > 0 ? state.wrappers : [state];
525
- const out: string[] = [];
530
+ const out: string[] = overlay && isSpawnableCodexCandidate(overlay.launcherPath, platform)
531
+ ? [overlay.launcherPath] : [];
526
532
  for (const file of files) {
527
533
  for (const value of [file.backupPath, file.originalPath, file.wrapperPath]) {
528
534
  if (typeof value !== "string" || value.length === 0) continue;
@@ -0,0 +1,80 @@
1
+ import { existsSync, lstatSync, readFileSync } from "node:fs";
2
+ import { SHIM_MARKER, UNIX_SHIM_REVISION_MARKER } from "./shim-templates";
3
+ import { readStateResult, stateFiles, statePath } from "./shim-state-file";
4
+ import { overlayDiagnostic } from "./shim-overlay";
5
+
6
+ export function isShim(path: string): boolean {
7
+ try {
8
+ return readFileSync(path, "utf8").includes(SHIM_MARKER);
9
+ } catch {
10
+ return false;
11
+ }
12
+ }
13
+
14
+ function isHealthyShim(path: string, platform: NodeJS.Platform): boolean {
15
+ try {
16
+ const content = readFileSync(path, "utf8");
17
+ if (content.length < 180 || !content.includes(SHIM_MARKER) || !content.includes("ensure")) return false;
18
+ if (platform !== "win32" && !content.includes(UNIX_SHIM_REVISION_MARKER)) return false;
19
+ if (platform !== "win32" && (lstatSync(path).mode & 0o111) === 0) return false;
20
+ return true;
21
+ } catch {
22
+ return false;
23
+ }
24
+ }
25
+
26
+ export interface CodexShimDiagnostic {
27
+ installed: boolean;
28
+ healthy: boolean;
29
+ summary: string;
30
+ runnable?: boolean;
31
+ active?: boolean | null;
32
+ }
33
+
34
+ /** Structured, secret-free shim state for CLI/GUI lifecycle diagnostics. */
35
+ export function diagnoseCodexShim(): CodexShimDiagnostic {
36
+ const result = readStateResult();
37
+ const state = result.state;
38
+ if (!state) {
39
+ if (result.present) {
40
+ return {
41
+ installed: true,
42
+ healthy: false,
43
+ runnable: false,
44
+ active: null,
45
+ summary: result.warning ?? `Codex autostart shim state is invalid or corrupt at ${statePath()}. Reinstall or remove the shim.`,
46
+ };
47
+ }
48
+ return {
49
+ installed: false,
50
+ healthy: false,
51
+ summary: "Codex autostart shim is not installed.",
52
+ };
53
+ }
54
+ if (state.mode === "path-overlay") return overlayDiagnostic(state);
55
+ const files = stateFiles(state);
56
+ const healthy = files.length > 0 && files.every(file => file.preserveOnly
57
+ ? existsSync(file.backupPath) && !existsSync(file.originalPath)
58
+ : existsSync(file.wrapperPath)
59
+ && (existsSync(file.backupPath) || (file.realPath ? existsSync(file.realPath) : false))
60
+ && isHealthyShim(file.wrapperPath, state.platform));
61
+ const summary = files.map(file => {
62
+ const wrapper = existsSync(file.wrapperPath)
63
+ ? isShim(file.wrapperPath)
64
+ ? "shim present"
65
+ : "present but not an opencodex shim"
66
+ : "missing";
67
+ const backup = existsSync(file.backupPath) ? "present" : "missing";
68
+ return `Codex autostart shim: wrapper ${wrapper} at ${file.wrapperPath}; original backup ${backup} at ${file.backupPath}.`;
69
+ }).join("\n");
70
+ // Presence alone is not health: a damaged wrapper still reads "shim present", so state the verdict.
71
+ const migration = healthy && state.platform !== "win32"
72
+ ? " Legacy Unix shim installed in place; automatic repair does not migrate it. Run ocx codex-shim install, then source the printed codex-shell-env.sh path and add that line after PATH setup in your shell startup file."
73
+ : "";
74
+ return { installed: true, healthy, summary: healthy ? `${summary}${migration}`
75
+ : `${summary}\nCodex autostart shim is unhealthy. Run ocx codex-shim install to repair it.` };
76
+ }
77
+
78
+ export function codexShimStatus(): string {
79
+ return diagnoseCodexShim().summary;
80
+ }
@@ -1,5 +1,7 @@
1
1
  import {
2
2
  closeSync,
3
+ constants,
4
+ fstatSync,
3
5
  existsSync,
4
6
  lstatSync,
5
7
  linkSync,
@@ -33,8 +35,9 @@ interface StableShimPathProbe {
33
35
  }
34
36
 
35
37
  function readShimProbePrefix(path: string): string {
36
- const fd = openSync(path, "r");
38
+ const fd = openSync(path, process.platform === "win32" ? "r" : constants.O_RDONLY | constants.O_NONBLOCK);
37
39
  try {
40
+ if (process.platform !== "win32" && !fstatSync(fd).isFile()) throw new Error("Shim probe entry is not a regular file");
38
41
  const buffer = Buffer.allocUnsafe(CODEX_SHIM_PROBE_BYTES);
39
42
  const bytesRead = readSync(fd, buffer, 0, buffer.length, 0);
40
43
  return buffer.toString("utf8", 0, bytesRead);
@@ -162,14 +165,16 @@ function shimPathFingerprint(path: string): ShimPathFingerprint | null {
162
165
  * fails EEXIST on an occupied destination. Checking existence and then renaming
163
166
  * would reintroduce exactly the race this function exists to close.
164
167
  */
165
- function restoreWithoutReplacing(from: string, to: string): void {
168
+ function restoreWithoutReplacing(from: string, to: string, guard?: () => void): void {
166
169
  const source = lstatSync(from);
167
170
  if (source.isSymbolicLink()) {
168
171
  symlinkSync(readlinkSync(from), to);
172
+ guard?.();
169
173
  unlinkSync(from);
170
174
  return;
171
175
  }
172
176
  linkSync(from, to);
177
+ guard?.();
173
178
  unlinkSync(from);
174
179
  }
175
180
 
@@ -0,0 +1,153 @@
1
+ import { closeSync, fstatSync, linkSync, lstatSync, openSync, readlinkSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { isAbsolute, join } from "node:path";
3
+ import { getConfigDir } from "../config";
4
+ import { restoreWithoutReplacing, stableShimPathProbe } from "./shim-fingerprint";
5
+ import { isExecutableCodexCandidate } from "./shim-path-resolution";
6
+ import { readBoundedRegularFile, stateFiles, type ShimState } from "./shim-state-file";
7
+ import { SHIM_MARKER, shQuote } from "./shim-templates";
8
+
9
+ export type UnixShimMigrationResult =
10
+ | { status: "native-restored"; launcherPath: string }
11
+ | { status: "native-repair-required"; message: string }
12
+ | { status: "refused"; message: string };
13
+
14
+ type Identity = { dev: number; ino: number; mode: number; size: number; mtimeMs: number; link?: string };
15
+ type MigrationRecord = { original: string; backup: string; wrapper: Identity; saved: Identity };
16
+
17
+ function identity(path: string): Identity | null {
18
+ try {
19
+ const stat = lstatSync(path);
20
+ if (!stat.isFile() && !stat.isSymbolicLink()) throw new Error("Unsupported migration entry");
21
+ return { dev: stat.dev, ino: stat.ino, mode: stat.mode, size: stat.size, mtimeMs: stat.mtimeMs,
22
+ ...(stat.isSymbolicLink() ? { link: readlinkSync(path) } : {}) };
23
+ } catch (error) {
24
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
25
+ throw error;
26
+ }
27
+ }
28
+ function same(left: Identity | null, right: Identity | null): boolean {
29
+ return !!left && !!right && JSON.stringify(left) === JSON.stringify(right);
30
+ }
31
+ /** Drop a journal this run created before any migration step, never a replacement at that path. */
32
+ function discardCreatedJournal(journal: string, created: { dev: number; ino: number }): void {
33
+ try {
34
+ const now = lstatSync(journal);
35
+ if (now.isFile() && now.dev === created.dev && now.ino === created.ino) unlinkSync(journal);
36
+ } catch {
37
+ // An unreadable or vanished path is left for the next run's ownership checks.
38
+ }
39
+ }
40
+ function binding(path: string, backup: string): boolean {
41
+ const probe = stableShimPathProbe(path);
42
+ return !!probe && probe.fingerprint.kind === "file" && probe.prefix.includes(SHIM_MARKER)
43
+ && probe.prefix.includes(`exec ${shQuote(backup)} "$@"`);
44
+ }
45
+
46
+ /** Caller holds the shared lock; legacy state remains intact until overlay commit. */
47
+ export function migrateLegacyUnixShim(state: ShimState): UnixShimMigrationResult {
48
+ const refused = (message: string): UnixShimMigrationResult => ({ status: "refused", message });
49
+ const repair = (): UnixShimMigrationResult => ({ status: "native-repair-required", message:
50
+ "Native Codex entry is missing or unusable. Repair Codex with its package manager, then rerun ocx codex-shim install; the recovery state was retained." });
51
+ const files = stateFiles(state);
52
+ if (state.platform !== process.platform || state.platform === "win32" || state.mode || files.length !== 1) {
53
+ return refused("Legacy Unix shim has ambiguous targets or platform; preserving all files.");
54
+ }
55
+ const file = files[0]!;
56
+ if (file.preserveOnly || file.realPath || file.wrapperPath !== file.originalPath
57
+ || !isAbsolute(file.originalPath) || /[\x00-\x1f\x7f]/.test(file.originalPath)
58
+ || file.backupPath !== `${file.originalPath}.opencodex-real`) {
59
+ return refused("Legacy Unix shim ownership is ambiguous; manual recovery is required.");
60
+ }
61
+ const original = file.originalPath;
62
+ const backup = file.backupPath;
63
+ const quarantine = `${original}.opencodex-migrating`;
64
+ const journal = join(getConfigDir(), "codex-shim.migration.json");
65
+ let journalIdentity: Identity | null = null;
66
+ try {
67
+ let record: MigrationRecord | null = null;
68
+ const journalBefore = identity(journal);
69
+ if (journalBefore) {
70
+ const entry = lstatSync(journal);
71
+ if (!entry.isFile() || entry.uid !== process.getuid?.() || (entry.mode & 0o022)) return refused("Migration journal is not privately owned.");
72
+ }
73
+ const stored = readBoundedRegularFile(journal, 16 * 1024);
74
+ const journalAfter = identity(journal);
75
+ if (journalBefore || journalAfter) {
76
+ if (!same(journalBefore, journalAfter)) return refused("Migration journal changed during inspection.");
77
+ }
78
+ if (stored) {
79
+ if ("warning" in stored) return refused("Migration recovery record is unreadable; preserving all artifacts.");
80
+ record = JSON.parse(stored.content) as MigrationRecord;
81
+ if (!record || record.original !== original || record.backup !== backup || !record.wrapper || !record.saved) {
82
+ return refused("Migration recovery record does not match the legacy state.");
83
+ }
84
+ journalIdentity = journalBefore;
85
+ }
86
+ let current = identity(original);
87
+ const saved = identity(backup);
88
+ const evacuated = identity(quarantine);
89
+ const probe = current && isExecutableCodexCandidate(original) ? stableShimPathProbe(original) : null;
90
+ if (current && (binding(original, backup) || probe?.prefix.includes(SHIM_MARKER))) {
91
+ if (!binding(original, backup)) return refused("Legacy wrapper binding changed; preserving it.");
92
+ if (!saved) return repair();
93
+ if (!record) {
94
+ if (evacuated) return refused("Unrecorded migration quarantine requires manual inspection.");
95
+ record = { original, backup, wrapper: current, saved };
96
+ const fd = openSync(journal, "wx", 0o600);
97
+ const created = fstatSync(fd);
98
+ try {
99
+ writeFileSync(fd, JSON.stringify(record) + "\n");
100
+ const written = fstatSync(fd);
101
+ journalIdentity = identity(journal);
102
+ if (journalIdentity?.ino !== created.ino || journalIdentity.dev !== created.dev
103
+ || journalIdentity.size !== written.size || journalIdentity.mtimeMs !== written.mtimeMs) throw new Error("Journal replaced");
104
+ } catch (error) {
105
+ // Nothing has moved yet, so a partial journal of ours would only block every retry.
106
+ discardCreatedJournal(journal, created);
107
+ journalIdentity = null;
108
+ throw error;
109
+ } finally { closeSync(fd); }
110
+ }
111
+ if (!same(current, record.wrapper) || !same(saved, record.saved)) return refused("Migration inputs changed; preserving all generations.");
112
+ if (evacuated) {
113
+ if (!same(evacuated, record.wrapper)) return refused("Migration quarantine changed; preserving it.");
114
+ } else {
115
+ // A hard link is exclusive publication: unlike rename it never overwrites quarantine.
116
+ linkSync(original, quarantine);
117
+ }
118
+ if (!same(identity(original), record.wrapper) || !same(identity(quarantine), record.wrapper)
119
+ || !same(identity(backup), record.saved)) return refused("Migration inputs changed before evacuation.");
120
+ unlinkSync(original);
121
+ current = null;
122
+ } else if (evacuated && (!record || !same(evacuated, record.wrapper) || !binding(quarantine, backup))) {
123
+ return refused("Migration quarantine ownership is uncertain; preserving the recovery record.");
124
+ }
125
+ if (!current) {
126
+ if (!saved) return repair();
127
+ if (!same(identity(backup), record?.saved ?? saved)) return refused("Legacy backup changed; retry after the package manager finishes.");
128
+ try {
129
+ restoreWithoutReplacing(backup, original, () => {
130
+ if (!same(identity(backup), record?.saved ?? saved)) throw new Error("Legacy backup replaced during publication");
131
+ const restored = identity(original);
132
+ if (saved.link !== undefined ? restored?.link !== saved.link : !same(restored, saved)) {
133
+ throw new Error("Native entry changed during restoration");
134
+ }
135
+ });
136
+ }
137
+ catch (error) {
138
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
139
+ // A concurrent package-manager publication wins over the saved generation.
140
+ }
141
+ }
142
+ const native = isExecutableCodexCandidate(original) ? stableShimPathProbe(original) : null;
143
+ if (!native || native.prefix.includes(SHIM_MARKER)) return repair();
144
+ if (record && identity(quarantine)) {
145
+ if (!same(identity(quarantine), record.wrapper) || !binding(quarantine, backup)) return refused("Migration quarantine changed during restoration.");
146
+ unlinkSync(quarantine);
147
+ }
148
+ if (journalIdentity && same(identity(journal), journalIdentity)) unlinkSync(journal);
149
+ return { status: "native-restored", launcherPath: original };
150
+ } catch {
151
+ return refused("Legacy migration could not complete safely; native and recovery artifacts were preserved. Retry after the package manager finishes.");
152
+ }
153
+ }