@bitkyc08/opencodex 2.11.1 → 2.12.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 (175) hide show
  1. package/README.md +1 -1
  2. package/bin/ocx.mjs +4 -0
  3. package/gui/dist/assets/index-BNVYzdn0.css +1 -0
  4. package/gui/dist/assets/index-Cw1_mxO8.js +70 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/src/adapters/command-code.ts +94 -5
  8. package/src/adapters/cursor/live-transport.ts +14 -1
  9. package/src/adapters/google-tool-schema.ts +80 -15
  10. package/src/adapters/google.ts +24 -7
  11. package/src/adapters/openai-chat.ts +233 -305
  12. package/src/adapters/openai-responses.ts +62 -0
  13. package/src/adapters/tool-catalog-nudge.ts +2 -11
  14. package/src/claude/outbound.ts +42 -14
  15. package/src/cli/claude.ts +61 -9
  16. package/src/cli/codex-shim-autorestore.ts +2 -0
  17. package/src/cli/codex-shim-readiness.ts +8 -1
  18. package/src/cli/config-command.ts +4 -1
  19. package/src/cli/doctor.ts +10 -0
  20. package/src/cli/help.ts +15 -0
  21. package/src/cli/index.ts +27 -11
  22. package/src/cli/lab.ts +367 -0
  23. package/src/cli/provider.ts +9 -1
  24. package/src/clients/config-export.ts +100 -3
  25. package/src/codex/app-server-processes.ts +11 -8
  26. package/src/codex/auth-api.ts +92 -28
  27. package/src/codex/auth-collision.ts +5 -3
  28. package/src/codex/catalog/metadata.ts +7 -3
  29. package/src/codex/catalog/provider-fetch.ts +191 -47
  30. package/src/codex/catalog.ts +2 -2
  31. package/src/codex/history-job.ts +83 -18
  32. package/src/codex/history-migration-guardian.ts +9 -16
  33. package/src/codex/history-provider.ts +178 -1
  34. package/src/codex/history-transition.ts +5 -5
  35. package/src/codex/history-worker.ts +22 -2
  36. package/src/codex/native-profile-store.ts +1 -1
  37. package/src/codex/plan.ts +15 -0
  38. package/src/codex/quota.ts +6 -6
  39. package/src/codex/routing.ts +3 -3
  40. package/src/codex/subagent-model-fallback.ts +246 -22
  41. package/src/codex/user-identity.ts +63 -12
  42. package/src/config.ts +265 -32
  43. package/src/images/loop.ts +3 -1
  44. package/src/images/plan.ts +23 -13
  45. package/src/integrations/omp-yaml-source.ts +225 -0
  46. package/src/integrations/ownership.ts +5 -5
  47. package/src/integrations/state.ts +45 -6
  48. package/src/integrations/writer.ts +38 -3
  49. package/src/lab/artifacts/sanitize.ts +586 -0
  50. package/src/lab/artifacts/secure-fs.ts +475 -0
  51. package/src/lab/artifacts/store.ts +310 -0
  52. package/src/lab/conformance/assertion.ts +325 -0
  53. package/src/lab/conformance/digest.ts +22 -0
  54. package/src/lab/conformance/executor.ts +741 -0
  55. package/src/lab/conformance/fixture-provider.ts +27 -0
  56. package/src/lab/conformance/fixtures/live-v1-cases.json +175 -0
  57. package/src/lab/conformance/fixtures/protocol-v1-cases.json +461 -0
  58. package/src/lab/conformance/harness-budget.ts +47 -0
  59. package/src/lab/conformance/index.ts +5 -0
  60. package/src/lab/conformance/jcs.ts +24 -0
  61. package/src/lab/conformance/json-pointer.ts +39 -0
  62. package/src/lab/conformance/manifest.ts +180 -0
  63. package/src/lab/conformance/mcp-stub.ts +179 -0
  64. package/src/lab/conformance/negative-controls.ts +160 -0
  65. package/src/lab/conformance/observation.ts +355 -0
  66. package/src/lab/conformance/runner.ts +57 -0
  67. package/src/lab/conformance/sse-normalize.ts +59 -0
  68. package/src/lab/conformance/suite-manifest.ts +78 -0
  69. package/src/lab/conformance/types.ts +191 -0
  70. package/src/lab/constants.ts +126 -0
  71. package/src/lab/digest.ts +64 -0
  72. package/src/lab/events/errors.ts +9 -0
  73. package/src/lab/events/limits.ts +117 -0
  74. package/src/lab/events/types.ts +228 -0
  75. package/src/lab/events/validate.ts +780 -0
  76. package/src/lab/index.ts +36 -0
  77. package/src/lab/ledger/artifact-refs.ts +127 -0
  78. package/src/lab/ledger/invalidation.ts +136 -0
  79. package/src/lab/ledger/purge.ts +241 -0
  80. package/src/lab/ledger/store.ts +263 -0
  81. package/src/lab/live/credential-lease.ts +53 -0
  82. package/src/lab/live/destination.ts +155 -0
  83. package/src/lab/live/executor.ts +311 -0
  84. package/src/lab/live/inert-tools.ts +56 -0
  85. package/src/lab/live/manifest.ts +85 -0
  86. package/src/lab/live/mcp-loopback.ts +57 -0
  87. package/src/lab/live/runner.ts +19 -0
  88. package/src/lab/live/sandbox.ts +61 -0
  89. package/src/lab/live/suite-manifest.ts +41 -0
  90. package/src/lab/live/transport.ts +116 -0
  91. package/src/lab/live/types.ts +197 -0
  92. package/src/lab/observe/from-conformance.ts +344 -0
  93. package/src/lab/observe/from-live.ts +103 -0
  94. package/src/lab/paths.ts +65 -0
  95. package/src/lab/projection/rebuild.ts +477 -0
  96. package/src/lab/projection/schema.ts +122 -0
  97. package/src/lab/projection/verdicts.ts +438 -0
  98. package/src/lab/projection/verification.ts +332 -0
  99. package/src/lab/query/catalog.ts +72 -0
  100. package/src/lab/query/connection.ts +107 -0
  101. package/src/lab/query/constants.ts +4 -0
  102. package/src/lab/query/cursor.ts +132 -0
  103. package/src/lab/query/dto-map.ts +277 -0
  104. package/src/lab/query/errors.ts +22 -0
  105. package/src/lab/query/index.ts +34 -0
  106. package/src/lab/query/queries.ts +444 -0
  107. package/src/lab/query/types.ts +266 -0
  108. package/src/lab/subject/behavior-fingerprint.ts +77 -0
  109. package/src/lab/subject/installation-salt.ts +81 -0
  110. package/src/lab/subject/route-subject.ts +73 -0
  111. package/src/lib/bounded-body.ts +104 -0
  112. package/src/lib/lab-live-execution-authority.ts +13 -0
  113. package/src/lib/lab-live-host.ts +30 -0
  114. package/src/lib/lab-live-pinned-sender.ts +45 -0
  115. package/src/lib/pinned-http.ts +36 -7
  116. package/src/oauth/index.ts +20 -1
  117. package/src/oauth/local-token-detect.ts +11 -2
  118. package/src/oauth/login-cli.ts +38 -4
  119. package/src/providers/alibaba-region-migration.ts +1 -1
  120. package/src/providers/codex-capacity.ts +7 -4
  121. package/src/providers/command-code-efforts.ts +5 -2
  122. package/src/providers/derive.ts +105 -0
  123. package/src/providers/label.ts +1 -1
  124. package/src/providers/openai-tiers.ts +59 -3
  125. package/src/providers/opencode-zen-rate-limit.ts +102 -0
  126. package/src/providers/quota.ts +67 -21
  127. package/src/providers/registry.ts +118 -5
  128. package/src/router.ts +15 -1
  129. package/src/routing/analytics.ts +8 -7
  130. package/src/routing/evaluator.ts +51 -5
  131. package/src/routing/profile.ts +10 -1
  132. package/src/routing/trace.ts +42 -6
  133. package/src/server/auth-cors.ts +15 -0
  134. package/src/server/background-lifecycle.ts +169 -0
  135. package/src/server/chat-completions.ts +18 -3
  136. package/src/server/claude-messages.ts +1 -0
  137. package/src/server/images.ts +78 -11
  138. package/src/server/index.ts +72 -18
  139. package/src/server/lifecycle.ts +6 -0
  140. package/src/server/live.ts +5 -1
  141. package/src/server/management/agent-settings-routes.ts +41 -24
  142. package/src/server/management/body.ts +6 -0
  143. package/src/server/management/config-routes.ts +51 -8
  144. package/src/server/management/lab-routes.ts +388 -0
  145. package/src/server/management/logs-usage-routes.ts +19 -1
  146. package/src/server/management/model-rows.ts +25 -11
  147. package/src/server/management/native-integration-routes.ts +3 -3
  148. package/src/server/management/provider-routes.ts +5 -0
  149. package/src/server/management/shared.ts +8 -1
  150. package/src/server/management/usage-summary-cache.ts +2 -0
  151. package/src/server/management/vision-sidecar-options.ts +125 -0
  152. package/src/server/management-api.ts +2 -0
  153. package/src/server/request-decompress.ts +106 -5
  154. package/src/server/responses/collaboration.ts +4 -1
  155. package/src/server/responses/core.ts +117 -31
  156. package/src/server/responses-json-events.ts +50 -12
  157. package/src/server/search.ts +23 -7
  158. package/src/server/ws-bridge.ts +3 -1
  159. package/src/service.ts +40 -20
  160. package/src/types.ts +74 -0
  161. package/src/update/job.ts +10 -2
  162. package/src/usage/cost.ts +0 -0
  163. package/src/usage/expected-prices.ts +9 -0
  164. package/src/usage/log.ts +9 -9
  165. package/src/usage/user-cost-overlay-reconciler.ts +313 -0
  166. package/src/usage/user-cost-overlays.ts +314 -0
  167. package/src/vision/describe.ts +2 -2
  168. package/src/vision/eligibility.ts +229 -0
  169. package/src/vision/index.ts +20 -2
  170. package/src/web-search/executor.ts +2 -2
  171. package/src/web-search/index.ts +4 -2
  172. package/src/web-search/loop.ts +3 -1
  173. package/src/web-search/parse.ts +72 -3
  174. package/gui/dist/assets/index-BA1dgo4q.css +0 -1
  175. package/gui/dist/assets/index-DDZpgzKk.js +0 -70
@@ -113,7 +113,12 @@ function fallbackChainKey(model: string, namespaces: unknown): string {
113
113
  return JSON.stringify(["account", selector, model.slice(slash + 1).toLowerCase()]);
114
114
  }
115
115
 
116
- function normalizedChain(primary: string, config: OcxConfig, extra: readonly string[] = []): string[] {
116
+ function normalizedChain(
117
+ primary: string,
118
+ config: OcxConfig,
119
+ extra: readonly string[] = [],
120
+ trailing: readonly string[] = [],
121
+ ): string[] {
117
122
  const chain: string[] = [];
118
123
  const seen = new Set<string>();
119
124
  const push = (model: string | undefined) => {
@@ -127,6 +132,7 @@ function normalizedChain(primary: string, config: OcxConfig, extra: readonly str
127
132
  push(primary);
128
133
  for (const model of extra) push(model);
129
134
  for (const model of config.subagentModelFallback ?? []) push(model);
135
+ for (const model of trailing) push(model);
130
136
  return chain;
131
137
  }
132
138
 
@@ -268,8 +274,9 @@ export function selectAvailableSubagentModel(
268
274
  now = Date.now(),
269
275
  nativeFallbackOnly = false,
270
276
  accountUsabilityOptions?: CodexAccountUsabilityOptions,
277
+ trailingFallback: readonly string[] = [],
271
278
  ): { model: string; rewritten: boolean; skipped: string[] } {
272
- const chain = normalizedChain(primary, config, extraFallback);
279
+ const chain = normalizedChain(primary, config, extraFallback, trailingFallback);
273
280
  const skipped: string[] = [];
274
281
  for (const candidate of chain) {
275
282
  if (nativeFallbackOnly) {
@@ -417,6 +424,37 @@ function subagentQuotaPrimeBlockedByHostCircuit(config: OcxConfig): boolean {
417
424
  return getUpstreamHostHealth(key)?.cooldownUntil !== undefined;
418
425
  }
419
426
 
427
+ /**
428
+ * Per-primary-model fallback chains from opencodex config (#1190).
429
+ *
430
+ * Storing `model_fallback` inside `$CODEX_HOME/agents/*.toml` makes Codex >= 0.146
431
+ * reject the whole role file as an unknown field. The config-keyed map is the
432
+ * supported home for that metadata; keys match the requested primary model id,
433
+ * using the same raw/encoded slug tolerance as the TOML role lookup.
434
+ */
435
+ export function resolveConfiguredModelFallbackForPrimary(
436
+ primary: string,
437
+ config: OcxConfig,
438
+ ): string[] {
439
+ const byModel = config.subagentModelFallbackByModel;
440
+ if (!byModel || typeof byModel !== "object") return [];
441
+ const entries: string[] = [];
442
+ const seen = new Set<string>();
443
+ const push = (model: string) => {
444
+ const trimmed = model.trim();
445
+ if (trimmed === "") return;
446
+ const key = fallbackChainKey(trimmed, config.codexAccountNamespaces);
447
+ if (seen.has(key)) return;
448
+ seen.add(key);
449
+ entries.push(trimmed);
450
+ };
451
+ for (const [key, chain] of Object.entries(byModel)) {
452
+ if (!slugsEquivalent(key, primary)) continue;
453
+ for (const model of chain) push(model);
454
+ }
455
+ return entries;
456
+ }
457
+
420
458
  /**
421
459
  * Best-effort quota refresh before subagent model selection.
422
460
  * Concurrent callers share one in-flight promise. The success TTL is updated only
@@ -479,21 +517,25 @@ export function applySubagentModelFallback(
479
517
  accountUsabilityOptions?: CodexAccountUsabilityOptions,
480
518
  ): { from?: string; to?: string; skipped?: string[] } | null {
481
519
  if (!isThreadSpawnRequest(headers)) return null;
482
- const roleFallback = resolveAgentModelFallbackForPrimary(
520
+ const tomlRoleFallback = resolveAgentModelFallbackForPrimary(
483
521
  parsed.modelId,
484
522
  getCodexHome(),
485
523
  config.codexAccountNamespaces,
486
524
  );
525
+ // Config-keyed chains are the supported per-role home (#1190); TOML `model_fallback`
526
+ // stays readable for backwards compatibility with homes written before Codex 0.146.
527
+ const configuredFallback = resolveConfiguredModelFallbackForPrimary(parsed.modelId, config);
487
528
  const globalFallback = config.subagentModelFallback ?? [];
488
- if (globalFallback.length === 0 && roleFallback.length === 0) return null;
529
+ if (globalFallback.length === 0 && configuredFallback.length === 0 && tomlRoleFallback.length === 0) return null;
489
530
  const selection = selectAvailableSubagentModel(
490
531
  parsed.modelId,
491
532
  config,
492
- roleFallback,
533
+ configuredFallback,
493
534
  accountId,
494
535
  now,
495
536
  nativeFallbackOnly,
496
537
  accountUsabilityOptions,
538
+ tomlRoleFallback,
497
539
  );
498
540
  if (!selection.rewritten) return selection.skipped.length > 0
499
541
  ? { from: parsed.modelId, to: parsed.modelId, skipped: selection.skipped }
@@ -510,33 +552,192 @@ export function subagentFallbackGuidanceText(config: OcxConfig): string {
510
552
  return ` Subagent model fallback chain (priority order): ${quoted}. When the primary model is quota-exhausted, opencodex rewrites thread_spawn requests to the next available model automatically.`;
511
553
  }
512
554
 
513
- const TOML_STRING_ARRAY = /^(model_fallback)\s*=\s*\[(.*)\]\s*$/s;
555
+ const TOML_MODEL_FALLBACK_KEY = /^\s*(?:model_fallback|"model_fallback"|'model_fallback')\s*=/;
514
556
 
515
- function parseTomlStringArray(raw: string): string[] {
516
- const matches = [...raw.matchAll(/"((?:\\.|[^"\\])*)"/g)];
517
- return matches.map(match => match[1]!.replace(/\\"/g, "\""));
557
+ type TomlModelFallbackField = { present: false; value: null } | { present: true; value: string[] | null };
558
+
559
+ type TomlScanState = {
560
+ inMultilineString: '"""' | "'''" | null;
561
+ arrayDepth: number;
562
+ };
563
+
564
+ /** Track TOML strings, comments, and array brackets on one line. */
565
+ function scanTomlLine(line: string, state: TomlScanState): void {
566
+ let i = 0;
567
+ while (i < line.length) {
568
+ const ch = line[i]!;
569
+ if (ch === "#") return;
570
+ if (ch === '"' || ch === "'") {
571
+ const delimiter = ch.repeat(3);
572
+ if (line.startsWith(delimiter, i)) {
573
+ const end = findTomlMultilineStringEnd(line, i + 3, ch);
574
+ if (end === -1) {
575
+ state.inMultilineString = delimiter as '"""' | "'''";
576
+ return;
577
+ }
578
+ i = end + 3;
579
+ continue;
580
+ }
581
+ i++;
582
+ while (i < line.length) {
583
+ if (ch === '"' && line[i] === "\\") {
584
+ i += 2;
585
+ continue;
586
+ }
587
+ if (line[i] === ch) break;
588
+ i++;
589
+ }
590
+ i++;
591
+ continue;
592
+ }
593
+ if (ch === "[") state.arrayDepth++;
594
+ else if (ch === "]") state.arrayDepth = Math.max(0, state.arrayDepth - 1);
595
+ i++;
596
+ }
518
597
  }
519
598
 
520
- function parseTomlModelFallback(content: string): string[] | null {
521
- const match = content.match(/^\s*model_fallback\s*=\s*\[(.*?)\]\s*$/ms);
522
- if (!match) return null;
523
- return parseTomlStringArray(match[1] ?? "");
599
+ /** Parse a TOML string starting at `start`; returns the decoded value and end offset. */
600
+ function parseTomlStringAt(text: string, start: number): { value: string; end: number } | null {
601
+ const quote = text[start]!;
602
+ const delimiter = quote.repeat(3);
603
+ if (text.startsWith(delimiter, start)) {
604
+ const end = findTomlMultilineStringEnd(text, start + 3, quote);
605
+ if (end === -1) return null;
606
+ let value = text.slice(start + 3, end).trim();
607
+ if (quote === '"') value = value.replace(/\\"/g, '"').replace(/\\\\/g, "\\");
608
+ return { value, end: end + 3 };
609
+ }
610
+ let i = start + 1;
611
+ let value = "";
612
+ while (i < text.length) {
613
+ const ch = text[i]!;
614
+ if (quote === '"' && ch === "\\") {
615
+ const next = text[i + 1];
616
+ if (next === '"' || next === "\\") {
617
+ value += next;
618
+ i += 2;
619
+ continue;
620
+ }
621
+ }
622
+ if (ch === quote) return { value, end: i + 1 };
623
+ value += ch;
624
+ i++;
625
+ }
626
+ return null;
627
+ }
628
+
629
+ function findTomlMultilineStringEnd(text: string, from: number, quote: string): number {
630
+ const delimiter = quote.repeat(3);
631
+ let index = text.indexOf(delimiter, from);
632
+ while (index !== -1) {
633
+ if (quote === "'") return index;
634
+ let backslashes = 0;
635
+ for (let j = index - 1; j >= 0 && text[j] === "\\"; j--) backslashes += 1;
636
+ if (backslashes % 2 === 0) return index;
637
+ index = text.indexOf(delimiter, index + 1);
638
+ }
639
+ return -1;
640
+ }
641
+
642
+ /** After an array close, only horizontal whitespace, an inline comment, or the line end is valid. */
643
+ function isValidTomlArrayTail(text: string, from: number): boolean {
644
+ let i = from;
645
+ while (i < text.length && (text[i] === " " || text[i] === "\t")) i += 1;
646
+ if (i >= text.length || text[i] === "\n" || text[i] === "\r") return true;
647
+ if (text[i] === "#") {
648
+ while (i < text.length && text[i] !== "\n") i += 1;
649
+ return true;
650
+ }
651
+ return false;
652
+ }
653
+
654
+ /** Parse a TOML string-array value; null when the value is not a well-formed array of strings. */
655
+ function parseTomlStringArrayValue(text: string): string[] | null {
656
+ const values: string[] = [];
657
+ let i = 0;
658
+ const skipIgnored = () => {
659
+ while (i < text.length) {
660
+ const ch = text[i]!;
661
+ if (ch === "#") {
662
+ while (i < text.length && text[i] !== "\n") i += 1;
663
+ continue;
664
+ }
665
+ if (/\s/.test(ch)) {
666
+ i += 1;
667
+ continue;
668
+ }
669
+ break;
670
+ }
671
+ };
672
+ skipIgnored();
673
+ if (text[i] !== "[") return null;
674
+ i += 1;
675
+ let expectValue = true;
676
+ for (;;) {
677
+ skipIgnored();
678
+ if (i >= text.length) return null;
679
+ const ch = text[i]!;
680
+ if (ch === "]") {
681
+ if (!isValidTomlArrayTail(text, i + 1)) return null;
682
+ return values;
683
+ }
684
+ if (ch === ",") {
685
+ if (expectValue) return null; // leading or doubled comma
686
+ expectValue = true;
687
+ i += 1;
688
+ continue;
689
+ }
690
+ if (ch === '"' || ch === "'") {
691
+ if (!expectValue) return null; // adjacent strings must be comma-separated
692
+ const parsed = parseTomlStringAt(text, i);
693
+ if (!parsed) return null;
694
+ values.push(parsed.value);
695
+ i = parsed.end;
696
+ expectValue = false;
697
+ continue;
698
+ }
699
+ return null;
700
+ }
701
+ }
702
+
703
+ /**
704
+ * TOML-aware, presence-aware parse of the `model_fallback` field. Quoted keys
705
+ * are recognized, and text inside strings (including multiline strings) is
706
+ * never treated as a key. `present` is true whenever the key exists, even when
707
+ * the value is not a readable string array; `value` is null in that case.
708
+ */
709
+ function parseTomlModelFallbackField(content: string): TomlModelFallbackField {
710
+ const lines = content.split(/\r?\n/);
711
+ const state: TomlScanState = { inMultilineString: null, arrayDepth: 0 };
712
+ for (let i = 0; i < lines.length; i += 1) {
713
+ const line = lines[i]!;
714
+ if (state.inMultilineString) {
715
+ const end = findTomlMultilineStringEnd(line, 0, state.inMultilineString[0]!);
716
+ if (end === -1) continue;
717
+ state.inMultilineString = null;
718
+ scanTomlLine(line.slice(end + 3), state);
719
+ continue;
720
+ }
721
+ if (state.arrayDepth === 0) {
722
+ const key = line.match(TOML_MODEL_FALLBACK_KEY);
723
+ if (key) {
724
+ const rest = `${line.slice(key[0].length)}\n${lines.slice(i + 1).join("\n")}`;
725
+ return { present: true, value: parseTomlStringArrayValue(rest) };
726
+ }
727
+ }
728
+ scanTomlLine(line, state);
729
+ }
730
+ return { present: false, value: null };
524
731
  }
525
732
 
526
733
  export function readAgentModelFallback(filePath: string): string[] | null {
527
734
  try {
528
735
  const content = readFileSync(filePath, "utf8");
529
- const multiline = parseTomlModelFallback(content);
530
- if (multiline) return multiline;
531
- for (const line of content.split(/\r?\n/)) {
532
- const match = line.match(TOML_STRING_ARRAY);
533
- if (!match) continue;
534
- return parseTomlStringArray(match[2] ?? "");
535
- }
736
+ const parsed = parseTomlModelFallbackField(content);
737
+ return parsed.present ? parsed.value : null;
536
738
  } catch {
537
739
  return null;
538
740
  }
539
- return null;
540
741
  }
541
742
 
542
743
  export function readCodexAgentModelFallback(role: string, codexHome = CODEX_HOME): string[] {
@@ -545,6 +746,29 @@ export function readCodexAgentModelFallback(role: string, codexHome = CODEX_HOME
545
746
  return readAgentModelFallback(file) ?? [];
546
747
  }
547
748
 
749
+ /**
750
+ * True when the role TOML carries a `model_fallback` key, even an empty array.
751
+ * Presence is what matters for the doctor scan: Codex >= 0.146 rejects the
752
+ * field as unknown and skips the whole role regardless of its value. Uses the
753
+ * same TOML-aware parse as the fallback-reading path, so quoted keys count and
754
+ * text inside string literals does not.
755
+ */
756
+ export function hasCodexAgentModelFallbackField(role: string, codexHome = CODEX_HOME): boolean {
757
+ const file = join(codexHome, "agents", `${role}.toml`);
758
+ if (!existsSync(file)) return false;
759
+ try {
760
+ const content = readFileSync(file, "utf8");
761
+ return parseTomlModelFallbackField(content).present;
762
+ } catch {
763
+ return false;
764
+ }
765
+ }
766
+
767
+ /** Roles whose TOML still carries `model_fallback`, including empty arrays. */
768
+ export function scanCodexAgentRolesWithTomlModelFallback(codexHome = CODEX_HOME): string[] {
769
+ return listCodexAgentRoles(codexHome).filter(role => hasCodexAgentModelFallbackField(role, codexHome));
770
+ }
771
+
548
772
  export function listCodexAgentRoles(codexHome = CODEX_HOME): string[] {
549
773
  const dir = join(codexHome, "agents");
550
774
  if (!existsSync(dir)) return [];
@@ -557,4 +781,4 @@ export function listCodexAgentRoles(codexHome = CODEX_HOME): string[] {
557
781
  export function shouldPrimeSubagentQuota(config: OcxConfig, now = Date.now()): boolean {
558
782
  const last = quotaPrimedAt.get("global") ?? 0;
559
783
  return now - last >= pollIntervalMs(config);
560
- }
784
+ }
@@ -17,6 +17,8 @@ import {
17
17
  } from "node:fs";
18
18
  import { isAbsolute, join, resolve } from "node:path";
19
19
 
20
+ import { resolveTrustedWindowsPowerShellExe } from "../lib/windows-elevation";
21
+
20
22
  import type {
21
23
  ResolveCodexCoordinatorDatabasePath,
22
24
  ResolveCodexCatalogSerializationDatabasePath,
@@ -30,6 +32,13 @@ const POSIX_TMP_REQUIRED_MODE = 0o1003;
30
32
  const POSIX_TMP_PATH = "/tmp";
31
33
  const SID_PATTERN = /^S-1-(?:\d+-)+\d+$/i;
32
34
 
35
+ /**
36
+ * Hard budget for the Windows identity-lookup PowerShell child. These lookups
37
+ * run at startup and on config writes; a hung child must fail the lookup
38
+ * (recoverable — the caller refuses) rather than wedge the proxy indefinitely.
39
+ */
40
+ const WINDOWS_POWERSHELL_LOOKUP_TIMEOUT_MS = 8_000;
41
+
33
42
  export class CodexUserIdentityRefusal extends Error {
34
43
  readonly code = "CODEX_USER_IDENTITY_REFUSED";
35
44
 
@@ -43,24 +52,66 @@ function refuse(message: string, cause?: unknown): never {
43
52
  throw new CodexUserIdentityRefusal(message, cause === undefined ? undefined : { cause });
44
53
  }
45
54
 
55
+ function windowsIdentityPowerShellCommand(expression: string): string[] {
56
+ return [
57
+ resolveTrustedWindowsPowerShellExe(),
58
+ "-NoLogo",
59
+ "-NoProfile",
60
+ "-NonInteractive",
61
+ "-WindowStyle",
62
+ "Hidden",
63
+ "-Command",
64
+ expression,
65
+ ];
66
+ }
67
+
68
+ function windowsIdentityPowerShellSpawnOptions(): {
69
+ stdin: "ignore";
70
+ stdout: "pipe";
71
+ stderr: "pipe";
72
+ timeout: number;
73
+ windowsHide: boolean;
74
+ } {
75
+ return {
76
+ stdin: "ignore",
77
+ stdout: "pipe",
78
+ stderr: "pipe",
79
+ timeout: WINDOWS_POWERSHELL_LOOKUP_TIMEOUT_MS,
80
+ windowsHide: true,
81
+ };
82
+ }
83
+
84
+ /** Test-only readback of the trusted executable and static arguments (#1278). */
85
+ export function windowsIdentityPowerShellCommandForTests(expression: string): string[] {
86
+ return windowsIdentityPowerShellCommand(expression);
87
+ }
88
+
89
+ /** Test-only readback of the spawn options shared by the identity lookups (#1278). */
90
+ export function windowsIdentityPowerShellSpawnOptionsForTests(): ReturnType<
91
+ typeof windowsIdentityPowerShellSpawnOptions
92
+ > {
93
+ return windowsIdentityPowerShellSpawnOptions();
94
+ }
95
+
46
96
  function powershellValue(expression: string): string {
97
+ let command: string[];
98
+ try {
99
+ command = windowsIdentityPowerShellCommand(expression);
100
+ } catch (cause) {
101
+ refuse("Windows effective-account lookup could not start.", cause);
102
+ }
47
103
  let result: ReturnType<typeof Bun.spawnSync>;
48
104
  try {
49
- result = Bun.spawnSync([
50
- "powershell.exe",
51
- "-NoLogo",
52
- "-NoProfile",
53
- "-NonInteractive",
54
- "-Command",
55
- expression,
56
- ], {
57
- stdin: "ignore",
58
- stdout: "pipe",
59
- stderr: "pipe",
60
- });
105
+ // `windowsHide` is the popup fix (#1278): the desktop proxy parent runs
106
+ // without a console, so a console-subsystem child spawned without
107
+ // CREATE_NO_WINDOW gets a fresh visible console window at startup and on
108
+ // every config write. `-WindowStyle Hidden` alone does not stop the
109
+ // allocation; the flag behind `windowsHide` does.
110
+ result = Bun.spawnSync(command, windowsIdentityPowerShellSpawnOptions());
61
111
  } catch (cause) {
62
112
  refuse("Windows effective-account lookup could not start.", cause);
63
113
  }
114
+ if (result.exitedDueToTimeout) refuse("Windows effective-account lookup timed out.");
64
115
  if (result.exitCode !== 0) refuse("Windows effective-account lookup failed.");
65
116
  const value = new TextDecoder().decode(result.stdout).trim();
66
117
  if (!value) refuse("Windows effective-account lookup returned an empty value.");