@bitkyc08/opencodex 2.7.43 → 2.8.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/bin/ocx.mjs +34 -8
- package/gui/dist/assets/index-BDjpkcRN.js +67 -0
- package/gui/dist/assets/index-BHsKRFh9.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/cursor/discovery.ts +4 -1
- package/src/adapters/cursor/effort-map.ts +3 -0
- package/src/adapters/kiro.ts +15 -1
- package/src/claude/alias.ts +94 -14
- package/src/claude/outbound.ts +6 -3
- package/src/cli/catalog-prewarm.ts +24 -0
- package/src/cli/claude.ts +32 -7
- package/src/cli/doctor.ts +48 -1
- package/src/cli/index.ts +5 -0
- package/src/cli/interactive-confirm.ts +5 -1
- package/src/cli/star-prompt.ts +26 -4
- package/src/cli/v2.ts +10 -1
- package/src/codex/account-store.ts +2 -0
- package/src/codex/catalog/bundled.ts +9 -2
- package/src/codex/catalog/parsing.ts +26 -1
- package/src/codex/catalog/provider-fetch.ts +240 -82
- package/src/codex/catalog/sync.ts +27 -5
- package/src/codex/catalog.ts +1 -1
- package/src/codex/features.ts +524 -5
- package/src/codex/quota.ts +77 -2
- package/src/codex/runtime.ts +10 -1
- package/src/config.ts +8 -0
- package/src/generated/jawcode-model-metadata.ts +12 -12
- package/src/github/star-state.ts +191 -0
- package/src/lib/bun-binary-validator.d.mts +3 -0
- package/src/lib/bun-binary-validator.mjs +18 -0
- package/src/lib/bun-runtime.ts +6 -20
- package/src/lib/destination-policy.ts +10 -3
- package/src/lib/provider-outbound.ts +5 -2
- package/src/lib/shadow-call.ts +30 -0
- package/src/lib/test-home-guard.ts +90 -0
- package/src/lib/win-exec.ts +12 -2
- package/src/oauth/index.ts +29 -5
- package/src/oauth/key-providers.ts +21 -2
- package/src/oauth/kiro-credentials.ts +57 -8
- package/src/oauth/kiro.ts +2 -1
- package/src/oauth/login-cli.ts +1 -1
- package/src/oauth/store.ts +2 -0
- package/src/providers/derive.ts +2 -2
- package/src/providers/model-discovery.ts +356 -0
- package/src/providers/registry.ts +114 -0
- package/src/router.ts +5 -3
- package/src/server/auth-cors.ts +4 -2
- package/src/server/live.ts +75 -25
- package/src/server/management/agent-settings-routes.ts +78 -4
- package/src/server/management/config-routes.ts +19 -7
- package/src/server/management/context.ts +11 -1
- package/src/server/management/model-routes.ts +46 -13
- package/src/server/management/provider-routes.ts +44 -9
- package/src/server/management/shared.ts +2 -2
- package/src/server/management/sidebar-routes.ts +39 -0
- package/src/server/management-api.ts +3 -1
- package/src/server/responses/core.ts +31 -20
- package/src/server/responses/upstream-error.ts +48 -0
- package/src/server/startup-action-control.ts +30 -14
- package/src/service.ts +237 -19
- package/src/storage/policy-job.ts +26 -5
- package/src/storage/restore-job.ts +16 -5
- package/src/storage/worker-lifecycle.ts +81 -0
- package/src/tray/windows.ts +32 -4
- package/src/types.ts +11 -0
- package/src/update/badge.ts +72 -0
- package/src/update/job.ts +8 -4
- package/src/usage/expected-prices.ts +6 -5
- package/src/usage/log.ts +8 -0
- package/src/web-search/loop.ts +57 -16
- package/gui/dist/assets/index-Czw-jpTU.css +0 -1
- package/gui/dist/assets/index-cmds12BG.js +0 -67
package/src/claude/outbound.ts
CHANGED
|
@@ -453,12 +453,15 @@ export function responsesSseToAnthropicSse(
|
|
|
453
453
|
if (line.startsWith("event: ")) eventName = line.slice(7).trim();
|
|
454
454
|
else if (line.startsWith("data: ")) dataLine += line.slice(6);
|
|
455
455
|
}
|
|
456
|
-
if (!
|
|
456
|
+
if (!dataLine) continue;
|
|
457
457
|
let data: unknown;
|
|
458
458
|
try { data = JSON.parse(dataLine); } catch { continue; }
|
|
459
459
|
if (!isRec(data)) continue;
|
|
460
|
-
|
|
461
|
-
|
|
460
|
+
// Responses-compatible gateways may omit the optional SSE event field
|
|
461
|
+
// while retaining the event name in the JSON payload's required type.
|
|
462
|
+
const resolvedEventName = eventName || (typeof data.type === "string" ? data.type : "");
|
|
463
|
+
if (!resolvedEventName || terminated) continue;
|
|
464
|
+
handleFrame(resolvedEventName, data);
|
|
462
465
|
}
|
|
463
466
|
}
|
|
464
467
|
// EOF without a terminal frame is a TRUNCATION, not success (devlog 100:
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { OcxConfig } from "../types";
|
|
2
|
+
|
|
3
|
+
type GatherRoutedModels = (config: OcxConfig) => Promise<unknown>;
|
|
4
|
+
|
|
5
|
+
export type CatalogPrewarmDeps = {
|
|
6
|
+
loadConfig?: () => OcxConfig;
|
|
7
|
+
importCatalog?: () => Promise<{ gatherRoutedModels: GatherRoutedModels }>;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* After the listen port is bound, kick off live provider discovery so the first
|
|
12
|
+
* GUI /v1/models and syncModelsToCodex share one gather flight instead of racing
|
|
13
|
+
* duplicate upstream /models fetches.
|
|
14
|
+
*/
|
|
15
|
+
export function scheduleCatalogPrewarm(deps: CatalogPrewarmDeps = {}): void {
|
|
16
|
+
void Promise.resolve()
|
|
17
|
+
.then(async () => {
|
|
18
|
+
const load = deps.loadConfig ?? (await import("../config")).loadConfig;
|
|
19
|
+
const { gatherRoutedModels } = await (deps.importCatalog?.() ?? import("../codex/catalog"));
|
|
20
|
+
return gatherRoutedModels(load());
|
|
21
|
+
})
|
|
22
|
+
.catch(() => {});
|
|
23
|
+
}
|
|
24
|
+
|
package/src/cli/claude.ts
CHANGED
|
@@ -48,6 +48,27 @@ export function buildClaudeEnv(
|
|
|
48
48
|
// leaving the child with no token at all (audit R2-1). It is opencodex state, never
|
|
49
49
|
// user auth, so dropping it unconditionally is safe.
|
|
50
50
|
if (env.ANTHROPIC_AUTH_TOKEN === PROXY_MARKER) delete env.ANTHROPIC_AUTH_TOKEN;
|
|
51
|
+
// Step 1b — drop Anthropic credentials that the bundled Bun runtime synthesized from a
|
|
52
|
+
// project `.env`/`.env.local` (issue #701). Claude Code disables claude.ai connectors the
|
|
53
|
+
// moment either token slot is populated, so an ambient project file silently moved a
|
|
54
|
+
// subscriber onto API billing while their OAuth login stayed healthy. The npm launcher
|
|
55
|
+
// runs under Node, which does NOT auto-load dotenv, so it records the slots that existed
|
|
56
|
+
// before Bun started; anything populated now but absent then came from the working
|
|
57
|
+
// directory, not from the user. A genuine shell export is still honored, which keeps
|
|
58
|
+
// auto-mode API-key auth working. An ABSENT marker means provenance is unknowable
|
|
59
|
+
// (a direct `bun src/cli/index.ts` run, a test, or an older launcher), and then we
|
|
60
|
+
// change nothing rather than guess — an EMPTY marker is different: the launcher ran
|
|
61
|
+
// and saw no pre-existing slots.
|
|
62
|
+
const preBunSlots = base.OCX_PRE_BUN_ANTHROPIC_ENV;
|
|
63
|
+
if (preBunSlots !== undefined) {
|
|
64
|
+
const exported = new Set(preBunSlots.split(",").filter(name => name.length > 0));
|
|
65
|
+
for (const name of ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"] as const) {
|
|
66
|
+
const value = env[name];
|
|
67
|
+
if (value !== undefined && value !== "" && !exported.has(name)) delete env[name];
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
// Never forward the seam itself to Claude Code.
|
|
71
|
+
delete env.OCX_PRE_BUN_ANTHROPIC_ENV;
|
|
51
72
|
const setDefault = (name: string, value: string | undefined) => {
|
|
52
73
|
if (value === undefined || value.length === 0) return;
|
|
53
74
|
if (env[name] !== undefined && env[name] !== "") return; // user wins
|
|
@@ -76,15 +97,19 @@ export function buildClaudeEnv(
|
|
|
76
97
|
if ((config.apiKeys?.length ?? 0) > 0) {
|
|
77
98
|
setDefault("ANTHROPIC_AUTH_TOKEN", config.apiKeys![0].key);
|
|
78
99
|
}
|
|
79
|
-
// Detection reads the
|
|
80
|
-
// spawned process cannot disagree.
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
100
|
+
// Detection reads the SANITIZED launch env — the exact object spawned below — so the
|
|
101
|
+
// resolver and the spawned process cannot disagree. It deliberately does NOT read the
|
|
102
|
+
// raw base: the provenance strip above already removed dotenv-only credentials, and
|
|
103
|
+
// letting a value the child never receives decide the marker left an auto-mode user
|
|
104
|
+
// with neither the credential NOR the proxy marker (#701 audit round 2). Injected deps
|
|
105
|
+
// are spread FIRST and `env` bound LAST, and the injection type excludes `env`, so a
|
|
106
|
+
// test fake cannot break that. `ownTokens` is bound last for the same reason: it is
|
|
107
|
+
// config-derived, and a fake that replaced it could make our own admission key look
|
|
108
|
+
// like user auth.
|
|
84
109
|
const resolved = resolveClaudeAuthMode(config, detectClaudeAuth({
|
|
85
|
-
...defaultAuthDetectDeps(
|
|
110
|
+
...defaultAuthDetectDeps(env as NodeJS.ProcessEnv),
|
|
86
111
|
...(deps.authDetect ?? {}),
|
|
87
|
-
env: () =>
|
|
112
|
+
env: () => env as NodeJS.ProcessEnv,
|
|
88
113
|
ownTokens: ownAdmissionTokens(config),
|
|
89
114
|
}));
|
|
90
115
|
if (!env.ANTHROPIC_AUTH_TOKEN && resolved.markerMode === "proxy") {
|
package/src/cli/doctor.ts
CHANGED
|
@@ -307,13 +307,47 @@ export type ConfiguredProxyDiagnostic = {
|
|
|
307
307
|
detail: string;
|
|
308
308
|
};
|
|
309
309
|
|
|
310
|
-
function envReferenceName(value: string): string | null {
|
|
310
|
+
export function envReferenceName(value: string): string | null {
|
|
311
311
|
const braced = value.match(/^\$\{(\w+)\}$/);
|
|
312
312
|
if (braced) return braced[1]!;
|
|
313
313
|
const bare = value.match(/^\$(\w+)$/);
|
|
314
314
|
return bare ? bare[1]! : null;
|
|
315
315
|
}
|
|
316
316
|
|
|
317
|
+
export type ProviderApiKeyDiagnostic = {
|
|
318
|
+
provider: string;
|
|
319
|
+
envName: string;
|
|
320
|
+
detail: string;
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
/** Warn when a key-auth provider's apiKey env reference resolves empty in this process. */
|
|
324
|
+
export function collectProviderApiKeyDiagnostics(
|
|
325
|
+
providers: Record<string, { authMode?: string; apiKey?: string }> = readConfigDiagnostics().config.providers ?? {},
|
|
326
|
+
env: EnvMap = process.env,
|
|
327
|
+
): ProviderApiKeyDiagnostic[] {
|
|
328
|
+
const resolveInEnv = (value: string): string | undefined => {
|
|
329
|
+
const name = envReferenceName(value);
|
|
330
|
+
if (!name) return value;
|
|
331
|
+
return env[name];
|
|
332
|
+
};
|
|
333
|
+
const rows: ProviderApiKeyDiagnostic[] = [];
|
|
334
|
+
for (const [provider, config] of Object.entries(providers)) {
|
|
335
|
+
if (config.authMode !== "key") continue;
|
|
336
|
+
const raw = typeof config.apiKey === "string" ? config.apiKey.trim() : "";
|
|
337
|
+
if (!raw) continue;
|
|
338
|
+
const envName = envReferenceName(raw);
|
|
339
|
+
if (!envName) continue;
|
|
340
|
+
const resolved = resolveInEnv(raw);
|
|
341
|
+
if (resolved?.trim()) continue;
|
|
342
|
+
rows.push({
|
|
343
|
+
provider,
|
|
344
|
+
envName,
|
|
345
|
+
detail: `provider ${provider}: env reference ${envName} is unset or empty in this process`,
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
return rows;
|
|
349
|
+
}
|
|
350
|
+
|
|
317
351
|
export function collectConfiguredProxy(): ConfiguredProxyDiagnostic {
|
|
318
352
|
const diagnostics = readConfigDiagnostics();
|
|
319
353
|
const rawProxy = typeof diagnostics.config.proxy === "string" ? diagnostics.config.proxy.trim() : "";
|
|
@@ -742,6 +776,16 @@ export async function runDoctor(args: string[] = []): Promise<void> {
|
|
|
742
776
|
console.log("\nConfigured proxy (value hidden)");
|
|
743
777
|
console.log(` ${configuredProxy.present ? "set " : "unset "} ${configuredProxy.key} (${configuredProxy.source}; ${configuredProxy.detail})`);
|
|
744
778
|
|
|
779
|
+
const providerApiKeys = collectProviderApiKeyDiagnostics(doctorConfig.providers);
|
|
780
|
+
console.log("\nProvider API keys (value hidden)");
|
|
781
|
+
if (providerApiKeys.length === 0) {
|
|
782
|
+
console.log(" ok no empty env-referenced provider keys detected in this process");
|
|
783
|
+
} else {
|
|
784
|
+
for (const row of providerApiKeys) {
|
|
785
|
+
console.log(` !! ${row.detail}`);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
745
789
|
console.log("\nRunning proxy process proxy env (presence only)");
|
|
746
790
|
if (runningProxyEnv.status === "not_running") {
|
|
747
791
|
console.log(" -- no running ocx proxy process found");
|
|
@@ -825,6 +869,9 @@ export async function runDoctor(args: string[] = []): Promise<void> {
|
|
|
825
869
|
serviceViable: startup.serviceViable,
|
|
826
870
|
});
|
|
827
871
|
if (proxyDown) hints.push(proxyDown);
|
|
872
|
+
for (const row of providerApiKeys) {
|
|
873
|
+
hints.push(`${row.detail}. Set ${row.envName} in the shell that starts the proxy, or store a literal key in config (value hidden here).`);
|
|
874
|
+
}
|
|
828
875
|
const anyDrvfs = paths.some(p => detectFsType(p.path, mounts).isDrvfs || detectFsType(p.path, mounts).isMntDrive);
|
|
829
876
|
const noProxy = currentProxyEnv.every(p => !p.present) && !configuredProxy.present;
|
|
830
877
|
if (!startup.rebootSafe) {
|
package/src/cli/index.ts
CHANGED
|
@@ -38,6 +38,7 @@ import { startTokenGuardian } from "../oauth/token-guardian";
|
|
|
38
38
|
import { startHistoryMigrationGuardian } from "../codex/history-migration-guardian";
|
|
39
39
|
import { maybeAutoRestoreCodexShim } from "./codex-shim-autorestore";
|
|
40
40
|
import { maybeShowStarPrompt } from "./star-prompt";
|
|
41
|
+
import { scheduleCatalogPrewarm } from "./catalog-prewarm";
|
|
41
42
|
import { maybeShowUpdatePrompt } from "../update/notify";
|
|
42
43
|
import { syncModelsToCodex } from "../codex/sync";
|
|
43
44
|
import { normalizeUpdateChannel, runGuiUpdateWorker } from "../update/job";
|
|
@@ -190,6 +191,10 @@ async function handleStart(options: { block?: boolean } = {}) {
|
|
|
190
191
|
for (let attempt = 0; ; attempt++) {
|
|
191
192
|
try {
|
|
192
193
|
server = startServer(port);
|
|
194
|
+
// Prewarm the live provider model cache as soon as the port is bound so the
|
|
195
|
+
// first GUI /v1/models (and syncModelsToCodex below) share one discovery flight
|
|
196
|
+
// instead of racing duplicate upstream /models fetches.
|
|
197
|
+
scheduleCatalogPrewarm();
|
|
193
198
|
break;
|
|
194
199
|
} catch (err) {
|
|
195
200
|
if (!isAddrInUse(err) || attempt >= 2) throw err;
|
|
@@ -25,7 +25,11 @@ export interface InteractiveConfirmOptions {
|
|
|
25
25
|
output?: NodeJS.WriteStream;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
// The highlight sets an explicit black-on-white pair rather than bare reverse
|
|
29
|
+
// video (\x1b[7m). Reverse alone inherits whatever foreground colour is in
|
|
30
|
+
// effect, so on some themes the selected label rendered as black text on a black
|
|
31
|
+
// block and the choice became invisible.
|
|
32
|
+
const REVERSE = "\x1b[30;47m";
|
|
29
33
|
const DIM = "\x1b[2m";
|
|
30
34
|
const RESET = "\x1b[0m";
|
|
31
35
|
const CLEAR_LINE = "\r\x1b[K";
|
package/src/cli/star-prompt.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { join } from "node:path";
|
|
|
3
3
|
import { spawnSync } from "node:child_process";
|
|
4
4
|
import { getConfigDir } from "../config";
|
|
5
5
|
import { recordOwnedConfigPath } from "../lib/config-ownership";
|
|
6
|
+
import { commandInvocation } from "../lib/win-exec";
|
|
6
7
|
import { isAgentDriven } from "./agent-driven";
|
|
7
8
|
import { interactiveConfirm } from "./interactive-confirm";
|
|
8
9
|
|
|
@@ -29,16 +30,37 @@ export function hasStarPromptRun(): boolean {
|
|
|
29
30
|
* that case the prompt stays silent instead of asking for something it would
|
|
30
31
|
* then fail to do.
|
|
31
32
|
*/
|
|
33
|
+
/**
|
|
34
|
+
* On Windows `gh` is a `.cmd` shim; a shell-less spawn of the bare name skips
|
|
35
|
+
* PATHEXT and refuses `.cmd` targets, so it stalls until the timeout instead of
|
|
36
|
+
* failing fast. Route every call through the launcher the rest of the CLI uses.
|
|
37
|
+
*/
|
|
38
|
+
/** Resolve `gh` once; callers keep their own spawnSync overload. */
|
|
39
|
+
function ghInvocation(args: string[]) {
|
|
40
|
+
const invocation = commandInvocation("gh", args);
|
|
41
|
+
return {
|
|
42
|
+
file: invocation.file,
|
|
43
|
+
args: invocation.args,
|
|
44
|
+
verbatim: invocation.options.windowsVerbatimArguments === true,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
32
48
|
function ghAvailable(): boolean {
|
|
33
|
-
const
|
|
49
|
+
const v = ghInvocation(["--version"]);
|
|
50
|
+
const version = spawnSync(v.file, v.args,
|
|
51
|
+
{ stdio: "ignore", timeout: 3000, windowsHide: true, windowsVerbatimArguments: v.verbatim });
|
|
34
52
|
if (version.error || version.status !== 0) return false;
|
|
35
|
-
const
|
|
53
|
+
const a = ghInvocation(["auth", "status"]);
|
|
54
|
+
const auth = spawnSync(a.file, a.args,
|
|
55
|
+
{ stdio: "ignore", timeout: 5000, windowsHide: true, windowsVerbatimArguments: a.verbatim });
|
|
36
56
|
return !auth.error && auth.status === 0;
|
|
37
57
|
}
|
|
38
58
|
|
|
39
59
|
function starRepo(): { ok: boolean; error?: string } {
|
|
40
|
-
const
|
|
41
|
-
|
|
60
|
+
const star = ghInvocation(["api", "-X", "PUT", `/user/starred/${REPO}`]);
|
|
61
|
+
const r = spawnSync(star.file, star.args,
|
|
62
|
+
{ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 10000, windowsHide: true,
|
|
63
|
+
windowsVerbatimArguments: star.verbatim });
|
|
42
64
|
if (r.error) return { ok: false, error: r.error.message };
|
|
43
65
|
if (r.status !== 0) return { ok: false, error: (r.stderr || r.stdout || "").trim() || `gh exited ${r.status}` };
|
|
44
66
|
return { ok: true };
|
package/src/cli/v2.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* - nothing in the catalog build path calls this module; no auto-flip exists.
|
|
12
12
|
*/
|
|
13
13
|
import { execFileSync } from "node:child_process";
|
|
14
|
-
import { getLogicalMaxThreads, hasAgentsMaxThreads, isMultiAgentV2Enabled, transitionMultiAgentV2 } from "../codex/features";
|
|
14
|
+
import { getAgentsEnabled, getAgentsMaxDepth, getLogicalMaxThreads, getSubagentDeveloperInstructions, hasAgentsMaxThreads, isMultiAgentV2Enabled, transitionMultiAgentV2 } from "../codex/features";
|
|
15
15
|
|
|
16
16
|
import { commandInvocation, type SpawnInvocation } from "../lib/win-exec";
|
|
17
17
|
import { loadConfig, saveConfig } from "../config";
|
|
@@ -85,6 +85,15 @@ export async function cmdV2(args: string[], deps: V2CliDeps = {}, findPort?: ()
|
|
|
85
85
|
log.log(multiAgentModeLine(cfg.multiAgentMode ?? "default"));
|
|
86
86
|
const threads = getLogicalMaxThreads();
|
|
87
87
|
log.log(`max_threads: ${threads ?? "(unset — codex default)"}`);
|
|
88
|
+
const v2Active = isEnabled();
|
|
89
|
+
const agentsEnabled = getAgentsEnabled();
|
|
90
|
+
log.log(`agents.enabled: ${agentsEnabled === null ? "(unset — upstream default true)" : agentsEnabled}`);
|
|
91
|
+
const maxDepth = getAgentsMaxDepth();
|
|
92
|
+
// max_depth is V1-only upstream; say so whenever V2 is active so the number
|
|
93
|
+
// cannot be misread as an effective V2 limit.
|
|
94
|
+
log.log(`agents.max_depth: ${maxDepth ?? "(unset — upstream default 1)"}${v2Active ? " (V1-only — ignored while multi_agent_v2 is enabled)" : ""}`);
|
|
95
|
+
const instructions = getSubagentDeveloperInstructions();
|
|
96
|
+
log.log(`subagent_developer_instructions: ${instructions === null ? "(unset — children inherit)" : instructions === "" ? '"" (clears inherited instructions)' : JSON.stringify(instructions)}`);
|
|
88
97
|
if (isEnabled() && hasMaxThreads()) {
|
|
89
98
|
log.log("WARNING: [agents] max_threads is set — codex refuses to start while multi_agent_v2 is enabled. Remove it from config.toml (concurrency lives in features.multi_agent_v2.max_concurrent_threads_per_session).");
|
|
90
99
|
}
|
|
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { closeSync, existsSync, readFileSync, mkdirSync, openSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { getConfigDir, atomicWriteFile, backupInvalidConfig, hardenConfigDir, hardenExistingSecret } from "../config";
|
|
5
|
+
import { assertNotRealHomeUnderTest } from "../lib/test-home-guard";
|
|
5
6
|
import type { CodexAccountCredentialRecord, CodexAccountCredentials } from "../types";
|
|
6
7
|
|
|
7
8
|
type LegacyCodexAccountStore = Record<string, CodexAccountCredentials>;
|
|
@@ -97,6 +98,7 @@ function loadCodexAccountRecordStore(): CodexAccountStore {
|
|
|
97
98
|
|
|
98
99
|
function persist(store: CodexAccountStore): void {
|
|
99
100
|
const dir = getConfigDir();
|
|
101
|
+
assertNotRealHomeUnderTest(dir);
|
|
100
102
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
101
103
|
atomicWriteFile(codexAccountsPath(), JSON.stringify(store, null, 2) + "\n");
|
|
102
104
|
}
|
|
@@ -151,14 +151,21 @@ export function loadBundledCodexCatalog(deps: BundledCatalogDeps = {}): RawCatal
|
|
|
151
151
|
let cacheKey: string | null = null;
|
|
152
152
|
const candidates = deps.commandCandidates?.() ?? (() => {
|
|
153
153
|
const resolved = resolveAndPersistCodexRuntime({
|
|
154
|
-
execFileSync
|
|
154
|
+
// Forward an INJECTED execFileSync only. Passing the real one unconditionally made
|
|
155
|
+
// resolveCacheKey() bail out (it refuses to memoize injected-dep resolves), so every
|
|
156
|
+
// catalog read re-ran the ~1s `codex --version` probe even on a warm cache hit.
|
|
157
|
+
...(deps.execFileSync ? { execFileSync: deps.execFileSync } : {}),
|
|
155
158
|
configDir: deps.configDir,
|
|
156
159
|
env: deps.env,
|
|
157
160
|
platform: deps.platform,
|
|
158
161
|
existsSync: deps.existsSync,
|
|
159
162
|
readFileSync: deps.readFileSync,
|
|
160
163
|
now: deps.now,
|
|
161
|
-
|
|
164
|
+
// Catalog loading only consumes `resolved.runtime.command`, never `newerAvailable`.
|
|
165
|
+
// Full PATH discovery probes every candidate launcher (100+ on a dev machine, ~1.2s),
|
|
166
|
+
// which alone can exceed the 3s budget `ocx claude` allows /api/claude-code. Priority
|
|
167
|
+
// selection is identical either way; callers wanting discovery diagnostics opt in.
|
|
168
|
+
discoverAlternatives: deps.discoverAlternatives ?? false,
|
|
162
169
|
});
|
|
163
170
|
if (useCache) {
|
|
164
171
|
cacheKey = [
|
|
@@ -110,6 +110,8 @@ export interface CatalogModel {
|
|
|
110
110
|
/** Whether Codex may send Responses text.verbosity for this routed model. */
|
|
111
111
|
supportsVerbosity?: boolean;
|
|
112
112
|
supportsReasoningSummaries?: boolean;
|
|
113
|
+
/** Normalized upstream capability names retained for management/API consumers (#485 follow-up). */
|
|
114
|
+
capabilities?: string[];
|
|
113
115
|
}
|
|
114
116
|
|
|
115
117
|
export type RawEntry = Record<string, unknown>;
|
|
@@ -272,6 +274,16 @@ export function ensureStrictCatalogFields(
|
|
|
272
274
|
if (!Array.isArray(entry.input_modalities) && !options.preserveExactInputModalities) {
|
|
273
275
|
entry.input_modalities = ["text"];
|
|
274
276
|
}
|
|
277
|
+
// Codex parses `input_modalities` as a closed enum. One out-of-enum value (zenmux advertises
|
|
278
|
+
// "video") makes its config loader reject the entire catalog, which takes down plugins, apps and
|
|
279
|
+
// MCP servers — not just that model. Normalize at the single point every entry passes through,
|
|
280
|
+
// because provider metadata, jawcode metadata and effort sync each write this field.
|
|
281
|
+
if (Array.isArray(entry.input_modalities)) {
|
|
282
|
+
const accepted = entry.input_modalities.filter(value =>
|
|
283
|
+
value === "text" || value === "image" || value === "audio");
|
|
284
|
+
// Never leave it empty: an entry with no modality at all is worse than a text-only one.
|
|
285
|
+
entry.input_modalities = accepted.length > 0 ? accepted : ["text"];
|
|
286
|
+
}
|
|
275
287
|
const contextWindow = typeof entry.context_window === "number" && entry.context_window > 0 ? entry.context_window : 128000;
|
|
276
288
|
entry.context_window = contextWindow;
|
|
277
289
|
if (
|
|
@@ -288,7 +300,18 @@ export function ensureStrictCatalogFields(
|
|
|
288
300
|
|
|
289
301
|
export type MultiAgentMode = "v1" | "default" | "v2";
|
|
290
302
|
|
|
291
|
-
|
|
303
|
+
/**
|
|
304
|
+
* @param v2FeatureEnabled When the native multi_agent_v2 feature is on, "default"
|
|
305
|
+
* mode stamps unpinned entries as "v2" instead of deleting the key. The native
|
|
306
|
+
* binary validates spawn_agent models against THIS catalog with its own
|
|
307
|
+
* `multi_agent_version == Some(V2)` test (codex-rs multi_agents_common.rs), so an
|
|
308
|
+
* absent pin means a clean refusal at spawn time — exactly the cross-provider
|
|
309
|
+
* spawns opencodex exists to enable (option B, devlog
|
|
310
|
+
* 260730_codex_rs_upstream_v2_live_handoff/060). Upstream pins are always
|
|
311
|
+
* preserved: a genuine "v1" pin is a real capability statement and stays excluded.
|
|
312
|
+
* With the feature off the output is byte-identical to the historical behavior.
|
|
313
|
+
*/
|
|
314
|
+
export function applyMultiAgentMode(entries: RawEntry[], mode: MultiAgentMode, v2FeatureEnabled = false): RawEntry[] {
|
|
292
315
|
if (mode === "default") {
|
|
293
316
|
// Restore upstream defaults: clear any stale forced multi_agent_version and
|
|
294
317
|
// re-apply upstream pins from the snapshot for native entries that have one.
|
|
@@ -298,6 +321,8 @@ export function applyMultiAgentMode(entries: RawEntry[], mode: MultiAgentMode):
|
|
|
298
321
|
const upstreamPin = upstream?.multi_agent_version;
|
|
299
322
|
if (typeof upstreamPin === "string") {
|
|
300
323
|
entry.multi_agent_version = upstreamPin;
|
|
324
|
+
} else if (v2FeatureEnabled) {
|
|
325
|
+
entry.multi_agent_version = "v2";
|
|
301
326
|
} else {
|
|
302
327
|
delete entry.multi_agent_version;
|
|
303
328
|
}
|