@bitkyc08/opencodex 2.26.0 → 2.28.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-D2sP-biU.js} +14 -14
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/anthropic.ts +60 -1
- package/src/adapters/base.ts +16 -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-antigravity-replay.ts +16 -8
- package/src/adapters/google.ts +22 -5
- package/src/adapters/openai-chat.ts +189 -60
- package/src/adapters/openai-responses.ts +37 -0
- 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/index.ts +19 -6
- package/src/cli/models.ts +13 -6
- package/src/codex/account-usability.ts +3 -0
- package/src/codex/app-server-processes.ts +269 -37
- package/src/codex/auth-api.ts +22 -5
- package/src/codex/auth-context.ts +108 -3
- package/src/codex/catalog/aggregation.ts +3 -0
- package/src/codex/catalog/metadata.ts +17 -3
- package/src/codex/catalog/native-models.ts +22 -14
- package/src/codex/catalog/parsing.ts +20 -3
- package/src/codex/catalog/provider-fetch.ts +8 -0
- package/src/codex/catalog/sync.ts +63 -15
- package/src/codex/convergence.ts +61 -13
- package/src/codex/log-guard/path-safety.ts +52 -3
- package/src/codex/model-entitlements.ts +353 -0
- package/src/codex/native-profile-startup.ts +100 -2
- package/src/codex/quota.ts +28 -3
- package/src/codex/routing.ts +14 -8
- 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 +110 -70
- package/src/images/loop.ts +11 -4
- package/src/lib/destination-policy.ts +47 -0
- package/src/lib/shadow-call.ts +15 -0
- package/src/lib/state-store-registrations.ts +8 -2
- package/src/oauth/index.ts +33 -5
- package/src/oauth/store.ts +11 -5
- package/src/providers/antigravity-models.ts +70 -5
- package/src/providers/derive.ts +12 -2
- package/src/providers/fastwire.ts +39 -8
- package/src/providers/quota.ts +9 -2
- package/src/providers/registry.ts +120 -6
- package/src/providers/service-tier.ts +50 -15
- package/src/responses/parser.ts +59 -11
- package/src/responses/state.ts +162 -5
- package/src/responses/tool-search-compat.ts +301 -0
- package/src/router.ts +17 -3
- package/src/routing/capability.ts +26 -9
- package/src/routing/compatibility/behavior.ts +44 -6
- package/src/routing/profile.ts +1 -1
- package/src/server/chat-native.ts +11 -2
- package/src/server/index.ts +59 -8
- package/src/server/management/agent-settings-routes.ts +16 -2
- package/src/server/management/shared.ts +3 -1
- package/src/server/request-log.ts +31 -0
- package/src/server/responses/collaboration.ts +34 -9
- package/src/server/responses/compact.ts +54 -7
- package/src/server/responses/core.ts +259 -43
- package/src/server/responses/input-admission.ts +7 -2
- package/src/server/responses/responses-field-backfill.ts +88 -6
- package/src/server/responses/terminal-guard.ts +10 -0
- package/src/server/responses-tool-search-repair.ts +217 -0
- package/src/server/system-env.ts +74 -5
- 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/usage/log.ts +4 -0
- package/src/web-search/loop.ts +11 -4
package/src/cli/index.ts
CHANGED
|
@@ -46,9 +46,8 @@ import { loadServiceTokenFromFile } from "../lib/service-secrets";
|
|
|
46
46
|
import { diagnoseService, isServiceOwnershipError, serviceCommand, serviceEnvironmentOwnedHere, serviceStartableFromTray, serviceStatusSummary, stopServiceIfInstalled, uninstallServiceIfInstalled } from "../service";
|
|
47
47
|
import { startupHealthSummary } from "../codex/autostart-health";
|
|
48
48
|
import { drainAndShutdown, isRecyclingForExit, startServer } from "../server";
|
|
49
|
-
import { injectSystemEnv, revertSystemEnv } from "../server/system-env";
|
|
49
|
+
import { injectSystemEnv, reconcileShellHook, revertSystemEnv, uninstallShellHook } from "../server/system-env";
|
|
50
50
|
import { buildDesktop3pRegistry } from "../claude/desktop-3p";
|
|
51
|
-
import { installShellHook, uninstallShellHook } from "../server/system-env";
|
|
52
51
|
import { startTokenGuardian } from "../oauth/token-guardian";
|
|
53
52
|
import { startHistoryMigrationGuardian } from "../codex/history-migration-guardian";
|
|
54
53
|
import { maybeShowStarPrompt } from "./star-prompt";
|
|
@@ -57,6 +56,18 @@ import { maybeShowUpdatePrompt } from "../update/notify";
|
|
|
57
56
|
import { syncModelsToCodex } from "../codex/sync";
|
|
58
57
|
import { setIntegrationEnabled, shouldSyncCodexOnStart, shouldSyncGrokOnStart, syncCodexOnStartIfEnabled } from "../codex/desired-state";
|
|
59
58
|
|
|
59
|
+
/**
|
|
60
|
+
* A failed shell-hook reconcile is not cosmetic: a stale hook keeps sourcing
|
|
61
|
+
* `claude-env.sh` from every new interactive shell, pointing at a proxy or a CLI that may no
|
|
62
|
+
* longer exist. `reconcileShellHook` already reports `state: "failed"`, but both call sites
|
|
63
|
+
* discarded it, so the one outcome the user has to act on was the one they never saw.
|
|
64
|
+
*/
|
|
65
|
+
function reportShellHookFailure(result: { state: "installed" | "absent" | "failed"; reason?: string }): void {
|
|
66
|
+
if (result.state !== "failed") return;
|
|
67
|
+
console.warn(` Claude shell hook not reconciled${result.reason ? `: ${result.reason}` : ""}`);
|
|
68
|
+
console.warn(" Check ~/.zshrc for the '# opencodex claude-env hook' block.");
|
|
69
|
+
}
|
|
70
|
+
|
|
60
71
|
|
|
61
72
|
import { removeOwnedConfigState } from "../lib/config-ownership";
|
|
62
73
|
import { withProcessRuntimeProvenance } from "../lib/bun-runtime";
|
|
@@ -366,9 +377,10 @@ async function handleStart(options: { block?: boolean } = {}) {
|
|
|
366
377
|
|
|
367
378
|
// System-wide env injection AFTER signal handlers are registered (crash safety:
|
|
368
379
|
// syncCleanup reverts even if injection itself or subsequent startup steps fail).
|
|
369
|
-
await injectSystemEnv(port, config).catch(() => {});
|
|
370
|
-
//
|
|
371
|
-
|
|
380
|
+
const systemEnv = await injectSystemEnv(port, config).catch(() => ({ injected: false }));
|
|
381
|
+
// The hook is useful only for an installed Claude Code CLI. Reconcile instead of
|
|
382
|
+
// appending unconditionally so stale OpenCodex-owned hooks are removed as well.
|
|
383
|
+
reportShellHookFailure(reconcileShellHook(systemEnv.injected));
|
|
372
384
|
|
|
373
385
|
await maybeShowStarPrompt(); // once-only Yes/No GitHub-star prompt on first interactive start
|
|
374
386
|
// Post-startup sync drives the readiness gate AND the #1046 stale app-server
|
|
@@ -455,7 +467,8 @@ async function handleEnsure(options: { existingIsSuccess?: boolean } = {}): Prom
|
|
|
455
467
|
});
|
|
456
468
|
if (synced?.status === "skipped") console.log(" Codex integration OFF; startup left Codex native.");
|
|
457
469
|
// Ensure env file exists for already-running proxy (may have been deleted or pre-dates this feature).
|
|
458
|
-
await injectSystemEnv(live.port, config).catch(() => {});
|
|
470
|
+
const systemEnv = await injectSystemEnv(live.port, config).catch(() => ({ injected: false }));
|
|
471
|
+
reportShellHookFailure(reconcileShellHook(systemEnv.injected));
|
|
459
472
|
// Refresh the Grok Build fence too (same contract as start). live.hostname is the
|
|
460
473
|
// hostname the running proxy actually bound — config.hostname may have drifted.
|
|
461
474
|
try {
|
package/src/cli/models.ts
CHANGED
|
@@ -5,11 +5,11 @@ import { randomUUID } from "node:crypto";
|
|
|
5
5
|
import { createInterface } from "node:readline/promises";
|
|
6
6
|
import { syncModelsToCodex } from "../codex/sync";
|
|
7
7
|
import { hasOwnProvider, isValidProviderName, loadConfig, saveConfig } from "../config";
|
|
8
|
-
import { canonicalizeReasoningEfforts, isDeclaredReasoningEffort } from "../reasoning-effort";
|
|
8
|
+
import { canonicalizeReasoningEfforts, isDeclaredReasoningEffort, modelRecordValue } from "../reasoning-effort";
|
|
9
9
|
import { encodedModelIdCollides, routedSlug, slugEquals } from "../providers/slug-codec";
|
|
10
10
|
import { knownModelIdsForProvider } from "../router";
|
|
11
11
|
import { findLiveProxy } from "../server/proxy-liveness";
|
|
12
|
-
import type
|
|
12
|
+
import { modelInList, type OcxConfig, type OcxCustomModel } from "../types";
|
|
13
13
|
|
|
14
14
|
const ADD_USAGE = "Usage: ocx models add <provider> <modelId> [--display-name <name>] [--context-window <tokens>] [--modalities text,image,audio] [--reasoning-efforts <none,minimal,low,medium,high,xhigh,max,ultra>] [--default-reasoning-effort <level>]";
|
|
15
15
|
const REMOVE_USAGE = "Usage: ocx models remove <customId|provider/modelId> [--yes]";
|
|
@@ -98,15 +98,22 @@ function collectModels(config: OcxConfig, providerFilter?: string): ModelEntry[]
|
|
|
98
98
|
if (seen.has(model)) return;
|
|
99
99
|
seen.add(model);
|
|
100
100
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
101
|
+
// Resolve exactly as the runtime does, or this command reports capabilities the
|
|
102
|
+
// proxy will not honour: `isModelTextOnly` matches noVisionModels with modelInList
|
|
103
|
+
// and reads modelInputModalities with modelRecordValue, so a `gpt-oss` entry covers
|
|
104
|
+
// `gpt-oss:120b`. A bare lookup reported that model as unclassified on every field.
|
|
105
|
+
// noVisionModels is checked first because `isModelTextOnly` returns true on that
|
|
106
|
+
// match before it ever reads modelInputModalities: a `gpt-oss` noVision entry beats
|
|
107
|
+
// an exact `gpt-oss:120b` entry that lists "image", and the proxy rejects the image.
|
|
108
|
+
const noVision = modelInList(prov.noVisionModels, model);
|
|
109
|
+
const modalities = noVision ? ["text"] : (modelRecordValue(inputModalities, model) ?? null);
|
|
110
|
+
const efforts = modelRecordValue(reasoningEfforts, model) ?? prov.reasoningEfforts ?? null;
|
|
104
111
|
|
|
105
112
|
entries.push({
|
|
106
113
|
provider: provName,
|
|
107
114
|
model,
|
|
108
115
|
isDefault,
|
|
109
|
-
contextWindow: contextWindows
|
|
116
|
+
contextWindow: modelRecordValue(contextWindows, model) ?? globalContext,
|
|
110
117
|
inputModalities: modalities,
|
|
111
118
|
reasoningEfforts: efforts,
|
|
112
119
|
});
|
|
@@ -10,6 +10,8 @@ export interface CodexAccountUsabilityOptions {
|
|
|
10
10
|
nativeMainSelectionOnly?: boolean;
|
|
11
11
|
/** Test seam for proving whether routing attempted a physical native-token read. */
|
|
12
12
|
isMainAccountTokenLive?: typeof isMainAccountTokenLive;
|
|
13
|
+
/** Confirmed account ids for an account-gated model; omitted for ordinary native models. */
|
|
14
|
+
modelEligibleAccountIds?: ReadonlySet<string>;
|
|
13
15
|
}
|
|
14
16
|
|
|
15
17
|
export function isCodexAccountUsable(
|
|
@@ -17,6 +19,7 @@ export function isCodexAccountUsable(
|
|
|
17
19
|
accountId: string,
|
|
18
20
|
options: CodexAccountUsabilityOptions = {},
|
|
19
21
|
): boolean {
|
|
22
|
+
if (options.modelEligibleAccountIds && !options.modelEligibleAccountIds.has(accountId)) return false;
|
|
20
23
|
if (accountId === MAIN_CODEX_ACCOUNT_ID) {
|
|
21
24
|
// Startup recovery owns the physical auth/vault boundary. Never parse or select
|
|
22
25
|
// native __main__ while an encrypted switch journal is pending or inconclusive.
|
|
@@ -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 {
|
package/src/codex/auth-api.ts
CHANGED
|
@@ -644,12 +644,13 @@ async function retryMainAccountInfoIfIdentityChanged(
|
|
|
644
644
|
requestAccountId: string | null,
|
|
645
645
|
retriesRemaining: number,
|
|
646
646
|
nativeMainLease: AdmissionLease,
|
|
647
|
+
explicitRefresh: boolean,
|
|
647
648
|
): Promise<MainAccountInfoFetchResult | null> {
|
|
648
649
|
const currentAccountId = getMainChatgptAccountId();
|
|
649
650
|
if (currentAccountId === null || currentAccountId === requestAccountId) return null;
|
|
650
651
|
reconcileMainCodexAccountRuntimeState();
|
|
651
652
|
return retriesRemaining > 0
|
|
652
|
-
? fetchMainAccountInfoWhileOwned(true, retriesRemaining - 1, nativeMainLease)
|
|
653
|
+
? fetchMainAccountInfoWhileOwned(true, retriesRemaining - 1, nativeMainLease, explicitRefresh)
|
|
653
654
|
: { info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true };
|
|
654
655
|
}
|
|
655
656
|
|
|
@@ -696,6 +697,13 @@ async function fetchMainAccountInfoWhileOwned(
|
|
|
696
697
|
forceRefresh: boolean,
|
|
697
698
|
retriesRemaining: number,
|
|
698
699
|
nativeMainLease: AdmissionLease,
|
|
700
|
+
/**
|
|
701
|
+
* Whether the *caller* asked for this refresh. `forceRefresh` also means "bypass the
|
|
702
|
+
* cache", and `retryMainAccountInfoIfIdentityChanged` re-enters with it set purely to
|
|
703
|
+
* re-read after the identity changed. Keeping the two apart stops that retry from
|
|
704
|
+
* promoting a background poll into operator intent below.
|
|
705
|
+
*/
|
|
706
|
+
explicitRefresh: boolean = forceRefresh,
|
|
699
707
|
): Promise<MainAccountInfoFetchResult> {
|
|
700
708
|
const writerGeneration = captureConfigGeneration();
|
|
701
709
|
reconcileMainCodexAccountRuntimeState();
|
|
@@ -724,7 +732,7 @@ async function fetchMainAccountInfoWhileOwned(
|
|
|
724
732
|
});
|
|
725
733
|
if (!resp.ok) {
|
|
726
734
|
const terminalAuthFailure = await isTerminalMainAuthResponse(resp, isMainAccountTokenVerifiablyLive());
|
|
727
|
-
const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease);
|
|
735
|
+
const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh);
|
|
728
736
|
if (retried) return retried;
|
|
729
737
|
if (terminalAuthFailure) {
|
|
730
738
|
clearMainAccountInfoCache();
|
|
@@ -733,7 +741,7 @@ async function fetchMainAccountInfoWhileOwned(
|
|
|
733
741
|
return { info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true };
|
|
734
742
|
}
|
|
735
743
|
const data = (await resp.json()) as WhamUsageResponse;
|
|
736
|
-
const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease);
|
|
744
|
+
const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh);
|
|
737
745
|
if (retried) return retried;
|
|
738
746
|
const plan = nonEmptyPlan(data.plan_type) ?? nonEmptyPlan(cached?.plan) ?? nonEmptyPlan(getMainAccountPlan());
|
|
739
747
|
const quota = parseUsageQuota({ ...data, ...(plan ? { plan_type: plan } : {}) });
|
|
@@ -745,7 +753,16 @@ async function fetchMainAccountInfoWhileOwned(
|
|
|
745
753
|
ts: Date.now(),
|
|
746
754
|
};
|
|
747
755
|
setMainAccountInfoCache(result);
|
|
748
|
-
|
|
756
|
+
// Only an explicit refresh may retract a reauth quarantine. A 200 from
|
|
757
|
+
// /wham/usage proves the token authenticates to the usage endpoint; it does not
|
|
758
|
+
// prove the account can serve Responses traffic, which is a different backend path
|
|
759
|
+
// and still answers 403 for a workspace the token may no longer select (#327).
|
|
760
|
+
// Letting the background poll clear the flag put such an account straight back into
|
|
761
|
+
// rotation: the next request failed the same way and re-marked it, so needsReauth
|
|
762
|
+
// never settled and the dashboard kept showing nothing — the symptom #327 reported.
|
|
763
|
+
// An explicit refresh is an operator asking to re-evaluate, normally right after
|
|
764
|
+
// signing in again, so it stays authoritative.
|
|
765
|
+
if (explicitRefresh) clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
|
|
749
766
|
// Mirror main quota + plan into the shared stores so the rotation engine can
|
|
750
767
|
// score and auto-switch the main account exactly like a pool account (Option A).
|
|
751
768
|
setMainAccountPlan(result.plan);
|
|
@@ -760,7 +777,7 @@ async function fetchMainAccountInfoWhileOwned(
|
|
|
760
777
|
...(freshResetCredits !== undefined ? { freshResetCredits } : {}),
|
|
761
778
|
};
|
|
762
779
|
} catch {
|
|
763
|
-
const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease);
|
|
780
|
+
const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh);
|
|
764
781
|
return retried ?? { info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true };
|
|
765
782
|
}
|
|
766
783
|
}
|