@bitkyc08/opencodex 2.67.0 → 2.68.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 +3 -0
- package/gui/dist/assets/App-DCBismRi.css +1 -0
- package/gui/dist/assets/App-mpsibsFj.js +51 -0
- package/gui/dist/assets/Tray-Bn8WhKBH.js +1 -0
- package/gui/dist/assets/{index-DtNmX7hW.css → index-BDUBS8PW.css} +1 -1
- package/gui/dist/assets/index-kUE8tuAK.js +86 -0
- package/gui/dist/assets/quota-summary-DL-bNuNL.js +1 -0
- package/gui/dist/index.html +21 -2
- package/package.json +1 -1
- package/src/adapters/anthropic-image-guard.ts +13 -1
- package/src/adapters/anthropic-image-normalize.ts +3 -2
- package/src/adapters/anthropic-output-schema.ts +36 -0
- package/src/adapters/anthropic.ts +10 -2
- package/src/adapters/base.ts +7 -0
- package/src/adapters/codebuddy/live-models.ts +180 -0
- package/src/adapters/coding-agent/protocol.ts +91 -12
- package/src/adapters/coding-agent/turn.ts +32 -27
- package/src/adapters/devin/cloud-direct/index.ts +1 -0
- package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +18 -4
- package/src/adapters/devin.ts +9 -6
- package/src/adapters/google-http.ts +4 -4
- package/src/adapters/google.ts +93 -3
- package/src/adapters/kiro/adapter.ts +6 -2
- package/src/adapters/kiro/stream.ts +12 -2
- package/src/adapters/kiro/usage.ts +3 -1
- package/src/adapters/kiro-errors.ts +11 -1
- package/src/adapters/kiro-events.ts +27 -1
- package/src/adapters/kiro-refusal.ts +30 -0
- package/src/adapters/kiro-retry.ts +71 -31
- package/src/adapters/openai-chat/deepseek-artifact-schema.ts +45 -0
- package/src/adapters/openai-chat/messages.ts +31 -4
- package/src/adapters/openai-chat/serialized-tool-call-content.ts +52 -7
- package/src/adapters/openai-chat/tool-call-id-remint.ts +65 -0
- package/src/adapters/openai-chat/tool-schema.ts +6 -2
- package/src/adapters/openai-chat.ts +2 -2
- package/src/adapters/openai-responses/muse-tool-choice.ts +31 -0
- package/src/adapters/openai-responses/passthrough.ts +9 -3
- package/src/adapters/opencode-go-additional-tools.ts +19 -10
- package/src/adapters/physical-send.ts +10 -3
- package/src/adapters/registry.ts +2 -1
- package/src/adapters/responses-tool-schema.ts +31 -3
- package/src/adapters/run-turn-queue.ts +63 -23
- package/src/adapters/unique-tool-call-ids.ts +63 -0
- package/src/adapters/xai-web-search.ts +32 -2
- package/src/bridge/sse.ts +14 -2
- package/src/chat/inbound.ts +20 -6
- package/src/claude/agents-inject.ts +4 -0
- package/src/claude/desktop-3p.ts +53 -13
- package/src/claude/desktop-profile.ts +41 -4
- package/src/claude/inbound-model-options.ts +14 -2
- package/src/claude/inbound.ts +1 -1
- package/src/claude/intercept/connect-proxy.ts +17 -1
- package/src/claude/intercept/local-ca.ts +7 -1
- package/src/claude/message-threads.ts +28 -0
- package/src/cli/account-api.ts +13 -0
- package/src/cli/account-auth.ts +49 -2
- package/src/cli/account-extended.ts +4 -2
- package/src/cli/account.ts +4 -2
- package/src/cli/capabilities.ts +17 -0
- package/src/cli/claude.ts +3 -1
- package/src/cli/dispatch.ts +62 -5
- package/src/cli/index.ts +84 -79
- package/src/cli/minimax.ts +4 -2
- package/src/cli/opencode.ts +6 -1
- package/src/cli/restart-handoff.ts +110 -0
- package/src/cli/status.ts +51 -0
- package/src/client/connect.ts +34 -15
- package/src/client/link-ingress.ts +102 -0
- package/src/client/link-join.ts +37 -16
- package/src/client/link-relay.ts +228 -45
- package/src/client/link-state.ts +54 -0
- package/src/client/link-status.ts +39 -0
- package/src/client/link-teardown.ts +2 -2
- package/src/client/link-tunnel.ts +615 -86
- package/src/client/machine-api.ts +10 -4
- package/src/client/machine-listener.ts +57 -10
- package/src/client/runtime.ts +210 -35
- package/src/clients/config-export/constants.ts +1 -12
- package/src/clients/config-export/contracts.ts +2 -0
- package/src/clients/config-export/model-metadata.ts +9 -3
- package/src/clients/config-export/omp.ts +1 -1
- package/src/clients/config-export/zcode-store.ts +2 -2
- package/src/clients/config-export.ts +36 -3
- package/src/codex/app-server-processes.ts +32 -0
- package/src/codex/app-server-restart-service.ts +20 -3
- package/src/codex/catalog/access-programs.ts +50 -0
- package/src/codex/catalog/build-entries.ts +13 -0
- package/src/codex/catalog/derive-entry.ts +1 -0
- package/src/codex/catalog/model-hints.ts +11 -0
- package/src/codex/catalog/parsing.ts +6 -2
- package/src/codex/catalog/provider-models.ts +81 -0
- package/src/codex/catalog/retained-sync.ts +3 -0
- package/src/codex/codex-write-lock.ts +2 -2
- package/src/codex/convergence.ts +2 -0
- package/src/codex/desired-state.ts +14 -4
- package/src/codex/home.ts +21 -3
- package/src/codex/inject/restore.ts +17 -0
- package/src/codex/inject/routing-classify.ts +3 -2
- package/src/codex/inject-coordination.ts +4 -1
- package/src/codex/inject.ts +8 -1
- package/src/codex/journal.ts +6 -2
- package/src/codex/management-convergence.ts +9 -0
- package/src/codex/model-entitlements.ts +28 -5
- package/src/codex/routing/idle-window.ts +58 -0
- package/src/codex/routing-drift.ts +134 -0
- package/src/codex/routing-healer.ts +419 -0
- package/src/codex/routing.ts +10 -0
- package/src/codex/runtime.ts +188 -57
- package/src/codex/sibling-handoff.ts +71 -0
- package/src/codex/sibling-start.ts +139 -0
- package/src/codex/sync.ts +5 -3
- package/src/combos/failover.ts +32 -5
- package/src/combos/request.ts +11 -3
- package/src/combos/reset-window.ts +10 -5
- package/src/combos/resolve.ts +36 -6
- package/src/config/diagnostics.ts +2 -1
- package/src/config/load-degrade.ts +2 -0
- package/src/config/paths.ts +7 -1
- package/src/config/process-state.ts +10 -1
- package/src/config/schema/compaction-recovery.ts +15 -0
- package/src/config/schema/config-schema.ts +2 -0
- package/src/config/schema/leaf-validators.ts +9 -3
- package/src/generated/compatibility-version.json +360 -204
- package/src/images/loop.ts +47 -32
- package/src/integrations/catalog-refresh.ts +4 -0
- package/src/integrations/omp-yaml-source.ts +1 -0
- package/src/lib/claude-request-projection.ts +101 -0
- package/src/lib/local-account-switch-capability.ts +48 -0
- package/src/lib/local-upstream.ts +146 -0
- package/src/lib/package-tree-integrity.ts +25 -3
- package/src/lib/package-tree-retarget.ts +130 -0
- package/src/lib/process-control.ts +4 -1
- package/src/lib/system-restart-contract.ts +106 -0
- package/src/lib/upstream-retry.ts +57 -1
- package/src/link/ports.ts +9 -0
- package/src/link/ssh-argv.ts +16 -0
- package/src/link/ssh-runner.ts +70 -2
- package/src/link/tunnel-state.ts +58 -9
- package/src/oauth/account-quota-rank.ts +29 -18
- package/src/oauth/generic-account-failover.ts +179 -22
- package/src/oauth/index.ts +17 -4
- package/src/oauth/kiro-account-load.ts +107 -0
- package/src/oauth/kiro-device-login.ts +312 -0
- package/src/oauth/kiro-terminal-failover.ts +28 -0
- package/src/oauth/login-flow-state.ts +7 -1
- package/src/oauth/pool-settings-capability.ts +23 -14
- package/src/oauth/store.ts +56 -3
- package/src/oauth/types.ts +4 -0
- package/src/plugins/loader.ts +350 -0
- package/src/plugins/upstream-hooks.ts +119 -0
- package/src/protocols/encoders/adapter-events.ts +9 -2
- package/src/providers/account-quota-disk.ts +42 -4
- package/src/providers/codebuddy-models.ts +6 -2
- package/src/providers/command-code-efforts.ts +33 -10
- package/src/providers/kiro-account-state-disk.ts +44 -0
- package/src/providers/kiro-model-catalog.ts +162 -0
- package/src/providers/kiro-models.ts +5 -4
- package/src/providers/kiro-quota-metrics.ts +35 -0
- package/src/providers/kiro-usage.ts +118 -14
- package/src/providers/quota/account-cache.ts +43 -7
- package/src/providers/quota/antigravity.ts +8 -3
- package/src/providers/quota/kiro-account-probe.ts +12 -0
- package/src/providers/quota/vendor-probes-key.ts +31 -26
- package/src/providers/quota/vendor-probes-oauth.ts +16 -7
- package/src/providers/quota-types.ts +14 -0
- package/src/providers/quota.ts +21 -21
- package/src/providers/registry/entries-extended.ts +5 -3
- package/src/providers/request-pacing.ts +410 -12
- package/src/remote-control/workspace-codex-runtime.ts +4 -3
- package/src/responses/citation-markers.ts +132 -65
- package/src/responses/hosted-tool-policy.ts +14 -3
- package/src/responses/parser-content.ts +3 -2
- package/src/responses/parser.ts +4 -1
- package/src/responses/schema.ts +4 -0
- package/src/responses/visualization-directives.ts +182 -0
- package/src/server/chat-native-sse.ts +24 -12
- package/src/server/chat-native.ts +137 -68
- package/src/server/claude-messages.ts +147 -12
- package/src/server/index/link-listener.ts +7 -0
- package/src/server/index/optional-listeners.ts +4 -1
- package/src/server/index/package-tree-guard.ts +35 -16
- package/src/server/index/serve-options.ts +4 -2
- package/src/server/index/startup-warnings.ts +6 -0
- package/src/server/index.ts +7 -7
- package/src/server/inference/client-encoder-delivery.ts +3 -0
- package/src/server/inference/context.ts +30 -2
- package/src/server/local-account-switch-auth.ts +79 -0
- package/src/server/management/agent-settings-routes.ts +11 -3
- package/src/server/management/config-routes.ts +28 -11
- package/src/server/management/context.ts +2 -0
- package/src/server/management/link-routes.ts +123 -39
- package/src/server/management/logs-usage-routes.ts +9 -42
- package/src/server/management/model-rows.ts +1 -0
- package/src/server/management/oauth-account-routes.ts +67 -12
- package/src/server/management/route-registry.ts +5 -5
- package/src/server/management/sibling-guard.ts +60 -0
- package/src/server/management/storage-log-guard-routes.ts +72 -6
- package/src/server/management/system-restart.ts +92 -135
- package/src/server/management/system-routes.ts +1 -1
- package/src/server/management-api.ts +31 -3
- package/src/server/management-auth.ts +3 -0
- package/src/server/port-reclaim.ts +111 -1
- package/src/server/proxy-liveness.ts +16 -0
- package/src/server/relay.ts +16 -15
- package/src/server/request-log-filter.ts +28 -27
- package/src/server/request-log.ts +7 -3
- package/src/server/request-metrics.ts +28 -0
- package/src/server/responses/adapter-continuation.ts +151 -21
- package/src/server/responses/adapter-delivery.ts +21 -7
- package/src/server/responses/adapter-dispatch.ts +239 -51
- package/src/server/responses/codex-ws-pool.ts +5 -4
- package/src/server/responses/codex-ws-request.ts +4 -1
- package/src/server/responses/compact.ts +8 -4
- package/src/server/responses/compaction-recovery-policy.ts +116 -0
- package/src/server/responses/compaction-recovery.ts +330 -0
- package/src/server/responses/core-combo-failure.ts +1 -0
- package/src/server/responses/core-combo.ts +5 -1
- package/src/server/responses/core-lifetime.ts +22 -0
- package/src/server/responses/core-options.ts +16 -1
- package/src/server/responses/core.ts +14 -4
- package/src/server/responses/empty-completion-guard.ts +2 -0
- package/src/server/responses/fetch-helpers.ts +82 -31
- package/src/server/responses/passthrough-delivery.ts +3 -2
- package/src/server/responses/passthrough-dispatch.ts +12 -2
- package/src/server/responses/passthrough-execution.ts +2 -2
- package/src/server/responses/request-send-budget.ts +21 -5
- package/src/server/responses/request-transport.ts +116 -18
- package/src/server/responses/reset-replay.ts +32 -0
- package/src/server/responses/run-turn-execution.ts +136 -19
- package/src/server/responses/sidecar-execution.ts +41 -8
- package/src/server/responses/terminal-guard.ts +2 -0
- package/src/server/responses/ws-upstream.ts +44 -4
- package/src/server/restart-replacement.ts +382 -0
- package/src/server/startup-health-cache.ts +23 -4
- package/src/server/stop-teardown.ts +35 -1
- package/src/server/system-env.ts +3 -0
- package/src/service/windows-taskxml.ts +11 -7
- package/src/service/windows-wrapper-exit.ts +8 -0
- package/src/stall-timeout.ts +35 -10
- package/src/storage/policy-job.ts +4 -0
- package/src/storage/scanner.ts +177 -29
- package/src/storage/storage-mutation-coordinator.ts +17 -0
- package/src/tray/windows-tray.ps1 +50 -23
- package/src/types/config.ts +4 -0
- package/src/types/provider.ts +5 -1
- package/src/types/request.ts +13 -2
- package/src/update/index.ts +3 -1
- package/src/update/job.ts +6 -2
- package/src/update/mise-launcher-target.ts +100 -0
- package/src/update/worker-launch.ts +46 -0
- package/src/usage/log.ts +2 -0
- package/src/web-search/loop.ts +5 -2
- package/gui/dist/assets/App-BJsT8Icc.css +0 -1
- package/gui/dist/assets/App-D3pNGiN4.js +0 -51
- package/gui/dist/assets/Tray-yb05wCjb.js +0 -1
- package/gui/dist/assets/index-CouvvtMV.js +0 -86
- package/gui/dist/assets/tray-data-CYGdjCJ7.js +0 -1
package/src/codex/runtime.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { execFileSync } from "node:child_process";
|
|
1
|
+
import { execFile, execFileSync } from "node:child_process";
|
|
2
2
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, unlinkSync } from "node:fs";
|
|
3
3
|
import { homedir, tmpdir } from "node:os";
|
|
4
4
|
import { delimiter, join } from "node:path";
|
|
@@ -70,11 +70,18 @@ export type RuntimeExecFile = (
|
|
|
70
70
|
},
|
|
71
71
|
) => string;
|
|
72
72
|
|
|
73
|
+
/** Async twin of RuntimeExecFile, used by resolveCodexRuntimeAsync; resolves with stdout. */
|
|
74
|
+
export type RuntimeExecFileAsync = (
|
|
75
|
+
...args: Parameters<RuntimeExecFile>
|
|
76
|
+
) => Promise<string>;
|
|
77
|
+
|
|
73
78
|
export interface ResolveCodexRuntimeDeps {
|
|
74
79
|
env?: NodeJS.ProcessEnv;
|
|
75
80
|
platform?: NodeJS.Platform;
|
|
76
81
|
configDir?: string;
|
|
77
82
|
execFileSync?: RuntimeExecFile;
|
|
83
|
+
/** Probe exec for resolveCodexRuntimeAsync. Injected impls bypass the process memo like execFileSync. */
|
|
84
|
+
execFile?: RuntimeExecFileAsync;
|
|
78
85
|
existsSync?: (path: string) => boolean;
|
|
79
86
|
readFileSync?: (path: string, encoding: "utf8") => string;
|
|
80
87
|
now?: () => number;
|
|
@@ -397,10 +404,10 @@ export function clearPersistedCodexRuntime(deps: ResolveCodexRuntimeDeps = {}):
|
|
|
397
404
|
}
|
|
398
405
|
}
|
|
399
406
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
407
|
+
type VersionProbeResult = { ok: true; version: string | null } | { ok: false; reason: string };
|
|
408
|
+
|
|
409
|
+
/** Checks that need no exec; a non-null answer settles the candidate without running `--version`. */
|
|
410
|
+
function versionProbePrecheck(command: string, deps: ResolveCodexRuntimeDeps): VersionProbeResult | null {
|
|
404
411
|
const platform = deps.platform ?? process.platform;
|
|
405
412
|
if (command.includes("/") || command.includes("\\") || /^[A-Za-z]:/.test(command)) {
|
|
406
413
|
const exists = deps.existsSync ?? existsSync;
|
|
@@ -414,7 +421,54 @@ function probeVersion(
|
|
|
414
421
|
// dashboard probe that cost made Windows report Codex as missing even when
|
|
415
422
|
// the App install was sitting under LOCALAPPDATA/OpenAI/Codex/bin (issue 4458).
|
|
416
423
|
if (deps.probeVersion === false) return { ok: true, version: null };
|
|
417
|
-
|
|
424
|
+
return null;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function versionProbeInvocation(command: string, deps: ResolveCodexRuntimeDeps, probeHome: string) {
|
|
428
|
+
const invocation = codexExecInvocation(command, ["--version"], deps.platform ?? process.platform, {
|
|
429
|
+
env: deps.env,
|
|
430
|
+
exists: deps.existsSync,
|
|
431
|
+
});
|
|
432
|
+
return {
|
|
433
|
+
file: invocation.file,
|
|
434
|
+
args: invocation.args,
|
|
435
|
+
options: {
|
|
436
|
+
encoding: "utf8" as const,
|
|
437
|
+
stdio: ["ignore", "pipe", "ignore"] as ["ignore", "pipe", "ignore"],
|
|
438
|
+
timeout: 8_000,
|
|
439
|
+
windowsHide: true,
|
|
440
|
+
env: { ...(deps.env ?? process.env), CODEX_HOME: probeHome },
|
|
441
|
+
...invocation.options,
|
|
442
|
+
},
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function versionProbeOutput(output: string): VersionProbeResult {
|
|
447
|
+
const version = parseCodexVersionOutput(output);
|
|
448
|
+
if (!version) return { ok: false, reason: "unrecognized --version output" };
|
|
449
|
+
return { ok: true, version };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function versionProbeError(error: unknown, probeHome: string | undefined): VersionProbeResult {
|
|
453
|
+
if (!probeHome) return { ok: false, reason: "probe sandbox unavailable" };
|
|
454
|
+
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
|
|
455
|
+
return { ok: false, reason: CODEX_PROGRAM_NOT_FOUND_REASON };
|
|
456
|
+
}
|
|
457
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
458
|
+
const redacted = redactUserPath(redactSecretString(message)).slice(0, 160);
|
|
459
|
+
return { ok: false, reason: `failed --version (${redacted})` };
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function removeProbeHome(probeHome: string | undefined): void {
|
|
463
|
+
if (!probeHome) return;
|
|
464
|
+
// Nested catch: a transient Windows EBUSY must never mask the probe result.
|
|
465
|
+
try { rmSync(probeHome, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function probeVersion(command: string, deps: ResolveCodexRuntimeDeps): VersionProbeResult {
|
|
469
|
+
const settled = versionProbePrecheck(command, deps);
|
|
470
|
+
if (settled) return settled;
|
|
471
|
+
const exec = deps.execFileSync ?? (execFileSync as unknown as RuntimeExecFile);
|
|
418
472
|
// Sandbox the probe's CODEX_HOME: a real Codex CLI creates state (tmp/, logs) under
|
|
419
473
|
// CODEX_HOME even for `--version`, and the probe inherits the caller's env — so a
|
|
420
474
|
// read-only `ocx status` would dirty the user's CODEX_HOME. Redirect it to a
|
|
@@ -423,36 +477,36 @@ function probeVersion(
|
|
|
423
477
|
let probeHome: string | undefined;
|
|
424
478
|
try {
|
|
425
479
|
probeHome = mkdtempSync(join(tmpdir(), "ocx-codex-probe-"));
|
|
426
|
-
const
|
|
427
|
-
|
|
428
|
-
exists: deps.existsSync,
|
|
429
|
-
});
|
|
430
|
-
const output = execFile(invocation.file, invocation.args, {
|
|
431
|
-
encoding: "utf8",
|
|
432
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
433
|
-
timeout: 8_000,
|
|
434
|
-
windowsHide: true,
|
|
435
|
-
env: { ...(deps.env ?? process.env), CODEX_HOME: probeHome },
|
|
436
|
-
...invocation.options,
|
|
437
|
-
});
|
|
438
|
-
const version = parseCodexVersionOutput(output);
|
|
439
|
-
if (!version) {
|
|
440
|
-
return { ok: false, reason: "unrecognized --version output" };
|
|
441
|
-
}
|
|
442
|
-
return { ok: true, version };
|
|
480
|
+
const { file, args, options } = versionProbeInvocation(command, deps, probeHome);
|
|
481
|
+
return versionProbeOutput(exec(file, args, options));
|
|
443
482
|
} catch (error) {
|
|
444
|
-
|
|
445
|
-
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
|
|
446
|
-
return { ok: false, reason: CODEX_PROGRAM_NOT_FOUND_REASON };
|
|
447
|
-
}
|
|
448
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
449
|
-
const redacted = redactUserPath(redactSecretString(message)).slice(0, 160);
|
|
450
|
-
return { ok: false, reason: `failed --version (${redacted})` };
|
|
483
|
+
return versionProbeError(error, probeHome);
|
|
451
484
|
} finally {
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
485
|
+
removeProbeHome(probeHome);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const defaultExecFileAsync: RuntimeExecFileAsync = (file, args, options) =>
|
|
490
|
+
new Promise((resolve, reject) => {
|
|
491
|
+
// execFile always pipes stdout/stderr; it takes no stdio option.
|
|
492
|
+
const { stdio: _stdio, ...execOptions } = options;
|
|
493
|
+
execFile(file, args, execOptions, (error, stdout) => (error ? reject(error) : resolve(String(stdout))));
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
/** probeVersion with the same sandbox and outcomes, but the exec never holds the event loop. */
|
|
497
|
+
async function probeVersionAsync(command: string, deps: ResolveCodexRuntimeDeps): Promise<VersionProbeResult> {
|
|
498
|
+
const settled = versionProbePrecheck(command, deps);
|
|
499
|
+
if (settled) return settled;
|
|
500
|
+
const exec = deps.execFile ?? defaultExecFileAsync;
|
|
501
|
+
let probeHome: string | undefined;
|
|
502
|
+
try {
|
|
503
|
+
probeHome = mkdtempSync(join(tmpdir(), "ocx-codex-probe-"));
|
|
504
|
+
const { file, args, options } = versionProbeInvocation(command, deps, probeHome);
|
|
505
|
+
return versionProbeOutput(await exec(file, args, options));
|
|
506
|
+
} catch (error) {
|
|
507
|
+
return versionProbeError(error, probeHome);
|
|
508
|
+
} finally {
|
|
509
|
+
removeProbeHome(probeHome);
|
|
456
510
|
}
|
|
457
511
|
}
|
|
458
512
|
|
|
@@ -566,23 +620,6 @@ interface RankedCandidate {
|
|
|
566
620
|
source: CodexRuntimeSource;
|
|
567
621
|
}
|
|
568
622
|
|
|
569
|
-
function tryCandidate(
|
|
570
|
-
candidate: RankedCandidate,
|
|
571
|
-
failures: RuntimeProbeFailure[],
|
|
572
|
-
deps: ResolveCodexRuntimeDeps,
|
|
573
|
-
): ResolvedCodexRuntime | null {
|
|
574
|
-
const probed = probeVersion(candidate.command, deps);
|
|
575
|
-
if (!probed.ok) {
|
|
576
|
-
failures.push({ command: candidate.command, source: candidate.source, reason: probed.reason });
|
|
577
|
-
return null;
|
|
578
|
-
}
|
|
579
|
-
return {
|
|
580
|
-
command: candidate.command,
|
|
581
|
-
version: probed.version,
|
|
582
|
-
source: candidate.source,
|
|
583
|
-
};
|
|
584
|
-
}
|
|
585
|
-
|
|
586
623
|
function sameRuntimeCommand(a: string, b: string): boolean {
|
|
587
624
|
return a.trim().toLowerCase() === b.trim().toLowerCase();
|
|
588
625
|
}
|
|
@@ -704,10 +741,26 @@ export function clearCodexRuntimeResolveCache(): void {
|
|
|
704
741
|
clearResolveCache();
|
|
705
742
|
}
|
|
706
743
|
|
|
707
|
-
/**
|
|
744
|
+
/**
|
|
745
|
+
* Observe the successful process memo; never resolves or probes.
|
|
746
|
+
*
|
|
747
|
+
* An expired memo stays observable while an async refresh for the same inputs is in flight
|
|
748
|
+
* and nothing has invalidated it since. The sync resolver used to hold the event loop for the
|
|
749
|
+
* whole probe, so nothing could read in that window; with the refresh off the loop, dropping
|
|
750
|
+
* the memo there would send catalog gather to the persisted runtime (or none) and have
|
|
751
|
+
* convergence reject its process-local candidate every expiry. A published result or a clear
|
|
752
|
+
* still retires it; the deferred selection never enters this memo (#4458).
|
|
753
|
+
*/
|
|
708
754
|
export function peekCodexRuntimeProcessCache(): CodexRuntimeProcessCachePeek {
|
|
709
755
|
const memo = resolveCache;
|
|
710
|
-
|
|
756
|
+
const refreshing = memo !== null
|
|
757
|
+
&& asyncResolveInflight !== null
|
|
758
|
+
&& asyncResolveInflight.key === memo.key
|
|
759
|
+
&& asyncResolveInflight.epoch === resolveCacheEpoch
|
|
760
|
+
// An expired memo only stands in for a refresh of the same selection; an out-of-process
|
|
761
|
+
// runtime switch changes the key without bumping the epoch.
|
|
762
|
+
&& resolveCacheKey({}) === memo.key;
|
|
763
|
+
if (!memo || (Date.now() - memo.at >= RESOLVE_CACHE_MS && !refreshing)) {
|
|
711
764
|
return Object.freeze({ kind: "unavailable" as const, epoch: resolveCacheEpoch });
|
|
712
765
|
}
|
|
713
766
|
return Object.freeze({
|
|
@@ -735,6 +788,7 @@ function resolveCacheKey(deps: ResolveCodexRuntimeDeps): string | null {
|
|
|
735
788
|
// Only memoize uninjected process-env resolves (settings/status hot paths).
|
|
736
789
|
if (
|
|
737
790
|
deps.execFileSync
|
|
791
|
+
|| deps.execFile
|
|
738
792
|
|| deps.existsSync
|
|
739
793
|
|| deps.readFileSync
|
|
740
794
|
|| deps.readdirSync
|
|
@@ -799,6 +853,59 @@ export function resolveCodexRuntime(deps: ResolveCodexRuntimeDeps = {}): Resolve
|
|
|
799
853
|
return cloneAndDeepFreeze(result);
|
|
800
854
|
}
|
|
801
855
|
|
|
856
|
+
let asyncResolveInflight: { key: string; epoch: number; promise: Promise<ResolveCodexRuntimeResult> } | null = null;
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* resolveCodexRuntime for server request paths: same selection and process memo, but every
|
|
860
|
+
* `codex --version` runs through async exec. The sync resolver blocked the whole proxy for
|
|
861
|
+
* ~0.6s per memo expiry while the dashboard polled settings, stalling unrelated requests.
|
|
862
|
+
*/
|
|
863
|
+
export async function resolveCodexRuntimeAsync(
|
|
864
|
+
deps: ResolveCodexRuntimeDeps = {},
|
|
865
|
+
): Promise<ResolveCodexRuntimeResult> {
|
|
866
|
+
// A deferred selection never execs, so the sync path is already nonblocking.
|
|
867
|
+
if (deps.probeVersion === false) return resolveCodexRuntime(deps);
|
|
868
|
+
const cacheKey = resolveCacheKey(deps);
|
|
869
|
+
if (cacheKey && resolveCache && resolveCache.key === cacheKey && Date.now() - resolveCache.at < RESOLVE_CACHE_MS) {
|
|
870
|
+
return cloneAndDeepFreeze(resolveCache.value);
|
|
871
|
+
}
|
|
872
|
+
if (!cacheKey) return cloneAndDeepFreeze(await resolveCodexRuntimeUncachedAsync(deps));
|
|
873
|
+
const startedEpoch = resolveCacheEpoch;
|
|
874
|
+
if (asyncResolveInflight?.key === cacheKey && asyncResolveInflight.epoch === startedEpoch) {
|
|
875
|
+
return cloneAndDeepFreeze(await asyncResolveInflight.promise);
|
|
876
|
+
}
|
|
877
|
+
const promise = resolveCodexRuntimeUncachedAsync(deps).then(result => {
|
|
878
|
+
// A persist or clear while the probes ran makes this answer the previous selection's;
|
|
879
|
+
// publishing it would resurrect authority the write just revoked. Another process can also
|
|
880
|
+
// rewrite codex-runtime.json without touching this epoch, so the key is read again too.
|
|
881
|
+
if (resolveCacheEpoch === startedEpoch && resolveCacheKey(deps) === cacheKey) {
|
|
882
|
+
publishResolveCache(cacheKey, Date.now(), result);
|
|
883
|
+
}
|
|
884
|
+
return result;
|
|
885
|
+
}).finally(() => {
|
|
886
|
+
if (asyncResolveInflight?.promise === promise) asyncResolveInflight = null;
|
|
887
|
+
});
|
|
888
|
+
asyncResolveInflight = { key: cacheKey, epoch: startedEpoch, promise };
|
|
889
|
+
return cloneAndDeepFreeze(await promise);
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* Stale-while-revalidate runtime read for hot UI paths; never waits on a probe.
|
|
894
|
+
*
|
|
895
|
+
* Returns the process memo when fresh, otherwise the last memo for the same inputs, and kicks
|
|
896
|
+
* resolveCodexRuntimeAsync to refresh it. With no memo yet (cold start, or right after a runtime
|
|
897
|
+
* switch cleared it) the answer is the exec-free deferred selection, so a version and
|
|
898
|
+
* newerAvailable appear once the first background refresh lands.
|
|
899
|
+
*/
|
|
900
|
+
export function getCodexRuntimeSnapshot(): ResolveCodexRuntimeResult {
|
|
901
|
+
const cacheKey = resolveCacheKey({});
|
|
902
|
+
const memo = resolveCache?.key === cacheKey ? resolveCache : null;
|
|
903
|
+
if (memo && Date.now() - memo.at < RESOLVE_CACHE_MS) return cloneAndDeepFreeze(memo.value);
|
|
904
|
+
resolveCodexRuntimeAsync().catch(() => { /* the next read retries */ });
|
|
905
|
+
if (memo) return cloneAndDeepFreeze(memo.value);
|
|
906
|
+
return resolveCodexRuntime({ discoverAlternatives: false, probeVersion: false });
|
|
907
|
+
}
|
|
908
|
+
|
|
802
909
|
/** Test-only: drop the short-lived process resolve cache. */
|
|
803
910
|
export function resetCodexRuntimeResolveCacheForTests(): void {
|
|
804
911
|
clearCodexRuntimeResolveCache();
|
|
@@ -814,7 +921,14 @@ export function setCodexRuntimeResolveCacheForTests(
|
|
|
814
921
|
publishResolveCache(key, Date.now(), value);
|
|
815
922
|
}
|
|
816
923
|
|
|
817
|
-
|
|
924
|
+
/**
|
|
925
|
+
* Candidate ranking and selection, written once for both drivers: each yield hands a candidate to
|
|
926
|
+
* the caller, which sends back its `--version` outcome. The sync driver keeps CLI behavior; the
|
|
927
|
+
* async one lets a server path resolve without blocking every other request on the probes.
|
|
928
|
+
*/
|
|
929
|
+
function* resolveCodexRuntimeSteps(
|
|
930
|
+
deps: ResolveCodexRuntimeDeps,
|
|
931
|
+
): Generator<RankedCandidate, ResolveCodexRuntimeResult, VersionProbeResult> {
|
|
818
932
|
const env = deps.env ?? process.env;
|
|
819
933
|
const failures: RuntimeProbeFailure[] = [];
|
|
820
934
|
const ordered: RankedCandidate[] = [];
|
|
@@ -868,9 +982,12 @@ function resolveCodexRuntimeUncached(deps: ResolveCodexRuntimeDeps = {}): Resolv
|
|
|
868
982
|
) {
|
|
869
983
|
continue;
|
|
870
984
|
}
|
|
871
|
-
const
|
|
872
|
-
if (!
|
|
873
|
-
|
|
985
|
+
const probed: VersionProbeResult = yield candidate;
|
|
986
|
+
if (!probed.ok) {
|
|
987
|
+
failures.push({ command: candidate.command, source: candidate.source, reason: probed.reason });
|
|
988
|
+
continue;
|
|
989
|
+
}
|
|
990
|
+
valid.push({ command: candidate.command, version: probed.version, source: candidate.source });
|
|
874
991
|
}
|
|
875
992
|
|
|
876
993
|
if (valid.length === 0) {
|
|
@@ -954,6 +1071,20 @@ function resolveCodexRuntimeUncached(deps: ResolveCodexRuntimeDeps = {}): Resolv
|
|
|
954
1071
|
};
|
|
955
1072
|
}
|
|
956
1073
|
|
|
1074
|
+
function resolveCodexRuntimeUncached(deps: ResolveCodexRuntimeDeps = {}): ResolveCodexRuntimeResult {
|
|
1075
|
+
const steps = resolveCodexRuntimeSteps(deps);
|
|
1076
|
+
let step = steps.next();
|
|
1077
|
+
while (!step.done) step = steps.next(probeVersion(step.value.command, deps));
|
|
1078
|
+
return step.value;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
async function resolveCodexRuntimeUncachedAsync(deps: ResolveCodexRuntimeDeps): Promise<ResolveCodexRuntimeResult> {
|
|
1082
|
+
const steps = resolveCodexRuntimeSteps(deps);
|
|
1083
|
+
let step = steps.next();
|
|
1084
|
+
while (!step.done) step = steps.next(await probeVersionAsync(step.value.command, deps));
|
|
1085
|
+
return step.value;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
957
1088
|
/** Resolve and persist a successful selection (unless source is ephemeral fallback-only with no path). */
|
|
958
1089
|
export function resolveAndPersistCodexRuntime(
|
|
959
1090
|
deps: ResolveCodexRuntimeDeps = {},
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/** A one-use restart handoff from a running sibling to its own replacement. */
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { lstatSync, readFileSync, realpathSync, renameSync, unlinkSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { atomicWriteFile } from "../config/atomic-write";
|
|
6
|
+
import { getConfigDir } from "../config/paths";
|
|
7
|
+
import { readRuntimePort } from "../config/process-state";
|
|
8
|
+
import { assertNotRealHomeUnderTest } from "../lib/test-home-guard";
|
|
9
|
+
import { siblingOfLivePort } from "./sibling-start";
|
|
10
|
+
|
|
11
|
+
const NONCE_RE = /^[A-Za-z0-9_-]{43}$/;
|
|
12
|
+
const MAX_HANDOFF_AGE_MS = 2 * 60_000;
|
|
13
|
+
const MAX_RECORD_BYTES = 512;
|
|
14
|
+
|
|
15
|
+
function handoffPath(home: string, nonce: string): string {
|
|
16
|
+
return join(home, `sibling-handoff-${nonce}.json`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Issue only while this home's runtime record still identifies the running sibling. */
|
|
20
|
+
export function issueSiblingHandoff(ownerPort: number): string {
|
|
21
|
+
const home = getConfigDir();
|
|
22
|
+
assertNotRealHomeUnderTest(home);
|
|
23
|
+
const runtime = readRuntimePort(process.pid);
|
|
24
|
+
if (siblingOfLivePort() !== ownerPort || !runtime
|
|
25
|
+
|| runtime.siblingOfPort !== ownerPort || runtime.port === ownerPort) {
|
|
26
|
+
throw new Error("Cannot hand off sibling status without this home's live sibling record.");
|
|
27
|
+
}
|
|
28
|
+
const nonce = randomBytes(32).toString("base64url");
|
|
29
|
+
const record = {
|
|
30
|
+
version: 1,
|
|
31
|
+
nonce,
|
|
32
|
+
home: realpathSync(home),
|
|
33
|
+
issuerPid: process.pid,
|
|
34
|
+
issuerPort: runtime.port,
|
|
35
|
+
ownerPort,
|
|
36
|
+
issuedAt: Date.now(),
|
|
37
|
+
};
|
|
38
|
+
atomicWriteFile(handoffPath(home, nonce), JSON.stringify(record));
|
|
39
|
+
return nonce;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Atomically claim and consume the record; a copied env cannot replay it. */
|
|
43
|
+
export function consumeSiblingHandoff(ownerPort: number, nonce: string | undefined): boolean {
|
|
44
|
+
if (!nonce || !NONCE_RE.test(nonce)) return false;
|
|
45
|
+
const home = getConfigDir();
|
|
46
|
+
let canonicalHome: string;
|
|
47
|
+
try { canonicalHome = realpathSync(home); }
|
|
48
|
+
catch { return false; }
|
|
49
|
+
const source = handoffPath(home, nonce);
|
|
50
|
+
const claimed = `${source}.claimed-${process.pid}`;
|
|
51
|
+
try { renameSync(source, claimed); }
|
|
52
|
+
catch { return false; }
|
|
53
|
+
try {
|
|
54
|
+
const stat = lstatSync(claimed);
|
|
55
|
+
if (!stat.isFile() || stat.size > MAX_RECORD_BYTES || stat.size === 0
|
|
56
|
+
|| (process.platform !== "win32" && ((stat.mode & 0o077) !== 0 || stat.uid !== process.getuid?.()))) return false;
|
|
57
|
+
const record = JSON.parse(readFileSync(claimed, "utf8")) as Record<string, unknown>;
|
|
58
|
+
const issuedAt = record.issuedAt;
|
|
59
|
+
const now = Date.now();
|
|
60
|
+
return record.version === 1 && record.nonce === nonce && record.home === canonicalHome
|
|
61
|
+
&& record.ownerPort === ownerPort && Number.isSafeInteger(record.issuerPid)
|
|
62
|
+
&& Number(record.issuerPid) > 0 && Number.isInteger(record.issuerPort)
|
|
63
|
+
&& Number(record.issuerPort) > 0 && Number(record.issuerPort) <= 65535
|
|
64
|
+
&& record.issuerPort !== ownerPort && typeof issuedAt === "number"
|
|
65
|
+
&& issuedAt <= now && now - issuedAt <= MAX_HANDOFF_AGE_MS;
|
|
66
|
+
} catch {
|
|
67
|
+
return false;
|
|
68
|
+
} finally {
|
|
69
|
+
try { unlinkSync(claimed); } catch { /* already consumed */ }
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The process-local "sibling instance" mark.
|
|
3
|
+
*
|
|
4
|
+
* A sibling is what `decideStartWithLiveOwner` calls `"sibling"`: `ocx start --port <other>` while a
|
|
5
|
+
* live proxy already serves the configured port. Only an independent `OPENCODEX_HOME` gets past the
|
|
6
|
+
* state-directory spend-ledger lease, and that independence ends at its own directory: the sibling
|
|
7
|
+
* still shares `CODEX_HOME`, `~/.claude`, `~/.grok`, the shell rc hook and the launchd domain with
|
|
8
|
+
* the live owner. A sibling that ran the ordinary startup sync therefore re-pointed Codex at its own
|
|
9
|
+
* port, and every thread broke with "Connection refused" once the sibling exited or was killed. The
|
|
10
|
+
* owner's client routing is the owner's, for this process's whole lifetime.
|
|
11
|
+
*
|
|
12
|
+
* So `handleStart` sets this mark the moment it takes the sibling path, before the server binds and
|
|
13
|
+
* before any client write, and the local-client gate (`localClientSyncAllowed` in
|
|
14
|
+
* `desired-state.ts`), the restore entry points, the catalog convergence funnel, the owned-catalog
|
|
15
|
+
* refresh, the Claude writers, the stop teardown and the management route guard all consult it. The
|
|
16
|
+
* sibling still serves direct requests on its own port.
|
|
17
|
+
*
|
|
18
|
+
* One-way and process-local on purpose. A mark that could be cleared would let a later code path
|
|
19
|
+
* resume writing mid-lifetime, and a persisted one would outlive the process it describes. Worker
|
|
20
|
+
* threads do not see it; that is acceptable because history jobs start only from inject and
|
|
21
|
+
* restore, and both return before spawning one.
|
|
22
|
+
*
|
|
23
|
+
* A sibling runs the no-op native-main lifecycle (the gate is closed), so it never contends for the
|
|
24
|
+
* native-main owner lease and cannot take ownership from the live proxy across that proxy's
|
|
25
|
+
* restarts. Its data-plane native-main admission is the same as a Codex-OFF proxy's: an `auth.json`
|
|
26
|
+
* refresh still runs under the machine-wide exclusive claim. The native-main mutation routes are
|
|
27
|
+
* refused by the management guard instead.
|
|
28
|
+
*
|
|
29
|
+
* The one hand-off is to this process's own replacement. A sibling's dashboard drain-and-restart and
|
|
30
|
+
* its standalone recycle spawn a fresh `ocx start` that re-probes; if the owner is down for that
|
|
31
|
+
* moment, the replacement used to start as an unmarked owner, re-point Codex at itself and persist
|
|
32
|
+
* `config.port`. Those spawns carry a port plus a one-use record nonce from `sibling-handoff.ts`.
|
|
33
|
+
* `handleStart` consumes the record before any probe; an env port alone grants nothing.
|
|
34
|
+
*
|
|
35
|
+
* Deliberately import-free: the gate, the server and the CLI all read it, and a leaf cannot form a
|
|
36
|
+
* cycle.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
let livePort: number | null = null;
|
|
40
|
+
|
|
41
|
+
/** Mark this process as a sibling of the proxy serving `port`. Idempotent; never unset. */
|
|
42
|
+
export function markSiblingStart(port: number): void {
|
|
43
|
+
livePort = port;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The live owner's port when this process is a sibling, otherwise `null`. */
|
|
47
|
+
export function siblingOfLivePort(): number | null {
|
|
48
|
+
return livePort;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Test seam only: production never clears the mark. */
|
|
52
|
+
export function resetSiblingStartForTests(): void {
|
|
53
|
+
livePort = null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The one line a sibling prints wherever it declines a shared client write.
|
|
58
|
+
*
|
|
59
|
+
* With the sibling's own port it describes the split; without one (a skip deep inside a writer
|
|
60
|
+
* that does not know the port) it names what was left alone.
|
|
61
|
+
*/
|
|
62
|
+
export function siblingSkipMessage(ownPort?: number): string {
|
|
63
|
+
const live = livePort ?? "unknown";
|
|
64
|
+
return ownPort === undefined
|
|
65
|
+
? `Client routing stays on the proxy at port ${live}; this instance does not rewrite Codex, Grok or Claude configs.`
|
|
66
|
+
: `Client routing stays on the proxy at port ${live}; this instance serves direct requests on port ${ownPort} only.`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The optional runtime-record field that tells a later `ocx stop` it is stopping a sibling.
|
|
71
|
+
* Empty when unmarked, so a non-sibling record stays byte-identical.
|
|
72
|
+
*/
|
|
73
|
+
export function siblingRuntimeField(): { siblingOfPort?: number } {
|
|
74
|
+
return livePort === null ? {} : { siblingOfPort: livePort };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The env var a sibling hands its own replacement `ocx start`: the live owner's port. */
|
|
78
|
+
export const SIBLING_OF_PORT_ENV = "OCX_SIBLING_OF_PORT";
|
|
79
|
+
export const SIBLING_HANDOFF_NONCE_ENV = "OCX_SIBLING_HANDOFF_NONCE";
|
|
80
|
+
|
|
81
|
+
type Env = Record<string, string | undefined>;
|
|
82
|
+
|
|
83
|
+
/** The owner port an inherited marker names, or `null` when absent or not a TCP port. */
|
|
84
|
+
export function parseSiblingMarker(raw: string | undefined): number | null {
|
|
85
|
+
const trimmed = raw?.trim() ?? "";
|
|
86
|
+
if (!/^[0-9]{1,5}$/.test(trimmed)) return null;
|
|
87
|
+
const port = Number(trimmed);
|
|
88
|
+
return port >= 1 && port <= 65535 ? port : null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Honor a port only when its one-use handoff record was consumed. Both env fields are stripped
|
|
93
|
+
* before any probe, including on refusal, so a later detached child cannot inherit them.
|
|
94
|
+
*/
|
|
95
|
+
export function honorSiblingMarker(env: Env, consumeHandoff: (port: number, nonce: string | undefined) => boolean): number | null {
|
|
96
|
+
const port = parseSiblingMarker(env[SIBLING_OF_PORT_ENV]);
|
|
97
|
+
const nonce = env[SIBLING_HANDOFF_NONCE_ENV];
|
|
98
|
+
delete env[SIBLING_OF_PORT_ENV];
|
|
99
|
+
delete env[SIBLING_HANDOFF_NONCE_ENV];
|
|
100
|
+
if (port === null || !consumeHandoff(port, nonce)) return null;
|
|
101
|
+
markSiblingStart(port);
|
|
102
|
+
return port;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** A copy of `env` for this process's own replacement, with a one-use handoff when marked. */
|
|
106
|
+
export function withSiblingMarker<T extends Env>(env: T, issueHandoff?: (port: number) => string): T {
|
|
107
|
+
const next: Env = withoutSiblingMarker(env);
|
|
108
|
+
if (livePort !== null) {
|
|
109
|
+
if (!issueHandoff) throw new Error("Sibling replacement requires a one-use handoff issuer.");
|
|
110
|
+
next[SIBLING_OF_PORT_ENV] = String(livePort);
|
|
111
|
+
next[SIBLING_HANDOFF_NONCE_ENV] = issueHandoff(livePort);
|
|
112
|
+
}
|
|
113
|
+
return next as T;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** A copy of `env` for a child that must start as an ordinary owner (`ocx ensure`, the tray). */
|
|
117
|
+
export function withoutSiblingMarker<T extends Env>(env: T): T {
|
|
118
|
+
const next: Env = { ...env };
|
|
119
|
+
delete next[SIBLING_OF_PORT_ENV];
|
|
120
|
+
delete next[SIBLING_HANDOFF_NONCE_ENV];
|
|
121
|
+
return next as T;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Whether `ocx stop` of a sibling runtime found the live owner instead of the sibling.
|
|
126
|
+
*
|
|
127
|
+
* A hard-killed sibling leaves its record behind with a dead pid, so the stop falls through to
|
|
128
|
+
* discovery, and discovery ends on the configured port, where the live owner answers. Stopping that
|
|
129
|
+
* proxy is the outage the sibling design exists to prevent. The found proxy is the owner when it
|
|
130
|
+
* answers on `siblingOfPort`, or when only the configured-port fallback reached it (the sibling's
|
|
131
|
+
* own record did not answer). `live` is `findLiveProxy`'s result.
|
|
132
|
+
*/
|
|
133
|
+
export function siblingStopFoundOwner(
|
|
134
|
+
siblingOfPort: number | undefined,
|
|
135
|
+
live: { port: number; source: "runtime" | "config" } | null,
|
|
136
|
+
): boolean {
|
|
137
|
+
if (siblingOfPort === undefined || live === null) return false;
|
|
138
|
+
return live.port === siblingOfPort || live.source === "config";
|
|
139
|
+
}
|
package/src/codex/sync.ts
CHANGED
|
@@ -284,9 +284,11 @@ export async function syncModelsToCodex(
|
|
|
284
284
|
if (result.status === "skipped") {
|
|
285
285
|
return {
|
|
286
286
|
status: "skipped",
|
|
287
|
-
// The apply direction's only
|
|
288
|
-
// carry whichever the injector reported so the caller can say the honest thing.
|
|
289
|
-
skippedReason: result.skippedReason === "hub-gated"
|
|
287
|
+
// The apply direction's only policy skips are desired OFF, the hub gate and a sibling
|
|
288
|
+
// instance; carry whichever the injector reported so the caller can say the honest thing.
|
|
289
|
+
skippedReason: result.skippedReason === "hub-gated" || result.skippedReason === "sibling"
|
|
290
|
+
? result.skippedReason
|
|
291
|
+
: "desired_disabled",
|
|
290
292
|
ok: true,
|
|
291
293
|
added: 0,
|
|
292
294
|
catalogPath: null,
|
package/src/combos/failover.ts
CHANGED
|
@@ -230,11 +230,17 @@ export function coolComboTarget(
|
|
|
230
230
|
const cooldownMs = serverDelayMs
|
|
231
231
|
?? parseResetCooldownMs(options?.resetAt, now)
|
|
232
232
|
?? options?.cooldownMs
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
233
|
+
// A spent account window or an unpaid/rejected credential does not turn over in a minute,
|
|
234
|
+
// so the 60s default would re-offer a target that cannot succeed. Only the duration
|
|
235
|
+
// changes; the scope and hop decisions are untouched.
|
|
236
|
+
?? (isAccountWindowExhausted(options?.message ?? "", options?.code)
|
|
237
|
+
|| PROVIDER_SCOPED_FAILURE_CODES.has(normalizedFailureCode(options?.code))
|
|
238
|
+
? MAX_COOLDOWN_MS
|
|
239
|
+
: isTransientRequestRateLimit({
|
|
240
|
+
status: options?.status,
|
|
241
|
+
code: options?.code,
|
|
242
|
+
message: options?.message,
|
|
243
|
+
}) ? COMBO_REQUEST_RATE_COOLDOWN_MS : DEFAULT_COOLDOWN_MS);
|
|
238
244
|
targetCooldowns.set(cooldownMapKey(comboId, target), {
|
|
239
245
|
// Local fallbacks are capped at ten minutes; explicit server delays at one day.
|
|
240
246
|
cooldownUntil: now + (serverDelayMs ?? Math.min(Math.max(cooldownMs, 1), MAX_COOLDOWN_MS)),
|
|
@@ -306,6 +312,27 @@ function normalizedFailureCode(code?: string | null): string {
|
|
|
306
312
|
return code?.trim().toLowerCase().replaceAll("-", "_") ?? "";
|
|
307
313
|
}
|
|
308
314
|
|
|
315
|
+
/**
|
|
316
|
+
* A spent account window, by structured code or upstream prose. Status is deliberately not
|
|
317
|
+
* consulted: the ChatGPT Codex backend reports a depleted plan window as HTTP 502
|
|
318
|
+
* `upstream_server_error` carrying `The usage limit has been reached`, never the documented 429,
|
|
319
|
+
* so any status gate misses it. Read in exactly ONE place -- the cooldown DURATION fallback. A
|
|
320
|
+
* status-blind prose match is safe for choosing how long to wait; it is not safe for choosing
|
|
321
|
+
* what to black out, so `isProviderScopedQuotaCap` and the scope/decision paths stay untouched
|
|
322
|
+
* and a Codex 502 still resolves `target` scope and `hop` through `status >= 500`.
|
|
323
|
+
*/
|
|
324
|
+
// `1308` is the vendor code for a spent five-hour window and carries no prose of its own when the
|
|
325
|
+
// upstream reports it bare, so it belongs here too. The rest of QUOTA_LIMIT_CODES stays out: those
|
|
326
|
+
// are quota-limit codes whose window length this gateway has no evidence for, and guessing long on
|
|
327
|
+
// them would hold a target that may clear sooner.
|
|
328
|
+
const ACCOUNT_EXHAUSTION_CODES = new Set(["usage_limit_exceeded", "usage_limit_reached", "1308"]);
|
|
329
|
+
const ACCOUNT_EXHAUSTION_TEXT = /usage limit (?:has been )?reached/;
|
|
330
|
+
|
|
331
|
+
function isAccountWindowExhausted(message: string, code?: string | null): boolean {
|
|
332
|
+
return ACCOUNT_EXHAUSTION_CODES.has(normalizedFailureCode(code))
|
|
333
|
+
|| ACCOUNT_EXHAUSTION_TEXT.test(message.toLowerCase());
|
|
334
|
+
}
|
|
335
|
+
|
|
309
336
|
function isProviderScopedQuotaCap(
|
|
310
337
|
status: number | undefined,
|
|
311
338
|
message: string,
|
package/src/combos/request.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { OcxComboDefaultEffort, OcxComboDefaultEffortMode, OcxComboReasoningEffortMode, OcxComboTarget, OcxConfig } from "../types";
|
|
2
|
-
import { isCodexReasoningEffort, resolveEffortAtOrBelow } from "../reasoning-effort";
|
|
2
|
+
import { isCodexReasoningEffort, isDeclaredReasoningEffort, resolveEffortAtOrBelow } from "../reasoning-effort";
|
|
3
3
|
import { resolveComboId } from "./types";
|
|
4
4
|
|
|
5
5
|
const warnedUnsupportedDefaults = new Set<string>();
|
|
@@ -54,6 +54,11 @@ function responsesInputNodeHasImage(value: unknown): boolean {
|
|
|
54
54
|
return false;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Clone a logical combo request for one concrete target and apply the combo's reasoning policy.
|
|
59
|
+
* Force mode overrides every valid declared caller effort only when the target ladder proves the
|
|
60
|
+
* configured default is representable; unknown capabilities stay conservative.
|
|
61
|
+
*/
|
|
57
62
|
export function concreteComboRequestBody(
|
|
58
63
|
body: unknown,
|
|
59
64
|
target: Pick<OcxComboTarget, "provider" | "model">,
|
|
@@ -79,9 +84,12 @@ export function concreteComboRequestBody(
|
|
|
79
84
|
const hasEffort = reasoningRecord !== undefined
|
|
80
85
|
&& Object.prototype.hasOwnProperty.call(reasoningRecord, "effort");
|
|
81
86
|
const callerEffort = reasoningRecord?.effort;
|
|
82
|
-
const
|
|
87
|
+
const declaredCallerEffort = typeof callerEffort === "string" && isDeclaredReasoningEffort(callerEffort);
|
|
83
88
|
const needsDefault = reasoning === undefined || (reasoningRecord !== undefined && !hasEffort);
|
|
84
|
-
|
|
89
|
+
// `none` and `minimal` are valid request sentinels even though they are outside the ranked
|
|
90
|
+
// low..ultra Codex ladder. Force mode means the combo policy wins over any valid declared
|
|
91
|
+
// caller effort, including those sentinels.
|
|
92
|
+
const shouldForce = defaultEffortMode === "force" && declaredCallerEffort;
|
|
85
93
|
if (!needsDefault && !shouldForce) return clone;
|
|
86
94
|
// Picker availability treats an unknown ladder as a wildcard, but runtime
|
|
87
95
|
// injection stays fail-closed until this concrete target advertises support.
|