@bitkyc08/opencodex 2.54.0 → 2.55.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-CkvITofZ.js → index-VuoiWj9J.js} +10 -10
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/anthropic-image-codec.ts +57 -0
- package/src/adapters/anthropic-image-normalize.ts +28 -1
- package/src/adapters/anthropic.ts +68 -6
- package/src/adapters/base.ts +8 -0
- package/src/adapters/coding-agent/protocol.ts +41 -16
- package/src/adapters/cursor/cursor-errors.ts +1 -1
- package/src/adapters/cursor/live-transport.ts +5 -1
- package/src/adapters/cursor/native-exec-fs.ts +10 -10
- package/src/adapters/cursor/native-exec-network.ts +2 -2
- package/src/adapters/cursor/native-exec-shell.ts +13 -12
- package/src/adapters/cursor/native-exec.ts +51 -10
- package/src/adapters/cursor/policy-error.ts +75 -0
- package/src/adapters/cursor/protobuf-request.ts +105 -1
- package/src/adapters/devin/cloud-direct/catalog.ts +34 -2
- package/src/adapters/devin/live-models.ts +33 -2
- package/src/adapters/google-wire-compiler.ts +8 -0
- package/src/adapters/google.ts +46 -0
- package/src/adapters/input-media-guard.ts +45 -0
- package/src/adapters/kiro/adapter.ts +8 -0
- package/src/adapters/kiro/payload.ts +28 -6
- package/src/adapters/kiro-events.ts +25 -6
- package/src/adapters/kiro-images.ts +30 -0
- package/src/adapters/kiro-retry.ts +8 -0
- package/src/adapters/openai-chat.ts +33 -4
- package/src/adapters/openai-responses.ts +26 -0
- package/src/adapters/registry.ts +4 -0
- package/src/bridge.ts +163 -116
- package/src/chat/image-parts.ts +151 -0
- package/src/chat/inbound.ts +70 -33
- package/src/cli/connect.ts +30 -9
- package/src/cli/dispatch.ts +7 -3
- package/src/cli/index.ts +3 -0
- package/src/cli/runtime-api.ts +25 -0
- package/src/cli/status.ts +21 -19
- package/src/cli/system-restart-client.ts +25 -0
- package/src/clients/config-export.ts +14 -4
- package/src/codex/app-server-processes.ts +25 -0
- package/src/codex/auth-context.ts +8 -0
- package/src/codex/autostart-health.ts +36 -2
- package/src/codex/catalog/provider-fetch.ts +41 -0
- package/src/codex/catalog-auto-refresh.ts +182 -0
- package/src/codex/catalog-refresh-status.ts +93 -0
- package/src/codex/history-provider.ts +55 -0
- package/src/codex/model-entitlements.ts +78 -0
- package/src/codex/native-profile-processes.ts +114 -15
- package/src/codex/prompt-text-probe.ts +274 -41
- package/src/codex/routing-adoption.ts +189 -0
- package/src/codex/routing.ts +520 -48
- package/src/codex/runtime.ts +249 -7
- package/src/combos/failover.ts +45 -0
- package/src/config.ts +124 -4
- package/src/generated/compatibility-version.json +110 -74
- package/src/generated/model-metadata.ts +1 -0
- package/src/lib/request-execution-budget.ts +202 -0
- package/src/lib/upstream-retry.ts +95 -8
- package/src/lib/workflow-budget.ts +172 -0
- package/src/oauth/devin.ts +57 -12
- package/src/providers/quota.ts +37 -6
- package/src/providers/registry.ts +53 -6
- package/src/responses/input-media.ts +65 -0
- package/src/responses/parser-content.ts +42 -0
- package/src/responses/schema.ts +12 -2
- package/src/server/audio-live.ts +1 -2
- package/src/server/audio-transcriptions.ts +1 -2
- package/src/server/auth-cors.ts +1 -1
- package/src/server/background-lifecycle.ts +18 -0
- package/src/server/chat-completions.ts +23 -8
- package/src/server/chat-native.ts +17 -17
- package/src/server/index.ts +24 -0
- package/src/server/management/request-history-routes.ts +5 -0
- package/src/server/request-log.ts +8 -2
- package/src/server/responses/compact.ts +51 -3
- package/src/server/responses/core.ts +238 -28
- package/src/server/search.ts +7 -9
- package/src/types/config.ts +51 -6
- package/src/usage/log.ts +37 -0
- package/src/vision/eligibility.ts +37 -4
- package/src/vision/index.ts +1 -0
- package/src/vision/plan.ts +45 -10
- package/src/web-search/alpha-search.ts +324 -0
- package/src/web-search/index.ts +13 -22
- package/src/web-search/passthrough-bridge.ts +195 -22
- package/src/web-search/sidecar-providers.ts +22 -0
package/src/codex/runtime.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
-
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, unlinkSync } from "node:fs";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, unlinkSync } from "node:fs";
|
|
3
|
+
import { homedir, tmpdir } from "node:os";
|
|
4
4
|
import { delimiter, join } from "node:path";
|
|
5
5
|
import { atomicWriteFile, getConfigDir } from "../config";
|
|
6
6
|
import { codexExecInvocation, isSpawnableCodexCandidate } from "./exec-invocation";
|
|
@@ -10,6 +10,7 @@ export type CodexRuntimeSource =
|
|
|
10
10
|
| "environment"
|
|
11
11
|
| "configured"
|
|
12
12
|
| "shim"
|
|
13
|
+
| "installed"
|
|
13
14
|
| "path"
|
|
14
15
|
| "fallback";
|
|
15
16
|
|
|
@@ -42,6 +43,13 @@ export interface ResolveCodexRuntimeResult {
|
|
|
42
43
|
readonly runtime: ResolvedCodexRuntime;
|
|
43
44
|
readonly failures: readonly RuntimeProbeFailure[];
|
|
44
45
|
readonly replacedConfigured?: Readonly<{ from: ResolvedCodexRuntime; reason: string }>;
|
|
46
|
+
/**
|
|
47
|
+
* Set when an unpinned, still-runnable persisted discovery is handed over to a
|
|
48
|
+
* strictly newer valid candidate. Distinct from replacedConfigured, which means
|
|
49
|
+
* the configured runtime became invalid; conflating "gone" with "superseded"
|
|
50
|
+
* would make the doctor output lie.
|
|
51
|
+
*/
|
|
52
|
+
readonly supersededDiscovered?: Readonly<{ from: ResolvedCodexRuntime; to: ResolvedCodexRuntime; reason: string }>;
|
|
45
53
|
readonly newerAvailable?: ResolvedCodexRuntime;
|
|
46
54
|
/** Set when the selected runtime could not be written to codex-runtime.json. */
|
|
47
55
|
readonly persistError?: string;
|
|
@@ -74,14 +82,47 @@ export interface ResolveCodexRuntimeDeps {
|
|
|
74
82
|
* newerAvailable discovery). Use for hot UI/status paths.
|
|
75
83
|
*/
|
|
76
84
|
discoverAlternatives?: boolean;
|
|
85
|
+
/**
|
|
86
|
+
* When false, select a spawnable candidate without running `codex --version`.
|
|
87
|
+
* The prompt probe needs a command it can spawn, not a version, and paying
|
|
88
|
+
* ~1s of blocking exec per candidate on a UI path is what made it report an
|
|
89
|
+
* absent candidate instead of the Windows Codex App install (issue 4458).
|
|
90
|
+
*/
|
|
91
|
+
probeVersion?: boolean;
|
|
92
|
+
/**
|
|
93
|
+
* Directory listing used by Windows App-root discovery. Injected so tests can
|
|
94
|
+
* exercise the LOCALAPPDATA OpenAI/Codex/bin layout without a real Windows
|
|
95
|
+
* filesystem. Must be listed in resolveCacheKey's injection guard: an injected
|
|
96
|
+
* listing that leaked into the process memo would pin every later test in this
|
|
97
|
+
* file to a fake install.
|
|
98
|
+
*/
|
|
99
|
+
readdirSync?: (path: string) => string[];
|
|
100
|
+
/**
|
|
101
|
+
* Stat used to order Windows App version directories by mtime. Same injection
|
|
102
|
+
* contract as readdirSync: a test-supplied impl must not populate the process
|
|
103
|
+
* memo.
|
|
104
|
+
*/
|
|
105
|
+
statSync?: (path: string) => { mtimeMs: number; isDirectory(): boolean };
|
|
77
106
|
}
|
|
78
107
|
|
|
108
|
+
/**
|
|
109
|
+
* How a `codex-runtime.json` record got onto disk.
|
|
110
|
+
*
|
|
111
|
+
* "pinned" is an intentional operator selection (doctor --fix). "discovered" is
|
|
112
|
+
* automatic resolve-and-persist. Absent is the pre-field shape and is treated
|
|
113
|
+
* as discovered, not pinned: every such file was written by
|
|
114
|
+
* resolveAndPersistCodexRuntime, so reading it as a pin would leave issue 4204
|
|
115
|
+
* unfixed on exactly the installs that have it.
|
|
116
|
+
*/
|
|
117
|
+
export type CodexRuntimePinOrigin = "pinned" | "discovered";
|
|
118
|
+
|
|
79
119
|
export interface PersistedCodexRuntimeState {
|
|
80
120
|
readonly version: 1;
|
|
81
121
|
readonly command: string;
|
|
82
122
|
readonly source: CodexRuntimeSource;
|
|
83
123
|
readonly selectedVersion?: string | null;
|
|
84
124
|
readonly updatedAt: string;
|
|
125
|
+
readonly origin?: CodexRuntimePinOrigin;
|
|
85
126
|
}
|
|
86
127
|
|
|
87
128
|
const PERSIST_FILE = "codex-runtime.json";
|
|
@@ -89,6 +130,16 @@ const CLAMP_PERSIST_FILE = "codex-runtime-clamp.json";
|
|
|
89
130
|
/** Probe rejection for an absolute candidate whose file is gone. Matched when retiring a dead pin (#4035). */
|
|
90
131
|
const PATH_MISSING_REASON = "path does not exist";
|
|
91
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Probe rejection when the selected command cannot even be spawned. Distinct
|
|
135
|
+
* from PATH_MISSING_REASON (the absolute path was gone before spawn) and from
|
|
136
|
+
* the generic `failed --version (...)` string (the binary ran and failed).
|
|
137
|
+
* Exported because the prompt probe classifies this as program-not-found, so a
|
|
138
|
+
* PATH fallback that is simply not installed must not look like an execution
|
|
139
|
+
* failure (issue 4458).
|
|
140
|
+
*/
|
|
141
|
+
export const CODEX_PROGRAM_NOT_FOUND_REASON = "program not found (ENOENT)";
|
|
142
|
+
|
|
92
143
|
function cloneAndDeepFreeze<T>(value: T): DeepReadonly<T> {
|
|
93
144
|
const clone = (current: unknown): unknown => {
|
|
94
145
|
if (Array.isArray(current)) return current.map(clone);
|
|
@@ -111,10 +162,15 @@ function isCodexRuntimeSource(value: unknown): value is CodexRuntimeSource {
|
|
|
111
162
|
return value === "environment"
|
|
112
163
|
|| value === "configured"
|
|
113
164
|
|| value === "shim"
|
|
165
|
+
|| value === "installed"
|
|
114
166
|
|| value === "path"
|
|
115
167
|
|| value === "fallback";
|
|
116
168
|
}
|
|
117
169
|
|
|
170
|
+
function isCodexRuntimePinOrigin(value: unknown): value is CodexRuntimePinOrigin {
|
|
171
|
+
return value === "pinned" || value === "discovered";
|
|
172
|
+
}
|
|
173
|
+
|
|
118
174
|
export function codexRuntimeStatePath(configDir: string = getConfigDir()): string {
|
|
119
175
|
return join(configDir, PERSIST_FILE);
|
|
120
176
|
}
|
|
@@ -249,6 +305,9 @@ export function parsePersistedCodexRuntime(
|
|
|
249
305
|
if (raw.selectedVersion !== undefined
|
|
250
306
|
&& raw.selectedVersion !== null
|
|
251
307
|
&& typeof raw.selectedVersion !== "string") return null;
|
|
308
|
+
// Absent origin is legal (pre-field files). A present value that is neither
|
|
309
|
+
// literal makes the whole record invalid, same as every other field.
|
|
310
|
+
if (raw.origin !== undefined && !isCodexRuntimePinOrigin(raw.origin)) return null;
|
|
252
311
|
return cloneAndDeepFreeze(raw as PersistedCodexRuntimeState);
|
|
253
312
|
} catch {
|
|
254
313
|
return null;
|
|
@@ -267,9 +326,35 @@ export function loadPersistedCodexRuntime(
|
|
|
267
326
|
}
|
|
268
327
|
}
|
|
269
328
|
|
|
329
|
+
/**
|
|
330
|
+
* True only when the operator intentionally pinned this runtime.
|
|
331
|
+
*
|
|
332
|
+
* A record with no origin is NOT pinned: every such file predates this field
|
|
333
|
+
* and was written by resolveAndPersistCodexRuntime, which is auto-discovery.
|
|
334
|
+
* Reading a missing origin as an intentional pin would leave issue 4204
|
|
335
|
+
* unfixed on exactly the installs that have it — the still-runnable 0.135.0
|
|
336
|
+
* CLI that kept winning over a 0.153.4 Desktop runtime sitting right there.
|
|
337
|
+
*/
|
|
338
|
+
export function persistedCodexRuntimeIsPinned(
|
|
339
|
+
state: DeepReadonly<PersistedCodexRuntimeState> | null | undefined,
|
|
340
|
+
): boolean {
|
|
341
|
+
return state?.origin === "pinned";
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Persist the selected Codex runtime.
|
|
346
|
+
*
|
|
347
|
+
* `origin` defaults to "pinned" ON PURPOSE: a direct call is a deliberate
|
|
348
|
+
* selection. src/cli/doctor.ts calls this from `doctor --fix`. The automatic
|
|
349
|
+
* discovery path is resolveAndPersistCodexRuntime, which passes "discovered"
|
|
350
|
+
* explicitly. Flipping the default would make doctor --fix look like an
|
|
351
|
+
* accident, and a later resolve would silently replace the operator's choice
|
|
352
|
+
* (issue 4204).
|
|
353
|
+
*/
|
|
270
354
|
export function persistCodexRuntime(
|
|
271
355
|
runtime: ResolvedCodexRuntime,
|
|
272
356
|
deps: ResolveCodexRuntimeDeps = {},
|
|
357
|
+
origin: CodexRuntimePinOrigin = "pinned",
|
|
273
358
|
): void {
|
|
274
359
|
const configDir = deps.configDir ?? getConfigDir();
|
|
275
360
|
mkdirSync(configDir, { recursive: true, mode: 0o700 });
|
|
@@ -279,6 +364,7 @@ export function persistCodexRuntime(
|
|
|
279
364
|
source: runtime.source,
|
|
280
365
|
selectedVersion: runtime.version,
|
|
281
366
|
updatedAt: new Date((deps.now ?? Date.now)()).toISOString(),
|
|
367
|
+
origin,
|
|
282
368
|
};
|
|
283
369
|
// Invalidate process authority before the persisted replacement is visible.
|
|
284
370
|
clearCodexRuntimeResolveCache();
|
|
@@ -313,7 +399,7 @@ export function clearPersistedCodexRuntime(deps: ResolveCodexRuntimeDeps = {}):
|
|
|
313
399
|
function probeVersion(
|
|
314
400
|
command: string,
|
|
315
401
|
deps: ResolveCodexRuntimeDeps,
|
|
316
|
-
): { ok: true; version: string } | { ok: false; reason: string } {
|
|
402
|
+
): { ok: true; version: string | null } | { ok: false; reason: string } {
|
|
317
403
|
const platform = deps.platform ?? process.platform;
|
|
318
404
|
if (command.includes("/") || command.includes("\\") || /^[A-Za-z]:/.test(command)) {
|
|
319
405
|
const exists = deps.existsSync ?? existsSync;
|
|
@@ -322,6 +408,11 @@ function probeVersion(
|
|
|
322
408
|
return { ok: false, reason: "not a spawnable Codex launcher on this platform" };
|
|
323
409
|
}
|
|
324
410
|
}
|
|
411
|
+
// The prompt probe needs a spawnable candidate, not a version. Running
|
|
412
|
+
// `codex --version` here is ~1s of blocking exec per candidate; on the
|
|
413
|
+
// dashboard probe that cost made Windows report Codex as missing even when
|
|
414
|
+
// the App install was sitting under LOCALAPPDATA/OpenAI/Codex/bin (issue 4458).
|
|
415
|
+
if (deps.probeVersion === false) return { ok: true, version: null };
|
|
325
416
|
const execFile = deps.execFileSync ?? (execFileSync as unknown as RuntimeExecFile);
|
|
326
417
|
// Sandbox the probe's CODEX_HOME: a real Codex CLI creates state (tmp/, logs) under
|
|
327
418
|
// CODEX_HOME even for `--version`, and the probe inherits the caller's env — so a
|
|
@@ -350,6 +441,9 @@ function probeVersion(
|
|
|
350
441
|
return { ok: true, version };
|
|
351
442
|
} catch (error) {
|
|
352
443
|
if (!probeHome) return { ok: false, reason: "probe sandbox unavailable" };
|
|
444
|
+
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
|
|
445
|
+
return { ok: false, reason: CODEX_PROGRAM_NOT_FOUND_REASON };
|
|
446
|
+
}
|
|
353
447
|
const message = error instanceof Error ? error.message : String(error);
|
|
354
448
|
const redacted = redactUserPath(redactSecretString(message)).slice(0, 160);
|
|
355
449
|
return { ok: false, reason: `failed --version (${redacted})` };
|
|
@@ -401,6 +495,53 @@ function pathCandidates(deps: ResolveCodexRuntimeDeps): string[] {
|
|
|
401
495
|
return [...new Set(out)];
|
|
402
496
|
}
|
|
403
497
|
|
|
498
|
+
/**
|
|
499
|
+
* Codex installs that PATH does not necessarily expose.
|
|
500
|
+
*
|
|
501
|
+
* The Windows Codex App writes codex.exe under
|
|
502
|
+
* LOCALAPPDATA/OpenAI/Codex/bin/<changing-version>/, which never appears on
|
|
503
|
+
* the service process PATH. The prompt probe used to hardcode four POSIX
|
|
504
|
+
* paths and miss that layout, then report an absent candidate (issue 4458).
|
|
505
|
+
* POSIX keeps those four paths so an install that resolved before this source
|
|
506
|
+
* existed still resolves.
|
|
507
|
+
*/
|
|
508
|
+
function installedCodexCandidates(deps: ResolveCodexRuntimeDeps): string[] {
|
|
509
|
+
const platform = deps.platform ?? process.platform;
|
|
510
|
+
const env = deps.env ?? process.env;
|
|
511
|
+
if (platform === "win32") {
|
|
512
|
+
const localAppData = env.LOCALAPPDATA?.trim();
|
|
513
|
+
if (!localAppData) return [];
|
|
514
|
+
const root = join(localAppData, "OpenAI", "Codex", "bin");
|
|
515
|
+
const readDir = deps.readdirSync ?? ((path: string) => readdirSync(path));
|
|
516
|
+
const stat = deps.statSync ?? ((path: string) => statSync(path));
|
|
517
|
+
try {
|
|
518
|
+
const names = readDir(root);
|
|
519
|
+
const dirs: Array<{ name: string; directory: string; mtimeMs: number }> = [];
|
|
520
|
+
for (const name of names) {
|
|
521
|
+
const directory = join(root, name);
|
|
522
|
+
try {
|
|
523
|
+
const st = stat(directory);
|
|
524
|
+
if (!st.isDirectory()) continue;
|
|
525
|
+
dirs.push({ name, directory, mtimeMs: st.mtimeMs });
|
|
526
|
+
} catch {
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
dirs.sort((a, b) => b.mtimeMs - a.mtimeMs || a.name.localeCompare(b.name));
|
|
531
|
+
return dirs.map(entry => join(entry.directory, "codex.exe"));
|
|
532
|
+
} catch {
|
|
533
|
+
return [];
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
const home = env.HOME?.trim() || env.USERPROFILE?.trim() || homedir();
|
|
537
|
+
return [
|
|
538
|
+
join(home, ".codex", "packages", "standalone", "current", "bin", "codex"),
|
|
539
|
+
join(home, ".local", "bin", "codex"),
|
|
540
|
+
"/usr/local/bin/codex",
|
|
541
|
+
"/opt/homebrew/bin/codex",
|
|
542
|
+
];
|
|
543
|
+
}
|
|
544
|
+
|
|
404
545
|
interface RankedCandidate {
|
|
405
546
|
command: string;
|
|
406
547
|
source: CodexRuntimeSource;
|
|
@@ -496,6 +637,20 @@ export type CodexRuntimeProcessCachePeek =
|
|
|
496
637
|
let resolveCacheEpoch = 0;
|
|
497
638
|
let resolveCache: ResolveCacheMemo | null = null;
|
|
498
639
|
|
|
640
|
+
/**
|
|
641
|
+
* Memo for probeVersion === false resolves. Kept separate from resolveCache
|
|
642
|
+
* because peekCodexRuntimeProcessCache is read by convergence and the bundled
|
|
643
|
+
* catalog as "what runtime are we on". Publishing a null version there would
|
|
644
|
+
* be read as "unknown version" and become process authority (issue 4458).
|
|
645
|
+
*/
|
|
646
|
+
interface DeferredResolveCacheMemo {
|
|
647
|
+
readonly key: string;
|
|
648
|
+
readonly at: number;
|
|
649
|
+
readonly value: DeepReadonly<ResolveCodexRuntimeResult>;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
let deferredResolveCache: DeferredResolveCacheMemo | null = null;
|
|
653
|
+
|
|
499
654
|
/**
|
|
500
655
|
* Bumped whenever persisted runtime state is replaced or process authority is cleared.
|
|
501
656
|
*
|
|
@@ -522,6 +677,7 @@ function publishResolveCache(key: string, at: number, value: ResolveCodexRuntime
|
|
|
522
677
|
function clearResolveCache(): void {
|
|
523
678
|
resolveCacheEpoch += 1;
|
|
524
679
|
resolveCache = null;
|
|
680
|
+
deferredResolveCache = null;
|
|
525
681
|
}
|
|
526
682
|
|
|
527
683
|
/** Clear process-local runtime authority without resolving a replacement. */
|
|
@@ -558,7 +714,15 @@ function persistedRuntimeCacheStamp(deps: ResolveCodexRuntimeDeps): string {
|
|
|
558
714
|
|
|
559
715
|
function resolveCacheKey(deps: ResolveCodexRuntimeDeps): string | null {
|
|
560
716
|
// Only memoize uninjected process-env resolves (settings/status hot paths).
|
|
561
|
-
if (
|
|
717
|
+
if (
|
|
718
|
+
deps.execFileSync
|
|
719
|
+
|| deps.existsSync
|
|
720
|
+
|| deps.readFileSync
|
|
721
|
+
|| deps.readdirSync
|
|
722
|
+
|| deps.statSync
|
|
723
|
+
|| deps.configDir
|
|
724
|
+
|| deps.now
|
|
725
|
+
) {
|
|
562
726
|
return null;
|
|
563
727
|
}
|
|
564
728
|
const env = deps.env ?? process.env;
|
|
@@ -567,6 +731,10 @@ function resolveCacheKey(deps: ResolveCodexRuntimeDeps): string | null {
|
|
|
567
731
|
path: env.PATH ?? "",
|
|
568
732
|
platform: deps.platform ?? process.platform,
|
|
569
733
|
discover: deps.discoverAlternatives !== false,
|
|
734
|
+
probeVersion: deps.probeVersion !== false,
|
|
735
|
+
localAppData: env.LOCALAPPDATA?.trim() ?? "",
|
|
736
|
+
homeDir: env.HOME?.trim() ?? "",
|
|
737
|
+
userProfile: env.USERPROFILE?.trim() ?? "",
|
|
570
738
|
home: process.env.OPENCODEX_HOME ?? "",
|
|
571
739
|
persisted: persistedRuntimeCacheStamp(deps),
|
|
572
740
|
});
|
|
@@ -577,6 +745,27 @@ function resolveCacheKey(deps: ResolveCodexRuntimeDeps): string | null {
|
|
|
577
745
|
*/
|
|
578
746
|
export function resolveCodexRuntime(deps: ResolveCodexRuntimeDeps = {}): ResolveCodexRuntimeResult {
|
|
579
747
|
const cacheKey = resolveCacheKey(deps);
|
|
748
|
+
// A deferred selection has no validated version and must not publish into
|
|
749
|
+
// runtime authority. peekCodexRuntimeProcessCache would otherwise report
|
|
750
|
+
// "available" with version null, which catalog/convergence read as unknown.
|
|
751
|
+
if (deps.probeVersion === false) {
|
|
752
|
+
if (cacheKey
|
|
753
|
+
&& deferredResolveCache
|
|
754
|
+
&& deferredResolveCache.key === cacheKey
|
|
755
|
+
&& Date.now() - deferredResolveCache.at < RESOLVE_CACHE_MS) {
|
|
756
|
+
return cloneAndDeepFreeze(deferredResolveCache.value);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
const deferred = resolveCodexRuntimeUncached(deps);
|
|
760
|
+
if (!cacheKey) return cloneAndDeepFreeze(deferred);
|
|
761
|
+
deferredResolveCache = {
|
|
762
|
+
key: cacheKey,
|
|
763
|
+
at: Date.now(),
|
|
764
|
+
value: cloneAndDeepFreeze(deferred),
|
|
765
|
+
};
|
|
766
|
+
return cloneAndDeepFreeze(deferredResolveCache.value);
|
|
767
|
+
}
|
|
768
|
+
|
|
580
769
|
if (cacheKey && resolveCache && resolveCache.key === cacheKey && Date.now() - resolveCache.at < RESOLVE_CACHE_MS) {
|
|
581
770
|
return cloneAndDeepFreeze(resolveCache.value);
|
|
582
771
|
}
|
|
@@ -623,18 +812,44 @@ function resolveCodexRuntimeUncached(deps: ResolveCodexRuntimeDeps = {}): Resolv
|
|
|
623
812
|
for (const command of pathCandidates(deps)) {
|
|
624
813
|
ordered.push({ command, source: "path" });
|
|
625
814
|
}
|
|
815
|
+
for (const command of installedCodexCandidates(deps)) {
|
|
816
|
+
ordered.push({ command, source: "installed" });
|
|
817
|
+
}
|
|
626
818
|
ordered.push({ command: "codex", source: "fallback" });
|
|
627
819
|
|
|
628
820
|
const seen = new Set<string>();
|
|
629
821
|
const valid: ResolvedCodexRuntime[] = [];
|
|
822
|
+
// A caller that declined PATH-wide discovery normally gets the first valid
|
|
823
|
+
// candidate and nothing else, which is right for a hot path and wrong for
|
|
824
|
+
// exactly one arrangement: an unpinned persisted selection sitting in front of
|
|
825
|
+
// a Codex App runtime that PATH never exposes.
|
|
826
|
+
//
|
|
827
|
+
// That arrangement is issue 4204. The catalog's bundled loader passes
|
|
828
|
+
// discoverAlternatives: false, so it stopped at a still-runnable codex-cli
|
|
829
|
+
// 0.135.0 and derived the reasoning ladder from it while the Desktop app was
|
|
830
|
+
// running 0.153.4 out of LOCALAPPDATA. Nothing downstream could notice,
|
|
831
|
+
// because the newer runtime was never probed.
|
|
832
|
+
//
|
|
833
|
+
// So the early stop keeps skipping PATH — which is the expensive part, 100+
|
|
834
|
+
// launcher probes on a dev machine — but still probes the `installed` roots,
|
|
835
|
+
// a bounded set with one entry per Codex App version directory. A pinned
|
|
836
|
+
// record skips even that: the operator's choice is not up for revision, and
|
|
837
|
+
// there is then nothing to compare it against.
|
|
838
|
+
const persistedIsUnpinned = Boolean(persisted?.command) && !persistedCodexRuntimeIsPinned(persisted);
|
|
630
839
|
for (const candidate of ordered) {
|
|
631
840
|
const key = candidate.command.toLowerCase();
|
|
632
841
|
if (seen.has(key)) continue;
|
|
633
842
|
seen.add(key);
|
|
843
|
+
if (
|
|
844
|
+
deps.discoverAlternatives === false
|
|
845
|
+
&& valid.length > 0
|
|
846
|
+
&& !(persistedIsUnpinned && candidate.source === "installed")
|
|
847
|
+
) {
|
|
848
|
+
continue;
|
|
849
|
+
}
|
|
634
850
|
const resolved = tryCandidate(candidate, failures, deps);
|
|
635
851
|
if (!resolved) continue;
|
|
636
852
|
valid.push(resolved);
|
|
637
|
-
if (deps.discoverAlternatives === false) break;
|
|
638
853
|
}
|
|
639
854
|
|
|
640
855
|
if (valid.length === 0) {
|
|
@@ -644,9 +859,10 @@ function resolveCodexRuntimeUncached(deps: ResolveCodexRuntimeDeps = {}): Resolv
|
|
|
644
859
|
};
|
|
645
860
|
}
|
|
646
861
|
|
|
647
|
-
// Prefer first valid in priority order (environment → configured → shim → path → fallback).
|
|
862
|
+
// Prefer first valid in priority order (environment → configured → shim → path → installed → fallback).
|
|
648
863
|
let selected = valid[0]!;
|
|
649
864
|
let replacedConfigured: ResolveCodexRuntimeResult["replacedConfigured"];
|
|
865
|
+
let supersededDiscovered: ResolveCodexRuntimeResult["supersededDiscovered"];
|
|
650
866
|
|
|
651
867
|
const envValid = envPath
|
|
652
868
|
? valid.find(item => sameRuntimeCommand(item.command, envPath) && item.source === "environment")
|
|
@@ -672,6 +888,31 @@ function resolveCodexRuntimeUncached(deps: ResolveCodexRuntimeDeps = {}): Resolv
|
|
|
672
888
|
} else if (!envValid && configuredStillValid) {
|
|
673
889
|
// Stick to configured even when a later PATH entry is also valid.
|
|
674
890
|
selected = valid.find(item => sameRuntimeCommand(item.command, persisted.command)) ?? selected;
|
|
891
|
+
// An explicit pin is the user's decision and this change must never
|
|
892
|
+
// silently replace it — issue 4204 says so in as many words. Stick.
|
|
893
|
+
// An unpinned record (missing origin, or origin "discovered") may hand
|
|
894
|
+
// over to a strictly newer valid candidate. Unknown (null) versions on
|
|
895
|
+
// either side are not evidence of an upgrade: compareCodexVersions treats
|
|
896
|
+
// null as less-than, which would otherwise make any known alternative
|
|
897
|
+
// look newer than a deferred probe. probeVersion === false yields null
|
|
898
|
+
// everywhere, so the comparison cannot fire there; equal versions stick.
|
|
899
|
+
if (!persistedCodexRuntimeIsPinned(persisted)) {
|
|
900
|
+
const newerDiscovered = valid
|
|
901
|
+
.filter(item =>
|
|
902
|
+
!sameRuntimeCommand(item.command, selected.command)
|
|
903
|
+
&& typeof item.version === "string"
|
|
904
|
+
&& typeof selected.version === "string"
|
|
905
|
+
&& compareCodexVersions(item.version, selected.version) > 0)
|
|
906
|
+
.sort((a, b) => compareCodexVersions(b.version, a.version))[0];
|
|
907
|
+
if (newerDiscovered) {
|
|
908
|
+
supersededDiscovered = {
|
|
909
|
+
from: selected,
|
|
910
|
+
to: newerDiscovered,
|
|
911
|
+
reason: `discovered runtime ${selected.version} superseded by newer runtime ${newerDiscovered.version}`,
|
|
912
|
+
};
|
|
913
|
+
selected = newerDiscovered;
|
|
914
|
+
}
|
|
915
|
+
}
|
|
675
916
|
}
|
|
676
917
|
}
|
|
677
918
|
|
|
@@ -687,6 +928,7 @@ function resolveCodexRuntimeUncached(deps: ResolveCodexRuntimeDeps = {}): Resolv
|
|
|
687
928
|
runtime: selected,
|
|
688
929
|
failures,
|
|
689
930
|
replacedConfigured,
|
|
931
|
+
supersededDiscovered,
|
|
690
932
|
newerAvailable: newer,
|
|
691
933
|
};
|
|
692
934
|
}
|
|
@@ -707,7 +949,7 @@ export function resolveAndPersistCodexRuntime(
|
|
|
707
949
|
&& (persistedRuntime.selectedVersion ?? null) === (result.runtime.version ?? null);
|
|
708
950
|
if (result.runtime.command && result.runtime.source !== "fallback" && !selectionUnchanged) {
|
|
709
951
|
try {
|
|
710
|
-
persistCodexRuntime(result.runtime, deps);
|
|
952
|
+
persistCodexRuntime(result.runtime, deps, "discovered");
|
|
711
953
|
} catch (error) {
|
|
712
954
|
const message = error instanceof Error ? error.message : String(error);
|
|
713
955
|
const persistError = redactUserPath(redactSecretString(message)).slice(0, 200);
|
package/src/combos/failover.ts
CHANGED
|
@@ -351,6 +351,49 @@ const PROVIDER_SCOPED_FAILURE_CODES = new Set([
|
|
|
351
351
|
"insufficient_balance",
|
|
352
352
|
]);
|
|
353
353
|
|
|
354
|
+
/**
|
|
355
|
+
* Precise target-local request incompatibilities are request-local, not terminal for a combo.
|
|
356
|
+
* Require a bounded, intact provider envelope; never infer compatibility from echoed prompt text.
|
|
357
|
+
* Only OpenCodex's exact error wrapper may be unwrapped, with a fixed depth budget. Unknown or
|
|
358
|
+
* conflicting codes fail closed. No fields are removed here and no same-target replay is added.
|
|
359
|
+
* Image rejection requires `param: input` and an exact model-scoped prefix.
|
|
360
|
+
*/
|
|
361
|
+
function isRequestLocalTargetIncompatibility(status: number, message: string, code?: string | null): boolean {
|
|
362
|
+
if (status !== 400 || message.length > 16_384) return false;
|
|
363
|
+
const genericCodes = new Set(["", "invalid_request_error", "unsupported_parameter", "unsupported_value"]);
|
|
364
|
+
if (!genericCodes.has(normalizedFailureCode(code))) return false;
|
|
365
|
+
let text = message.trim();
|
|
366
|
+
for (let depth = 0; depth < 3; depth += 1) {
|
|
367
|
+
if (text.startsWith("Provider error 400: ")) text = text.slice("Provider error 400: ".length);
|
|
368
|
+
let payload: unknown;
|
|
369
|
+
try { payload = JSON.parse(text); } catch { return false; }
|
|
370
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false;
|
|
371
|
+
const error = (payload as Record<string, unknown>).error;
|
|
372
|
+
if (!error || typeof error !== "object" || Array.isArray(error)) return false;
|
|
373
|
+
const e = error as Record<string, unknown>;
|
|
374
|
+
if (e.code !== undefined && e.code !== null && typeof e.code !== "string") return false;
|
|
375
|
+
const errorCode = normalizedFailureCode(typeof e.code === "string" ? e.code : undefined);
|
|
376
|
+
if (!genericCodes.has(errorCode) || typeof e.message !== "string") return false;
|
|
377
|
+
if (e.type !== "invalid_request_error" && e.type !== "upstream_error") return false;
|
|
378
|
+
if (e.message.startsWith("Provider error 400: ") && e.param === undefined
|
|
379
|
+
&& (errorCode === "" || errorCode === "invalid_request_error")) {
|
|
380
|
+
text = e.message;
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
if (e.type !== "invalid_request_error") return false;
|
|
384
|
+
if (e.message === "Unsupported parameter: user") {
|
|
385
|
+
return (e.param === undefined || e.param === "user") && errorCode !== "unsupported_value";
|
|
386
|
+
}
|
|
387
|
+
if (errorCode === "unsupported_value"
|
|
388
|
+
&& (e.param === "reasoning.effort" || e.param === "reasoning_effort")
|
|
389
|
+
&& e.message.startsWith("Unsupported value:") && e.message.includes("not supported")) return true;
|
|
390
|
+
return e.param === "input"
|
|
391
|
+
&& (errorCode === "" || errorCode === "invalid_request_error")
|
|
392
|
+
&& /^Model '[^']{1,256}' does not support image inputs\./.test(e.message);
|
|
393
|
+
}
|
|
394
|
+
return false;
|
|
395
|
+
}
|
|
396
|
+
|
|
354
397
|
export function comboFailureCooldownScope(
|
|
355
398
|
status: number,
|
|
356
399
|
message: string,
|
|
@@ -363,6 +406,7 @@ export function comboFailureCooldownScope(
|
|
|
363
406
|
|| REQUEST_SHAPE_FAILURE_CODES.has(code)
|
|
364
407
|
|| isRequestLocalFreePromptCap(status, message, options?.code)
|
|
365
408
|
|| isProviderTargetContextOverflow(status, message, options?.code)
|
|
409
|
+
|| isRequestLocalTargetIncompatibility(status, message, options?.code)
|
|
366
410
|
) return "none";
|
|
367
411
|
if (isProviderScopedQuotaCap(status, message, options?.code)) return "provider";
|
|
368
412
|
// A rejected or unpaid credential is provider-wide evidence: every target that routes
|
|
@@ -465,6 +509,7 @@ export function comboFailureDecision(
|
|
|
465
509
|
// `free_rate_limited` no longer routes through `isProviderScopedQuotaCap` (it is a
|
|
466
510
|
// per-request cap, not provider-wide evidence), so keep its hop verdict explicit here.
|
|
467
511
|
if (failureCode === "free_rate_limited") return "hop";
|
|
512
|
+
if (isRequestLocalTargetIncompatibility(status, message, options?.code)) return "hop";
|
|
468
513
|
if (["origin_rejected", "context_length_exceeded", "invalid_request_error"].includes(error.code ?? "")) {
|
|
469
514
|
return "stop";
|
|
470
515
|
}
|