@bitkyc08/opencodex 2.82.0 → 2.84.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/App-CZtqiGcw.js +56 -0
- package/gui/dist/assets/Tray-C9TGtwad.js +1 -0
- package/gui/dist/assets/{index-BAbcj51J.css → index-BB6uK6tU.css} +1 -1
- package/gui/dist/assets/index-CTm4l-HQ.js +94 -0
- package/gui/dist/assets/{quota-summary-DTBC8n7-.js → quota-summary-BbEeRfPY.js} +1 -1
- package/gui/dist/index.html +2 -2
- package/package.json +2 -2
- package/src/adapters/coding-agent/protocol.ts +15 -8
- package/src/adapters/cursor/catalog.ts +2 -2
- package/src/adapters/cursor/effort-map.ts +1 -1
- package/src/adapters/devin/live-models.ts +2 -2
- package/src/adapters/google-truncation.ts +22 -0
- package/src/adapters/google.ts +7 -3
- package/src/adapters/ollama-native.ts +47 -15
- package/src/adapters/openai-chat/messages.ts +8 -4
- package/src/adapters/openai-responses/empty-assistant.ts +32 -0
- package/src/adapters/openai-responses/passthrough.ts +5 -0
- package/src/adapters/openai-responses/plaintext-reasoning-notice.ts +85 -0
- package/src/adapters/openai-responses/reasoning.ts +11 -1
- package/src/adapters/openai-responses/request-strips.ts +10 -2
- package/src/adapters/qoder/adapter.ts +16 -14
- package/src/bridge/errors.ts +26 -1
- package/src/bridge.ts +1 -1
- package/src/claude/context-windows.ts +57 -4
- package/src/claude/desktop-picker.ts +1 -1
- package/src/claude/intercept/connect-proxy.ts +10 -3
- package/src/claude/intercept/picker-ca-persistence.ts +3 -1
- package/src/claude/intercept/picker-listener.ts +49 -1
- package/src/claude/intercept/settings-no-proxy.ts +146 -0
- package/src/claude/intercept/settings.ts +101 -9
- package/src/cli/account-api.ts +65 -0
- package/src/cli/account-auth.ts +44 -12
- package/src/cli/account.ts +41 -5
- package/src/cli/capabilities-access-remote.ts +8 -0
- package/src/cli/capabilities-base.ts +13 -1
- package/src/cli/catalog-command-result.ts +2 -1
- package/src/cli/claude.ts +15 -6
- package/src/cli/config-command.ts +8 -2
- package/src/cli/debug.ts +8 -0
- package/src/cli/dispatch.ts +16 -3
- package/src/cli/export-command.ts +14 -1
- package/src/cli/hub-disable.ts +99 -0
- package/src/cli/hub.ts +4 -2
- package/src/cli/index.ts +13 -4
- package/src/cli/link-child.ts +104 -0
- package/src/cli/link-enrollment.ts +96 -0
- package/src/cli/link.ts +39 -24
- package/src/cli/observe-stream.ts +6 -1
- package/src/cli/observe.ts +10 -2
- package/src/cli/provider.ts +12 -0
- package/src/cli/registry.ts +5 -4
- package/src/cli/resolve.ts +1 -1
- package/src/cli/runtime-api.ts +27 -2
- package/src/cli/status.ts +104 -20
- package/src/client/connect.ts +39 -4
- package/src/client/hub-client.ts +14 -7
- package/src/client/hub-state.ts +25 -14
- package/src/client/link-home.ts +248 -0
- package/src/client/link-management-relay.ts +61 -0
- package/src/client/link-relay.ts +6 -1
- package/src/client/link-status.ts +14 -0
- package/src/client/machine-api.ts +14 -7
- package/src/client/machine-listener.ts +29 -4
- package/src/clients/config-export/model-metadata.ts +16 -0
- package/src/clients/config-export/zcode-store.ts +46 -11
- package/src/clients/config-export/zcode.ts +3 -5
- package/src/clients/config-export.ts +47 -17
- package/src/clients/omo-role-models.ts +6 -3
- package/src/codex/agent-role-models.ts +26 -8
- package/src/codex/auth-api/account-list.ts +17 -2
- package/src/codex/auth-api/account-selection.ts +17 -7
- package/src/codex/auth-api/pool-mode-gate.ts +4 -5
- package/src/codex/auth-api/routes.ts +5 -1
- package/src/codex/auth-context.ts +8 -3
- package/src/codex/autostart-health.ts +3 -1
- package/src/codex/catalog/bundled.ts +5 -1
- package/src/codex/catalog/effort.ts +2 -2
- package/src/codex/catalog/metadata.ts +4 -2
- package/src/codex/catalog/native-models.ts +17 -3
- package/src/codex/catalog/provider-models.ts +25 -0
- package/src/codex/desktop-app/darwin.ts +1 -1
- package/src/codex/desktop-app/handoff.ts +1 -1
- package/src/codex/desktop-app/linux.ts +1 -1
- package/src/codex/desktop-app/lock.ts +1 -1
- package/src/codex/desktop-app/types.ts +1 -1
- package/src/codex/desktop-app/windows.ts +1 -1
- package/src/codex/desktop-app-restart.ts +1 -1
- package/src/codex/main-account.ts +6 -7
- package/src/codex/native-profile-startup.ts +4 -1
- package/src/codex/prompt-journal.ts +20 -2
- package/src/codex/prompt-layers/encoding.ts +49 -0
- package/src/codex/prompt-layers/import-source.ts +30 -0
- package/src/codex/prompt-layers/toml-edit.ts +269 -37
- package/src/codex/prompt-layers/toml-read.ts +52 -4
- package/src/codex/prompt-layers.ts +383 -20
- package/src/codex/prompt-lock-claim.ts +13 -3
- package/src/codex/prompt-lock-evidence.ts +3 -3
- package/src/codex/prompt-lock-owner.ts +14 -6
- package/src/codex/prompt-text-probe.ts +34 -5
- package/src/codex/quota.ts +13 -6
- package/src/codex/role-auto-assign.ts +2 -5
- package/src/codex/routing/active-account.ts +21 -0
- package/src/codex/routing.ts +1 -1
- package/src/codex/runtime.ts +196 -8
- package/src/codex/shim-overlay.ts +8 -4
- package/src/codex/shim.ts +1 -1
- package/src/combos/failover.ts +3 -0
- package/src/config/api-key-reconcile.ts +34 -0
- package/src/config/diagnostics.ts +16 -0
- package/src/config/feature-flags.ts +5 -0
- package/src/config/live-reconcile.ts +43 -29
- package/src/config/live-topology.ts +29 -0
- package/src/config/load-degrade.ts +2 -1
- package/src/config/paths.ts +3 -0
- package/src/config/salvage.ts +3 -1
- package/src/config/schema/config-schema.ts +2 -0
- package/src/generated/compatibility-version.json +373 -217
- package/src/grok/grpc-web.ts +23 -1
- package/src/grok/inject.ts +245 -221
- package/src/grok/reset-coupon-ledger.ts +293 -57
- package/src/grok/reset-coupons.ts +15 -6
- package/src/integrations/cursor-detect.ts +1 -1
- package/src/integrations/mutation-plan.ts +13 -7
- package/src/integrations/registry.ts +45 -11
- package/src/integrations/state.ts +19 -7
- package/src/integrations/target.ts +42 -5
- package/src/integrations/writer.ts +14 -6
- package/src/lib/app-owned-memory-stores.ts +4 -0
- package/src/lib/app-owned-memory.ts +11 -0
- package/src/lib/errors.ts +25 -0
- package/src/lib/json-byte-size.ts +31 -0
- package/src/lib/optional-shutdown-hooks.ts +35 -0
- package/src/lib/outbound-credential-registry.ts +210 -0
- package/src/lib/passive-read.ts +35 -0
- package/src/lib/redact.ts +2 -2
- package/src/lib/shadow-call.ts +44 -5
- package/src/link/codex-settings.ts +155 -0
- package/src/link/management-routes.ts +89 -0
- package/src/link/remote-probe.ts +38 -0
- package/src/link/ssh-argv.ts +26 -5
- package/src/link/ssh-runner.ts +58 -27
- package/src/link/status-projection.ts +22 -4
- package/src/link/supervisor.ts +144 -49
- package/src/link/tunnel-state.ts +11 -4
- package/src/oauth/anthropic-continuity.ts +4 -3
- package/src/oauth/anthropic-identity.ts +13 -2
- package/src/oauth/anthropic-model-quota.ts +13 -1
- package/src/oauth/anthropic-routing.ts +230 -23
- package/src/oauth/anthropic.ts +18 -4
- package/src/oauth/google-antigravity.ts +24 -7
- package/src/oauth/index.ts +25 -16
- package/src/oauth/kiro-account-identity.ts +106 -0
- package/src/oauth/kiro.ts +48 -5
- package/src/oauth/meta-muse-device.ts +1 -1
- package/src/oauth/plan.ts +29 -0
- package/src/oauth/store.ts +40 -3
- package/src/oauth/types.ts +3 -1
- package/src/plugins/loader.ts +11 -0
- package/src/providers/anthropic-billing-ownership.ts +74 -0
- package/src/providers/anthropic-billing-recovery.ts +31 -0
- package/src/providers/anthropic-billing-refusal.ts +55 -0
- package/src/providers/anthropic-fast.ts +1 -1
- package/src/providers/anthropic-reset-admission.ts +62 -0
- package/src/providers/anthropic-reset-grant-ledger.ts +29 -7
- package/src/providers/api-key-resolve.ts +17 -2
- package/src/providers/devin-cli-authmode-migration.ts +1 -1
- package/src/providers/devin-provider-merge-migration.ts +1 -1
- package/src/providers/github-copilot-auto.ts +169 -0
- package/src/providers/key-failover.ts +52 -5
- package/src/providers/model-presets.ts +1 -1
- package/src/providers/model-rename-startup.ts +5 -3
- package/src/providers/muse-subscription-usage.ts +1 -1
- package/src/providers/quota/account-cache.ts +11 -0
- package/src/providers/quota/anthropic-account-quota.ts +4 -2
- package/src/providers/quota/report-cache.ts +14 -0
- package/src/providers/quota/vendor-probes-oauth.ts +3 -3
- package/src/providers/quota.ts +4 -1
- package/src/providers/registry/entries-core.ts +20 -20
- package/src/providers/registry/entries-extended.ts +4 -1
- package/src/providers/registry/model-seeds.ts +20 -3
- package/src/providers/request-pacing.ts +9 -0
- package/src/providers/stale-no-vision-seed-migration.ts +72 -0
- package/src/providers/xai-fast-model.ts +1 -1
- package/src/responses/compaction.ts +14 -0
- package/src/responses/parser.ts +7 -1
- package/src/server/auth-cors.ts +42 -0
- package/src/server/configured-credentials.ts +194 -0
- package/src/server/inbound-body-admission.ts +24 -7
- package/src/server/index/link-listener.ts +30 -17
- package/src/server/index/link-management.ts +58 -0
- package/src/server/index/optional-listeners.ts +5 -1
- package/src/server/index/serve-options.ts +21 -2
- package/src/server/index.ts +1 -1
- package/src/server/lifecycle.ts +33 -5
- package/src/server/management/agent-settings-routes.ts +28 -10
- package/src/server/management/anthropic-reset-grant-routes.ts +17 -1
- package/src/server/management/codex-agent-role-routes.ts +19 -6
- package/src/server/management/codex-prompt-routes.ts +136 -5
- package/src/server/management/codex-role-auto-assign.ts +1 -1
- package/src/server/management/grok-coupon-routes.ts +333 -57
- package/src/server/management/link-enrollment.ts +192 -0
- package/src/server/management/link-route-state.ts +185 -0
- package/src/server/management/link-routes.ts +92 -390
- package/src/server/management/model-rows.ts +3 -1
- package/src/server/management/provider-overwrite-carry.ts +10 -0
- package/src/server/management/provider-routes.ts +1 -0
- package/src/server/management/route-registry.ts +7 -5
- package/src/server/management/shared.ts +3 -0
- package/src/server/management-api.ts +3 -1
- package/src/server/messages-native.ts +68 -42
- package/src/server/relay-eager.ts +4 -0
- package/src/server/relay.ts +18 -7
- package/src/server/request-log-failure-attribution.ts +3 -0
- package/src/server/request-log-filter.ts +6 -1
- package/src/server/request-log-terminal-status.ts +243 -0
- package/src/server/request-log.ts +15 -118
- package/src/server/responses/adapter-continuation.ts +9 -1
- package/src/server/responses/adapter-dispatch.ts +293 -81
- package/src/server/responses/code-mode-collab-surface.ts +205 -0
- package/src/server/responses/codex-ws-exchange.ts +147 -6
- package/src/server/responses/codex-ws-wire.ts +203 -0
- package/src/server/responses/collaboration.ts +36 -7
- package/src/server/responses/compact.ts +129 -50
- package/src/server/responses/context-overflow.ts +13 -1
- package/src/server/responses/core-codex-account.ts +105 -1
- package/src/server/responses/core-combo-failure.ts +14 -0
- package/src/server/responses/core-combo.ts +7 -2
- package/src/server/responses/core-errors.ts +29 -1
- package/src/server/responses/core-opaque-recovery.ts +5 -5
- package/src/server/responses/core-options.ts +2 -0
- package/src/server/responses/core.ts +6 -6
- package/src/server/responses/deferred-collab-surface.ts +158 -0
- package/src/server/responses/deferred-send-reporter.ts +25 -0
- package/src/server/responses/empty-completion-guard.ts +18 -2
- package/src/server/responses/fetch-helpers.ts +12 -1
- package/src/server/responses/jev-model-invoke.ts +1 -1
- package/src/server/responses/passthrough-delivery.ts +4 -1
- package/src/server/responses/passthrough-dispatch.ts +130 -64
- package/src/server/responses/passthrough-execution.ts +3 -3
- package/src/server/responses/request-prepare.ts +6 -4
- package/src/server/responses/request-transport.ts +131 -13
- package/src/server/responses/run-turn-execution.ts +13 -5
- package/src/server/responses/sidecar-execution.ts +16 -2
- package/src/server/responses/spend-storage-error.ts +33 -0
- package/src/server/responses/ws-upstream.ts +9 -1
- package/src/server/startup-action-control.ts +22 -2
- package/src/server/startup-health-cache.ts +4 -0
- package/src/server/system-env-shell.ts +7 -2
- package/src/server/system-env.ts +11 -1
- package/src/service/cli.ts +5 -1
- package/src/service/desktop-startup.ts +147 -34
- package/src/service/desktop-supervision.d.mts +27 -1
- package/src/service/desktop-supervision.mjs +111 -5
- package/src/service/health-sentinel-launch.ts +176 -0
- package/src/service/health-sentinel-main.ts +447 -0
- package/src/service/health-sentinel-policy.ts +241 -0
- package/src/service/health-sentinel-probe.ts +54 -0
- package/src/service/health-sentinel-windows.ts +87 -0
- package/src/service/linux-linger.ts +20 -0
- package/src/types/config.ts +5 -0
- package/src/types/request.ts +5 -0
- package/src/usage/expected-prices.ts +5 -1
- package/src/usage/log.ts +19 -1
- package/gui/dist/assets/App-Ds_qL7o8.js +0 -54
- package/gui/dist/assets/Tray-BqHF5Trd.js +0 -1
- package/gui/dist/assets/index-D3FBCNiP.js +0 -94
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { lstatSync, readFileSync, type Stats } from "node:fs";
|
|
1
|
+
import { lstatSync, readFileSync, type BigIntStats, type Stats } from "node:fs";
|
|
2
2
|
import { resolveTrustedWindowsPowerShellExe, resolveTrustedWindowsRegExe } from "../lib/windows-elevation";
|
|
3
3
|
import { hostname } from "node:os";
|
|
4
4
|
|
|
@@ -6,11 +6,12 @@ import { LockFileBusy, lockFileOperation } from "./prompt-lock-io";
|
|
|
6
6
|
|
|
7
7
|
export interface HostIdentity { hostname: string; machine: string }
|
|
8
8
|
export interface OwnerEvidence { pid: number; host?: HostIdentity; processStart?: string }
|
|
9
|
+
export type LockStat = Stats | BigIntStats;
|
|
9
10
|
export interface OwnerDeps {
|
|
10
11
|
isProcessAlive: (pid: number) => boolean | undefined;
|
|
11
12
|
hostIdentity: () => HostIdentity | undefined;
|
|
12
13
|
processStart: (pid: number) => string | undefined;
|
|
13
|
-
lstat: (path: string) =>
|
|
14
|
+
lstat: (path: string) => LockStat;
|
|
14
15
|
uid: () => number | undefined;
|
|
15
16
|
platform: NodeJS.Platform;
|
|
16
17
|
}
|
|
@@ -94,7 +95,9 @@ export const ownerDefaults: OwnerDeps = {
|
|
|
94
95
|
return code === "ESRCH" ? false : code === "EPERM" ? true : undefined;
|
|
95
96
|
}
|
|
96
97
|
},
|
|
97
|
-
|
|
98
|
+
// File IDs can exceed Number's exact range on Windows. Keep every bit for
|
|
99
|
+
// the entry/parent witnesses used before destructive operations and retries.
|
|
100
|
+
...identity, lstat: path => lstatSync(path, { bigint: true }),
|
|
98
101
|
uid: () => process.getuid?.(), platform: process.platform,
|
|
99
102
|
};
|
|
100
103
|
export function ownEvidence(deps: OwnerDeps): OwnerEvidence {
|
|
@@ -118,11 +121,16 @@ export function ownerState(record: OwnerEvidence | null, deps: OwnerDeps): "dead
|
|
|
118
121
|
return deps.isProcessAlive(record.pid) === false ? "dead" : "live";
|
|
119
122
|
} catch { return "live"; }
|
|
120
123
|
}
|
|
124
|
+
/** Type, no symlink, and current-user ownership on POSIX, judged on one captured observation. */
|
|
125
|
+
export function safeNamespaceStat(stat: LockStat, kind: "file" | "directory", deps: OwnerDeps): boolean {
|
|
126
|
+
const uid = deps.uid();
|
|
127
|
+
return !stat.isSymbolicLink() && (kind === "file" ? stat.isFile() : stat.isDirectory())
|
|
128
|
+
&& (deps.platform === "win32" || (uid !== undefined
|
|
129
|
+
&& stat.uid === (typeof stat.uid === "bigint" ? BigInt(uid) : uid)));
|
|
130
|
+
}
|
|
121
131
|
export function safeNamespace(path: string, kind: "file" | "directory", deps: OwnerDeps, missing = false): boolean {
|
|
122
132
|
try {
|
|
123
|
-
|
|
124
|
-
return !stat.isSymbolicLink() && (kind === "file" ? stat.isFile() : stat.isDirectory())
|
|
125
|
-
&& (deps.platform === "win32" || (uid !== undefined && stat.uid === uid));
|
|
133
|
+
return safeNamespaceStat(lockFileOperation(() => deps.lstat(path), deps.platform), kind, deps);
|
|
126
134
|
} catch (error) {
|
|
127
135
|
if (error instanceof LockFileBusy) throw error;
|
|
128
136
|
return missing && (error as NodeJS.ErrnoException).code === "ENOENT";
|
|
@@ -776,18 +776,23 @@ function extractSections(raw: string): Map<string, string> {
|
|
|
776
776
|
}
|
|
777
777
|
if (!Array.isArray(parsed)) return sections;
|
|
778
778
|
for (const item of parsed) {
|
|
779
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
|
|
779
780
|
const content = (item as { content?: unknown }).content;
|
|
780
781
|
if (!Array.isArray(content)) continue;
|
|
781
|
-
const text = content.map(part =>
|
|
782
|
+
const text = content.map(part => {
|
|
783
|
+
if (!part || typeof part !== "object" || Array.isArray(part)) return "";
|
|
784
|
+
const value = (part as { text?: unknown }).text;
|
|
785
|
+
return typeof value === "string" ? value : "";
|
|
786
|
+
}).join("");
|
|
782
787
|
// Tag names are NOT all snake_case: Codex renders `<permissions instructions>`
|
|
783
788
|
// with a space. A `[a-z_]+` pattern silently skipped it, and the layer was
|
|
784
789
|
// reported as "sent nothing" while its text sat right there.
|
|
785
790
|
for (const match of text.matchAll(/<([a-zA-Z_][a-zA-Z0-9_ -]*)>([\s\S]*?)<\/\1>/g)) {
|
|
786
791
|
sections.set(match[1]!, match[2]!.trim());
|
|
787
792
|
}
|
|
788
|
-
// AGENTS.md is NOT tagged: it arrives as a
|
|
789
|
-
//
|
|
790
|
-
//
|
|
793
|
+
// AGENTS.md is NOT tagged: it arrives as a `# AGENTS.md instructions`
|
|
794
|
+
// block among the tagged sections. Matching only on tags would report the
|
|
795
|
+
// layer as unrendered while its text sits in the same message.
|
|
791
796
|
//
|
|
792
797
|
// Bounded at both ends. Capturing to end-of-message swept up any unrelated
|
|
793
798
|
// untagged prose that happened to follow, and stripping tag-shaped blocks
|
|
@@ -795,8 +800,25 @@ function extractSections(raw: string): Map<string, string> {
|
|
|
795
800
|
// AGENTS.md. Codex wraps the body in <INSTRUCTIONS>, so that is the boundary.
|
|
796
801
|
// No line anchor: the block is concatenated directly onto the previous
|
|
797
802
|
// section's closing tag, so requiring a newline before it never matched.
|
|
798
|
-
|
|
803
|
+
//
|
|
804
|
+
// The `for <path>` suffix is OPTIONAL. Upstream emits three shapes from
|
|
805
|
+
// `contextual_user_fragment().render()` (core/src/context.rs) and this has
|
|
806
|
+
// to read all three rather than the one that existed when the probe was
|
|
807
|
+
// written: the legacy single-root form `# AGENTS.md instructions for
|
|
808
|
+
// <dir>`, the no-directory form `# AGENTS.md instructions`, and the
|
|
809
|
+
// multi-environment form where each project's block is labeled `for \`<env>\`
|
|
810
|
+
// with root <cwd>` INSIDE the INSTRUCTIONS wrapper. The labels are part of
|
|
811
|
+
// what the model sees, so they stay in the captured body.
|
|
812
|
+
const projectDoc = /# AGENTS\.md instructions(?: for [^\n]*)?\n+<INSTRUCTIONS>\n?([\s\S]*?)\n?<\/INSTRUCTIONS>/.exec(text);
|
|
799
813
|
if (projectDoc) sections.set("__agents_md", projectDoc[1]!.trim());
|
|
814
|
+
else if (/# AGENTS\.md instructions/.test(text)) {
|
|
815
|
+
// The header rendered but in a shape this extractor does not recognize -
|
|
816
|
+
// a future upstream format, or prose truncated by the output cap. Claiming
|
|
817
|
+
// the layer "sent nothing" would be a lie about a block that is plainly
|
|
818
|
+
// present; the sentinel maps it to `unmapped`, the existing honest answer
|
|
819
|
+
// for "rendered in a form we cannot show".
|
|
820
|
+
sections.set("__agents_md_unmatched", "");
|
|
821
|
+
}
|
|
800
822
|
}
|
|
801
823
|
return sections;
|
|
802
824
|
}
|
|
@@ -814,6 +836,13 @@ function mapSectionsToLayers(sections: Map<string, string>): Record<string, Laye
|
|
|
814
836
|
? { text: null, reason: "not-rendered", bytes: 0 }
|
|
815
837
|
: { text, reason: "ok", bytes: Buffer.byteLength(text, "utf8") };
|
|
816
838
|
}
|
|
839
|
+
// `__agents_md_unmatched` is the sentinel for "the AGENTS.md header rendered in
|
|
840
|
+
// a shape the bounded extractor does not recognize". `unmapped` is the honest
|
|
841
|
+
// answer for it: the text exists but cannot be shown, which `not-rendered`
|
|
842
|
+
// would deny outright.
|
|
843
|
+
if (layers["agents-md"]?.reason === "not-rendered" && sections.has("__agents_md_unmatched")) {
|
|
844
|
+
layers["agents-md"] = { text: null, reason: "unmapped", bytes: 0 };
|
|
845
|
+
}
|
|
817
846
|
return layers;
|
|
818
847
|
}
|
|
819
848
|
|
package/src/codex/quota.ts
CHANGED
|
@@ -319,9 +319,12 @@ export function setAccountQuotaFromParsed(
|
|
|
319
319
|
credentialGeneration: historyEvidence.writer.credentialGeneration, windows: historyWindows(historyEvidence.raw),
|
|
320
320
|
}, updatedAt);
|
|
321
321
|
}
|
|
322
|
-
//
|
|
322
|
+
// Rotation shares validated main-window retirement, but never inherits policy-only
|
|
323
323
|
// evidence that outlived its disk TTL. Policy has a separate, identity-checked base.
|
|
324
|
-
const
|
|
324
|
+
const displayQuota: MainPolicyQuotaObservation = isMain && mainWriter && policyQuota?.shortWindowAbsent === true
|
|
325
|
+
? { ...quota, shortWindowAbsent: true }
|
|
326
|
+
: quota;
|
|
327
|
+
const next = mergeAccountQuota(displayQuota, legacyExisting, updatedAt);
|
|
325
328
|
accountQuota.set(accountId, next);
|
|
326
329
|
if (isMain) {
|
|
327
330
|
if (mainWriter && policyQuota && snapshotHasUsage(policyQuota)) {
|
|
@@ -440,7 +443,7 @@ function mergeAccountQuota(
|
|
|
440
443
|
}
|
|
441
444
|
if (quota.shortResetAt !== undefined) next.shortResetAt = quota.shortResetAt;
|
|
442
445
|
if (quota.shortWindowSeconds !== undefined) next.shortWindowSeconds = quota.shortWindowSeconds;
|
|
443
|
-
} else if (
|
|
446
|
+
} else if (quota.shortWindowAbsent !== true) {
|
|
444
447
|
// Unknown usage is not a lower reading. Retain the entire known tuple: pairing
|
|
445
448
|
// its percentage with new metadata would silently extend or shorten its reset.
|
|
446
449
|
// An elapsed reset is the exception. It describes a window that has already rolled over,
|
|
@@ -879,14 +882,17 @@ function filterMainPolicyMonthlyQuota(
|
|
|
879
882
|
/**
|
|
880
883
|
* Parse ordinary main-policy usage, rejecting messages with invalid numeric window percentages.
|
|
881
884
|
* A measured long primary, or explicitly absent primary with measured weekly secondary,
|
|
882
|
-
* proves replacement
|
|
885
|
+
* proves replacement with long/null topology; an allowed two-window response may omit tertiary.
|
|
886
|
+
* Null supplies no usable evidence.
|
|
883
887
|
*/
|
|
884
888
|
export function parseMainPolicyUsageQuota(data: WhamUsageResponse): MainPolicyQuotaObservation | null {
|
|
885
889
|
const windows = [data.rate_limit?.primary_window, data.rate_limit?.secondary_window, data.rate_limit?.tertiary_window];
|
|
886
890
|
if (windows.some(window => isInvalidPolicyUsagePercent(window?.used_percent))) return null;
|
|
887
891
|
const quota = filterMainPolicyMonthlyQuota(parseUsageQuota(data), isThirtyDayOnlyCodexPlan(data.plan_type));
|
|
888
892
|
const [primary, secondary, tertiary] = windows;
|
|
889
|
-
//
|
|
893
|
+
// Primary and secondary must explicitly report their topology. WHAM also sends
|
|
894
|
+
// two-window responses without tertiary; an explicit allowed verdict disambiguates those
|
|
895
|
+
// from partial observations. A present tertiary still requires measured long/null evidence.
|
|
890
896
|
// Policy trusts one complete snapshot only when every non-null window is >=24h AND
|
|
891
897
|
// carries a valid usage reading: a long window without used_percent leaves that
|
|
892
898
|
// window's usage unknown, and unknown usage must never release a block.
|
|
@@ -894,7 +900,8 @@ export function parseMainPolicyUsageQuota(data: WhamUsageResponse): MainPolicyQu
|
|
|
894
900
|
if (quota && (isMeasuredLongWindow(primary)
|
|
895
901
|
|| (primary === null && isMeasuredLongWindow(secondary) && quota.weeklyPercent !== undefined))
|
|
896
902
|
&& (secondary === null || isMeasuredLongWindow(secondary))
|
|
897
|
-
&& (tertiary === null || isMeasuredLongWindow(tertiary)
|
|
903
|
+
&& (tertiary === null || isMeasuredLongWindow(tertiary)
|
|
904
|
+
|| (tertiary === undefined && data.rate_limit?.allowed === true))) {
|
|
898
905
|
return { ...quota, shortWindowAbsent: true };
|
|
899
906
|
}
|
|
900
907
|
return quota;
|
|
@@ -123,7 +123,7 @@ export type RoleProposal = RoleProposalInput & (
|
|
|
123
123
|
readonly moveUpIf: string;
|
|
124
124
|
readonly moveDownIf: string;
|
|
125
125
|
readonly proposedModel: string | null;
|
|
126
|
-
/**
|
|
126
|
+
/** The sized effort on the picked model's ladder; null only when that model has no ladder. */
|
|
127
127
|
readonly proposedEffort: string | null;
|
|
128
128
|
readonly reason: string | null;
|
|
129
129
|
}
|
|
@@ -134,7 +134,6 @@ export function buildRoleProposals(
|
|
|
134
134
|
roles: readonly RoleProposalInput[],
|
|
135
135
|
sizing: ReadonlyMap<string, RoleSizingOutcome>,
|
|
136
136
|
classified: readonly ClassifiedCandidate[],
|
|
137
|
-
options: { readonly alwaysProposeEffort?: boolean } = {},
|
|
138
137
|
): RoleProposal[] {
|
|
139
138
|
return roles.map(role => {
|
|
140
139
|
const outcome = sizing.get(role.role) ?? { unsized: "the role was not sized" };
|
|
@@ -151,9 +150,7 @@ export function buildRoleProposals(
|
|
|
151
150
|
reason: `no available model is classified ${tier} or above`,
|
|
152
151
|
};
|
|
153
152
|
}
|
|
154
|
-
const proposedEffort =
|
|
155
|
-
? null
|
|
156
|
-
: mapEffortToLevel(effort, pick.efforts, pick.defaultEffort);
|
|
153
|
+
const proposedEffort = mapEffortToLevel(effort, pick.efforts, pick.defaultEffort);
|
|
157
154
|
return { ...sized, status: "proposed", proposedModel: pick.model, proposedEffort, reason: null };
|
|
158
155
|
});
|
|
159
156
|
}
|
|
@@ -2,6 +2,7 @@ import { saveConfigPreservingClaudeCode } from "../../config";
|
|
|
2
2
|
import { clearCodexAccountPin, pinnedCodexAccountId } from "../account-priority";
|
|
3
3
|
import {
|
|
4
4
|
POOL_KEY_CODEX,
|
|
5
|
+
clearPoolRotationState,
|
|
5
6
|
normalizeCodexAccountPoolStrategy,
|
|
6
7
|
seedPoolRotationAccount,
|
|
7
8
|
} from "../pool-rotation";
|
|
@@ -74,6 +75,26 @@ export function resetCodexRoutingForManualSelection(accountId: string): void {
|
|
|
74
75
|
}
|
|
75
76
|
}
|
|
76
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Operator released the selection instead of naming a new account: return routing to
|
|
80
|
+
* automatic without steering. A release names no replacement, so bound threads keep
|
|
81
|
+
* their captured account and quota-avoidance health stands — nothing new overrules it.
|
|
82
|
+
* What still has to go is the steering a selection planted: the one-shot manual
|
|
83
|
+
* preference (releasing account A must not keep blocking every other pick), the
|
|
84
|
+
* runtime cursor, and the rotation seeds on the shared ring and each independent
|
|
85
|
+
* quota-scope ring, which would otherwise force the next pick onto that same account.
|
|
86
|
+
*/
|
|
87
|
+
export function resetCodexRoutingForClearedSelection(): void {
|
|
88
|
+
runtimeActiveCodexAccountId = undefined;
|
|
89
|
+
manualPreference.delete(POOL_KEY_CODEX);
|
|
90
|
+
clearPoolRotationState(POOL_KEY_CODEX);
|
|
91
|
+
for (const scope of new Set(Object.values(NATIVE_MODEL_QUOTA_SCOPES))) {
|
|
92
|
+
if (isIndependentCodexQuotaScope(scope)) {
|
|
93
|
+
clearPoolRotationState(codexPoolKeyForScope(scope));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
77
98
|
/** Effective active: automatic runtime cursor, else operator/persisted selection. */
|
|
78
99
|
/**
|
|
79
100
|
* Unspent operator selections, keyed by pool scope.
|
package/src/codex/routing.ts
CHANGED
|
@@ -215,7 +215,7 @@ export {
|
|
|
215
215
|
pickAlternateCodexAccount,
|
|
216
216
|
} from "./routing/selection";
|
|
217
217
|
export {
|
|
218
|
-
resetCodexRoutingForManualSelection,
|
|
218
|
+
resetCodexRoutingForManualSelection, resetCodexRoutingForClearedSelection,
|
|
219
219
|
getEffectiveActiveCodexAccountId,
|
|
220
220
|
isEffectiveCodexAccountPinned,
|
|
221
221
|
} from "./routing/active-account";
|
package/src/codex/runtime.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { execFile, execFileSync } from "node:child_process";
|
|
2
|
-
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { closeSync, constants, existsSync, fstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, realpathSync, rmSync, statSync, unlinkSync } from "node:fs";
|
|
3
3
|
import { homedir, tmpdir } from "node:os";
|
|
4
|
-
import { delimiter, join } from "node:path";
|
|
4
|
+
import { delimiter, join, posix } from "node:path";
|
|
5
5
|
import { atomicWriteFile, getConfigDir } from "../config";
|
|
6
6
|
import { codexExecInvocation, isSpawnableCodexCandidate } from "./exec-invocation";
|
|
7
7
|
import { resolveCodexHomeDir } from "./home";
|
|
8
8
|
import { decodeOverlayState } from "./shim-state-file";
|
|
9
9
|
import { redactSecretString, redactUserPath } from "../lib/redact";
|
|
10
10
|
import { SUBPROCESS_KILL_GRACE_MS, waitForSubprocessExit } from "../lib/bounded-subprocess";
|
|
11
|
+
import { isPassiveRead } from "../lib/passive-read";
|
|
11
12
|
|
|
12
13
|
export type CodexRuntimeSource =
|
|
13
14
|
| "environment"
|
|
@@ -89,6 +90,8 @@ export interface ResolveCodexRuntimeDeps {
|
|
|
89
90
|
execFile?: RuntimeExecFileAsync;
|
|
90
91
|
existsSync?: (path: string) => boolean;
|
|
91
92
|
readFileSync?: (path: string, encoding: "utf8") => string;
|
|
93
|
+
/** Bounded in-process macOS bundle identity reader; injected readers bypass the memo. */
|
|
94
|
+
readBundleIdentifier?: (infoPlistPath: string) => string | null;
|
|
92
95
|
now?: () => number;
|
|
93
96
|
/**
|
|
94
97
|
* When false, stop after the first valid priority candidate (skip PATH-wide
|
|
@@ -225,10 +228,11 @@ export function loadLastEffortClamp(
|
|
|
225
228
|
}
|
|
226
229
|
}
|
|
227
230
|
|
|
231
|
+
/** Return whether a nonempty observation changed, so catalog notices share persistence's identity. */
|
|
228
232
|
export function persistEffortClamp(
|
|
229
233
|
diagnostic: EffortClampDiagnostic | null,
|
|
230
234
|
deps: ResolveCodexRuntimeDeps = {},
|
|
231
|
-
):
|
|
235
|
+
): boolean {
|
|
232
236
|
const configDir = deps.configDir ?? getConfigDir();
|
|
233
237
|
const path = codexRuntimeClampStatePath(configDir);
|
|
234
238
|
if (!diagnostic || diagnostic.removedEfforts.length === 0) {
|
|
@@ -237,8 +241,16 @@ export function persistEffortClamp(
|
|
|
237
241
|
} catch {
|
|
238
242
|
/* absent is fine */
|
|
239
243
|
}
|
|
240
|
-
return;
|
|
244
|
+
return false;
|
|
241
245
|
}
|
|
246
|
+
// This is an observation, not a heartbeat: rewriting updatedAt on every sync both churns disk
|
|
247
|
+
// and makes identical clamps look new after restart. Compare sets, not catalog enumeration order.
|
|
248
|
+
const previous = loadLastEffortClamp(deps);
|
|
249
|
+
const setKey = (values: readonly string[]) => JSON.stringify([...new Set(values)].sort());
|
|
250
|
+
if (previous && previous.runtimePath === diagnostic.runtimePath
|
|
251
|
+
&& previous.runtimeVersion === diagnostic.runtimeVersion
|
|
252
|
+
&& setKey(previous.removedEfforts) === setKey(diagnostic.removedEfforts)
|
|
253
|
+
&& setKey(previous.affectedModels) === setKey(diagnostic.affectedModels)) return false;
|
|
242
254
|
const payload: PersistedClampState = {
|
|
243
255
|
version: 1,
|
|
244
256
|
updatedAt: new Date((deps.now ?? Date.now)()).toISOString(),
|
|
@@ -249,6 +261,7 @@ export function persistEffortClamp(
|
|
|
249
261
|
};
|
|
250
262
|
mkdirSync(configDir, { recursive: true, mode: 0o700 });
|
|
251
263
|
atomicWriteFile(path, `${JSON.stringify(payload, null, 2)}\n`);
|
|
264
|
+
return true;
|
|
252
265
|
}
|
|
253
266
|
|
|
254
267
|
export function displayCodexRuntimePath(command: string): string {
|
|
@@ -425,6 +438,9 @@ function versionProbePrecheck(command: string, deps: ResolveCodexRuntimeDeps): V
|
|
|
425
438
|
// `codex --version` here is ~1s of blocking exec per candidate; on the
|
|
426
439
|
// dashboard probe that cost made Windows report Codex as missing even when
|
|
427
440
|
// the App install was sitting under LOCALAPPDATA/OpenAI/Codex/bin (issue 4458).
|
|
441
|
+
if (!macCodexAppIdentityMatches(command, deps)) {
|
|
442
|
+
return { ok: false, reason: "Codex app bundle identity mismatch or unavailable" };
|
|
443
|
+
}
|
|
428
444
|
if (deps.probeVersion === false) return { ok: true, version: null };
|
|
429
445
|
return null;
|
|
430
446
|
}
|
|
@@ -599,6 +615,161 @@ function pathCandidates(deps: ResolveCodexRuntimeDeps): string[] {
|
|
|
599
615
|
return [...new Set(out)];
|
|
600
616
|
}
|
|
601
617
|
|
|
618
|
+
/** Conventional Codex desktop bundles, ordered system before user. */
|
|
619
|
+
function macCodexAppBundles(deps: ResolveCodexRuntimeDeps): string[] {
|
|
620
|
+
const env = deps.env ?? process.env;
|
|
621
|
+
const home = env.HOME?.trim() || env.USERPROFILE?.trim() || homedir();
|
|
622
|
+
return ["/Applications/ChatGPT.app", posix.join(home, "Applications/ChatGPT.app")];
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function macCodexAppCommands(deps: ResolveCodexRuntimeDeps): string[] {
|
|
626
|
+
return macCodexAppBundles(deps).flatMap(bundle => [
|
|
627
|
+
`${bundle}/Contents/Resources/codex-cli/bin/codex`, `${bundle}/Contents/Resources/codex`,
|
|
628
|
+
]);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function realpathOrNull(path: string): string | null {
|
|
632
|
+
try { return realpathSync(path); } catch { return null; }
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Classify any path inside either bundle, including backing executables, by normalized and
|
|
637
|
+
* canonical forms with a segment boundary: a symlinked HOME (or /tmp -> /private/tmp) must not
|
|
638
|
+
* let a canonical bundle path skip the identity check or the handover exclusion.
|
|
639
|
+
*/
|
|
640
|
+
function macCodexAppBundleFor(command: string, deps: ResolveCodexRuntimeDeps): string | undefined {
|
|
641
|
+
if ((deps.platform ?? process.platform) !== "darwin" || !posix.isAbsolute(command)) return undefined;
|
|
642
|
+
const forms = [posix.normalize(command), realpathOrNull(command)].filter((form): form is string => Boolean(form));
|
|
643
|
+
return macCodexAppBundles(deps).find(bundle => {
|
|
644
|
+
const roots = [posix.normalize(bundle), realpathOrNull(bundle)].filter((root): root is string => Boolean(root));
|
|
645
|
+
return forms.some(form => roots.some(root => form.startsWith(`${root}/`)));
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function macCodexAppIdentityMatches(command: string, deps: ResolveCodexRuntimeDeps): boolean {
|
|
650
|
+
const bundle = macCodexAppBundleFor(command, deps);
|
|
651
|
+
if (!bundle) return true;
|
|
652
|
+
try {
|
|
653
|
+
return (deps.readBundleIdentifier ?? readBoundedBundleIdentifier)(`${bundle}/Contents/Info.plist`) === "com.openai.codex";
|
|
654
|
+
} catch { return false; }
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/** XML plist subset only: no external entity expansion, subprocess, or unbounded read. */
|
|
658
|
+
function readBoundedBundleIdentifier(path: string): string | null {
|
|
659
|
+
let fd: number | undefined;
|
|
660
|
+
try {
|
|
661
|
+
fd = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK);
|
|
662
|
+
const st = fstatSync(fd);
|
|
663
|
+
const limit = 64 * 1024;
|
|
664
|
+
if (!st.isFile() || st.size > limit) return null;
|
|
665
|
+
const bytes = Buffer.alloc(limit + 1);
|
|
666
|
+
let size = 0;
|
|
667
|
+
while (size < bytes.length) {
|
|
668
|
+
const count = readSync(fd, bytes, size, bytes.length - size, null);
|
|
669
|
+
if (!count) break;
|
|
670
|
+
size += count;
|
|
671
|
+
}
|
|
672
|
+
if (size > limit) return null;
|
|
673
|
+
// XML 1.0 Char: tab, LF, CR, U+0020-D7FF, U+E000-FFFD, U+10000-10FFFF (no surrogates after fatal decoding).
|
|
674
|
+
const xmlChar = (code: number): boolean => code === 9 || code === 10 || code === 13
|
|
675
|
+
|| (code >= 0x20 && code <= 0xd7ff) || (code >= 0xe000 && code <= 0xfffd) || (code >= 0x10000 && code <= 0x10ffff);
|
|
676
|
+
const allXmlChars = (text: string): boolean => [...text].every(char => xmlChar(char.codePointAt(0)!));
|
|
677
|
+
const decoded = new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, size));
|
|
678
|
+
// Every character is checked before anything is stripped, the prolog included.
|
|
679
|
+
if (!allXmlChars(decoded)) return null;
|
|
680
|
+
// Only a well-formed UTF-8 declaration and Apple's DOCTYPE are accepted, anchored in the prolog, in that order.
|
|
681
|
+
const declaration = /^<\?xml\s+version=(["'])1\.[0-9]\1(?:\s+encoding=(["'])utf-8\2)?(?:\s+standalone=(["'])(?:yes|no)\3)?\s*\?>/i;
|
|
682
|
+
if (decoded.startsWith("<?") && !declaration.test(decoded)) return null;
|
|
683
|
+
const xml = decoded
|
|
684
|
+
.replace(declaration, "")
|
|
685
|
+
.replace(/^\s*<!DOCTYPE plist PUBLIC "-\/\/Apple\/\/DTD PLIST 1\.0\/\/EN" "http:\/\/www\.apple\.com\/DTDs\/PropertyList-1\.0\.dtd">/, "");
|
|
686
|
+
type Node = { name: string; text: string; children: Node[] };
|
|
687
|
+
const root: Node = { name: "root", text: "", children: [] };
|
|
688
|
+
const stack = [root];
|
|
689
|
+
const tokens = xml.match(/<!--[\s\S]*?-->|<[^>]*>|[^<]+/g) ?? [];
|
|
690
|
+
if (tokens.join("") !== xml) return null;
|
|
691
|
+
for (const token of tokens) {
|
|
692
|
+
if (token.startsWith("<!--")) {
|
|
693
|
+
// A comment may not contain "--" or end in "-" (XML 1.0 §2.5).
|
|
694
|
+
if (/--|-$/.test(token.slice(4, -3))) return null;
|
|
695
|
+
continue;
|
|
696
|
+
}
|
|
697
|
+
const parent = stack[stack.length - 1]!;
|
|
698
|
+
if (!token.startsWith("<")) {
|
|
699
|
+
if (token.includes("]]>")) return null;
|
|
700
|
+
parent.text += token;
|
|
701
|
+
continue;
|
|
702
|
+
}
|
|
703
|
+
const close = /^<\/(\w+)\s*>$/.exec(token);
|
|
704
|
+
if (close) {
|
|
705
|
+
if (stack.length === 1 || parent.name !== close[1]) return null;
|
|
706
|
+
stack.pop();
|
|
707
|
+
continue;
|
|
708
|
+
}
|
|
709
|
+
const open = /^<(plist|dict|array|key|string|integer|real|date|data|true|false)(?:\s+version="1\.0")?\s*(\/?)>$/.exec(token);
|
|
710
|
+
if (!open || (token.includes("version=") && open[1] !== "plist")) return null;
|
|
711
|
+
const node: Node = { name: open[1]!, text: "", children: [] };
|
|
712
|
+
parent.children.push(node);
|
|
713
|
+
if (!open[2]) stack.push(node);
|
|
714
|
+
}
|
|
715
|
+
if (stack.length !== 1 || root.text.trim() || root.children.length !== 1) return null;
|
|
716
|
+
const plist = root.children[0]!;
|
|
717
|
+
if (plist.name !== "plist" || plist.text.trim() || plist.children.length !== 1) return null;
|
|
718
|
+
const dict = plist.children[0]!;
|
|
719
|
+
if (dict.name !== "dict" || dict.text.trim() || dict.children.length % 2) return null;
|
|
720
|
+
const decode = (text: string): string => text.replace(/&([^;]*);|&/g, (_match, entity: string | undefined) => {
|
|
721
|
+
const named: Record<string, string> = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" };
|
|
722
|
+
if (entity && Object.hasOwn(named, entity)) return named[entity]!;
|
|
723
|
+
if (!entity || !/^#(?:[0-9]+|x[0-9a-fA-F]+)$/.test(entity)) throw new Error("invalid XML entity");
|
|
724
|
+
const value = entity.startsWith("#x") ? parseInt(entity.slice(2), 16) : Number(entity.slice(1));
|
|
725
|
+
if (!xmlChar(value)) throw new Error("invalid XML character");
|
|
726
|
+
return String.fromCodePoint(value);
|
|
727
|
+
});
|
|
728
|
+
// The identity only counts inside a document that is valid as a whole: every node, entity and
|
|
729
|
+
// literal character is checked, not just the CFBundleIdentifier pair.
|
|
730
|
+
const leafFormats: Record<string, RegExp> = {
|
|
731
|
+
integer: /^\s*[+-]?(?:\d+|0x[0-9a-fA-F]+)\s*$/,
|
|
732
|
+
real: /^\s*(?:[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?|[+-]?(?:nan|inf(?:inity)?))\s*$/i,
|
|
733
|
+
date: /^\s*\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z\s*$/,
|
|
734
|
+
data: /^[A-Za-z0-9+/=\s]*$/,
|
|
735
|
+
};
|
|
736
|
+
const validNode = (node: Node, inDict: "key" | "value" | null): boolean => {
|
|
737
|
+
if (!allXmlChars(node.text)) return false;
|
|
738
|
+
if (inDict === "key" && node.name !== "key") return false;
|
|
739
|
+
if (inDict !== "key" && node.name === "key") return false;
|
|
740
|
+
switch (node.name) {
|
|
741
|
+
case "dict":
|
|
742
|
+
return !node.text.trim() && node.children.length % 2 === 0
|
|
743
|
+
&& node.children.every((child, index) => validNode(child, index % 2 === 0 ? "key" : "value"));
|
|
744
|
+
case "array":
|
|
745
|
+
return !node.text.trim() && node.children.every(child => validNode(child, null));
|
|
746
|
+
case "key": case "string":
|
|
747
|
+
decode(node.text);
|
|
748
|
+
return node.children.length === 0;
|
|
749
|
+
case "true": case "false":
|
|
750
|
+
return node.children.length === 0 && !node.text.trim();
|
|
751
|
+
case "integer": case "real": case "date": case "data":
|
|
752
|
+
return node.children.length === 0 && leafFormats[node.name]!.test(decode(node.text));
|
|
753
|
+
default:
|
|
754
|
+
return false;
|
|
755
|
+
}
|
|
756
|
+
};
|
|
757
|
+
if (!validNode(dict, null)) return null;
|
|
758
|
+
let identifier: string | null = null;
|
|
759
|
+
let keys = 0;
|
|
760
|
+
for (let i = 0; i < dict.children.length; i += 2) {
|
|
761
|
+
const key = dict.children[i]!, value = dict.children[i + 1]!;
|
|
762
|
+
if (key.name !== "key" || key.children.length) return null;
|
|
763
|
+
if (decode(key.text) !== "CFBundleIdentifier") continue;
|
|
764
|
+
keys++;
|
|
765
|
+
if (value.name !== "string" || value.children.length) return null;
|
|
766
|
+
identifier = decode(value.text);
|
|
767
|
+
}
|
|
768
|
+
return keys === 1 ? identifier : null;
|
|
769
|
+
} catch { return null; }
|
|
770
|
+
finally { if (fd !== undefined) closeSync(fd); }
|
|
771
|
+
}
|
|
772
|
+
|
|
602
773
|
/**
|
|
603
774
|
* Codex installs that PATH does not necessarily expose.
|
|
604
775
|
*
|
|
@@ -647,6 +818,10 @@ function installedCodexCandidates(deps: ResolveCodexRuntimeDeps): string[] {
|
|
|
647
818
|
"/usr/local/bin/codex",
|
|
648
819
|
"/opt/homebrew/bin/codex",
|
|
649
820
|
];
|
|
821
|
+
if (platform === "darwin") {
|
|
822
|
+
const exists = deps.existsSync ?? existsSync;
|
|
823
|
+
return [...posix, ...macCodexAppCommands(deps).filter(command => exists(command) && macCodexAppIdentityMatches(command, deps))];
|
|
824
|
+
}
|
|
650
825
|
if (platform !== "linux") return posix;
|
|
651
826
|
// Windows Codex Desktop in WSL app-server mode ships its Linux binary under the
|
|
652
827
|
// effective Codex home as bin/wsl/<version-hash>/codex, and a Desktop update replaces
|
|
@@ -839,6 +1014,7 @@ function resolveCacheKey(deps: ResolveCodexRuntimeDeps): string | null {
|
|
|
839
1014
|
|| deps.execFile
|
|
840
1015
|
|| deps.existsSync
|
|
841
1016
|
|| deps.readFileSync
|
|
1017
|
+
|| deps.readBundleIdentifier
|
|
842
1018
|
|| deps.readdirSync
|
|
843
1019
|
|| deps.statSync
|
|
844
1020
|
|| deps.configDir
|
|
@@ -974,7 +1150,8 @@ export function getCodexRuntimeSnapshot(): ResolveCodexRuntimeResult {
|
|
|
974
1150
|
const cacheKey = resolveCacheKey({});
|
|
975
1151
|
const memo = resolveCache?.key === cacheKey ? resolveCache : null;
|
|
976
1152
|
if (memo && Date.now() - memo.at < RESOLVE_CACHE_MS) return cloneAndDeepFreeze(memo.value);
|
|
977
|
-
|
|
1153
|
+
// A passive read (a Remote Link Child's, #6959) starts no version probes on this machine.
|
|
1154
|
+
if (!isPassiveRead()) resolveCodexRuntimeAsync().catch(() => { /* the next read retries */ });
|
|
978
1155
|
if (memo) return cloneAndDeepFreeze(memo.value);
|
|
979
1156
|
return resolveCodexRuntime({ discoverAlternatives: false, probeVersion: false });
|
|
980
1157
|
}
|
|
@@ -1020,10 +1197,18 @@ function* resolveCodexRuntimeSteps(
|
|
|
1020
1197
|
for (const command of pathCandidates(deps)) {
|
|
1021
1198
|
ordered.push({ command, source: "path" });
|
|
1022
1199
|
}
|
|
1023
|
-
|
|
1200
|
+
const installed = installedCodexCandidates(deps);
|
|
1201
|
+
// Order by the generated app commands only: an established install path that links into the
|
|
1202
|
+
// app keeps its precedence, while identity and handover use the canonical classification.
|
|
1203
|
+
const appCommands = new Set(macCodexAppCommands(deps));
|
|
1204
|
+
const appCandidates = installed.filter(command => appCommands.has(command));
|
|
1205
|
+
for (const command of installed.filter(command => !appCommands.has(command))) {
|
|
1024
1206
|
ordered.push({ command, source: "installed" });
|
|
1025
1207
|
}
|
|
1026
1208
|
ordered.push({ command: "codex", source: "fallback" });
|
|
1209
|
+
for (const command of appCandidates) {
|
|
1210
|
+
ordered.push({ command, source: "installed" });
|
|
1211
|
+
}
|
|
1027
1212
|
|
|
1028
1213
|
const seen = new Set<string>();
|
|
1029
1214
|
const valid: ResolvedCodexRuntime[] = [];
|
|
@@ -1045,13 +1230,16 @@ function* resolveCodexRuntimeSteps(
|
|
|
1045
1230
|
// there is then nothing to compare it against.
|
|
1046
1231
|
const persistedIsUnpinned = Boolean(persisted?.command) && !persistedCodexRuntimeIsPinned(persisted);
|
|
1047
1232
|
for (const candidate of ordered) {
|
|
1233
|
+
// Deferred resolution accepts the bare fallback without proving it exists; when a verified
|
|
1234
|
+
// app CLI is present it must not preempt that app. Other platforms keep the old behaviour.
|
|
1235
|
+
if (candidate.source === "fallback" && deps.probeVersion === false && appCandidates.length > 0) continue;
|
|
1048
1236
|
const key = candidate.command.toLowerCase();
|
|
1049
1237
|
if (seen.has(key)) continue;
|
|
1050
1238
|
seen.add(key);
|
|
1051
1239
|
if (
|
|
1052
1240
|
deps.discoverAlternatives === false
|
|
1053
1241
|
&& valid.length > 0
|
|
1054
|
-
&& !(persistedIsUnpinned && candidate.source === "installed")
|
|
1242
|
+
&& !(persistedIsUnpinned && candidate.source === "installed" && !macCodexAppBundleFor(candidate.command, deps))
|
|
1055
1243
|
) {
|
|
1056
1244
|
continue;
|
|
1057
1245
|
}
|
|
@@ -1111,6 +1299,7 @@ function* resolveCodexRuntimeSteps(
|
|
|
1111
1299
|
const newerDiscovered = valid
|
|
1112
1300
|
.filter(item =>
|
|
1113
1301
|
!sameRuntimeCommand(item.command, selected.command)
|
|
1302
|
+
&& !macCodexAppBundleFor(item.command, deps)
|
|
1114
1303
|
&& typeof item.version === "string"
|
|
1115
1304
|
&& typeof selected.version === "string"
|
|
1116
1305
|
&& compareCodexVersions(item.version, selected.version) > 0)
|
|
@@ -1232,6 +1421,5 @@ export function formatClampLogLines(diagnostic: EffortClampDiagnostic): string[]
|
|
|
1232
1421
|
const efforts = diagnostic.removedEfforts.join(", ");
|
|
1233
1422
|
return [
|
|
1234
1423
|
`[opencodex] Removed unsupported reasoning efforts: ${efforts}`,
|
|
1235
|
-
"[opencodex] Run ocx doctor for diagnosis and recovery.",
|
|
1236
1424
|
];
|
|
1237
1425
|
}
|
|
@@ -404,7 +404,9 @@ function failure(error: unknown): InstallResult {
|
|
|
404
404
|
const message = error instanceof Error ? error.message : "Codex overlay operation failed";
|
|
405
405
|
return { installed: false, refused: true, message: truncateRetainedUtf8(message.replace(/[\r\n]/g, " "), MAX_DIAGNOSTIC_VALUE_BYTES) };
|
|
406
406
|
}
|
|
407
|
-
function installLocked(
|
|
407
|
+
function installLocked(
|
|
408
|
+
discover: () => string | null, automatic: boolean, check: () => void, describeMiss?: () => string | null,
|
|
409
|
+
): InstallResult {
|
|
408
410
|
recoverJournal(check);
|
|
409
411
|
const pinnedState = snapshot(statePath());
|
|
410
412
|
const result = readStateResult();
|
|
@@ -428,18 +430,20 @@ function installLocked(discover: () => string | null, automatic: boolean, check:
|
|
|
428
430
|
const discovered = automatic ? null : discover();
|
|
429
431
|
launcher = discovered ? resolve(discovered) : null;
|
|
430
432
|
}
|
|
431
|
-
|
|
433
|
+
// A discovery that refused a candidate (fnm's temporary multishell path) says why; reporting it
|
|
434
|
+
// as plain absence sent users to reinstall a Codex that was already there (#6963).
|
|
435
|
+
if (!launcher) throw new Error(describeMiss?.() || "Native Codex launcher not found; install or repair Codex with its package manager");
|
|
432
436
|
check();
|
|
433
437
|
return publishOverlay(launcher, overlay, check, pinnedState);
|
|
434
438
|
}
|
|
435
|
-
export function installUnixOverlay(discover: () => string | null): InstallResult {
|
|
439
|
+
export function installUnixOverlay(discover: () => string | null, describeMiss?: () => string | null): InstallResult {
|
|
436
440
|
let lock: ReturnType<typeof tryAcquireShimRestoreLock> = null;
|
|
437
441
|
try {
|
|
438
442
|
const check = namespace(true);
|
|
439
443
|
lock = tryAcquireShimRestoreLock();
|
|
440
444
|
if (!lock) throw new Error("Codex shim operation is already in progress; retry later");
|
|
441
445
|
check();
|
|
442
|
-
return installLocked(discover, false, check);
|
|
446
|
+
return installLocked(discover, false, check, describeMiss);
|
|
443
447
|
} catch (error) { return failure(error); }
|
|
444
448
|
finally { lock?.release(); }
|
|
445
449
|
}
|
package/src/codex/shim.ts
CHANGED
|
@@ -1079,7 +1079,7 @@ function installCodexShimInternal(options: InstallCodexShimInternalOptions): { i
|
|
|
1079
1079
|
}
|
|
1080
1080
|
|
|
1081
1081
|
export function installCodexShim(deps: RuntimePreflightDeps = {}): { installed: boolean; message: string; refused?: boolean; runnable?: boolean } {
|
|
1082
|
-
if (process.platform !== "win32") return installUnixOverlay(findCodexOnPath);
|
|
1082
|
+
if (process.platform !== "win32") return installUnixOverlay(findCodexOnPath, lastCodexDiscoveryError);
|
|
1083
1083
|
return installCodexShimInternal({ ...deps, allowFreshInstall: true });
|
|
1084
1084
|
}
|
|
1085
1085
|
|
package/src/combos/failover.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { parseResetCooldownMs } from "../codex/routing";
|
|
2
2
|
import { classifyError, isCyberPolicyCode } from "../lib/errors";
|
|
3
3
|
import { isNonReplayableUpstreamCode } from "../lib/upstream-retry";
|
|
4
|
+
import { isAnthropicBillingFailure } from "../providers/anthropic-billing-refusal";
|
|
4
5
|
import type { OcxComboTarget } from "../types";
|
|
5
6
|
import { targetKey } from "./types";
|
|
6
7
|
import {
|
|
@@ -563,6 +564,7 @@ export function comboFailureCooldownScope(
|
|
|
563
564
|
options?: { code?: string | null },
|
|
564
565
|
): ComboFailureCooldownScope {
|
|
565
566
|
const code = normalizedFailureCode(options?.code);
|
|
567
|
+
if (isAnthropicBillingFailure(status, message, options?.code)) return "provider";
|
|
566
568
|
// Request-shape refusals first: an oversized request must not cool a healthy target.
|
|
567
569
|
// A native transport can surface a zero-output model overflow as a generic
|
|
568
570
|
// upstream_server_error carrying precise context-window prose, so consult the bounded
|
|
@@ -847,6 +849,7 @@ export function comboFailureDecision(
|
|
|
847
849
|
// reaches that list and returns terminal before anything can ask whether the next target
|
|
848
850
|
// could have served the request (#4903).
|
|
849
851
|
if (isResponseFormatCapabilityRefusal(status, message, options?.code)) return "hop";
|
|
852
|
+
if (isAnthropicBillingFailure(status, message, options?.code)) return "hop";
|
|
850
853
|
if (["origin_rejected", "context_length_exceeded", "invalid_request_error"].includes(error.code ?? "")) {
|
|
851
854
|
return "stop";
|
|
852
855
|
}
|