@bitkyc08/opencodex 2.91.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.
Files changed (148) hide show
  1. package/bin/ocx.mjs +37 -3
  2. package/gui/dist/assets/{App-nZ34cR5G.js → App-sUf_8afJ.js} +18 -18
  3. package/gui/dist/assets/{Tray-BuLy7hL1.js → Tray-BSsxznDm.js} +1 -1
  4. package/gui/dist/assets/index-BlFE_VB_.js +94 -0
  5. package/gui/dist/assets/quota-summary-BRlbXgw0.js +1 -0
  6. package/gui/dist/index.html +1 -1
  7. package/gui/dist/provider-icons/workbuddy-color.svg +29 -0
  8. package/package.json +4 -3
  9. package/src/adapters/google-vertex-endpoint.ts +9 -1
  10. package/src/adapters/google-vertex-key-project.ts +223 -0
  11. package/src/adapters/google.ts +13 -3
  12. package/src/adapters/openai-chat/tool-name-registry.ts +3 -1
  13. package/src/adapters/openai-chat.ts +2 -2
  14. package/src/adapters/openai-responses/request-strips.ts +60 -0
  15. package/src/adapters/registry.ts +5 -0
  16. package/src/adapters/workbuddy-schema.ts +61 -0
  17. package/src/adapters/workbuddy.ts +68 -0
  18. package/src/bridge/response-json.ts +1 -1
  19. package/src/bridge/sse.ts +1 -1
  20. package/src/chat/outbound.ts +11 -4
  21. package/src/cli/capabilities-agents-routing.ts +3 -3
  22. package/src/cli/codex-cli-update.ts +8 -0
  23. package/src/cli/combo-result.ts +6 -0
  24. package/src/cli/combo-stats.ts +3 -0
  25. package/src/cli/combo.ts +66 -42
  26. package/src/cli/launcher-context.ts +49 -2
  27. package/src/cli/link.ts +45 -32
  28. package/src/cli/provider.ts +2 -1
  29. package/src/cli/usage-compare.ts +68 -12
  30. package/src/client/link-ingress.ts +1 -1
  31. package/src/client/link-readiness.ts +97 -0
  32. package/src/client/link-relay-transport.ts +79 -11
  33. package/src/client/link-relay.ts +74 -15
  34. package/src/client/link-tunnel.ts +45 -18
  35. package/src/client/machine-listener.ts +20 -2
  36. package/src/client/runtime.ts +20 -2
  37. package/src/codex/account-store.ts +17 -3
  38. package/src/codex/app-server-restart-service.ts +6 -5
  39. package/src/codex/catalog/effort.ts +9 -2
  40. package/src/codex/catalog/gather-capture.ts +4 -2
  41. package/src/codex/catalog/provider-models.ts +14 -8
  42. package/src/codex/chatgpt-refresh-failure.ts +6 -2
  43. package/src/codex/cli-install-provenance.ts +418 -0
  44. package/src/codex/cli-installation-targets.ts +5 -1
  45. package/src/codex/cli-update-plan.ts +43 -16
  46. package/src/codex/desktop-app/picker-freshness.ts +10 -8
  47. package/src/codex/desktop-compatibility/usage-activation.ts +2 -1
  48. package/src/codex/desktop-compatibility/usage-controller.ts +7 -1
  49. package/src/codex/pool-refresh-backoff.ts +3 -1
  50. package/src/codex/user-identity.ts +35 -6
  51. package/src/codex/windows-current-sid.ts +162 -0
  52. package/src/combos/jev-quota-activation.ts +5 -1
  53. package/src/combos/jev-quota-route.ts +1 -1
  54. package/src/combos/jev-quota.ts +9 -1
  55. package/src/combos/jev-session-pin-config.ts +51 -0
  56. package/src/combos/jev.ts +6 -0
  57. package/src/combos/types.ts +19 -0
  58. package/src/config/provider-validation.ts +7 -0
  59. package/src/config/schema/leaf-validators.ts +1 -0
  60. package/src/generated/compatibility-version.json +194 -122
  61. package/src/lib/decision-quota-sink.ts +10 -0
  62. package/src/lib/translator-budget.ts +4 -4
  63. package/src/lib/upstream-retry.ts +4 -1
  64. package/src/link/diagnostics.ts +193 -0
  65. package/src/link/relay-auth.ts +6 -0
  66. package/src/link/status-projection.ts +39 -0
  67. package/src/link/supervisor.ts +45 -12
  68. package/src/link/tunnel-history.ts +109 -0
  69. package/src/link/tunnel-state.ts +80 -17
  70. package/src/messaging/remote-files.ts +24 -1
  71. package/src/oauth/generic-account-failover.ts +91 -7
  72. package/src/oauth/index.ts +15 -0
  73. package/src/oauth/model-discovery-auth.ts +5 -0
  74. package/src/oauth/store.ts +4 -1
  75. package/src/oauth/token-guardian.ts +7 -0
  76. package/src/oauth/types.ts +9 -0
  77. package/src/oauth/workbuddy.ts +139 -0
  78. package/src/providers/antigravity-quota-identity.ts +37 -0
  79. package/src/providers/base-url-choices.ts +8 -5
  80. package/src/providers/derive.ts +23 -3
  81. package/src/providers/model-discovery.ts +2 -0
  82. package/src/providers/model-rename-fields.ts +1 -0
  83. package/src/providers/quota/account-cache.ts +3 -2
  84. package/src/providers/quota/antigravity.ts +25 -14
  85. package/src/providers/quota/report-cache.ts +13 -4
  86. package/src/providers/quota/vendor-probes-key.ts +60 -0
  87. package/src/providers/quota/workbuddy.ts +83 -0
  88. package/src/providers/quota-decision-antigravity.ts +64 -0
  89. package/src/providers/quota-decision-publication.ts +11 -0
  90. package/src/providers/quota-decision-snapshot.ts +21 -4
  91. package/src/providers/quota-types.ts +2 -0
  92. package/src/providers/quota.ts +3 -1
  93. package/src/providers/registry/entries-core.ts +9 -0
  94. package/src/providers/registry/entries-extended.ts +20 -7
  95. package/src/providers/registry/model-ids.ts +3 -0
  96. package/src/providers/registry/model-seeds.ts +27 -28
  97. package/src/providers/registry/types.ts +19 -1
  98. package/src/providers/resolved-model-policy.ts +4 -4
  99. package/src/providers/routed-config.ts +15 -0
  100. package/src/providers/stale-model-roster-migration.ts +83 -15
  101. package/src/providers/workbuddy-credit-balance.ts +81 -0
  102. package/src/providers/workbuddy-models.ts +37 -0
  103. package/src/providers/workbuddy.ts +60 -0
  104. package/src/responses/reasoning-replay-cache.ts +18 -14
  105. package/src/router.ts +2 -0
  106. package/src/routing/analytics.ts +2 -3
  107. package/src/routing/fallback-policy.ts +3 -3
  108. package/src/server/auth-cors.ts +1 -0
  109. package/src/server/chat-completions.ts +14 -1
  110. package/src/server/chat-native-reasoning.ts +10 -3
  111. package/src/server/chat-native.ts +13 -2
  112. package/src/server/claude-messages.ts +3 -0
  113. package/src/server/index/link-relay-sessions.ts +9 -5
  114. package/src/server/index/optional-listeners.ts +4 -0
  115. package/src/server/local-management-read-client.ts +3 -1
  116. package/src/server/management/combo-routes.ts +10 -4
  117. package/src/server/management/link-routes.ts +5 -2
  118. package/src/server/management/provider-routes.ts +1 -1
  119. package/src/server/port-reclaim.ts +51 -9
  120. package/src/server/request-log-conversation.ts +49 -0
  121. package/src/server/request-log.ts +2 -0
  122. package/src/server/responses/anthropic-thinking-recovery.ts +1 -0
  123. package/src/server/responses/compact.ts +10 -2
  124. package/src/server/responses/core-combo.ts +53 -31
  125. package/src/server/responses/core-lifetime.ts +2 -2
  126. package/src/server/responses/core-normalize.ts +6 -3
  127. package/src/server/responses/core-opaque-recovery.ts +1 -0
  128. package/src/server/responses/core-options.ts +5 -0
  129. package/src/server/responses/core-replay.ts +2 -1
  130. package/src/server/responses/jev-combo-choice.ts +39 -0
  131. package/src/server/responses/jev-session-pin.ts +203 -0
  132. package/src/server/responses/passthrough-delivery.ts +4 -0
  133. package/src/server/responses/request-prepare.ts +10 -5
  134. package/src/server/responses/request-transport.ts +25 -6
  135. package/src/server/responses/run-turn-execution.ts +2 -4
  136. package/src/server/responses/sidecar-execution.ts +2 -4
  137. package/src/server/responses-phase-inference.ts +267 -0
  138. package/src/server/sse-payload-rewrite.ts +14 -1
  139. package/src/service/definition.ts +20 -8
  140. package/src/types/config.ts +6 -0
  141. package/src/types/provider.ts +9 -1
  142. package/src/types/request.ts +1 -0
  143. package/src/types/wire.ts +1 -0
  144. package/src/update/codex-cli-update-launch-policy.d.mts +9 -0
  145. package/src/update/codex-cli-update-launch-policy.mjs +30 -0
  146. package/src/usage/jev-stats.ts +54 -14
  147. package/gui/dist/assets/index-DGXS51sp.js +0 -94
  148. package/gui/dist/assets/quota-summary-DO1Sx5TM.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
- const fresh = getFreshCached(name, ttlMs);
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
- /** Endpoint verdict only: no credential identity, body, or arbitrary upstream string. */
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
- if (await exists(node) !== true) {
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" };