@bitkyc08/opencodex 2.48.0 → 2.49.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 (97) hide show
  1. package/README.md +8 -5
  2. package/SPONSORS.md +1 -1
  3. package/assets/sponsors/orcarouter.png +0 -0
  4. package/assets/sponsors/packycode.png +0 -0
  5. package/gui/dist/assets/index-BoBRSehJ.css +1 -0
  6. package/gui/dist/assets/index-BtyONQrZ.js +115 -0
  7. package/gui/dist/index.html +2 -2
  8. package/gui/dist/provider-icons/packycode.svg +19 -0
  9. package/gui/dist/provider-icons/qoder.svg +5 -0
  10. package/package.json +5 -3
  11. package/src/adapters/anthropic.ts +31 -16
  12. package/src/adapters/codebuddy/adapter.ts +85 -0
  13. package/src/adapters/codebuddy/profiles.ts +52 -0
  14. package/src/adapters/coding-agent/profile.ts +100 -0
  15. package/src/adapters/coding-agent/protocol.ts +463 -0
  16. package/src/adapters/coding-agent/turn.ts +353 -0
  17. package/src/adapters/google.ts +15 -11
  18. package/src/adapters/mimo-free.ts +3 -0
  19. package/src/adapters/openai-chat.ts +2 -2
  20. package/src/adapters/openai-responses.ts +18 -11
  21. package/src/adapters/qoder/adapter.ts +70 -0
  22. package/src/adapters/qoder/live-models.ts +89 -0
  23. package/src/adapters/qoder/profiles.ts +36 -0
  24. package/src/adapters/registry.ts +12 -0
  25. package/src/adapters/responses-tool-schema.ts +113 -8
  26. package/src/cli/dispatch.ts +2 -0
  27. package/src/cli/doctor.ts +40 -0
  28. package/src/cli/effort.ts +24 -8
  29. package/src/cli/help.ts +2 -0
  30. package/src/cli/index.ts +24 -1
  31. package/src/cli/provider-runtime.ts +2 -1
  32. package/src/cli/registry.ts +2 -2
  33. package/src/cli/system-command.ts +10 -3
  34. package/src/cli/usage-report.ts +9 -5
  35. package/src/codex/account-lifecycle.ts +35 -2
  36. package/src/codex/auth-api.ts +6 -3
  37. package/src/codex/auth-collision.ts +12 -2
  38. package/src/codex/auth-context.ts +75 -7
  39. package/src/codex/catalog/provider-fetch.ts +73 -9
  40. package/src/codex/inject.ts +127 -30
  41. package/src/codex/internal/catalog-writer.ts +3 -0
  42. package/src/codex/journal.ts +61 -12
  43. package/src/codex/model-cache.ts +11 -4
  44. package/src/codex/native-profile-startup.ts +72 -5
  45. package/src/codex/native-profile-store.ts +2 -2
  46. package/src/codex/ocx-compaction-history.ts +226 -0
  47. package/src/codex/project-config-warnings.ts +3 -1
  48. package/src/codex/quota.ts +18 -8
  49. package/src/codex/reserve-availability.ts +21 -5
  50. package/src/codex/runtime.ts +45 -1
  51. package/src/codex/sync.ts +5 -0
  52. package/src/config.ts +1 -0
  53. package/src/generated/compatibility-version.json +126 -74
  54. package/src/images/loop.ts +1 -0
  55. package/src/images/xai-video-client.ts +2 -0
  56. package/src/integrations/registry.ts +1 -0
  57. package/src/lib/process-control.ts +52 -8
  58. package/src/lib/upstream-retry.ts +1 -0
  59. package/src/oauth/chatgpt.ts +83 -0
  60. package/src/oauth/xai.ts +151 -8
  61. package/src/providers/api-key-selection-capture.ts +10 -0
  62. package/src/providers/api-key-selection.ts +2 -7
  63. package/src/providers/caller-authorization.ts +36 -0
  64. package/src/providers/codebuddy-models.ts +184 -0
  65. package/src/providers/derive.ts +5 -0
  66. package/src/providers/free-directory.ts +26 -2
  67. package/src/providers/openai-sidecar.ts +35 -11
  68. package/src/providers/qoder-models.ts +25 -0
  69. package/src/providers/registry.ts +139 -0
  70. package/src/responses/compaction.ts +4 -0
  71. package/src/responses/task-input.ts +21 -1
  72. package/src/router.ts +1 -1
  73. package/src/server/chat-completions.ts +26 -9
  74. package/src/server/claude-messages.ts +12 -2
  75. package/src/server/images.ts +1 -0
  76. package/src/server/management/account-selection-stream.ts +13 -4
  77. package/src/server/management/config-routes.ts +24 -5
  78. package/src/server/management/logs-usage-routes.ts +1 -0
  79. package/src/server/management/native-integration-routes.ts +2 -1
  80. package/src/server/management/provider-routes.ts +24 -0
  81. package/src/server/management-api.ts +14 -0
  82. package/src/server/port-reclaim.ts +11 -26
  83. package/src/server/request-log.ts +6 -0
  84. package/src/server/responses/collaboration.ts +4 -9
  85. package/src/server/responses/core.ts +141 -19
  86. package/src/server/responses/fetch-helpers.ts +18 -7
  87. package/src/server/responses/policy-fallback.ts +12 -0
  88. package/src/service.ts +36 -2
  89. package/src/storage/cleanup.ts +77 -45
  90. package/src/types/config.ts +7 -1
  91. package/src/usage/log.ts +24 -0
  92. package/src/vision/anthropic-describe.ts +1 -0
  93. package/src/web-search/anthropic-executor.ts +1 -0
  94. package/src/web-search/loop.ts +1 -0
  95. package/src/web-search/progress-stream.ts +4 -0
  96. package/gui/dist/assets/index-B5r7LNHN.js +0 -115
  97. package/gui/dist/assets/index-D5SiRo8X.css +0 -1
@@ -62,12 +62,40 @@ export interface RestoreJournalResult {
62
62
  configChanged: boolean;
63
63
  profileChanged: boolean;
64
64
  complete: boolean;
65
+ /** A changed artifact has no recorded injected hash, so snapshot ownership is unknown. */
66
+ unverified: boolean;
65
67
  }
66
68
 
67
69
  function sha256(content: string | null): string | null {
68
70
  return content === null ? null : createHash("sha256").update(content).digest("hex");
69
71
  }
70
72
 
73
+ function compareJournalState(journal: Journal, config: string | null, profile: string | null) {
74
+ const originalConfig = Buffer.from(journal.originalConfig, "base64").toString("utf-8");
75
+ const originalProfile = journal.originalProfile === null
76
+ ? null
77
+ : Buffer.from(journal.originalProfile, "base64").toString("utf-8");
78
+ const configAlreadyOriginal = config === originalConfig;
79
+ const profileAlreadyOriginal = profile === originalProfile;
80
+ const configHashKnown = typeof journal.injectedConfigHash === "string" && journal.injectedConfigHash.length > 0;
81
+ const profileHashKnown = journal.injectedProfileHash !== undefined;
82
+ return {
83
+ originalConfig,
84
+ originalProfile,
85
+ configAlreadyOriginal,
86
+ profileAlreadyOriginal,
87
+ configUnchanged: configAlreadyOriginal || (configHashKnown && sha256(config) === journal.injectedConfigHash),
88
+ profileUnchanged: profileAlreadyOriginal || (profileHashKnown && sha256(profile) === (journal.injectedProfileHash ?? null)),
89
+ unverified: (!configHashKnown && !configAlreadyOriginal) || (!profileHashKnown && !profileAlreadyOriginal),
90
+ };
91
+ }
92
+
93
+ /** Read-only check of the pre-injection snapshot input, never the newly injected bytes. */
94
+ export function hasUnverifiedJournalBaseline(config: string | null, profile: string | null): boolean {
95
+ const journal = readJournal(false);
96
+ return journal !== null && compareJournalState(journal, config, profile).unverified;
97
+ }
98
+
71
99
  export interface WriteJournalOptions {
72
100
  /**
73
101
  * The caller's verdict on the config it is about to transform: false when
@@ -114,7 +142,7 @@ export function writeJournal(options: WriteJournalOptions = {}): void {
114
142
  const journal: Journal = {
115
143
  version: 1,
116
144
  originalConfig: Buffer.from(config).toString("base64"),
117
- originalProfile: profile ? Buffer.from(profile).toString("base64") : null,
145
+ originalProfile: profile !== null ? Buffer.from(profile).toString("base64") : null,
118
146
  pid: process.pid,
119
147
  owner: options.owner?.kind === "client"
120
148
  ? { kind: "client", apiKeyId: options.owner.apiKeyId }
@@ -208,22 +236,34 @@ export function journalOwner(): JournalOwner | null {
208
236
  export function restoreJournalState(): RestoreJournalResult {
209
237
  const journal = readJournal();
210
238
  if (!journal) {
211
- return { configRestored: false, profileRestored: false, configChanged: false, profileChanged: false, complete: false };
239
+ return { configRestored: false, profileRestored: false, configChanged: false, profileChanged: false, complete: false, unverified: false };
212
240
  }
213
- const currentConfig = existsSync(CODEX_CONFIG_PATH) ? readFileSync(CODEX_CONFIG_PATH, "utf-8") : "";
241
+ const currentConfig = existsSync(CODEX_CONFIG_PATH) ? readFileSync(CODEX_CONFIG_PATH, "utf-8") : null;
214
242
  const currentProfile = existsSync(CODEX_PROFILE_PATH) ? readFileSync(CODEX_PROFILE_PATH, "utf-8") : null;
215
- const configUnchanged = !journal.injectedConfigHash || sha256(currentConfig) === journal.injectedConfigHash;
216
- const profileUnchanged = journal.injectedProfileHash === undefined || sha256(currentProfile) === (journal.injectedProfileHash ?? null);
243
+ const comparison = compareJournalState(journal, currentConfig, currentProfile);
244
+ const { configUnchanged, profileUnchanged } = comparison;
245
+ // A legacy record or interruption before markJournalInjectedState is not proof that
246
+ // later bytes belong to OpenCodex. Keep the whole pair and its recovery evidence intact.
247
+ if (comparison.unverified) {
248
+ return {
249
+ configRestored: comparison.configAlreadyOriginal,
250
+ profileRestored: comparison.profileAlreadyOriginal,
251
+ configChanged: !configUnchanged,
252
+ profileChanged: !profileUnchanged,
253
+ complete: false,
254
+ unverified: true,
255
+ };
256
+ }
217
257
 
218
- let configRestored = false;
219
- let profileRestored = false;
220
- if (configUnchanged) {
221
- atomicWriteFile(CODEX_CONFIG_PATH, Buffer.from(journal.originalConfig, "base64").toString("utf-8"));
258
+ let configRestored = comparison.configAlreadyOriginal;
259
+ let profileRestored = comparison.profileAlreadyOriginal;
260
+ if (configUnchanged && !configRestored) {
261
+ atomicWriteFile(CODEX_CONFIG_PATH, comparison.originalConfig);
222
262
  configRestored = true;
223
263
  }
224
- if (profileUnchanged) {
225
- if (journal.originalProfile !== null) {
226
- atomicWriteFile(CODEX_PROFILE_PATH, Buffer.from(journal.originalProfile, "base64").toString("utf-8"));
264
+ if (profileUnchanged && !profileRestored) {
265
+ if (comparison.originalProfile !== null) {
266
+ atomicWriteFile(CODEX_PROFILE_PATH, comparison.originalProfile);
227
267
  profileRestored = true;
228
268
  } else if (existsSync(CODEX_PROFILE_PATH)) {
229
269
  // "There was no profile before, so remove the one we generated." Claiming success
@@ -249,6 +289,7 @@ export function restoreJournalState(): RestoreJournalResult {
249
289
  configChanged: !configUnchanged,
250
290
  profileChanged: !profileUnchanged,
251
291
  complete,
292
+ unverified: false,
252
293
  };
253
294
  }
254
295
 
@@ -267,6 +308,10 @@ export function reconcileJournal(options: ReconcileJournalOptions = {}): boolean
267
308
  if (owner?.kind === "client") {
268
309
  if (options.activeClientApiKeyId === owner.apiKeyId) return false;
269
310
  const restored = restoreJournalState();
311
+ if (restored.unverified) {
312
+ console.error("⚠️ Codex journal recovery was not verified; current configuration files and the journal were preserved.");
313
+ return false;
314
+ }
270
315
  if (!restored.configRestored && !restored.profileRestored) return false;
271
316
  console.error(`⚠️ Uncommitted or mismatched client routing (${owner.apiKeyId}) was restored from the Codex journal.`);
272
317
  return true;
@@ -281,6 +326,10 @@ export function reconcileJournal(options: ReconcileJournalOptions = {}): boolean
281
326
  }
282
327
  }
283
328
  const restored = restoreJournalState();
329
+ if (restored.unverified) {
330
+ console.error("⚠️ Codex journal recovery was not verified; current configuration files and the journal were preserved.");
331
+ return false;
332
+ }
284
333
  if (!restored.configRestored && !restored.profileRestored) return false;
285
334
  console.error(`⚠️ Previous session (PID ${pid}) did not shut down cleanly. Codex state restored from journal.`);
286
335
  return true;
@@ -18,6 +18,8 @@ interface CacheEntry {
18
18
  models: CatalogModel[];
19
19
  fetchedAt: number;
20
20
  sizeBytes: number;
21
+ /** Irreversible credential/account identity for entitlement-sensitive catalogs. */
22
+ authorityIdentity?: string;
21
23
  }
22
24
 
23
25
  export type ProviderModelDiscoveryFailureReason =
@@ -149,15 +151,19 @@ export function isModelsFetchCoolingDown(provider: string, cooldownMs = MODELS_F
149
151
  }
150
152
 
151
153
  /** Fresh cached models for a provider, or null when absent/stale (caller should re-fetch). */
152
- export function getFreshCached(provider: string, ttlMs: number, now = Date.now()): CatalogModel[] | null {
154
+ export function getFreshCached(provider: string, ttlMs: number, now = Date.now(), authorityIdentity?: string): CatalogModel[] | null {
153
155
  const entry = cache.get(provider);
154
156
  if (!entry) return null;
157
+ if (authorityIdentity !== undefined && entry.authorityIdentity !== authorityIdentity) return null;
155
158
  return now - entry.fetchedAt < ttlMs ? entry.models : null;
156
159
  }
157
160
 
158
161
  /** Last-known-good models regardless of age — the fallback when a live fetch fails. */
159
- export function getStaleCached(provider: string): CatalogModel[] | null {
160
- return cache.get(provider)?.models ?? null;
162
+ export function getStaleCached(provider: string, authorityIdentity?: string): CatalogModel[] | null {
163
+ const entry = cache.get(provider);
164
+ if (!entry) return null;
165
+ if (authorityIdentity !== undefined && entry.authorityIdentity !== authorityIdentity) return null;
166
+ return entry.models;
161
167
  }
162
168
 
163
169
  /** Capture the cache generation before an asynchronous provider discovery starts. */
@@ -181,12 +187,13 @@ export function setCached(
181
187
  models: CatalogModel[],
182
188
  now = Date.now(),
183
189
  generation?: string,
190
+ authorityIdentity?: string,
184
191
  ): boolean {
185
192
  if (generation !== undefined && !isModelCacheGenerationCurrent(provider, generation)) return false;
186
193
  deleteCachedProvider(provider);
187
194
  const sizeBytes = modelCacheEncoder.encode(provider).byteLength
188
195
  + modelCacheEncoder.encode(JSON.stringify(models)).byteLength;
189
- cache.set(provider, { models, fetchedAt: now, sizeBytes });
196
+ cache.set(provider, { models, fetchedAt: now, sizeBytes, ...(authorityIdentity ? { authorityIdentity } : {}) });
190
197
  cacheBytes += sizeBytes;
191
198
  if (oldestCachedAt === null || now < oldestCachedAt) {
192
199
  oldestCachedProvider = provider;
@@ -1,4 +1,6 @@
1
1
  import { NativeProfileManager } from "./native-profile-manager";
2
+ import { loadConfig } from "../config";
3
+ import { initializeMainAccountPolicyBinding } from "./account-lifecycle";
2
4
  import { clearAccountNeedsReauth } from "./account-runtime-state";
3
5
  import { MAIN_CODEX_ACCOUNT_ID } from "./main-account";
4
6
  import {
@@ -73,6 +75,7 @@ interface StartupEntry {
73
75
  owner: NativeMainOwnerReference;
74
76
  unsubscribe: () => void;
75
77
  recoveryStarted: boolean;
78
+ policyBindingPending: boolean;
76
79
  settled: Promise<NativeMainStartupGateSnapshot>;
77
80
  resolveAcquisition?: (value: NativeMainStartupGateSnapshot) => void;
78
81
  deps: NativeMainStartupGateDeps;
@@ -172,17 +175,21 @@ async function runOwnedStageSweep(entry: StartupEntry): Promise<boolean> {
172
175
  }
173
176
 
174
177
  function scheduleStageSweep(entry: StartupEntry): void {
175
- if (entry.sweepStopping || entry.sweepTimer || startupEntries.get(entry.homeId) !== entry) return;
178
+ if (entry.sweepStopping || entry.sweepTimer || entry.sweepInFlight || entry.policyBindingPending
179
+ || startupEntries.get(entry.homeId) !== entry) return;
176
180
  const intervalMs = Math.max(10, entry.deps.stageSweepIntervalMs ?? NATIVE_STAGE_SWEEP_INTERVAL_MS);
177
181
  entry.sweepTimer = setTimeout(() => {
178
182
  entry.sweepTimer = undefined;
179
183
  if (entry.sweepStopping || startupEntries.get(entry.homeId) !== entry) return;
184
+ const sweepEpoch = entry.epoch;
180
185
  entry.sweepInFlight = (async () => {
181
186
  const safe = await runOwnedStageSweep(entry);
182
- if (entry.sweepStopping || startupEntries.get(entry.homeId) !== entry) return;
187
+ if (entry.sweepStopping || startupEntries.get(entry.homeId) !== entry
188
+ || entry.epoch !== sweepEpoch || entry.policyBindingPending) return;
183
189
  if (!safe) snapshot = { status: "blocked", homeId: entry.homeId, reason: "stage-cleanup-required" };
184
190
  else if (snapshot.homeId === entry.homeId && snapshot.status === "blocked" && snapshot.reason === "stage-cleanup-required") {
185
- snapshot = ready(entry.homeId);
191
+ if (loadConfig().codexMainAccountHardLock === true) rearmOwnedMainPolicyBinding(entry);
192
+ else snapshot = ready(entry.homeId);
186
193
  }
187
194
  })().finally(() => {
188
195
  entry.sweepInFlight = undefined;
@@ -218,8 +225,29 @@ function convergeOwnedStartup(entry: StartupEntry): void {
218
225
  ));
219
226
  const stageSweepSafe = recoveryState === "none" ? await runOwnedStageSweep(entry) : false;
220
227
  if (startupEntries.get(entry.homeId) === entry && entry.epoch === currentEpoch && recoveryState === "none" && stageSweepSafe) {
221
- clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
222
- snapshot = ready(entry.homeId);
228
+ if (loadConfig().codexMainAccountHardLock === true) {
229
+ await withNativeMainOwnerOperation(entry.manager.context, () => withNativeMainExclusiveClaim(
230
+ entry.manager.context,
231
+ async () => {
232
+ if (startupEntries.get(entry.homeId) !== entry || entry.epoch !== currentEpoch) return;
233
+ if (probe(entry.manager.context) !== "none") {
234
+ snapshot = { status: "blocked", homeId: entry.homeId, reason: "manual-recovery" };
235
+ return;
236
+ }
237
+ // The HMAC is deliberately not persisted. Bind only the pinned owned home,
238
+ // after recovery/cleanup, and before caller-owned admission can observe ready.
239
+ if (loadConfig().codexMainAccountHardLock === true) {
240
+ initializeMainAccountPolicyBinding(entry.manager.context.authPath);
241
+ }
242
+ clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
243
+ snapshot = ready(entry.homeId);
244
+ },
245
+ { waitMs: 10_000 },
246
+ ));
247
+ } else {
248
+ clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
249
+ snapshot = ready(entry.homeId);
250
+ }
223
251
  } else if (startupEntries.get(entry.homeId) === entry && entry.epoch === currentEpoch && recoveryState === "none") {
224
252
  snapshot = { status: "blocked", homeId: entry.homeId, reason: "stage-cleanup-required" };
225
253
  } else if (startupEntries.get(entry.homeId) === entry && entry.epoch === currentEpoch) {
@@ -230,12 +258,35 @@ function convergeOwnedStartup(entry: StartupEntry): void {
230
258
  snapshot = { status: "blocked", homeId: entry.homeId, reason: "manual-recovery" };
231
259
  }
232
260
  }
261
+ entry.policyBindingPending = false;
233
262
  if (startupEntries.get(entry.homeId) === entry && entry.epoch === currentEpoch) scheduleStageSweep(entry);
234
263
  return snapshot;
235
264
  })();
236
265
  if (acquisitionWaiter) void entry.settled.then(acquisitionWaiter);
237
266
  }
238
267
 
268
+ /** Join an active startup, or rearm its held owner before publishing another ready transition. */
269
+ function rearmOwnedMainPolicyBinding(entry: StartupEntry): boolean {
270
+ if (entry.sweepStopping || startupEntries.get(entry.homeId) !== entry) return false;
271
+ const owner = entry.owner.snapshot();
272
+ if (entry.policyBindingPending && (owner.status === "held" || owner.status === "acquiring")) {
273
+ snapshot = { status: "blocked", homeId: entry.homeId, reason: "recovery-pending" };
274
+ settled = entry.settled;
275
+ return true;
276
+ }
277
+ if (owner.status !== "held") {
278
+ snapshot = { status: "blocked", homeId: entry.homeId, reason: ownerBlockedReason(owner) };
279
+ return false;
280
+ }
281
+ if (entry.sweepTimer) clearTimeout(entry.sweepTimer);
282
+ entry.sweepTimer = undefined;
283
+ entry.epoch = ++epoch;
284
+ entry.policyBindingPending = true;
285
+ entry.recoveryStarted = false;
286
+ convergeOwnedStartup(entry);
287
+ return true;
288
+ }
289
+
239
290
  function observeOwner(entry: StartupEntry, owner: NativeMainOwnerSnapshot): void {
240
291
  if (startupEntries.get(entry.homeId) !== entry) return;
241
292
  if (owner.status === "acquiring") {
@@ -283,6 +334,7 @@ export function startNativeMainStartupLifecycle(
283
334
  owner,
284
335
  unsubscribe: () => {},
285
336
  recoveryStarted: false,
337
+ policyBindingPending: true,
286
338
  settled: acquisition,
287
339
  resolveAcquisition,
288
340
  deps,
@@ -291,6 +343,12 @@ export function startNativeMainStartupLifecycle(
291
343
  };
292
344
  startupEntries.set(homeId, entry);
293
345
  entry.unsubscribe = owner.subscribe(ownerState => observeOwner(entry!, ownerState));
346
+ } else if (!entry.policyBindingPending
347
+ && snapshot.status === "ready" && snapshot.homeId === homeId
348
+ && loadConfig().codexMainAccountHardLock === true) {
349
+ // A new same-process listener can enable protection or follow a credential replacement.
350
+ // Re-read its pinned home through the held owner before admitting caller-owned main.
351
+ rearmOwnedMainPolicyBinding(entry);
294
352
  }
295
353
  entry.refs += 1;
296
354
  let released = false;
@@ -602,6 +660,8 @@ export function blockNativeMainRecovery(
602
660
 
603
661
  export function completeNativeMainRecovery(homeId: string): boolean {
604
662
  if (snapshot.status !== "blocked" || snapshot.homeId !== homeId) return false;
663
+ const entry = startupEntries.get(homeId);
664
+ if (entry && loadConfig().codexMainAccountHardLock === true) return rearmOwnedMainPolicyBinding(entry);
605
665
  epoch += 1;
606
666
  clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
607
667
  snapshot = ready(homeId);
@@ -615,6 +675,13 @@ export function nativeMainStartupGateSnapshot(): NativeMainStartupGateSnapshot {
615
675
  return { ...snapshot };
616
676
  }
617
677
 
678
+ /** Read-only: caller-owned credentials must not trigger physical-main ownership reprobes. */
679
+ export function isMainAccountPolicyBindingPending(): boolean {
680
+ const current = nativeMainStartupGateSnapshot();
681
+ return current.status === "blocked" && current.reason === "recovery-pending"
682
+ && current.homeId !== null && startupEntries.get(current.homeId)?.policyBindingPending === true;
683
+ }
684
+
618
685
  export function waitForNativeMainStartupGate(): Promise<NativeMainStartupGateSnapshot> {
619
686
  const reason = activeServiceOwnershipBlockReason();
620
687
  if (reason) return Promise.resolve(serviceOwnershipSnapshot(reason));
@@ -41,7 +41,7 @@ const KEYRING_SERVICE = "opencodex.native-main-profile.v1";
41
41
  const SHARED_METADATA_DIR = ".opencodex-native-main-profiles";
42
42
  const INSTANCE_STAGING_DIR = "native-main-profile-staging";
43
43
  const LEGACY_METADATA_DIR = "native-main-profiles";
44
- const MAX_AUTH_BYTES = 4 * 1024 * 1024;
44
+ export const MAX_AUTH_BYTES = 4 * 1024 * 1024;
45
45
  export const MAX_NATIVE_PROFILE_METADATA_BYTES = 4 * 1024 * 1024;
46
46
  export const MAX_NATIVE_PROFILE_JOURNAL_BYTES = 17 * 1024 * 1024;
47
47
  export const MAX_NATIVE_PROFILES = 32;
@@ -376,7 +376,7 @@ export function resolveNativeProfileContext(options: { codexHome?: string; confi
376
376
  };
377
377
  }
378
378
 
379
- function readBounded(path: string, limit: number, testSeam?: BoundedReadTestSeam): Buffer {
379
+ export function readBounded(path: string, limit: number, testSeam?: BoundedReadTestSeam): Buffer {
380
380
  let fd: number | undefined;
381
381
  let failed = false;
382
382
  try {
@@ -0,0 +1,226 @@
1
+ import { createHash } from "node:crypto";
2
+ import {
3
+ chmodSync,
4
+ closeSync,
5
+ constants,
6
+ existsSync,
7
+ fsyncSync,
8
+ lstatSync,
9
+ mkdirSync,
10
+ openSync,
11
+ readFileSync,
12
+ realpathSync,
13
+ truncateSync,
14
+ unlinkSync,
15
+ writeFileSync,
16
+ } from "node:fs";
17
+ import { basename, isAbsolute, join, relative, resolve } from "node:path";
18
+
19
+ import { Database } from "bun:sqlite";
20
+
21
+ import { getConfigDir } from "../config";
22
+ import { hardenSecretPath } from "../lib/windows-secret-acl";
23
+ import { renameAtomicFile } from "../lib/windows-atomic-replace";
24
+ import {
25
+ decodeCompactionSummary,
26
+ isCompactionItemType,
27
+ SUMMARY_PREFIX,
28
+ } from "../responses/compaction";
29
+ import { resolveCodexHomeDir } from "./home";
30
+ import { resolveCodexStateDbPath } from "./paths";
31
+
32
+ export interface OcxCompactionRewriteResult {
33
+ content: string;
34
+ replaced: number;
35
+ }
36
+
37
+ export interface OcxCompactionHistoryRecoveryResult {
38
+ rolloutPath: string;
39
+ backupPath: string | null;
40
+ replaced: number;
41
+ }
42
+
43
+ export interface OcxCompactionHistoryRecoveryOptions {
44
+ threadId: string;
45
+ codexHome?: string;
46
+ stateDbPath?: string;
47
+ backupRoot?: string;
48
+ now?: () => Date;
49
+ }
50
+
51
+ const THREAD_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
52
+
53
+ function digest(content: string | Buffer): string {
54
+ return createHash("sha256").update(content).digest("hex");
55
+ }
56
+
57
+ function pathInside(root: string, candidate: string): boolean {
58
+ const rel = relative(root, candidate);
59
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
60
+ }
61
+
62
+ function resolveOwnedRolloutPath(codexHome: string, rawPath: string): string {
63
+ const candidate = resolve(isAbsolute(rawPath) ? rawPath : join(codexHome, rawPath));
64
+ const roots = [join(codexHome, "sessions"), join(codexHome, "archived_sessions")]
65
+ .filter(existsSync)
66
+ .map(root => realpathSync.native(root));
67
+ const entry = lstatSync(candidate);
68
+ if (!entry.isFile() || entry.isSymbolicLink()) {
69
+ throw new Error("the referenced rollout is not a regular file");
70
+ }
71
+ const canonical = realpathSync.native(candidate);
72
+ if (!roots.some(root => pathInside(root, canonical))) {
73
+ throw new Error("the referenced rollout is outside Codex session storage");
74
+ }
75
+ return canonical;
76
+ }
77
+
78
+ function writePrivateFile(path: string, content: string): void {
79
+ const fd = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600);
80
+ try {
81
+ if (process.platform !== "win32") chmodSync(path, 0o600);
82
+ else hardenSecretPath(path, { required: true, timeoutMemoKey: path });
83
+ writeFileSync(fd, content, "utf8");
84
+ fsyncSync(fd);
85
+ } finally {
86
+ closeSync(fd);
87
+ }
88
+ }
89
+
90
+ function safeRemovePrivateFile(path: string): void {
91
+ try { truncateSync(path, 0); } catch { /* best effort before unlink */ }
92
+ try { unlinkSync(path); } catch { /* caller reports the original failure */ }
93
+ }
94
+
95
+ function isRecord(value: unknown): value is Record<string, unknown> {
96
+ return value !== null && typeof value === "object" && !Array.isArray(value);
97
+ }
98
+
99
+ function lowerCompactionItem(item: unknown): { item: unknown; changed: boolean } {
100
+ if (!isRecord(item) || !isCompactionItemType(item.type)) {
101
+ return { item, changed: false };
102
+ }
103
+ if (typeof item.encrypted_content !== "string") {
104
+ return { item, changed: false };
105
+ }
106
+ const summary = decodeCompactionSummary(item.encrypted_content);
107
+ if (summary === null) return { item, changed: false };
108
+ return {
109
+ item: {
110
+ type: "message",
111
+ role: "user",
112
+ content: [{ type: "input_text", text: `${SUMMARY_PREFIX}\n${summary}` }],
113
+ },
114
+ changed: true,
115
+ };
116
+ }
117
+
118
+ function rewriteJsonlLine(line: string): { line: string; replaced: number } {
119
+ let record: unknown;
120
+ try {
121
+ record = JSON.parse(line);
122
+ } catch {
123
+ return { line, replaced: 0 };
124
+ }
125
+ if (!isRecord(record) || record.type !== "compacted" || !isRecord(record.payload)) {
126
+ return { line, replaced: 0 };
127
+ }
128
+ const history = record.payload.replacement_history;
129
+ if (!Array.isArray(history)) return { line, replaced: 0 };
130
+
131
+ let replaced = 0;
132
+ const replacementHistory = history.map(item => {
133
+ const lowered = lowerCompactionItem(item);
134
+ if (lowered.changed) replaced += 1;
135
+ return lowered.item;
136
+ });
137
+ if (replaced === 0) return { line, replaced: 0 };
138
+
139
+ return {
140
+ line: JSON.stringify({
141
+ ...record,
142
+ payload: { ...record.payload, replacement_history: replacementHistory },
143
+ }),
144
+ replaced,
145
+ };
146
+ }
147
+
148
+ /**
149
+ * Convert OpenCodeX-owned `ocx1:` compaction items into ordinary replayable user messages.
150
+ *
151
+ * Only the authoritative `compacted.payload.replacement_history` snapshot is changed. Earlier
152
+ * response-item events are historical output and are deliberately preserved byte-for-byte.
153
+ * Native opaque compactions are also untouched because OpenCodeX cannot decode them safely.
154
+ */
155
+ export function rewriteOcxCompactionsForNativeReplay(content: string): OcxCompactionRewriteResult {
156
+ const parts = content.split(/(\r?\n)/);
157
+ let replaced = 0;
158
+ for (let index = 0; index < parts.length; index += 2) {
159
+ const line = parts[index];
160
+ if (!line) continue;
161
+ const rewritten = rewriteJsonlLine(line);
162
+ if (rewritten.replaced === 0) continue;
163
+ parts[index] = rewritten.line;
164
+ replaced += rewritten.replaced;
165
+ }
166
+ return replaced === 0
167
+ ? { content, replaced: 0 }
168
+ : { content: parts.join(""), replaced };
169
+ }
170
+
171
+ /**
172
+ * Repair one explicitly selected Codex rollout for direct native replay.
173
+ *
174
+ * The original bytes are copied to an owner-private backup before the rollout is atomically
175
+ * replaced. A last-moment digest check refuses a concurrent Codex append instead of losing it.
176
+ */
177
+ export function recoverOcxCompactionHistory(
178
+ options: OcxCompactionHistoryRecoveryOptions,
179
+ ): OcxCompactionHistoryRecoveryResult {
180
+ if (!THREAD_ID_RE.test(options.threadId)) throw new Error("thread id must be a UUID");
181
+ const codexHome = realpathSync.native(options.codexHome ?? resolveCodexHomeDir());
182
+ const stateDbPath = options.stateDbPath ?? resolveCodexStateDbPath({ codexHome });
183
+ if (!existsSync(stateDbPath)) throw new Error("Codex state database was not found");
184
+
185
+ const db = new Database(stateDbPath, { readonly: true });
186
+ let rawRolloutPath: string | undefined;
187
+ try {
188
+ db.exec("PRAGMA busy_timeout = 1000");
189
+ rawRolloutPath = db.query<{ rollout_path: string }, [string]>(
190
+ "SELECT rollout_path FROM threads WHERE id = ? LIMIT 1",
191
+ ).get(options.threadId)?.rollout_path;
192
+ } finally {
193
+ db.close();
194
+ }
195
+ if (!rawRolloutPath) throw new Error("thread was not found in the Codex state database");
196
+
197
+ const rolloutPath = resolveOwnedRolloutPath(codexHome, rawRolloutPath);
198
+ const originalBytes = readFileSync(rolloutPath);
199
+ const original = originalBytes.toString("utf8");
200
+ if (!Buffer.from(original, "utf8").equals(originalBytes)) {
201
+ throw new Error("the rollout is not valid UTF-8 and cannot be repaired safely");
202
+ }
203
+ const rewritten = rewriteOcxCompactionsForNativeReplay(original);
204
+ if (rewritten.replaced === 0) {
205
+ return { rolloutPath, backupPath: null, replaced: 0 };
206
+ }
207
+
208
+ const stamp = (options.now ?? (() => new Date()))().toISOString().replace(/[:.]/g, "-");
209
+ const backupDir = resolve(options.backupRoot ?? join(getConfigDir(), "history-recovery-backups", options.threadId));
210
+ mkdirSync(backupDir, { recursive: true, mode: 0o700 });
211
+ const backupPath = join(backupDir, `${basename(rolloutPath)}.${stamp}.bak`);
212
+ writePrivateFile(backupPath, original);
213
+
214
+ const tempPath = `${rolloutPath}.ocx-repair-${process.pid}-${crypto.randomUUID()}.tmp`;
215
+ try {
216
+ writePrivateFile(tempPath, rewritten.content);
217
+ if (digest(readFileSync(rolloutPath)) !== digest(originalBytes)) {
218
+ throw new Error("the rollout changed while it was being repaired; close Codex and retry");
219
+ }
220
+ renameAtomicFile(tempPath, rolloutPath);
221
+ } catch (error) {
222
+ safeRemovePrivateFile(tempPath);
223
+ throw error;
224
+ }
225
+ return { rolloutPath, backupPath, replaced: rewritten.replaced };
226
+ }
@@ -69,7 +69,9 @@ function multilineCloseIndex(
69
69
  backslashes += 1;
70
70
  }
71
71
  if (backslashes % 2 === 0) break;
72
- index = line.indexOf(delimiter, index + delimiter.length);
72
+ // An escaped quote can overlap the real terminator (backslash plus four quotes).
73
+ // Keep overlapping candidates instead of skipping the entire rejected delimiter.
74
+ index = line.indexOf(delimiter, index + 1);
73
75
  }
74
76
  return index;
75
77
  }
@@ -336,6 +336,9 @@ function mergeAccountQuota(
336
336
  }
337
337
 
338
338
  if (snapshotHasCustom(quota)) next.customWindows = quota.customWindows;
339
+ // Ordinary response headers omit model-specific windows reported by WHAM.
340
+ // Absence is a partial update; an explicit list (including []) still replaces it.
341
+ else if (existing?.customWindows !== undefined) next.customWindows = existing.customWindows;
339
342
 
340
343
  if (quota.resetCredits !== undefined) next.resetCredits = quota.resetCredits;
341
344
  else if (existing?.resetCredits !== undefined) next.resetCredits = existing.resetCredits;
@@ -795,6 +798,10 @@ export function parseUsageQuota(data: WhamUsageResponse): Omit<StoredAccountQuot
795
798
  });
796
799
  const sparkWindows = [spark?.rate_limit?.primary_window, spark?.rate_limit?.secondary_window]
797
800
  .filter((window): window is WhamUsageWindow => !!window);
801
+ const sparkShort = sparkWindows.find(window => {
802
+ const percent = normalizeUsagePercent(window.used_percent);
803
+ return percent !== undefined && isExplicitShortWindow(window);
804
+ });
798
805
  const sparkWeekly = sparkWindows.find(window => {
799
806
  const percent = normalizeUsagePercent(window.used_percent);
800
807
  const seconds = window.limit_window_seconds;
@@ -803,16 +810,19 @@ export function parseUsageQuota(data: WhamUsageResponse): Omit<StoredAccountQuot
803
810
  && !isExplicitMonthlyWindow(window)
804
811
  && (seconds === undefined || seconds >= WEEKLY_WINDOW_MIN_SECONDS);
805
812
  });
806
- const sparkPercent = normalizeUsagePercent(sparkWeekly?.used_percent);
807
- if (sparkPercent !== undefined) {
808
- const sparkWindow: { label: string; percent: number; resetAt?: number } = {
809
- label: "GPT-5.3-Codex-Spark Weekly",
810
- percent: sparkPercent,
811
- };
812
- const resetAt = normalizeResetAt(sparkWeekly?.reset_at);
813
+ const sparkCustomWindows: Array<{ label: string; percent: number; resetAt?: number }> = [];
814
+ for (const [label, window] of [
815
+ ["GPT-5.3-Codex-Spark 5h", sparkShort],
816
+ ["GPT-5.3-Codex-Spark Weekly", sparkWeekly],
817
+ ] as const) {
818
+ const percent = normalizeUsagePercent(window?.used_percent);
819
+ if (percent === undefined) continue;
820
+ const sparkWindow: { label: string; percent: number; resetAt?: number } = { label, percent };
821
+ const resetAt = normalizeResetAt(window?.reset_at);
813
822
  if (resetAt !== undefined) sparkWindow.resetAt = resetAt;
814
- quota.customWindows = [sparkWindow];
823
+ sparkCustomWindows.push(sparkWindow);
815
824
  }
825
+ if (sparkCustomWindows.length > 0) quota.customWindows = sparkCustomWindows;
816
826
  if (resetCredits !== undefined) quota.resetCredits = resetCredits;
817
827
 
818
828
  return hasKnownQuotaValue(quota) || resetCredits !== undefined ? quota : null;
@@ -44,14 +44,30 @@ function owned(token: Token, writer: MainQuotaWriter): boolean {
44
44
  function record(value: unknown): value is Record<string, unknown> {
45
45
  return value !== null && typeof value === "object" && !Array.isArray(value);
46
46
  }
47
- function userId(token: string): string | undefined {
47
+ /**
48
+ * The ChatGPT per-user identity carried by a native credential, plus whether the token's own two
49
+ * encodings of it disagree. Precedence stays on the RAW claims, so an empty or non-string
50
+ * `chatgpt_user_id` still blocks the `user_id` fallback exactly as before; `conflict` is a
51
+ * separate observation for callers that must fail closed on an ambiguous identity.
52
+ */
53
+ export function nativeUserIdClaims(token: string): { userId: string | undefined; conflict: boolean } {
54
+ const none = { userId: undefined, conflict: false };
48
55
  try {
49
56
  const payload: unknown = JSON.parse(Buffer.from(token.split(".")[1] ?? "", "base64url").toString("utf8"));
50
57
  const auth = record(payload) ? payload["https://api.openai.com/auth"] : undefined;
51
- if (!record(auth)) return;
52
- const value = auth.chatgpt_user_id ?? auth.user_id;
53
- return typeof value === "string" && value.length > 0 ? value : undefined;
54
- } catch { return; }
58
+ if (!record(auth)) return none;
59
+ const named = (value: unknown): string | undefined =>
60
+ typeof value === "string" && value.length > 0 ? value : undefined;
61
+ const primary = named(auth.chatgpt_user_id);
62
+ const secondary = named(auth.user_id);
63
+ return {
64
+ userId: named(auth.chatgpt_user_id ?? auth.user_id),
65
+ conflict: primary !== undefined && secondary !== undefined && primary !== secondary,
66
+ };
67
+ } catch { return none; }
68
+ }
69
+ function userId(token: string): string | undefined {
70
+ return nativeUserIdClaims(token).userId;
55
71
  }
56
72
  function identityMatches(data: WhamUsageResponse, token: Token): boolean {
57
73
  if (data.account_id != null && data.account_id !== token.chatgptAccountId) return false;