@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.
- package/gui/dist/assets/{index-RL6b1bTV.js → index-7jlKgmJd.js} +14 -14
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/anthropic.ts +1 -1
- package/src/adapters/base.ts +14 -2
- package/src/adapters/command-code.ts +4 -3
- package/src/adapters/cursor/cursor-errors.ts +15 -0
- package/src/adapters/cursor/live-transport.ts +14 -1
- package/src/adapters/google.ts +1 -1
- package/src/adapters/openai-chat.ts +38 -6
- package/src/adapters/tool-catalog-nudge.ts +1 -1
- package/src/bridge.ts +11 -5
- package/src/cli/doctor.ts +76 -0
- package/src/cli/help.ts +2 -0
- package/src/cli/models.ts +13 -6
- package/src/codex/app-server-processes.ts +269 -37
- package/src/codex/auth-context.ts +53 -1
- package/src/codex/catalog/aggregation.ts +3 -0
- package/src/codex/catalog/parsing.ts +20 -3
- package/src/codex/catalog/provider-fetch.ts +8 -0
- package/src/codex/catalog/sync.ts +6 -4
- package/src/codex/log-guard/path-safety.ts +52 -3
- package/src/codex/native-profile-startup.ts +100 -2
- package/src/codex/user-identity.ts +21 -1
- package/src/config/provider-name.ts +24 -0
- package/src/config.ts +11 -24
- package/src/generated/compatibility-version.json +73 -45
- package/src/images/loop.ts +11 -4
- package/src/lib/state-store-registrations.ts +8 -2
- package/src/providers/antigravity-models.ts +70 -5
- package/src/providers/derive.ts +12 -2
- package/src/providers/registry.ts +46 -1
- package/src/providers/service-tier.ts +34 -7
- package/src/responses/parser.ts +56 -2
- package/src/responses/state.ts +162 -5
- package/src/router.ts +10 -3
- package/src/routing/compatibility/behavior.ts +3 -3
- package/src/routing/profile.ts +1 -1
- package/src/server/index.ts +5 -0
- package/src/server/management/shared.ts +3 -1
- package/src/server/responses/collaboration.ts +34 -9
- package/src/server/responses/core.ts +13 -4
- package/src/server/responses/input-admission.ts +7 -2
- package/src/service-manager-probe.ts +99 -0
- package/src/service.ts +86 -6
- package/src/tray/windows.ts +25 -5
- package/src/types/accounts.ts +37 -0
- package/src/types/config.ts +818 -0
- package/src/types/provider.ts +521 -0
- package/src/types/request.ts +358 -0
- package/src/types/tools.ts +131 -0
- package/src/types/wire.ts +80 -0
- package/src/types.ts +103 -1883
- package/src/usage/cost.ts +37 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
643
|
+
windowsProcessStartPowerShellCommand(pids),
|
|
576
644
|
], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 5_000, windowsHide: true });
|
|
577
|
-
|
|
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
|
|
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
|
|
694
|
-
? processes.map(proc =>
|
|
695
|
-
: ((
|
|
696
|
-
|
|
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
|
-
/**
|
|
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(
|
|
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(
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
}
|