@bitkyc08/opencodex 2.90.0 → 2.92.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 +37 -3
- package/gui/dist/assets/App-DMps4OLk.css +1 -0
- package/gui/dist/assets/{App-DgxkE6Md.js → App-sUf_8afJ.js} +18 -18
- package/gui/dist/assets/{Tray-DbTNc_88.js → Tray-BSsxznDm.js} +1 -1
- package/gui/dist/assets/index-BlFE_VB_.js +94 -0
- package/gui/dist/assets/quota-summary-BRlbXgw0.js +1 -0
- package/gui/dist/index.html +1 -1
- package/gui/dist/provider-icons/workbuddy-color.svg +29 -0
- package/package.json +4 -3
- package/src/adapters/anthropic.ts +2 -5
- package/src/adapters/claude-cli/usage-admission.ts +14 -0
- package/src/adapters/google-vertex-endpoint.ts +9 -1
- package/src/adapters/google-vertex-key-project.ts +223 -0
- package/src/adapters/google.ts +13 -3
- package/src/adapters/openai-chat/tool-name-registry.ts +3 -1
- package/src/adapters/openai-chat.ts +2 -2
- package/src/adapters/openai-responses/passthrough.ts +2 -1
- package/src/adapters/openai-responses/request-strips.ts +92 -0
- package/src/adapters/openai-responses/transport.ts +6 -0
- package/src/adapters/registry.ts +5 -0
- package/src/adapters/workbuddy-schema.ts +61 -0
- package/src/adapters/workbuddy.ts +68 -0
- package/src/bridge/response-json.ts +1 -1
- package/src/bridge/sse.ts +1 -1
- package/src/chat/inbound.ts +20 -2
- package/src/chat/outbound.ts +47 -8
- package/src/chat/reasoning-details.ts +137 -0
- package/src/cli/capabilities-agents-routing.ts +53 -3
- package/src/cli/capabilities-base.ts +15 -2
- package/src/cli/capabilities-observe-system.ts +4 -4
- package/src/cli/codex-cli-update.ts +8 -0
- package/src/cli/codex-shim-autorestore.ts +5 -0
- package/src/cli/combo-result.ts +6 -0
- package/src/cli/combo-stats.ts +3 -0
- package/src/cli/combo.ts +66 -42
- package/src/cli/dispatch.ts +30 -0
- package/src/cli/fallback.ts +127 -0
- package/src/cli/help.ts +1 -0
- package/src/cli/index.ts +1 -2
- package/src/cli/launcher-context.ts +49 -2
- package/src/cli/link-apply-restart.ts +72 -0
- package/src/cli/link-child.ts +6 -2
- package/src/cli/link-cleanup.ts +95 -0
- package/src/cli/link-enrollment.ts +4 -0
- package/src/cli/link.ts +123 -35
- package/src/cli/provider.ts +46 -3
- package/src/cli/registry.ts +13 -1
- package/src/cli/restart-failure.ts +4 -0
- package/src/cli/runtime-api.ts +1 -1
- package/src/cli/status-codex-settings.ts +56 -0
- package/src/cli/status-fallback.ts +29 -0
- package/src/cli/status-install.ts +52 -0
- package/src/cli/status.ts +20 -1
- package/src/cli/system-restart-client.ts +32 -1
- package/src/cli/update-restart-child.ts +4 -3
- package/src/cli/update-restart-home.ts +20 -8
- package/src/cli/usage-compare.ts +68 -12
- package/src/client/connect.ts +82 -6
- package/src/client/home-unreachable.ts +16 -0
- package/src/client/hub-relay.ts +8 -2
- package/src/client/link-home.ts +119 -6
- package/src/client/link-ingress.ts +1 -1
- package/src/client/link-parity-reporter.ts +107 -0
- package/src/client/link-readiness.ts +97 -0
- package/src/client/link-relay-transport.ts +84 -9
- package/src/client/link-relay.ts +98 -15
- package/src/client/link-tunnel.ts +45 -18
- package/src/client/machine-listener.ts +103 -3
- package/src/client/runtime-restart.ts +127 -0
- package/src/client/runtime.ts +155 -25
- package/src/codex/account-store.ts +17 -3
- package/src/codex/app-server-restart-service.ts +6 -5
- package/src/codex/auth-context.ts +10 -6
- package/src/codex/catalog/effort.ts +9 -2
- package/src/codex/catalog/gather-capture.ts +4 -2
- package/src/codex/catalog/parsing.ts +4 -0
- package/src/codex/catalog/provider-models.ts +14 -8
- package/src/codex/chatgpt-refresh-failure.ts +6 -2
- package/src/codex/cli-install-provenance.ts +418 -0
- package/src/codex/cli-installation-targets.ts +5 -1
- package/src/codex/cli-update-plan.ts +43 -16
- package/src/codex/desktop-app/picker-freshness.ts +10 -8
- package/src/codex/desktop-compatibility/usage-activation.ts +2 -1
- package/src/codex/desktop-compatibility/usage-controller.ts +7 -1
- package/src/codex/desktop-compatibility/windows-key-protection.ts +18 -6
- package/src/codex/pool-refresh-backoff.ts +3 -1
- package/src/codex/quota-history.ts +7 -0
- package/src/codex/quota.ts +10 -0
- package/src/codex/subagent-model-fallback.ts +24 -1
- package/src/codex/user-identity.ts +35 -6
- package/src/codex/windows-current-sid.ts +162 -0
- package/src/combos/index.ts +1 -0
- package/src/combos/jev-quota-activation.ts +5 -1
- package/src/combos/jev-quota-route.ts +1 -1
- package/src/combos/jev-quota.ts +9 -1
- package/src/combos/jev-session-pin-config.ts +51 -0
- package/src/combos/jev.ts +6 -0
- package/src/combos/resolve.ts +12 -0
- package/src/combos/types.ts +19 -0
- package/src/config/diagnostics.ts +5 -0
- package/src/config/load-degrade.ts +2 -0
- package/src/config/provider-validation.ts +7 -0
- package/src/config/schema/config-schema.ts +3 -0
- package/src/config/schema/fallback-policy.ts +40 -0
- package/src/config/schema/leaf-validators.ts +1 -0
- package/src/generated/compatibility-version.json +375 -195
- package/src/lib/authorization-scheme.ts +95 -0
- package/src/lib/decision-quota-sink.ts +10 -0
- package/src/lib/provider-client-headers.ts +1 -0
- package/src/lib/redact.ts +2 -2
- package/src/lib/system-restart-contract.ts +24 -0
- package/src/lib/test-home-guard.ts +78 -11
- package/src/lib/translator-budget.ts +4 -4
- package/src/lib/upstream-retry.ts +4 -1
- package/src/link/child-parity.ts +124 -0
- package/src/link/codex-settings.ts +37 -0
- package/src/link/compatibility.ts +119 -0
- package/src/link/diagnostics.ts +193 -0
- package/src/link/management-routes.ts +2 -0
- package/src/link/peer-report.ts +100 -0
- package/src/link/relay-auth.ts +6 -0
- package/src/link/remote-platform.ts +78 -0
- package/src/link/ssh-argv.ts +19 -0
- package/src/link/status-projection.ts +63 -2
- package/src/link/supervisor.ts +45 -12
- package/src/link/tunnel-history.ts +109 -0
- package/src/link/tunnel-state.ts +80 -17
- package/src/messaging/remote-files.ts +24 -1
- package/src/oauth/generic-account-failover.ts +91 -7
- package/src/oauth/index.ts +16 -1
- package/src/oauth/kimi.ts +2 -0
- package/src/oauth/model-discovery-auth.ts +5 -0
- package/src/oauth/nous.ts +4 -1
- package/src/oauth/store.ts +6 -2
- package/src/oauth/token-guardian.ts +7 -0
- package/src/oauth/types.ts +9 -0
- package/src/oauth/workbuddy.ts +139 -0
- package/src/protocols/codecs/chat.ts +3 -3
- package/src/protocols/encoders/adapter-events.ts +26 -2
- package/src/protocols/encoders/chat.ts +55 -3
- package/src/providers/antigravity-quota-identity.ts +37 -0
- package/src/providers/base-url-choices.ts +8 -5
- package/src/providers/derive.ts +23 -3
- package/src/providers/mistral-transport.ts +37 -4
- package/src/providers/model-discovery.ts +2 -0
- package/src/providers/model-rename-fields.ts +1 -0
- package/src/providers/quota/account-cache.ts +3 -2
- package/src/providers/quota/antigravity.ts +27 -22
- package/src/providers/quota/report-cache.ts +13 -4
- package/src/providers/quota/vendor-probes-key.ts +60 -0
- package/src/providers/quota/workbuddy.ts +83 -0
- package/src/providers/quota-decision-antigravity.ts +64 -0
- package/src/providers/quota-decision-publication.ts +11 -0
- package/src/providers/quota-decision-snapshot.ts +21 -4
- package/src/providers/quota-types.ts +2 -0
- package/src/providers/quota.ts +3 -1
- package/src/providers/registry/entries-core.ts +9 -0
- package/src/providers/registry/entries-extended.ts +20 -7
- package/src/providers/registry/model-ids.ts +3 -0
- package/src/providers/registry/model-seeds.ts +27 -28
- package/src/providers/registry/types.ts +19 -1
- package/src/providers/resolved-model-policy.ts +4 -4
- package/src/providers/routed-config.ts +15 -0
- package/src/providers/stale-model-roster-migration.ts +83 -15
- package/src/providers/workbuddy-credit-balance.ts +81 -0
- package/src/providers/workbuddy-models.ts +37 -0
- package/src/providers/workbuddy.ts +60 -0
- package/src/quota-pool/assemble.ts +326 -0
- package/src/quota-pool/contract.ts +58 -0
- package/src/quota-pool/identity.ts +203 -0
- package/src/remote/features.ts +74 -0
- package/src/remote/protocol.ts +2 -0
- package/src/remote/served-by.ts +57 -0
- package/src/responses/parser.ts +7 -0
- package/src/responses/reasoning-envelope.ts +12 -0
- package/src/responses/reasoning-replay-cache.ts +18 -14
- package/src/router.ts +2 -0
- package/src/routing/analytics.ts +2 -3
- package/src/routing/fallback-policy.ts +290 -0
- package/src/routing/fallback-state.ts +179 -0
- package/src/server/audio-client.ts +2 -1
- package/src/server/audio-dictation.ts +2 -1
- package/src/server/audio-upstream.ts +2 -1
- package/src/server/auth-cors.ts +7 -5
- package/src/server/background-lifecycle.ts +4 -0
- package/src/server/chat-completions.ts +22 -2
- package/src/server/chat-native-reasoning.ts +10 -3
- package/src/server/chat-native.ts +13 -2
- package/src/server/claude-messages.ts +3 -0
- package/src/server/gui-session.ts +2 -1
- package/src/server/index/link-listener.ts +11 -1
- package/src/server/index/link-management.ts +47 -0
- package/src/server/index/link-relay-sessions.ts +20 -7
- package/src/server/index/optional-listeners.ts +11 -2
- package/src/server/index/serve-options.ts +19 -2
- package/src/server/live.ts +5 -0
- package/src/server/local-management-read-client.ts +3 -1
- package/src/server/management/combo-routes.ts +10 -4
- package/src/server/management/context.ts +1 -1
- package/src/server/management/fallback-routes.ts +75 -0
- package/src/server/management/link-enrollment.ts +21 -3
- package/src/server/management/link-route-state.ts +2 -0
- package/src/server/management/link-routes.ts +16 -2
- package/src/server/management/provider-routes.ts +1 -1
- package/src/server/management/quota-pool-routes.ts +24 -0
- package/src/server/management/route-registry.ts +3 -0
- package/src/server/management-api.ts +16 -0
- package/src/server/management-auth.ts +2 -1
- package/src/server/port-reclaim.ts +51 -9
- package/src/server/request-log-conversation.ts +49 -0
- package/src/server/request-log.ts +2 -0
- package/src/server/responses/agent-task-recovery.ts +1 -1
- package/src/server/responses/anthropic-thinking-recovery.ts +1 -0
- package/src/server/responses/compact.ts +12 -4
- package/src/server/responses/core-auth.ts +11 -1
- package/src/server/responses/core-combo.ts +125 -32
- package/src/server/responses/core-lifetime.ts +2 -2
- package/src/server/responses/core-normalize.ts +6 -3
- package/src/server/responses/core-opaque-recovery.ts +1 -0
- package/src/server/responses/core-options.ts +5 -0
- package/src/server/responses/core-replay.ts +2 -1
- package/src/server/responses/encrypted-payload.ts +3 -3
- package/src/server/responses/jev-combo-choice.ts +39 -0
- package/src/server/responses/jev-session-pin.ts +203 -0
- package/src/server/responses/main-hard-lock-routing.ts +54 -2
- package/src/server/responses/passthrough-delivery.ts +4 -0
- package/src/server/responses/policy-fallback.ts +44 -1
- package/src/server/responses/request-prepare.ts +19 -6
- package/src/server/responses/request-transport.ts +25 -6
- package/src/server/responses/run-turn-execution.ts +2 -4
- package/src/server/responses/sidecar-execution.ts +2 -4
- package/src/server/responses-phase-inference.ts +267 -0
- package/src/server/restart-replacement.ts +50 -2
- package/src/server/sse-payload-rewrite.ts +14 -1
- package/src/server/ws-bridge.ts +5 -0
- package/src/service/cli.ts +25 -2
- package/src/service/definition.ts +20 -8
- package/src/service/diagnostics.ts +20 -6
- package/src/service/status-json.ts +116 -0
- package/src/types/config.ts +13 -0
- package/src/types/fallback.ts +38 -0
- package/src/types/provider.ts +9 -1
- package/src/types/request.ts +1 -0
- package/src/types/wire.ts +1 -0
- package/src/update/codex-cli-update-launch-policy.d.mts +9 -0
- package/src/update/codex-cli-update-launch-policy.mjs +30 -0
- package/src/usage/jev-stats.ts +54 -14
- package/gui/dist/assets/App-dnkJ85te.css +0 -1
- package/gui/dist/assets/index-TmaHa54u.js +0 -94
- package/gui/dist/assets/quota-summary-B16x5DZZ.js +0 -1
|
@@ -117,6 +117,7 @@ import type {
|
|
|
117
117
|
CatalogTrustedOpenAiApiPolicySnapshot,
|
|
118
118
|
} from "../convergence-types";
|
|
119
119
|
import type { CapturedProviderGather, CatalogGatherProviderAuthOutcome, CatalogGatherProviderModelOutcome, ModelsAuthResolution, ModelsAuthResolver } from "./gather-capture";
|
|
120
|
+
import { workBuddyCatalogIdentity } from "../../providers/workbuddy";
|
|
120
121
|
import { QUIET_AUTHORITATIVE_CATALOG_PROVIDERS, applyConfigHintsToCachedModels, applyProviderConfigHints, boundedOwnedBy, catalogHintsFromModelsApiItem, catalogHintsFromProviderConfig, withVisionSidecarInput } from "./model-hints";
|
|
121
122
|
import { mergeConfiguredModelsIntoLiveCatalog, shouldExposeProviderModel, warnDroppedConfiguredIdsOnce } from "./model-visibility";
|
|
122
123
|
import { captureModelsRequest, captureProviderGather, materializeCapturedHeaders } from "./gather-capture";
|
|
@@ -161,6 +162,7 @@ export function observedModelsAuthResolver(
|
|
|
161
162
|
observed: true,
|
|
162
163
|
oauthAccountId: observation.snapshot.accountId,
|
|
163
164
|
oauthGeneration: observation.snapshot.generation,
|
|
165
|
+
...(observation.snapshot.workbuddy ? { workbuddy: observation.snapshot.workbuddy } : {}),
|
|
164
166
|
...(observation.snapshot.providerUserId ? { oauthProviderUserId: observation.snapshot.providerUserId } : {}),
|
|
165
167
|
...(observation.snapshot.apiBaseUrl ? { oauthApiBaseUrl: observation.snapshot.apiBaseUrl } : {}),
|
|
166
168
|
...(observation.snapshot.projectId ? { oauthProjectId: observation.snapshot.projectId } : {}),
|
|
@@ -280,6 +282,7 @@ export async function fetchProviderModelsWithAuth(
|
|
|
280
282
|
observed: false,
|
|
281
283
|
oauthAccountId: snapshot.accountId,
|
|
282
284
|
oauthGeneration: snapshot.generation,
|
|
285
|
+
...(snapshot.workbuddy ? { workbuddy: snapshot.workbuddy } : {}),
|
|
283
286
|
...(snapshot.providerUserId ? { oauthProviderUserId: snapshot.providerUserId } : {}),
|
|
284
287
|
...(snapshot.apiBaseUrl ? { oauthApiBaseUrl: snapshot.apiBaseUrl } : {}),
|
|
285
288
|
...(snapshot.projectId ? { oauthProjectId: snapshot.projectId } : {}),
|
|
@@ -646,7 +649,10 @@ export async function fetchProviderModelsWithAuth(
|
|
|
646
649
|
const cloudCodeAssist = effectiveGoogleMode(name, prov) === "cloud-code-assist";
|
|
647
650
|
const project = prov.project ?? auth.oauthProjectId;
|
|
648
651
|
if (cloudCodeAssist && !project) return observed(configured, "degraded");
|
|
649
|
-
|
|
652
|
+
// WorkBuddy model entitlements belong to the captured account and tenant.
|
|
653
|
+
const catalogAuthority = name === "workbuddy-ai"
|
|
654
|
+
? workBuddyCatalogIdentity(apiKey!, auth.workbuddy) : undefined;
|
|
655
|
+
const fresh = getFreshCached(name, ttlMs, Date.now(), catalogAuthority);
|
|
650
656
|
if (fresh) {
|
|
651
657
|
return observed(
|
|
652
658
|
withConfiguredRetention(
|
|
@@ -655,10 +661,10 @@ export async function fetchProviderModelsWithAuth(
|
|
|
655
661
|
"authoritative",
|
|
656
662
|
); // dedups Codex's frequent /v1/models polling within the TTL
|
|
657
663
|
}
|
|
658
|
-
if (isModelsFetchCoolingDown(name)) {
|
|
664
|
+
if (isModelsFetchCoolingDown(name, undefined, undefined, catalogAuthority)) {
|
|
659
665
|
// A recently-failed provider (unreachable API, missing proxy, bad key) must not re-pay the
|
|
660
666
|
// fetch timeout on every catalog poll — the dashboard polls this path per page load.
|
|
661
|
-
const stale = getStaleCached(name);
|
|
667
|
+
const stale = getStaleCached(name, catalogAuthority);
|
|
662
668
|
return observed(
|
|
663
669
|
withConfiguredRetention(
|
|
664
670
|
stale
|
|
@@ -671,7 +677,7 @@ export async function fetchProviderModelsWithAuth(
|
|
|
671
677
|
// The captured request predates any refresh, so a refreshing gather rebuilds it
|
|
672
678
|
// from the auth it resolved: the token and its origin, together.
|
|
673
679
|
const request = resolveAuth.kind === "refreshing" && name !== "anthropic2"
|
|
674
|
-
? captureModelsRequest(name, prov, auth.oauthApiBaseUrl)
|
|
680
|
+
? captureModelsRequest(name, prov, auth.oauthApiBaseUrl, auth.workbuddy)
|
|
675
681
|
: captured.request;
|
|
676
682
|
const url = request.url;
|
|
677
683
|
let headers = materializeCapturedHeaders(request, apiKey);
|
|
@@ -701,9 +707,9 @@ export async function fetchProviderModelsWithAuth(
|
|
|
701
707
|
// suppress an identical repeated failure (#395 log flood). The failure stays observable via the
|
|
702
708
|
// discovery-status API regardless.
|
|
703
709
|
const shouldLog = shouldLogDiscoveryFailure(name, failure);
|
|
704
|
-
markModelsFetchFailure(name);
|
|
710
|
+
markModelsFetchFailure(name, undefined, catalogAuthority);
|
|
705
711
|
markProviderDiscoveryFailed(name, failure);
|
|
706
|
-
const stale = getStaleCached(name);
|
|
712
|
+
const stale = getStaleCached(name, catalogAuthority);
|
|
707
713
|
return {
|
|
708
714
|
models: withConfiguredRetention(
|
|
709
715
|
stale
|
|
@@ -812,7 +818,7 @@ export async function fetchProviderModelsWithAuth(
|
|
|
812
818
|
// Entitlement is per account (Claude 4.6 vs 5.5, Argon): remember what THIS account
|
|
813
819
|
// lists so account selection can prefer one that serves the requested model.
|
|
814
820
|
if (auth.oauthAccountId) recordAntigravityAccountRoster(name, auth.oauthAccountId, antigravity);
|
|
815
|
-
if (!setCached(name, forCache, Date.now(), cacheGeneration)) {
|
|
821
|
+
if (!setCached(name, forCache, Date.now(), cacheGeneration, catalogAuthority)) {
|
|
816
822
|
return observed(withConfiguredRetention(configured), "degraded");
|
|
817
823
|
}
|
|
818
824
|
markProviderDiscoveryOk(name, live.length);
|
|
@@ -898,7 +904,7 @@ export async function fetchProviderModelsWithAuth(
|
|
|
898
904
|
`[opencodex] Provider model discovery for "${name}" returned an authoritative empty catalog; ${droppedConfiguredIds.length > 0 ? `dropping configured model ids: ${droppedConfiguredIds.join(", ")}` : "no models will be exposed"}.`,
|
|
899
905
|
);
|
|
900
906
|
}
|
|
901
|
-
if (!maySendAnthropicDiscovery() || !setCached(name, forCache, Date.now(), cacheGeneration)) {
|
|
907
|
+
if (!maySendAnthropicDiscovery() || !setCached(name, forCache, Date.now(), cacheGeneration, catalogAuthority)) {
|
|
902
908
|
return observed(withConfiguredRetention(configured), "degraded");
|
|
903
909
|
}
|
|
904
910
|
markProviderDiscoveryOk(name, liveModelCount);
|
|
@@ -45,12 +45,16 @@ export function classifyChatgptRefreshFailure(status: number, body: string): Cha
|
|
|
45
45
|
return { reason, ...(code ? { code } : {}) };
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
/**
|
|
48
|
+
/**
|
|
49
|
+
* Endpoint verdict plus, for pool accounts, the redacted `p<hex6>` log label so a multi-account
|
|
50
|
+
* pool can be triaged from the log. Never a token, email, body, or arbitrary upstream string.
|
|
51
|
+
*/
|
|
49
52
|
export function noteChatgptRefreshFailure(
|
|
50
53
|
kind: "pool" | "native main",
|
|
51
54
|
status: number,
|
|
52
55
|
failure: ChatgptRefreshFailure,
|
|
56
|
+
accountLabel?: string,
|
|
53
57
|
): void {
|
|
54
|
-
console.warn(`[codex] ${kind} refresh: ${failure.reason === "unknown" ? "transient" : "reauth"}`
|
|
58
|
+
console.warn(`[codex] ${kind} refresh${accountLabel ? ` ${accountLabel}` : ""}: ${failure.reason === "unknown" ? "transient" : "reauth"}`
|
|
55
59
|
+ ` status=${status} code=${failure.code ?? "none"}`);
|
|
56
60
|
}
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
readSync,
|
|
10
10
|
realpathSync,
|
|
11
11
|
statSync,
|
|
12
|
+
type Stats,
|
|
12
13
|
} from "node:fs";
|
|
13
14
|
import { posix, win32 } from "node:path";
|
|
14
15
|
import { getConfigDir } from "../config";
|
|
@@ -33,6 +34,18 @@ const CODEX_PACKAGE = "@openai/codex";
|
|
|
33
34
|
const MAX_MANIFEST_BYTES = 256 * 1024;
|
|
34
35
|
const MAX_RUNTIME_STATE_BYTES = 256 * 1024;
|
|
35
36
|
const MAX_MANIFEST_ANCESTORS = 12;
|
|
37
|
+
/** The published package entrypoint is a few KB; 1 MiB leaves wide headroom. */
|
|
38
|
+
const MAX_PACKAGE_ENTRYPOINT_BYTES = 1024 * 1024;
|
|
39
|
+
/**
|
|
40
|
+
* The native companion binary is streamed in HELD_READ_CHUNK_BYTES chunks and
|
|
41
|
+
* never buffered whole, so this bound limits read time, not memory. Published
|
|
42
|
+
* backings outgrew the former 256 MiB: `@openai/codex@0.163.0-linux-x64` ships a
|
|
43
|
+
* 296,865,480-byte binary, in platform packages of 341-430 MB unpacked. 1 GiB
|
|
44
|
+
* leaves more than 3x headroom over that binary while staying finite; larger
|
|
45
|
+
* files are refused by fstat before any read.
|
|
46
|
+
*/
|
|
47
|
+
export const MAX_NATIVE_BACKING_BYTES = 1024 * 1024 * 1024;
|
|
48
|
+
const HELD_READ_CHUNK_BYTES = 1024 * 1024;
|
|
36
49
|
|
|
37
50
|
export type CodexCliInstallKind =
|
|
38
51
|
| "npm-global"
|
|
@@ -64,6 +77,7 @@ export type CodexCliInstallEvidence =
|
|
|
64
77
|
| "version_manager_path"
|
|
65
78
|
| "package_manifest"
|
|
66
79
|
| "package_manifest_digest"
|
|
80
|
+
| "install_content_digest"
|
|
67
81
|
| "shim_backing"
|
|
68
82
|
| "global_npm_layout";
|
|
69
83
|
|
|
@@ -92,6 +106,20 @@ export interface CodexCliInstallReport {
|
|
|
92
106
|
* identity binds this value so two different install roots cannot share a plan.
|
|
93
107
|
*/
|
|
94
108
|
readonly installDigest: string | null;
|
|
109
|
+
/**
|
|
110
|
+
* sha256 digest over the bounded content observation of the versioned file
|
|
111
|
+
* set the supported npm-global layout defines: the package manifest, its
|
|
112
|
+
* declared bin/codex.js entrypoint, and the platform backing package manifest
|
|
113
|
+
* plus vendored binary at the locations the layout's modeled Node lookup
|
|
114
|
+
* produces. This binds the bytes that were actually read under a
|
|
115
|
+
* held-identity window; it is not an execution guarantee. It does not prove
|
|
116
|
+
* which bytes a launcher run would execute, that the files are authentic
|
|
117
|
+
* upstream artifacts, or that the rest of the install tree is unchanged — a
|
|
118
|
+
* later apply phase must re-observe under its own held handles rather than
|
|
119
|
+
* treating this plan-time digest as durable proof. Null when the caller did
|
|
120
|
+
* not request the observation or when the required set could not be bound.
|
|
121
|
+
*/
|
|
122
|
+
readonly installContentDigest: string | null;
|
|
95
123
|
readonly packageVersion: string | null;
|
|
96
124
|
readonly shim: Readonly<{
|
|
97
125
|
status: "not-tracked" | "matched" | "unknown";
|
|
@@ -110,6 +138,21 @@ export interface CodexCliInstallProvenanceDeps {
|
|
|
110
138
|
readonly readFile?: (path: string) => Buffer;
|
|
111
139
|
readonly boundedFileReadMode?: "native-hardened" | "injected-test";
|
|
112
140
|
readonly realpath?: (path: string) => string;
|
|
141
|
+
/**
|
|
142
|
+
* The architecture the launcher would report for its own interpreter. The
|
|
143
|
+
* published entrypoint picks its native backing by (platform, arch); the
|
|
144
|
+
* value is recorded inside the content digest so a different launcher
|
|
145
|
+
* architecture can never silently share a plan.
|
|
146
|
+
*/
|
|
147
|
+
readonly arch?: NodeJS.Architecture;
|
|
148
|
+
/**
|
|
149
|
+
* Opt-in switch for the bounded install-content observation. Reading the
|
|
150
|
+
* required file set can stream a vendored binary of up to
|
|
151
|
+
* MAX_NATIVE_BACKING_BYTES, so a caller that only needs the ownership
|
|
152
|
+
* classification leaves this unset and receives a null installContentDigest;
|
|
153
|
+
* the update-plan path sets it because its planId binds the digest.
|
|
154
|
+
*/
|
|
155
|
+
readonly observeInstallContent?: boolean;
|
|
113
156
|
readonly inspectShim?: (
|
|
114
157
|
command: string,
|
|
115
158
|
platform: NodeJS.Platform,
|
|
@@ -183,6 +226,7 @@ function unknownReport(
|
|
|
183
226
|
reason,
|
|
184
227
|
location: extra.location ?? null,
|
|
185
228
|
installDigest: null,
|
|
229
|
+
installContentDigest: null,
|
|
186
230
|
packageVersion: null,
|
|
187
231
|
shim: Object.freeze({ status: "not-tracked", backingKind: null }),
|
|
188
232
|
evidence: Object.freeze([]),
|
|
@@ -636,6 +680,359 @@ function shimReport(shim: CodexShimBackingForCommand): CodexCliInstallReport["sh
|
|
|
636
680
|
return Object.freeze({ status: "not-tracked" as const, backingKind: null });
|
|
637
681
|
}
|
|
638
682
|
|
|
683
|
+
/**
|
|
684
|
+
* The published @openai/codex bin/codex.js selection table, mirrored as layout
|
|
685
|
+
* knowledge: the supported npm-global layout places the platform backing
|
|
686
|
+
* package where Node's module lookup from the package's bin directory finds
|
|
687
|
+
* "<package>/package.json", then "<package-dir>/vendor/<triple>/bin/codex"
|
|
688
|
+
* (codex.exe on Windows), with an in-package "vendor/<triple>" fallback when
|
|
689
|
+
* that resolve would throw. This table locates files for observation; it does
|
|
690
|
+
* not prove which file a launcher run would select. A launcher following a
|
|
691
|
+
* different rule simply produces different observed entrypoint bytes.
|
|
692
|
+
*/
|
|
693
|
+
const CODEX_PLATFORM_BACKING: Readonly<Record<string, Readonly<{ triple: string; package: string }>>> = {
|
|
694
|
+
"linux:x64": { triple: "x86_64-unknown-linux-musl", package: "@openai/codex-linux-x64" },
|
|
695
|
+
"linux:arm64": { triple: "aarch64-unknown-linux-musl", package: "@openai/codex-linux-arm64" },
|
|
696
|
+
"android:x64": { triple: "x86_64-unknown-linux-musl", package: "@openai/codex-linux-x64" },
|
|
697
|
+
"android:arm64": { triple: "aarch64-unknown-linux-musl", package: "@openai/codex-linux-arm64" },
|
|
698
|
+
"darwin:x64": { triple: "x86_64-apple-darwin", package: "@openai/codex-darwin-x64" },
|
|
699
|
+
"darwin:arm64": { triple: "aarch64-apple-darwin", package: "@openai/codex-darwin-arm64" },
|
|
700
|
+
"win32:x64": { triple: "x86_64-pc-windows-msvc", package: "@openai/codex-win32-x64" },
|
|
701
|
+
"win32:arm64": { triple: "aarch64-pc-windows-msvc", package: "@openai/codex-win32-arm64" },
|
|
702
|
+
};
|
|
703
|
+
|
|
704
|
+
/** Node's Module._nodeModulePaths: every ancestor's node_modules child, closest first. */
|
|
705
|
+
function nodeModuleDirs(fromDir: string, platform: NodeJS.Platform): string[] {
|
|
706
|
+
const tools = pathTools(platform);
|
|
707
|
+
const dirs: string[] = [];
|
|
708
|
+
let cursor = fromDir;
|
|
709
|
+
for (;;) {
|
|
710
|
+
// A directory literally named "node_modules" contributes no nested
|
|
711
|
+
// node_modules entry of its own in Node's lookup.
|
|
712
|
+
if (tools.basename(cursor) !== "node_modules") dirs.push(tools.join(cursor, "node_modules"));
|
|
713
|
+
const parent = tools.dirname(cursor);
|
|
714
|
+
if (parent === cursor) break;
|
|
715
|
+
cursor = parent;
|
|
716
|
+
}
|
|
717
|
+
return dirs;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* Node's global lookup list for the launcher process: NODE_PATH entries first,
|
|
722
|
+
* then $HOME/.node_modules and $HOME/.node_libraries, then the lib/node directory
|
|
723
|
+
* beside the install prefix of the node binary the captured PATH resolves.
|
|
724
|
+
* 'complete' is false when any input cannot be modeled from the bound
|
|
725
|
+
* environment — in that case 'require.resolve threw' cannot be proven and the
|
|
726
|
+
* in-package vendor fallback lane stays unverifiable.
|
|
727
|
+
*/
|
|
728
|
+
function globalModuleDirs(
|
|
729
|
+
env: NodeJS.ProcessEnv,
|
|
730
|
+
platform: NodeJS.Platform,
|
|
731
|
+
deps: CodexCliInstallProvenanceDeps,
|
|
732
|
+
): { dirs: string[]; complete: boolean } {
|
|
733
|
+
const tools = pathTools(platform);
|
|
734
|
+
const dirs: string[] = [];
|
|
735
|
+
let complete = true;
|
|
736
|
+
const nodePath = platform === "win32" ? caseInsensitiveEnv(env, "NODE_PATH") : env.NODE_PATH;
|
|
737
|
+
if (nodePath) {
|
|
738
|
+
for (const entry of nodePath.split(tools.delimiter)) {
|
|
739
|
+
if (!entry) continue;
|
|
740
|
+
// Node resolves a relative NODE_PATH entry against the launcher process's
|
|
741
|
+
// cwd, which this read-only inspection cannot know.
|
|
742
|
+
if (isSafeLocalInspectionPath(entry, deps)) dirs.push(tools.resolve(entry));
|
|
743
|
+
else complete = false;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
const home = platform === "win32"
|
|
747
|
+
? caseInsensitiveEnv(env, "USERPROFILE") ?? caseInsensitiveEnv(env, "HOME")
|
|
748
|
+
: env.HOME;
|
|
749
|
+
if (home && isSafeLocalInspectionPath(home, deps)) {
|
|
750
|
+
dirs.push(tools.join(home, ".node_modules"), tools.join(home, ".node_libraries"));
|
|
751
|
+
} else {
|
|
752
|
+
complete = false;
|
|
753
|
+
}
|
|
754
|
+
const nodeCommand = resolveCandidateCommandPath("node", deps);
|
|
755
|
+
const canonicalNode = nodeCommand ? canonicalize(nodeCommand, deps) : null;
|
|
756
|
+
if (canonicalNode) {
|
|
757
|
+
// POSIX node lives at <prefix>/bin/node, so the lookup prefix is two levels
|
|
758
|
+
// up; a Windows prefix sits beside the executable. The win32 arm is
|
|
759
|
+
// unreachable today — inspection returns before any candidate I/O there.
|
|
760
|
+
const prefix = platform === "win32"
|
|
761
|
+
? tools.dirname(canonicalNode)
|
|
762
|
+
: tools.dirname(tools.dirname(canonicalNode));
|
|
763
|
+
dirs.push(tools.join(prefix, "lib", "node"));
|
|
764
|
+
} else {
|
|
765
|
+
complete = false;
|
|
766
|
+
}
|
|
767
|
+
return { dirs, complete };
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
interface InstallContentFile {
|
|
771
|
+
readonly role: "manifest" | "entrypoint" | "backing-manifest" | "backing-binary";
|
|
772
|
+
readonly path: string;
|
|
773
|
+
readonly maxBytes: number;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
interface ObservedContentFile {
|
|
777
|
+
readonly digest: string;
|
|
778
|
+
/** Retained only for small role files; the streamed binary keeps none. */
|
|
779
|
+
readonly bytes: Buffer | null;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function fileContentDomain(role: InstallContentFile["role"]): string {
|
|
783
|
+
return role === "manifest" ? "codex-cli-package-manifest-v1" : "codex-cli-observed-file-v1";
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* Hash every requested file under one observation window. Native POSIX mode
|
|
788
|
+
* opens all files first (O_NOFOLLOW | O_NONBLOCK), streams each digest, then
|
|
789
|
+
* re-verifies every descriptor's identity and each canonical path's linkage
|
|
790
|
+
* before returning. Injected-test mode performs the equivalent stat -> read ->
|
|
791
|
+
* stat sequence per file plus a final identity pass over the whole set. The
|
|
792
|
+
* optional finalize hook runs its own checks while the held set is still open,
|
|
793
|
+
* so linkage and selector re-verification completes inside the window rather
|
|
794
|
+
* than after the descriptors close. Any read, size, identity or finalize
|
|
795
|
+
* failure inside the window fails the set closed.
|
|
796
|
+
*/
|
|
797
|
+
function observeHeldFileSet(
|
|
798
|
+
requests: readonly InstallContentFile[],
|
|
799
|
+
deps: CodexCliInstallProvenanceDeps,
|
|
800
|
+
finalize?: () => boolean,
|
|
801
|
+
): Map<string, ObservedContentFile> | null {
|
|
802
|
+
const keepBytes = (role: InstallContentFile["role"]): boolean => role !== "backing-binary";
|
|
803
|
+
if (deps.boundedFileReadMode === "injected-test") {
|
|
804
|
+
const stat = deps.stat;
|
|
805
|
+
const read = deps.readFile;
|
|
806
|
+
if (!stat || !read) return null;
|
|
807
|
+
const results = new Map<string, ObservedContentFile>();
|
|
808
|
+
const identities: { before: Stats; req: InstallContentFile }[] = [];
|
|
809
|
+
try {
|
|
810
|
+
for (const req of requests) {
|
|
811
|
+
const before = stat(req.path);
|
|
812
|
+
if (!before.isFile() || before.size > req.maxBytes) return null;
|
|
813
|
+
const bytes = read(req.path);
|
|
814
|
+
const after = stat(req.path);
|
|
815
|
+
if (bytes.byteLength !== before.size
|
|
816
|
+
|| before.dev !== after.dev || before.ino !== after.ino
|
|
817
|
+
|| before.size !== after.size || before.mtimeMs !== after.mtimeMs) return null;
|
|
818
|
+
identities.push({ before, req });
|
|
819
|
+
results.set(req.role, {
|
|
820
|
+
digest: sha256(fileContentDomain(req.role), bytes),
|
|
821
|
+
bytes: keepBytes(req.role) ? bytes : null,
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
// A replacement that slipped in after a file's own read still failed.
|
|
825
|
+
for (const { before, req } of identities) {
|
|
826
|
+
const now = stat(req.path);
|
|
827
|
+
if (before.dev !== now.dev || before.ino !== now.ino
|
|
828
|
+
|| before.size !== now.size || before.mtimeMs !== now.mtimeMs) return null;
|
|
829
|
+
}
|
|
830
|
+
if (finalize && !finalize()) return null;
|
|
831
|
+
return results;
|
|
832
|
+
} catch {
|
|
833
|
+
return null;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
const held: { fd: number; req: InstallContentFile; before: Stats }[] = [];
|
|
837
|
+
const openedFds: number[] = [];
|
|
838
|
+
try {
|
|
839
|
+
const flags = process.platform === "win32"
|
|
840
|
+
? fsConstants.O_RDONLY
|
|
841
|
+
: fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK;
|
|
842
|
+
for (const req of requests) {
|
|
843
|
+
if (!isSafeLocalInspectionPath(req.path, deps)) return null;
|
|
844
|
+
const fd = openSync(req.path, flags);
|
|
845
|
+
// The descriptor joins the close set before any check that can bail, so
|
|
846
|
+
// an fstat failure or a refused type/size cannot leak it.
|
|
847
|
+
openedFds.push(fd);
|
|
848
|
+
const before = fstatSync(fd);
|
|
849
|
+
// O_NONBLOCK keeps a FIFO open nonblocking, so the type check must come
|
|
850
|
+
// first: anything that is not a regular file is refused before a read.
|
|
851
|
+
if (!before.isFile() || before.size > req.maxBytes) return null;
|
|
852
|
+
held.push({ fd, req, before });
|
|
853
|
+
}
|
|
854
|
+
const results = new Map<string, ObservedContentFile>();
|
|
855
|
+
for (const { fd, req, before } of held) {
|
|
856
|
+
const hasher = createHash("sha256")
|
|
857
|
+
.update(fileContentDomain(req.role), "utf8")
|
|
858
|
+
.update("\0", "utf8");
|
|
859
|
+
const bytes = keepBytes(req.role) ? Buffer.allocUnsafe(before.size) : null;
|
|
860
|
+
const chunk = Buffer.allocUnsafe(Math.max(1, Math.min(HELD_READ_CHUNK_BYTES, before.size)));
|
|
861
|
+
let offset = 0;
|
|
862
|
+
while (offset < before.size) {
|
|
863
|
+
const count = readSync(fd, chunk, 0, Math.min(chunk.length, before.size - offset), offset);
|
|
864
|
+
if (count <= 0) return null;
|
|
865
|
+
hasher.update(chunk.subarray(0, count));
|
|
866
|
+
if (bytes) chunk.copy(bytes, offset, 0, count);
|
|
867
|
+
offset += count;
|
|
868
|
+
}
|
|
869
|
+
// A file that grew inside the window is a different file than was hashed.
|
|
870
|
+
if (readSync(fd, chunk, 0, 1, offset) !== 0) return null;
|
|
871
|
+
results.set(req.role, { digest: hasher.digest("hex"), bytes });
|
|
872
|
+
}
|
|
873
|
+
for (const { fd, req, before } of held) {
|
|
874
|
+
const after = fstatSync(fd);
|
|
875
|
+
const lexical = lstatSync(req.path);
|
|
876
|
+
const followed = statSync(req.path);
|
|
877
|
+
if (before.dev !== after.dev || before.ino !== after.ino
|
|
878
|
+
|| before.size !== after.size || before.mtimeMs !== after.mtimeMs
|
|
879
|
+
|| before.ctimeMs !== after.ctimeMs
|
|
880
|
+
|| lexical.isSymbolicLink() || !lexical.isFile()
|
|
881
|
+
|| lexical.dev !== before.dev || lexical.ino !== before.ino
|
|
882
|
+
|| followed.dev !== before.dev || followed.ino !== before.ino) return null;
|
|
883
|
+
}
|
|
884
|
+
if (finalize && !finalize()) return null;
|
|
885
|
+
return results;
|
|
886
|
+
} catch {
|
|
887
|
+
return null;
|
|
888
|
+
} finally {
|
|
889
|
+
for (const fd of openedFds) {
|
|
890
|
+
try { closeSync(fd); } catch { /* the observation already failed */ }
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* Bind the versioned file set the supported npm-global layout defines: the
|
|
897
|
+
* manifest, its declared bin/codex.js entrypoint, and the platform backing
|
|
898
|
+
* (manifest plus vendored binary) at the locations the layout's modeled Node
|
|
899
|
+
* lookup produces. The bound set is deliberately small and versioned; this is
|
|
900
|
+
* not an execution proof, an artifact-authenticity claim, or a
|
|
901
|
+
* whole-tree-integrity claim.
|
|
902
|
+
*/
|
|
903
|
+
function observeInstallContent(
|
|
904
|
+
manifest: PackageManifestEvidence,
|
|
905
|
+
npmPrefix: string,
|
|
906
|
+
platform: NodeJS.Platform,
|
|
907
|
+
env: NodeJS.ProcessEnv,
|
|
908
|
+
deps: CodexCliInstallProvenanceDeps,
|
|
909
|
+
selection: { readonly lexical: string; readonly canonical: string },
|
|
910
|
+
): string | null {
|
|
911
|
+
const arch = deps.arch ?? process.arch;
|
|
912
|
+
const backing = CODEX_PLATFORM_BACKING[platform + ":" + arch];
|
|
913
|
+
if (!backing) return null;
|
|
914
|
+
const tools = pathTools(platform);
|
|
915
|
+
const normalizedPrefix = normalizePath(npmPrefix, platform);
|
|
916
|
+
// A filesystem-root prefix cannot bound anything; treat it as unsupported.
|
|
917
|
+
if (!normalizedPrefix || normalizedPrefix === "/") return null;
|
|
918
|
+
const withinPrefix = (path: string): boolean => {
|
|
919
|
+
const normalized = normalizePath(path, platform);
|
|
920
|
+
return normalized === normalizedPrefix || normalized.startsWith(normalizedPrefix + "/");
|
|
921
|
+
};
|
|
922
|
+
// Every lexical path that produced a bound canonical target — the selected
|
|
923
|
+
// launcher included — is re-resolved at the window's final observed boundary.
|
|
924
|
+
// Held descriptors do not prevent link mutation; this check observes whether
|
|
925
|
+
// the selection still reaches the bound files at that instant.
|
|
926
|
+
const links: { readonly lexical: string; readonly canonical: string }[] = [
|
|
927
|
+
{ lexical: selection.lexical, canonical: selection.canonical },
|
|
928
|
+
];
|
|
929
|
+
const canonicalBound = (path: string): string | null => {
|
|
930
|
+
const canonical = canonicalize(path, deps);
|
|
931
|
+
if (!canonical || !withinPrefix(canonical)) return null;
|
|
932
|
+
links.push({ lexical: path, canonical });
|
|
933
|
+
return canonical;
|
|
934
|
+
};
|
|
935
|
+
const entrypoint = canonicalBound(tools.join(manifest.root, ...manifest.binPath.split("/")));
|
|
936
|
+
if (!entrypoint) return null;
|
|
937
|
+
|
|
938
|
+
const globals = globalModuleDirs(env, platform, deps);
|
|
939
|
+
const lstat = deps.lstat ?? lstatSync;
|
|
940
|
+
// Probe one modeled lookup lane in order; the first hit wins.
|
|
941
|
+
const probeDirs = (
|
|
942
|
+
dirs: readonly string[],
|
|
943
|
+
): { kind: "none" } | { kind: "hit"; path: string; dir: string } | { kind: "refuse" } => {
|
|
944
|
+
for (const dir of dirs) {
|
|
945
|
+
const probe = tools.join(dir, ...backing.package.split("/"), "package.json");
|
|
946
|
+
let entry;
|
|
947
|
+
try {
|
|
948
|
+
entry = lstat(probe);
|
|
949
|
+
} catch (error) {
|
|
950
|
+
const code = (error as NodeJS.ErrnoException | null)?.code;
|
|
951
|
+
if (code === "ENOENT" || code === "ENOTDIR") continue;
|
|
952
|
+
return { kind: "refuse" };
|
|
953
|
+
}
|
|
954
|
+
if (!entry.isFile() && !entry.isSymbolicLink()) continue;
|
|
955
|
+
// First hit wins, matching the lookup order the layout models.
|
|
956
|
+
const canonical = canonicalize(probe, deps);
|
|
957
|
+
if (!canonical || !withinPrefix(canonical)) return { kind: "refuse" };
|
|
958
|
+
links.push({ lexical: probe, canonical });
|
|
959
|
+
return { kind: "hit", path: canonical, dir: tools.dirname(canonical) };
|
|
960
|
+
}
|
|
961
|
+
return { kind: "none" };
|
|
962
|
+
};
|
|
963
|
+
const probeBacking = (): ReturnType<typeof probeDirs> => {
|
|
964
|
+
const local = probeDirs(nodeModuleDirs(tools.dirname(entrypoint), platform));
|
|
965
|
+
if (local.kind !== "none") return local;
|
|
966
|
+
// Missing lookup seeds leave unknown earlier global lanes. Even an
|
|
967
|
+
// observed in-prefix global hit cannot prove which package wins there.
|
|
968
|
+
if (!globals.complete) return { kind: "refuse" };
|
|
969
|
+
return probeDirs(globals.dirs);
|
|
970
|
+
};
|
|
971
|
+
const selectedBacking = probeBacking();
|
|
972
|
+
if (selectedBacking.kind === "refuse") return null;
|
|
973
|
+
const backingManifestPath = selectedBacking.kind === "hit" ? selectedBacking.path : null;
|
|
974
|
+
const backingPackageDir = selectedBacking.kind === "hit" ? selectedBacking.dir : null;
|
|
975
|
+
|
|
976
|
+
const binName = platform === "win32" ? "codex.exe" : "codex";
|
|
977
|
+
const backingBinary = canonicalBound(backingPackageDir
|
|
978
|
+
? tools.join(backingPackageDir, "vendor", backing.triple, "bin", binName)
|
|
979
|
+
: tools.join(manifest.root, "vendor", backing.triple, "bin", binName));
|
|
980
|
+
// The launcher cannot run without the vendored binary; a missing one makes
|
|
981
|
+
// the required set incomplete, not merely shorter.
|
|
982
|
+
if (!backingBinary) return null;
|
|
983
|
+
|
|
984
|
+
const requests: InstallContentFile[] = [
|
|
985
|
+
{ role: "manifest", path: manifest.path, maxBytes: MAX_MANIFEST_BYTES },
|
|
986
|
+
{ role: "entrypoint", path: entrypoint, maxBytes: MAX_PACKAGE_ENTRYPOINT_BYTES },
|
|
987
|
+
...(backingManifestPath
|
|
988
|
+
? [{ role: "backing-manifest" as const, path: backingManifestPath, maxBytes: MAX_MANIFEST_BYTES }]
|
|
989
|
+
: []),
|
|
990
|
+
{ role: "backing-binary", path: backingBinary, maxBytes: MAX_NATIVE_BACKING_BYTES },
|
|
991
|
+
];
|
|
992
|
+
const observed = observeHeldFileSet(requests, deps, () => {
|
|
993
|
+
// Re-run the selector after the final file identity pass, while native
|
|
994
|
+
// descriptors remain open. An earlier lane gaining a package or the
|
|
995
|
+
// selected one disappearing makes the bound set ambiguous.
|
|
996
|
+
const selectedAgain = probeBacking();
|
|
997
|
+
if (selectedAgain.kind !== selectedBacking.kind
|
|
998
|
+
|| (selectedAgain.kind === "hit" && selectedBacking.kind === "hit"
|
|
999
|
+
&& selectedAgain.path !== selectedBacking.path)) return false;
|
|
1000
|
+
// A canonical-path lstat cannot see a retargeted lexical selection link.
|
|
1001
|
+
for (const link of links) {
|
|
1002
|
+
const resolved = canonicalize(link.lexical, deps);
|
|
1003
|
+
if (!resolved || !samePath(resolved, link.canonical, platform)) return false;
|
|
1004
|
+
}
|
|
1005
|
+
return true;
|
|
1006
|
+
});
|
|
1007
|
+
if (!observed) return null;
|
|
1008
|
+
// The manifest was already hashed once while provenance was being decided.
|
|
1009
|
+
// If the bytes read now differ, ownership answered a different file than the
|
|
1010
|
+
// content claim would describe.
|
|
1011
|
+
if (observed.get("manifest")?.digest !== manifest.digest) return null;
|
|
1012
|
+
const backingManifest = observed.get("backing-manifest");
|
|
1013
|
+
if (backingManifest?.bytes) {
|
|
1014
|
+
// A backing manifest carrying an exports map can make the real
|
|
1015
|
+
// require.resolve throw instead of returning this file; without modeling
|
|
1016
|
+
// export conditions the resolution lane would be ambiguous, so it is refused.
|
|
1017
|
+
try {
|
|
1018
|
+
const value: unknown = JSON.parse(backingManifest.bytes.toString("utf8"));
|
|
1019
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
1020
|
+
if ("exports" in value) return null;
|
|
1021
|
+
} catch {
|
|
1022
|
+
return null;
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
const record = {
|
|
1026
|
+
layout: "npm-global" as const,
|
|
1027
|
+
platform,
|
|
1028
|
+
arch,
|
|
1029
|
+
triple: backing.triple,
|
|
1030
|
+
backingKind: backingManifestPath ? "optional-package" as const : "package-vendor" as const,
|
|
1031
|
+
files: requests.map(req => ({ role: req.role, digest: observed.get(req.role)?.digest })),
|
|
1032
|
+
};
|
|
1033
|
+
return sha256("codex-cli-install-content-v1", JSON.stringify(record));
|
|
1034
|
+
}
|
|
1035
|
+
|
|
639
1036
|
/** Inspect ownership of one configured Codex CLI candidate, without mutation. */
|
|
640
1037
|
export async function inspectCodexCliInstall(
|
|
641
1038
|
deps: CodexCliInstallProvenanceDeps = {},
|
|
@@ -687,6 +1084,7 @@ export async function inspectCodexCliInstall(
|
|
|
687
1084
|
reason: appBundle ? "app_bundle" : "version_manager_owned",
|
|
688
1085
|
location: publicExecutableLocation(lexicalCandidatePath, platform),
|
|
689
1086
|
installDigest: null,
|
|
1087
|
+
installContentDigest: null,
|
|
690
1088
|
packageVersion: null,
|
|
691
1089
|
shim: Object.freeze({ status: "unknown", backingKind: null }),
|
|
692
1090
|
evidence: Object.freeze([appBundle ? "app_bundle_path" : "version_manager_path"]),
|
|
@@ -770,6 +1168,7 @@ export async function inspectCodexCliInstall(
|
|
|
770
1168
|
reason: "app_bundle",
|
|
771
1169
|
location,
|
|
772
1170
|
installDigest: null,
|
|
1171
|
+
installContentDigest: null,
|
|
773
1172
|
packageVersion: null,
|
|
774
1173
|
shim: shimReport(shim),
|
|
775
1174
|
evidence: Object.freeze(["canonical_path", "app_bundle_path"]),
|
|
@@ -792,6 +1191,7 @@ export async function inspectCodexCliInstall(
|
|
|
792
1191
|
reason: "version_manager_owned",
|
|
793
1192
|
location,
|
|
794
1193
|
installDigest: null,
|
|
1194
|
+
installContentDigest: null,
|
|
795
1195
|
packageVersion: null,
|
|
796
1196
|
shim: shimReport(shim),
|
|
797
1197
|
evidence: Object.freeze(["canonical_path", "version_manager_path"]),
|
|
@@ -821,6 +1221,21 @@ export async function inspectCodexCliInstall(
|
|
|
821
1221
|
deps,
|
|
822
1222
|
);
|
|
823
1223
|
const manifestOwned = global && linked;
|
|
1224
|
+
// The npm-global prefix bounds every content path this observation may
|
|
1225
|
+
// follow: it is the manifest root minus the fixed package suffix. An
|
|
1226
|
+
// install that cannot prove the layout keeps a null digest.
|
|
1227
|
+
const npmPrefix = manifestOwned
|
|
1228
|
+
? manifest.root.slice(0, manifest.root.length - "/lib/node_modules/@openai/codex".length)
|
|
1229
|
+
: "";
|
|
1230
|
+
const installContentDigest = manifestOwned && deps.observeInstallContent === true
|
|
1231
|
+
// The selection's own lexical->canonical link is part of the claim:
|
|
1232
|
+
// verified again at the end of the observation so a retargeted launcher
|
|
1233
|
+
// cannot leave the digest bound to bytes it no longer selects.
|
|
1234
|
+
? observeInstallContent(manifest, npmPrefix, platform, env, deps, {
|
|
1235
|
+
lexical: ownershipPath,
|
|
1236
|
+
canonical: canonicalOwnershipPath,
|
|
1237
|
+
})
|
|
1238
|
+
: null;
|
|
824
1239
|
const versionMatches = candidate.version === null || candidate.version === manifest.version;
|
|
825
1240
|
const reason: CodexCliInstallReason = !global || !linked
|
|
826
1241
|
? "npm_global_unverified"
|
|
@@ -848,11 +1263,13 @@ export async function inspectCodexCliInstall(
|
|
|
848
1263
|
// package roots that resolve to the same "<path>/codex" string must still
|
|
849
1264
|
// produce different plans.
|
|
850
1265
|
installDigest: sha256("codex-cli-install-root-v1", normalizePath(manifest.root, platform)),
|
|
1266
|
+
installContentDigest,
|
|
851
1267
|
packageVersion: manifest.version,
|
|
852
1268
|
shim: shimReport(shim),
|
|
853
1269
|
evidence: Object.freeze<CodexCliInstallEvidence[]>([
|
|
854
1270
|
"canonical_path", "package_manifest", "package_manifest_digest",
|
|
855
1271
|
...(manifestOwned ? ["global_npm_layout" as const] : []),
|
|
1272
|
+
...(installContentDigest ? ["install_content_digest" as const] : []),
|
|
856
1273
|
]),
|
|
857
1274
|
});
|
|
858
1275
|
}
|
|
@@ -882,6 +1299,7 @@ export async function inspectCodexCliInstall(
|
|
|
882
1299
|
reason: "unverified_standalone",
|
|
883
1300
|
location,
|
|
884
1301
|
installDigest: null,
|
|
1302
|
+
installContentDigest: null,
|
|
885
1303
|
packageVersion: null,
|
|
886
1304
|
shim: shimReport(shim),
|
|
887
1305
|
evidence: Object.freeze(["canonical_path"]),
|
|
@@ -141,10 +141,14 @@ export async function deriveCodexCliInstallationInput(
|
|
|
141
141
|
|
|
142
142
|
// The npm cmd-shim itself prefers %dp0%\node.exe before falling back to PATH.
|
|
143
143
|
let node = win32.join(prefix, "node.exe");
|
|
144
|
-
|
|
144
|
+
const localNode = await exists(node);
|
|
145
|
+
if (localNode === false) {
|
|
145
146
|
const resolved = await scanPath("node.exe", snapshot.path, null, exists);
|
|
146
147
|
if (!resolved) return { kind: "unavailable", reason: "toolchain_unresolved" };
|
|
147
148
|
node = resolved;
|
|
149
|
+
} else if (localNode !== true) {
|
|
150
|
+
// A refused local probe is not proof of absence and must not fall through to PATH.
|
|
151
|
+
return { kind: "unavailable", reason: "toolchain_unresolved" };
|
|
148
152
|
}
|
|
149
153
|
const npmCli = win32.join(win32.dirname(node), "node_modules", "npm", "bin", "npm-cli.js");
|
|
150
154
|
if (await exists(npmCli) !== true) return { kind: "unavailable", reason: "toolchain_unresolved" };
|