@bitkyc08/opencodex 2.26.0 → 2.27.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 (55) hide show
  1. package/gui/dist/assets/{index-RL6b1bTV.js → index-7jlKgmJd.js} +14 -14
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +1 -1
  4. package/src/adapters/anthropic.ts +1 -1
  5. package/src/adapters/base.ts +14 -2
  6. package/src/adapters/command-code.ts +4 -3
  7. package/src/adapters/cursor/cursor-errors.ts +15 -0
  8. package/src/adapters/cursor/live-transport.ts +14 -1
  9. package/src/adapters/google.ts +1 -1
  10. package/src/adapters/openai-chat.ts +38 -6
  11. package/src/adapters/tool-catalog-nudge.ts +1 -1
  12. package/src/bridge.ts +11 -5
  13. package/src/cli/doctor.ts +76 -0
  14. package/src/cli/help.ts +2 -0
  15. package/src/cli/models.ts +13 -6
  16. package/src/codex/app-server-processes.ts +269 -37
  17. package/src/codex/auth-context.ts +53 -1
  18. package/src/codex/catalog/aggregation.ts +3 -0
  19. package/src/codex/catalog/parsing.ts +20 -3
  20. package/src/codex/catalog/provider-fetch.ts +8 -0
  21. package/src/codex/catalog/sync.ts +6 -4
  22. package/src/codex/log-guard/path-safety.ts +52 -3
  23. package/src/codex/native-profile-startup.ts +100 -2
  24. package/src/codex/user-identity.ts +21 -1
  25. package/src/config/provider-name.ts +24 -0
  26. package/src/config.ts +11 -24
  27. package/src/generated/compatibility-version.json +73 -45
  28. package/src/images/loop.ts +11 -4
  29. package/src/lib/state-store-registrations.ts +8 -2
  30. package/src/providers/antigravity-models.ts +70 -5
  31. package/src/providers/derive.ts +12 -2
  32. package/src/providers/registry.ts +46 -1
  33. package/src/providers/service-tier.ts +34 -7
  34. package/src/responses/parser.ts +56 -2
  35. package/src/responses/state.ts +162 -5
  36. package/src/router.ts +10 -3
  37. package/src/routing/compatibility/behavior.ts +3 -3
  38. package/src/routing/profile.ts +1 -1
  39. package/src/server/index.ts +5 -0
  40. package/src/server/management/shared.ts +3 -1
  41. package/src/server/responses/collaboration.ts +34 -9
  42. package/src/server/responses/core.ts +13 -4
  43. package/src/server/responses/input-admission.ts +7 -2
  44. package/src/service-manager-probe.ts +99 -0
  45. package/src/service.ts +86 -6
  46. package/src/tray/windows.ts +25 -5
  47. package/src/types/accounts.ts +37 -0
  48. package/src/types/config.ts +818 -0
  49. package/src/types/provider.ts +521 -0
  50. package/src/types/request.ts +358 -0
  51. package/src/types/tools.ts +131 -0
  52. package/src/types/wire.ts +80 -0
  53. package/src/types.ts +103 -1883
  54. package/src/usage/cost.ts +37 -1
  55. package/src/web-search/loop.ts +11 -4
@@ -7,7 +7,7 @@
7
7
  * Never match broad `*codex*` patterns that hit unrelated tools such as
8
8
  * `hermes-codex-bridge-mcp`.
9
9
  */
10
- import { execFileSync } from "node:child_process";
10
+ import { execFile, execFileSync, type ExecFileException } from "node:child_process";
11
11
  import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
12
12
  import { isProcessAlive, waitForExit } from "../lib/process-control";
13
13
  import {
@@ -98,9 +98,30 @@ export interface CodexAppServerProcessIo {
98
98
  waitExit?: (pid: number, timeoutMs: number) => boolean;
99
99
  now?: () => number;
100
100
  readStartMs?: (pid: number) => number | null;
101
+ /** Async process-list seam used by the request-path Windows collector. */
102
+ listSnapshotsAsync?: () => Promise<ProcessSnapshot[]>;
103
+ /** Async batch start-time seam used by the request-path Windows collector. */
104
+ readStartMsBatchAsync?: (pids: readonly number[]) => Promise<Map<number, number | null>>;
101
105
  catalogMtimeMs?: () => number | null;
102
106
  }
103
107
 
108
+ function execFileTextAsync(
109
+ file: string,
110
+ args: readonly string[],
111
+ timeoutMs: number,
112
+ ): Promise<string> {
113
+ return new Promise((resolve, reject) => {
114
+ execFile(file, [...args], {
115
+ encoding: "utf-8",
116
+ timeout: timeoutMs,
117
+ windowsHide: true,
118
+ }, (error: ExecFileException | null, stdout: string) => {
119
+ if (error) reject(error);
120
+ else resolve(stdout);
121
+ });
122
+ });
123
+ }
124
+
104
125
  /** Split a process command line into argv-like tokens (handles simple quotes). */
105
126
  export function tokenizeCommandLine(commandLine: string): string[] {
106
127
  const tokens: string[] = [];
@@ -375,7 +396,7 @@ export function parseWindowsSnapshotOutput(output: string): ProcessSnapshot[] {
375
396
  return out;
376
397
  }
377
398
 
378
- export function listWindowsSnapshots(runPowerShell?: (psCommand: string) => string): ProcessSnapshot[] {
399
+ function windowsSnapshotPowerShellCommand(): string {
379
400
  // Newlines keep -Command as a real script (space-joined statements need ';').
380
401
  // Double-quoted format string so `t expands to a real tab.
381
402
  // Codex candidates only: basename token codex / codex.exe / codex.cmd /
@@ -384,7 +405,7 @@ export function listWindowsSnapshots(runPowerShell?: (psCommand: string) => stri
384
405
  // path with "opencodex".
385
406
  const basenameMatch = powerShellSingleQuotedIgnoreCaseMatch(WINDOWS_CODEX_BASENAME_CANDIDATE_RE.source);
386
407
  const codeModeMatch = powerShellSingleQuotedIgnoreCaseMatch(WINDOWS_CODEX_CODE_MODE_HOST_CANDIDATE_RE.source);
387
- const psCommand = [
408
+ return [
388
409
  "$ErrorActionPreference='SilentlyContinue'",
389
410
  "$me=[System.Security.Principal.WindowsIdentity]::GetCurrent().Name",
390
411
  // -ErrorAction Stop plus the outer try is what makes a TOP-LEVEL query failure
@@ -412,9 +433,13 @@ export function listWindowsSnapshots(runPowerShell?: (psCommand: string) => stri
412
433
  "}",
413
434
  "} catch { \"__OCX_ENUM_INCOMPLETE__\" }",
414
435
  ].join("\n");
436
+ }
437
+
438
+ export function listWindowsSnapshots(runPowerShell?: (psCommand: string) => string): ProcessSnapshot[] {
415
439
  // Top-level exec failure propagates (see listDarwinSnapshots note). The
416
440
  // executable resolves from the trusted System32 directory (never PATH), and
417
441
  // windowsHide keeps the enumeration console-less on desktop sessions (#1278).
442
+ const psCommand = windowsSnapshotPowerShellCommand();
418
443
  const output = runPowerShell
419
444
  ? runPowerShell(psCommand)
420
445
  : execFileSync(resolveTrustedWindowsPowerShellExe(), [
@@ -425,6 +450,15 @@ export function listWindowsSnapshots(runPowerShell?: (psCommand: string) => stri
425
450
  return parseWindowsSnapshotOutput(output);
426
451
  }
427
452
 
453
+ async function listWindowsSnapshotsAsync(): Promise<ProcessSnapshot[]> {
454
+ const output = await execFileTextAsync(resolveTrustedWindowsPowerShellExe(), [
455
+ "-NoProfile", "-NoLogo", "-NonInteractive",
456
+ "-Command",
457
+ windowsSnapshotPowerShellCommand(),
458
+ ], 8_000);
459
+ return parseWindowsSnapshotOutput(output);
460
+ }
461
+
428
462
  function defaultListSnapshots(platform: NodeJS.Platform, getuid: () => number | undefined): ProcessSnapshot[] {
429
463
  if (platform === "win32") return listWindowsSnapshots();
430
464
  if (platform === "darwin") return listDarwinSnapshots(getuid());
@@ -533,6 +567,41 @@ export function readProcessStartMs(pid: number, platform: NodeJS.Platform = proc
533
567
  return readLinuxProcStartMs(pid);
534
568
  }
535
569
 
570
+ function windowsProcessStartPowerShellCommand(pids: readonly number[]): string {
571
+ const filter = pids.map(pid => `ProcessId=${pid}`).join(" OR ");
572
+ return `Get-CimInstance Win32_Process -Filter "${filter}" | ForEach-Object { "$($_.ProcessId)\t$($_.CreationDate.ToUniversalTime().ToString("o"))" }`;
573
+ }
574
+
575
+ function parseWindowsProcessStartTimes(
576
+ stdout: string,
577
+ pids: readonly number[],
578
+ ): Map<number, number | null> {
579
+ const byPid = new Map<number, number>();
580
+ for (const line of stdout.split(/\r?\n/)) {
581
+ const tab = line.indexOf("\t");
582
+ if (tab <= 0) continue;
583
+ const pid = Number(line.slice(0, tab));
584
+ const parsed = Date.parse(line.slice(tab + 1).trim());
585
+ if (Number.isSafeInteger(pid) && Number.isFinite(parsed)) byPid.set(pid, parsed);
586
+ }
587
+ return new Map(pids.map(pid => [pid, byPid.get(pid) ?? null]));
588
+ }
589
+
590
+ async function readWindowsProcessStartMsBatchAsync(
591
+ pids: readonly number[],
592
+ ): Promise<Map<number, number | null>> {
593
+ try {
594
+ const stdout = await execFileTextAsync(resolveTrustedWindowsPowerShellExe(), [
595
+ "-NoProfile", "-NoLogo", "-NonInteractive",
596
+ "-Command",
597
+ windowsProcessStartPowerShellCommand(pids),
598
+ ], 5_000);
599
+ return parseWindowsProcessStartTimes(stdout, pids);
600
+ } catch {
601
+ return new Map(pids.map(pid => [pid, null]));
602
+ }
603
+ }
604
+
536
605
  /**
537
606
  * Start times for many pids in ONE platform call where possible, so the
538
607
  * staleness check does not serialize per-process ps/PowerShell invocations
@@ -568,22 +637,12 @@ export function readProcessStartMsBatch(
568
637
  }
569
638
  if (platform === "win32") {
570
639
  try {
571
- const filter = pids.map(pid => `ProcessId=${pid}`).join(" OR ");
572
640
  const stdout = execFileSync(resolveTrustedWindowsPowerShellExe(), [
573
641
  "-NoProfile", "-NoLogo", "-NonInteractive",
574
642
  "-Command",
575
- `Get-CimInstance Win32_Process -Filter "${filter}" | ForEach-Object { "$($_.ProcessId)\t$($_.CreationDate.ToUniversalTime().ToString("o"))" }`,
643
+ windowsProcessStartPowerShellCommand(pids),
576
644
  ], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 5_000, windowsHide: true });
577
- const byPid = new Map<number, number>();
578
- for (const line of stdout.split(/\r?\n/)) {
579
- const tab = line.indexOf("\t");
580
- if (tab <= 0) continue;
581
- const pid = Number(line.slice(0, tab));
582
- const parsed = Date.parse(line.slice(tab + 1).trim());
583
- if (Number.isSafeInteger(pid) && Number.isFinite(parsed)) byPid.set(pid, parsed);
584
- }
585
- for (const pid of pids) out.set(pid, byPid.get(pid) ?? null);
586
- return out;
645
+ return parseWindowsProcessStartTimes(stdout, pids);
587
646
  } catch {
588
647
  for (const pid of pids) out.set(pid, null);
589
648
  return out;
@@ -610,9 +669,65 @@ function defaultCatalogMtimeMs(): number | null {
610
669
  }
611
670
  }
612
671
 
672
+ function codexAppServerProcessesFromSnapshots(
673
+ snapshots: readonly ProcessSnapshot[],
674
+ ): CodexAppServerProcess[] {
675
+ const processes: CodexAppServerProcess[] = [];
676
+ const seen = new Set<number>();
677
+ for (const snapshot of snapshots) {
678
+ if (seen.has(snapshot.pid)) continue;
679
+ if (!isCodexAppServerCommandLine(snapshot.commandLine, snapshot.executable)) continue;
680
+ seen.add(snapshot.pid);
681
+ processes.push({ pid: snapshot.pid, commandLine: snapshot.commandLine });
682
+ }
683
+ return processes;
684
+ }
685
+
686
+ function catalogStatusFromProcesses(
687
+ processes: readonly CodexAppServerProcess[],
688
+ catalogMtimeMs: number | null,
689
+ starts: ReadonlyMap<number, number | null>,
690
+ ): CodexAppServerCatalogStatus {
691
+ const withStarts = processes.map(proc => ({
692
+ pid: proc.pid,
693
+ startedAtMs: starts.get(proc.pid) ?? null,
694
+ }));
695
+ if (catalogMtimeMs === null || withStarts.some(proc => proc.startedAtMs === null)) {
696
+ return { state: "unknown", processes: withStarts, catalogMtimeMs };
697
+ }
698
+ // `<=` is deliberate: coarse clocks (ps lstart is second-granularity) can
699
+ // report equal values when the catalog actually changed after startup.
700
+ const stale = withStarts.some(proc => proc.startedAtMs! <= catalogMtimeMs);
701
+ return { state: stale ? "stale" : "fresh", processes: withStarts, catalogMtimeMs };
702
+ }
703
+
613
704
  // Short TTL: process listing + stat run once per window even under per-turn
614
705
  // guidance calls (#857).
615
706
  let catalogStateCache: { atMs: number; status: CodexAppServerCatalogStatus } | null = null;
707
+ interface RequestCatalogStateIdentity {
708
+ platform: NodeJS.Platform;
709
+ listSnapshots?: CodexAppServerProcessIo["listSnapshots"];
710
+ listSnapshotsAsync?: CodexAppServerProcessIo["listSnapshotsAsync"];
711
+ readStartMs?: CodexAppServerProcessIo["readStartMs"];
712
+ readStartMsBatchAsync?: CodexAppServerProcessIo["readStartMsBatchAsync"];
713
+ catalogMtimeMs?: CodexAppServerProcessIo["catalogMtimeMs"];
714
+ now?: CodexAppServerProcessIo["now"];
715
+ }
716
+
717
+ interface RequestCatalogStateFlight {
718
+ generation: number;
719
+ identity: RequestCatalogStateIdentity;
720
+ promise: Promise<CodexAppServerCatalogStatus>;
721
+ }
722
+
723
+ let requestCatalogStateGeneration = 0;
724
+ let requestCatalogStateCache: {
725
+ generation: number;
726
+ identity: RequestCatalogStateIdentity;
727
+ atMs: number;
728
+ status: CodexAppServerCatalogStatus;
729
+ } | null = null;
730
+ let requestCatalogStateFlight: RequestCatalogStateFlight | null = null;
616
731
  const CATALOG_STATE_TTL_MS = 5_000;
617
732
  /**
618
733
  * `unknown` is a failure to observe, not an observation, so it gets a much shorter
@@ -627,6 +742,19 @@ export function catalogStateTtlMs(state: CodexAppServerCatalogState): number {
627
742
  return state === "unknown" ? CATALOG_STATE_UNKNOWN_TTL_MS : CATALOG_STATE_TTL_MS;
628
743
  }
629
744
 
745
+ function sameRequestCatalogStateIdentity(
746
+ left: RequestCatalogStateIdentity,
747
+ right: RequestCatalogStateIdentity,
748
+ ): boolean {
749
+ return left.platform === right.platform
750
+ && left.listSnapshots === right.listSnapshots
751
+ && left.listSnapshotsAsync === right.listSnapshotsAsync
752
+ && left.readStartMs === right.readStartMs
753
+ && left.readStartMsBatchAsync === right.readStartMsBatchAsync
754
+ && left.catalogMtimeMs === right.catalogMtimeMs
755
+ && left.now === right.now;
756
+ }
757
+
630
758
  /**
631
759
  * Compare the on-disk catalog mtime against the start time of running Codex
632
760
  * app-servers (#857): a server that started before the catalog changed keeps
@@ -676,33 +804,17 @@ export function collectCodexAppServerCatalogState(
676
804
  snapshots = [];
677
805
  enumerationFailed = true;
678
806
  }
679
- const processes: CodexAppServerProcess[] = [];
680
- const seen = new Set<number>();
681
- for (const snapshot of snapshots) {
682
- if (seen.has(snapshot.pid)) continue;
683
- if (!isCodexAppServerCommandLine(snapshot.commandLine, snapshot.executable)) continue;
684
- seen.add(snapshot.pid);
685
- processes.push({ pid: snapshot.pid, commandLine: snapshot.commandLine });
686
- }
807
+ const processes = codexAppServerProcessesFromSnapshots(snapshots);
687
808
  if (processes.length === 0) {
688
809
  return enumerationFailed
689
810
  ? { state: "unknown", processes: [], catalogMtimeMs: null }
690
811
  : { state: "not_running", processes: [], catalogMtimeMs: null };
691
812
  }
692
813
  const catalogMtimeMs = (io.catalogMtimeMs ?? defaultCatalogMtimeMs)();
693
- const withStarts = io.readStartMs
694
- ? processes.map(proc => ({ pid: proc.pid, startedAtMs: io.readStartMs!(proc.pid) }))
695
- : (() => {
696
- const batch = readProcessStartMsBatch(processes.map(proc => proc.pid), platform);
697
- return processes.map(proc => ({ pid: proc.pid, startedAtMs: batch.get(proc.pid) ?? null }));
698
- })();
699
- if (catalogMtimeMs === null || withStarts.some(proc => proc.startedAtMs === null)) {
700
- return { state: "unknown", processes: withStarts, catalogMtimeMs };
701
- }
702
- // `<=` is deliberate: coarse clocks (ps lstart is second-granularity) can
703
- // report equal values when the catalog actually changed after startup.
704
- const stale = withStarts.some(proc => proc.startedAtMs! <= catalogMtimeMs);
705
- return { state: stale ? "stale" : "fresh", processes: withStarts, catalogMtimeMs };
814
+ const starts = io.readStartMs
815
+ ? new Map(processes.map(proc => [proc.pid, io.readStartMs!(proc.pid)] as const))
816
+ : readProcessStartMsBatch(processes.map(proc => proc.pid), platform);
817
+ return catalogStatusFromProcesses(processes, catalogMtimeMs, starts);
706
818
  };
707
819
  const status = compute();
708
820
  if (fullyDefault) {
@@ -711,9 +823,129 @@ export function collectCodexAppServerCatalogState(
711
823
  return status;
712
824
  }
713
825
 
714
- /** Test hook: drop the memoized catalog state. */
826
+ /**
827
+ * Request-path catalog state collector.
828
+ *
829
+ * [Decision Log]
830
+ * - 목적과 의도: keep Windows CIM discovery from blocking Bun's event loop while v2 guidance is built.
831
+ * - 기존 구현 및 제약 조건: CLI/service operations still need the synchronous, fail-closed collector; the request path needs only advisory state.
832
+ * - 검토한 주요 대안: remove stale-catalog guidance, move all process work to workers, or add a Windows-only async boundary.
833
+ * - 선택한 방식: retain the synchronous API and use async PowerShell plus an identity-scoped in-flight refresh, short cache, and invalidation generation only for Windows requests.
834
+ * - 다른 대안 대신 이 방식을 선택한 이유: it fixes unrelated `/healthz` starvation without widening the process-matching or restart contract.
835
+ * - 장점, 단점 및 영향: concurrent turns share one CIM walk, invalidated pre-write results cannot repopulate the cache, and the event loop stays responsive; a cold v2 turn can still await the bounded advisory probe.
836
+ */
837
+ export async function collectCodexAppServerCatalogStateForRequest(
838
+ io: CodexAppServerProcessIo = {},
839
+ ): Promise<CodexAppServerCatalogStatus> {
840
+ const platform = io.platform ?? process.platform;
841
+ if (platform !== "win32") return collectCodexAppServerCatalogState(io);
842
+
843
+ const now = (io.now ?? Date.now)();
844
+ const generation = requestCatalogStateGeneration;
845
+ const identity: RequestCatalogStateIdentity = {
846
+ platform,
847
+ listSnapshots: io.listSnapshots,
848
+ listSnapshotsAsync: io.listSnapshotsAsync,
849
+ readStartMs: io.readStartMs,
850
+ readStartMsBatchAsync: io.readStartMsBatchAsync,
851
+ catalogMtimeMs: io.catalogMtimeMs,
852
+ now: io.now,
853
+ };
854
+ if (requestCatalogStateCache
855
+ && requestCatalogStateCache.generation === generation
856
+ && sameRequestCatalogStateIdentity(requestCatalogStateCache.identity, identity)
857
+ && now - requestCatalogStateCache.atMs < catalogStateTtlMs(requestCatalogStateCache.status.state)) {
858
+ return requestCatalogStateCache.status;
859
+ }
860
+ if (requestCatalogStateFlight
861
+ && requestCatalogStateFlight.generation === generation
862
+ && sameRequestCatalogStateIdentity(requestCatalogStateFlight.identity, identity)) {
863
+ return requestCatalogStateFlight.promise;
864
+ }
865
+
866
+ const refresh = async (): Promise<CodexAppServerCatalogStatus> => {
867
+ let snapshots: ProcessSnapshot[];
868
+ try {
869
+ snapshots = io.listSnapshotsAsync
870
+ ? await io.listSnapshotsAsync()
871
+ : io.listSnapshots
872
+ ? io.listSnapshots()
873
+ : await listWindowsSnapshotsAsync();
874
+ } catch {
875
+ return { state: "unknown", processes: [], catalogMtimeMs: null };
876
+ }
877
+ const processes = codexAppServerProcessesFromSnapshots(snapshots);
878
+ if (processes.length === 0) {
879
+ return { state: "not_running", processes: [], catalogMtimeMs: null };
880
+ }
881
+ let catalogMtimeMs: number | null;
882
+ try {
883
+ catalogMtimeMs = (io.catalogMtimeMs ?? defaultCatalogMtimeMs)();
884
+ } catch {
885
+ catalogMtimeMs = null;
886
+ }
887
+ const pids = processes.map(proc => proc.pid);
888
+ const starts = io.readStartMsBatchAsync
889
+ ? await io.readStartMsBatchAsync(pids)
890
+ : io.readStartMs
891
+ ? new Map(pids.map(pid => [pid, io.readStartMs!(pid)] as const))
892
+ : await readWindowsProcessStartMsBatchAsync(pids);
893
+ return catalogStatusFromProcesses(processes, catalogMtimeMs, starts);
894
+ };
895
+
896
+ const pending = refresh().catch(() => ({
897
+ state: "unknown" as const,
898
+ processes: [],
899
+ catalogMtimeMs: null,
900
+ }));
901
+ let flight: RequestCatalogStateFlight;
902
+ const promise = pending.then(status => {
903
+ // A catalog write can invalidate while slow CIM is still running. The result
904
+ // describes the PRE-write world, so it must neither repopulate the post-write
905
+ // cache nor reach the caller.
906
+ //
907
+ // Suppressing only the cache write is not enough. The awaiting request still
908
+ // received `fresh`, and `fresh` is the one state that authorizes positive
909
+ // guidance (`src/server/responses/collaboration.ts:279-280` returns null for
910
+ // `stale`/`unknown` but describes the catalog for `fresh`). So the request
911
+ // would advertise the newly written disk catalog to an app-server whose
912
+ // in-memory copy the write just made stale — a wrong answer, which is worse
913
+ // than the slow answer this whole change exists to fix.
914
+ //
915
+ // Degrade to `unknown` instead: it is the honest description of what an
916
+ // invalidated observation knows, and the guidance path already treats it as
917
+ // "say nothing positive".
918
+ if (requestCatalogStateGeneration !== generation) {
919
+ return { state: "unknown" as const, processes: [], catalogMtimeMs: null };
920
+ }
921
+ if (requestCatalogStateFlight === flight) {
922
+ requestCatalogStateCache = {
923
+ generation,
924
+ identity,
925
+ atMs: (io.now ?? Date.now)(),
926
+ status,
927
+ };
928
+ }
929
+ return status;
930
+ }).finally(() => {
931
+ if (requestCatalogStateFlight === flight) requestCatalogStateFlight = null;
932
+ });
933
+ flight = { generation, identity, promise };
934
+ requestCatalogStateFlight = flight;
935
+ return flight.promise;
936
+ }
937
+
938
+ /**
939
+ * Drop memoized catalog state after a relevant catalog/cache write and before
940
+ * the post-write state read. Advancing the generation prevents an older
941
+ * in-flight Windows CIM refresh from publishing its pre-write result after the
942
+ * write has completed.
943
+ */
715
944
  export function resetCodexAppServerCatalogStateCache(): void {
716
945
  catalogStateCache = null;
946
+ requestCatalogStateGeneration += 1;
947
+ requestCatalogStateCache = null;
948
+ requestCatalogStateFlight = null;
717
949
  }
718
950
 
719
951
  export interface RestartCodexAppServersResult {
@@ -12,7 +12,8 @@ import { ConfigMutationLockError } from "../config";
12
12
  import { isCodexAccountUsable } from "./account-usability";
13
13
  import { reconcileMainCodexAccountRuntimeState } from "./account-lifecycle";
14
14
  import { MAIN_CODEX_ACCOUNT_ID, getMainAccountToken, isMainAccountTokenLive } from "./main-account";
15
- import { isNativeMainTrafficBlocked } from "./native-profile-startup";
15
+ import { isNativeMainTrafficBlocked, nativeMainStartupGateSnapshot } from "./native-profile-startup";
16
+ import type { NativeMainStartupBlockReason } from "./native-profile-startup";
16
17
  import {
17
18
  codexQuotaScopeForModel,
18
19
  getCodexQuotaHealthSnapshot,
@@ -116,12 +117,63 @@ export const CODEX_MAIN_PROFILE_MAINTENANCE_MESSAGE =
116
117
  "OpenCodex local native-main profile maintenance is active; retry this request";
117
118
 
118
119
  export class CodexMainProfileDrainingError extends Error {
120
+ /**
121
+ * Which startup-gate state fenced this request, when one did. Undefined means the
122
+ * fence came from somewhere other than the startup gate — the turn-drain claim race
123
+ * throws this same error while the gate reads `ready`, and inventing a reason there
124
+ * would point the next report at a gate that never closed.
125
+ *
126
+ * Captured here rather than at the throw sites because this is the last moment it is
127
+ * both in scope and still true: every catch site has already lost it, and re-reading
128
+ * the gate later can observe a recovery that completed in between (#2108).
129
+ */
130
+ readonly reason?: NativeMainStartupBlockReason;
131
+
119
132
  constructor() {
120
133
  super(CODEX_MAIN_PROFILE_MAINTENANCE_MESSAGE);
121
134
  this.name = "CodexMainProfileDrainingError";
135
+ const gate = nativeMainStartupGateSnapshot();
136
+ if (gate.status !== "blocked") return;
137
+ this.reason = gate.reason;
138
+ reportNativeMainFenceReason(gate.reason);
122
139
  }
123
140
  }
124
141
 
142
+ /**
143
+ * #2108: a reboot could leave this fence closed until `ocx restart`, and the report was
144
+ * unactionable because the settled reason was never written anywhere. It cannot ride the
145
+ * message (claude-messages.ts matches that string exactly to keep the fence a 503 rather
146
+ * than an Anthropic 529) and it cannot ride a header (/api/logs reads only error.message
147
+ * from the body, and the Claude surface rebuilds its response headers from scratch), so
148
+ * stdout is the one surface that covers every path this fence fires on.
149
+ *
150
+ * Deduped per distinct reason: the original report shows three 503s in eleven seconds and
151
+ * a real client retries harder than that, so a per-request line would bury the signal.
152
+ * Only the reason is emitted; the snapshot's homeId is derived from a profile directory.
153
+ */
154
+ const reportedFenceReasons = new Set<NativeMainStartupBlockReason>();
155
+
156
+ function reportNativeMainFenceReason(reason: NativeMainStartupBlockReason): void {
157
+ if (reportedFenceReasons.has(reason)) return;
158
+ reportedFenceReasons.add(reason);
159
+ console.warn(
160
+ `native-main admission is fenced (reason: ${reason}); native model requests return 503 until it clears`,
161
+ );
162
+ }
163
+
164
+ /**
165
+ * Test-only reset for the dedup set above.
166
+ *
167
+ * The dedup is process-lifetime module state, so it is order-sensitive across test files
168
+ * sharing one Bun process: whichever file constructs this error first consumes the one-shot
169
+ * warn, and a later file asserting on it would see nothing and pass vacuously. Any test that
170
+ * asserts on the warn must call this first — an `afterEach` in the asserting file is not
171
+ * enough on its own, because the consuming file may not be the asserting one.
172
+ */
173
+ export function __resetNativeMainFenceReasonLog(): void {
174
+ reportedFenceReasons.clear();
175
+ }
176
+
125
177
  export function codexMainProfileDrainingResponse(): Response {
126
178
  const response = formatErrorResponse(503, "server_busy", CODEX_MAIN_PROFILE_MAINTENANCE_MESSAGE);
127
179
  const headers = new Headers(response.headers);
@@ -183,6 +183,9 @@ export function deriveComboCatalogModel(
183
183
  ? { supportsServiceTier: false }
184
184
  : {}),
185
185
  ...(members.some(member => member.supportsReasoningSummaries === false) ? { supportsReasoningSummaries: false } : {}),
186
+ ...(members.every(member => member.codexToolMode === "shell")
187
+ ? { codexToolMode: "shell" as const }
188
+ : {}),
186
189
  };
187
190
  }
188
191
 
@@ -126,6 +126,12 @@ export interface CatalogModel {
126
126
  /** Whether this exact routed model has a verified OpenAI-compatible service tier. */
127
127
  supportsServiceTier?: boolean;
128
128
  supportsReasoningSummaries?: boolean;
129
+ /**
130
+ * Codex tool calling mode for this routed model.
131
+ * "code_mode_only" (default) sets entry.tool_mode = "code_mode_only".
132
+ * "shell" leaves tool_mode unset so Codex declares top-level shell tools (exec_command).
133
+ */
134
+ codexToolMode?: "code_mode_only" | "shell";
129
135
  /** Normalized upstream capability names retained for management/API consumers (#485 follow-up). */
130
136
  capabilities?: string[];
131
137
  /** OpenCodex-only catalog ownership marker; Codex ignores the serialized extension field. */
@@ -423,7 +429,14 @@ export function catalogEntryIsNativeChatGpt(entry: RawEntry): boolean {
423
429
 
424
430
  export const ROUTED_CODEX_TOOL_MODE = "code_mode_only";
425
431
 
426
- export function applyRoutedCodexToolMode(entry: RawEntry): RawEntry {
432
+ export function applyRoutedCodexToolMode(
433
+ entry: RawEntry,
434
+ toolMode?: "code_mode_only" | "shell" | string,
435
+ ): RawEntry {
436
+ if (toolMode === "shell") {
437
+ delete entry.tool_mode;
438
+ return entry;
439
+ }
427
440
  entry.tool_mode = ROUTED_CODEX_TOOL_MODE;
428
441
  return entry;
429
442
  }
@@ -490,10 +503,14 @@ export function applyMultiAgentMode(
490
503
  return entries;
491
504
  }
492
505
 
493
- export function normalizeRoutedCatalogEntry(entry: RawEntry, parallelToolCalls = false): RawEntry {
506
+ export function normalizeRoutedCatalogEntry(
507
+ entry: RawEntry,
508
+ parallelToolCalls = false,
509
+ toolMode?: "code_mode_only" | "shell" | string,
510
+ ): RawEntry {
494
511
  delete entry.model_messages;
495
512
  delete entry.tool_mode;
496
- applyRoutedCodexToolMode(entry);
513
+ applyRoutedCodexToolMode(entry, toolMode);
497
514
  delete entry.multi_agent_version;
498
515
  delete entry.use_responses_lite;
499
516
  delete entry.supports_websockets;
@@ -413,6 +413,7 @@ function captureProviderGather(
413
413
  name,
414
414
  provider,
415
415
  registryTransportMatch,
416
+ configured,
416
417
  );
417
418
  const observedAuth = authResolver.kind === "observed"
418
419
  && provider.authMode !== "forward"
@@ -676,6 +677,7 @@ export function applyProviderConfigHints(name: string, prov: OcxProviderConfig,
676
677
  ...(prov.parallelToolCalls === true || (prov.adapter === "openai-chat" && prov.parallelToolCalls !== false)
677
678
  ? { parallelToolCalls: true }
678
679
  : {}),
680
+ ...(prov.codexToolMode !== undefined ? { codexToolMode: prov.codexToolMode } : {}),
679
681
  };
680
682
  const capped = applyProviderContextCap(hinted.contextWindow, providerCap);
681
683
  if (providerCap !== undefined && capped !== hinted.contextWindow) {
@@ -1881,6 +1883,11 @@ async function gatherRoutedModelsUncached(
1881
1883
  ...(Array.isArray(cm.reasoningEfforts) ? { reasoningEfforts: [...cm.reasoningEfforts] } : {}),
1882
1884
  ...(cm.defaultReasoningEffort ? { defaultReasoningEffort: cm.defaultReasoningEffort } : {}),
1883
1885
  ...(typeof supportsServiceTier === "boolean" ? { supportsServiceTier } : {}),
1886
+ ...(cm.codexToolMode !== undefined
1887
+ ? { codexToolMode: cm.codexToolMode }
1888
+ : effectiveProvider?.codexToolMode !== undefined
1889
+ ? { codexToolMode: effectiveProvider.codexToolMode }
1890
+ : {}),
1884
1891
  };
1885
1892
  // #962: the dedupe below drops the provider-derived row this custom row replaces. Inherit that
1886
1893
  // row's provider capability metadata (reasoning ladder, default effort, parallel tool calls,
@@ -1905,6 +1912,7 @@ async function gatherRoutedModelsUncached(
1905
1912
  ...(base.parallelToolCalls === undefined && replaced.parallelToolCalls !== undefined ? { parallelToolCalls: replaced.parallelToolCalls } : {}),
1906
1913
  ...(base.supportsVerbosity === undefined && replaced.supportsVerbosity !== undefined ? { supportsVerbosity: replaced.supportsVerbosity } : {}),
1907
1914
  ...(base.supportsReasoningSummaries === undefined && replaced.supportsReasoningSummaries !== undefined ? { supportsReasoningSummaries: replaced.supportsReasoningSummaries } : {}),
1915
+ ...(base.codexToolMode === undefined && replaced.codexToolMode !== undefined ? { codexToolMode: replaced.codexToolMode } : {}),
1908
1916
  ...(base.capabilities === undefined && replaced.capabilities !== undefined ? { capabilities: replaced.capabilities } : {}),
1909
1917
  } : base;
1910
1918
  // Vision-sidecar coverage ONLY: if the custom model is in the enriched provider's
@@ -331,7 +331,9 @@ export function deriveEntry(
331
331
  // This exact provider/model pair is the ChatGPT/Codex forward surface. Keep the pinned
332
332
  // native tool/search/responses-lite contract while preserving the routed slug and wire id.
333
333
  if (!codexForwardNativeCapabilityAlias) {
334
- normalizeRoutedCatalogEntry(e, model?.parallelToolCalls === true);
334
+ normalizeRoutedCatalogEntry(e, model?.parallelToolCalls === true, model?.codexToolMode);
335
+ } else if (model?.codexToolMode !== undefined) {
336
+ applyRoutedCodexToolMode(e, model.codexToolMode);
335
337
  }
336
338
  if (model) applyCatalogMetadata(e, model.provider, model.id, model.contextCap);
337
339
  applyCatalogModelMetadata(e, model);
@@ -358,8 +360,8 @@ export function deriveEntry(
358
360
  });
359
361
  }
360
362
  // Fallback when no template is available (best-effort; strict parser may need more).
361
- // All routed fallbacks enable deferred code-mode tool exposure; otherwise the nested catalog
362
- // expands into `exec.description` and can exceed Cursor's 120 KB serialized tool limit (#1830).
363
+ // Routed fallbacks default to code-mode tool exposure (or shell mode when codexToolMode === "shell");
364
+ // otherwise the nested catalog expands into `exec.description` and can exceed Cursor's 120 KB serialized tool limit (#1830).
363
365
  // Cursor still omits hosted web-search metadata because runTurn bypasses that separate sidecar.
364
366
  const isCursorFallback = isRouted && model?.provider === "cursor";
365
367
  const entry: RawEntry = {
@@ -373,7 +375,7 @@ export function deriveEntry(
373
375
  : {}),
374
376
  };
375
377
  if (isRouted) {
376
- applyRoutedCodexToolMode(entry);
378
+ applyRoutedCodexToolMode(entry, model?.codexToolMode);
377
379
  applyReasoningLevels(entry, model?.reasoningEfforts, model?.defaultReasoningEffort, preserveExact);
378
380
  }
379
381
  else {
@@ -1,5 +1,5 @@
1
- import { realpathSync } from "node:fs";
2
- import { resolve, sep } from "node:path";
1
+ import { lstatSync, realpathSync } from "node:fs";
2
+ import { dirname, resolve, sep } from "node:path";
3
3
 
4
4
  import { samePathIdentity } from "../user-identity";
5
5
 
@@ -35,5 +35,54 @@ export function normalizeTrustedDarwinSystemAlias(path: string): string {
35
35
  * Arbitrary ancestor symlinks remain refused.
36
36
  */
37
37
  export function sameLogGuardPathIdentity(realPath: string, requestedPath: string): boolean {
38
- return samePathIdentity(realPath, normalizeTrustedDarwinSystemAlias(requestedPath));
38
+ const requested = normalizeTrustedDarwinSystemAlias(requestedPath);
39
+ if (samePathIdentity(realPath, requested)) return true;
40
+ return sameWindowsCanonicalPath(realPath, requested);
41
+ }
42
+
43
+ /**
44
+ * On Windows, is the difference between these two spellings the OS canonicalizing the
45
+ * request rather than a redirection?
46
+ *
47
+ * `realpathSync.native` expands 8.3 short components — the `RUNNER~1` form that appears
48
+ * throughout `%TEMP%` — so the canonical path and the requested path can disagree as
49
+ * strings while naming the same file. Reading that as an ancestor-symlink redirection made
50
+ * every Log Guard mutation refuse with `unsafe_path` on Windows, which is what the CI shards
51
+ * were reporting.
52
+ *
53
+ * The first version of this re-canonicalized the requested path and compared the two
54
+ * canonical forms. That was wrong, and the Windows shard proved it: the caller already
55
+ * passes `realpathSync.native(requested)` as `realPath`, so re-resolving the request
56
+ * produced the same value on BOTH sides and a symlinked database compared equal. The
57
+ * widening let through exactly what the guard exists to refuse.
58
+ *
59
+ * The comparison is therefore link-aware. A short-name expansion rewrites the spelling of
60
+ * components that are all still directories on the same chain, so it is enough to require
61
+ * that no component of the request is a link: with none present, any remaining difference
62
+ * is the OS's own canonical spelling. A symlink or junction anywhere in the chain fails
63
+ * closed as before.
64
+ */
65
+ function sameWindowsCanonicalPath(realPath: string, requestedPath: string): boolean {
66
+ if (process.platform !== "win32") return false;
67
+ try {
68
+ if (pathChainContainsLink(requestedPath)) return false;
69
+ return samePathIdentity(realPath, realpathSync.native(requestedPath));
70
+ } catch {
71
+ return false;
72
+ }
73
+ }
74
+
75
+ /** Is any component of this path a symlink or junction? Fails closed on an unreadable one. */
76
+ function pathChainContainsLink(path: string): boolean {
77
+ let current = resolve(path);
78
+ for (;;) {
79
+ try {
80
+ if (lstatSync(current).isSymbolicLink()) return true;
81
+ } catch {
82
+ return true;
83
+ }
84
+ const parent = dirname(current);
85
+ if (parent === current) return false;
86
+ current = parent;
87
+ }
39
88
  }