@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.
Files changed (250) hide show
  1. package/bin/ocx.mjs +37 -3
  2. package/gui/dist/assets/App-DMps4OLk.css +1 -0
  3. package/gui/dist/assets/{App-DgxkE6Md.js → App-sUf_8afJ.js} +18 -18
  4. package/gui/dist/assets/{Tray-DbTNc_88.js → Tray-BSsxznDm.js} +1 -1
  5. package/gui/dist/assets/index-BlFE_VB_.js +94 -0
  6. package/gui/dist/assets/quota-summary-BRlbXgw0.js +1 -0
  7. package/gui/dist/index.html +1 -1
  8. package/gui/dist/provider-icons/workbuddy-color.svg +29 -0
  9. package/package.json +4 -3
  10. package/src/adapters/anthropic.ts +2 -5
  11. package/src/adapters/claude-cli/usage-admission.ts +14 -0
  12. package/src/adapters/google-vertex-endpoint.ts +9 -1
  13. package/src/adapters/google-vertex-key-project.ts +223 -0
  14. package/src/adapters/google.ts +13 -3
  15. package/src/adapters/openai-chat/tool-name-registry.ts +3 -1
  16. package/src/adapters/openai-chat.ts +2 -2
  17. package/src/adapters/openai-responses/passthrough.ts +2 -1
  18. package/src/adapters/openai-responses/request-strips.ts +92 -0
  19. package/src/adapters/openai-responses/transport.ts +6 -0
  20. package/src/adapters/registry.ts +5 -0
  21. package/src/adapters/workbuddy-schema.ts +61 -0
  22. package/src/adapters/workbuddy.ts +68 -0
  23. package/src/bridge/response-json.ts +1 -1
  24. package/src/bridge/sse.ts +1 -1
  25. package/src/chat/inbound.ts +20 -2
  26. package/src/chat/outbound.ts +47 -8
  27. package/src/chat/reasoning-details.ts +137 -0
  28. package/src/cli/capabilities-agents-routing.ts +53 -3
  29. package/src/cli/capabilities-base.ts +15 -2
  30. package/src/cli/capabilities-observe-system.ts +4 -4
  31. package/src/cli/codex-cli-update.ts +8 -0
  32. package/src/cli/codex-shim-autorestore.ts +5 -0
  33. package/src/cli/combo-result.ts +6 -0
  34. package/src/cli/combo-stats.ts +3 -0
  35. package/src/cli/combo.ts +66 -42
  36. package/src/cli/dispatch.ts +30 -0
  37. package/src/cli/fallback.ts +127 -0
  38. package/src/cli/help.ts +1 -0
  39. package/src/cli/index.ts +1 -2
  40. package/src/cli/launcher-context.ts +49 -2
  41. package/src/cli/link-apply-restart.ts +72 -0
  42. package/src/cli/link-child.ts +6 -2
  43. package/src/cli/link-cleanup.ts +95 -0
  44. package/src/cli/link-enrollment.ts +4 -0
  45. package/src/cli/link.ts +123 -35
  46. package/src/cli/provider.ts +46 -3
  47. package/src/cli/registry.ts +13 -1
  48. package/src/cli/restart-failure.ts +4 -0
  49. package/src/cli/runtime-api.ts +1 -1
  50. package/src/cli/status-codex-settings.ts +56 -0
  51. package/src/cli/status-fallback.ts +29 -0
  52. package/src/cli/status-install.ts +52 -0
  53. package/src/cli/status.ts +20 -1
  54. package/src/cli/system-restart-client.ts +32 -1
  55. package/src/cli/update-restart-child.ts +4 -3
  56. package/src/cli/update-restart-home.ts +20 -8
  57. package/src/cli/usage-compare.ts +68 -12
  58. package/src/client/connect.ts +82 -6
  59. package/src/client/home-unreachable.ts +16 -0
  60. package/src/client/hub-relay.ts +8 -2
  61. package/src/client/link-home.ts +119 -6
  62. package/src/client/link-ingress.ts +1 -1
  63. package/src/client/link-parity-reporter.ts +107 -0
  64. package/src/client/link-readiness.ts +97 -0
  65. package/src/client/link-relay-transport.ts +84 -9
  66. package/src/client/link-relay.ts +98 -15
  67. package/src/client/link-tunnel.ts +45 -18
  68. package/src/client/machine-listener.ts +103 -3
  69. package/src/client/runtime-restart.ts +127 -0
  70. package/src/client/runtime.ts +155 -25
  71. package/src/codex/account-store.ts +17 -3
  72. package/src/codex/app-server-restart-service.ts +6 -5
  73. package/src/codex/auth-context.ts +10 -6
  74. package/src/codex/catalog/effort.ts +9 -2
  75. package/src/codex/catalog/gather-capture.ts +4 -2
  76. package/src/codex/catalog/parsing.ts +4 -0
  77. package/src/codex/catalog/provider-models.ts +14 -8
  78. package/src/codex/chatgpt-refresh-failure.ts +6 -2
  79. package/src/codex/cli-install-provenance.ts +418 -0
  80. package/src/codex/cli-installation-targets.ts +5 -1
  81. package/src/codex/cli-update-plan.ts +43 -16
  82. package/src/codex/desktop-app/picker-freshness.ts +10 -8
  83. package/src/codex/desktop-compatibility/usage-activation.ts +2 -1
  84. package/src/codex/desktop-compatibility/usage-controller.ts +7 -1
  85. package/src/codex/desktop-compatibility/windows-key-protection.ts +18 -6
  86. package/src/codex/pool-refresh-backoff.ts +3 -1
  87. package/src/codex/quota-history.ts +7 -0
  88. package/src/codex/quota.ts +10 -0
  89. package/src/codex/subagent-model-fallback.ts +24 -1
  90. package/src/codex/user-identity.ts +35 -6
  91. package/src/codex/windows-current-sid.ts +162 -0
  92. package/src/combos/index.ts +1 -0
  93. package/src/combos/jev-quota-activation.ts +5 -1
  94. package/src/combos/jev-quota-route.ts +1 -1
  95. package/src/combos/jev-quota.ts +9 -1
  96. package/src/combos/jev-session-pin-config.ts +51 -0
  97. package/src/combos/jev.ts +6 -0
  98. package/src/combos/resolve.ts +12 -0
  99. package/src/combos/types.ts +19 -0
  100. package/src/config/diagnostics.ts +5 -0
  101. package/src/config/load-degrade.ts +2 -0
  102. package/src/config/provider-validation.ts +7 -0
  103. package/src/config/schema/config-schema.ts +3 -0
  104. package/src/config/schema/fallback-policy.ts +40 -0
  105. package/src/config/schema/leaf-validators.ts +1 -0
  106. package/src/generated/compatibility-version.json +375 -195
  107. package/src/lib/authorization-scheme.ts +95 -0
  108. package/src/lib/decision-quota-sink.ts +10 -0
  109. package/src/lib/provider-client-headers.ts +1 -0
  110. package/src/lib/redact.ts +2 -2
  111. package/src/lib/system-restart-contract.ts +24 -0
  112. package/src/lib/test-home-guard.ts +78 -11
  113. package/src/lib/translator-budget.ts +4 -4
  114. package/src/lib/upstream-retry.ts +4 -1
  115. package/src/link/child-parity.ts +124 -0
  116. package/src/link/codex-settings.ts +37 -0
  117. package/src/link/compatibility.ts +119 -0
  118. package/src/link/diagnostics.ts +193 -0
  119. package/src/link/management-routes.ts +2 -0
  120. package/src/link/peer-report.ts +100 -0
  121. package/src/link/relay-auth.ts +6 -0
  122. package/src/link/remote-platform.ts +78 -0
  123. package/src/link/ssh-argv.ts +19 -0
  124. package/src/link/status-projection.ts +63 -2
  125. package/src/link/supervisor.ts +45 -12
  126. package/src/link/tunnel-history.ts +109 -0
  127. package/src/link/tunnel-state.ts +80 -17
  128. package/src/messaging/remote-files.ts +24 -1
  129. package/src/oauth/generic-account-failover.ts +91 -7
  130. package/src/oauth/index.ts +16 -1
  131. package/src/oauth/kimi.ts +2 -0
  132. package/src/oauth/model-discovery-auth.ts +5 -0
  133. package/src/oauth/nous.ts +4 -1
  134. package/src/oauth/store.ts +6 -2
  135. package/src/oauth/token-guardian.ts +7 -0
  136. package/src/oauth/types.ts +9 -0
  137. package/src/oauth/workbuddy.ts +139 -0
  138. package/src/protocols/codecs/chat.ts +3 -3
  139. package/src/protocols/encoders/adapter-events.ts +26 -2
  140. package/src/protocols/encoders/chat.ts +55 -3
  141. package/src/providers/antigravity-quota-identity.ts +37 -0
  142. package/src/providers/base-url-choices.ts +8 -5
  143. package/src/providers/derive.ts +23 -3
  144. package/src/providers/mistral-transport.ts +37 -4
  145. package/src/providers/model-discovery.ts +2 -0
  146. package/src/providers/model-rename-fields.ts +1 -0
  147. package/src/providers/quota/account-cache.ts +3 -2
  148. package/src/providers/quota/antigravity.ts +27 -22
  149. package/src/providers/quota/report-cache.ts +13 -4
  150. package/src/providers/quota/vendor-probes-key.ts +60 -0
  151. package/src/providers/quota/workbuddy.ts +83 -0
  152. package/src/providers/quota-decision-antigravity.ts +64 -0
  153. package/src/providers/quota-decision-publication.ts +11 -0
  154. package/src/providers/quota-decision-snapshot.ts +21 -4
  155. package/src/providers/quota-types.ts +2 -0
  156. package/src/providers/quota.ts +3 -1
  157. package/src/providers/registry/entries-core.ts +9 -0
  158. package/src/providers/registry/entries-extended.ts +20 -7
  159. package/src/providers/registry/model-ids.ts +3 -0
  160. package/src/providers/registry/model-seeds.ts +27 -28
  161. package/src/providers/registry/types.ts +19 -1
  162. package/src/providers/resolved-model-policy.ts +4 -4
  163. package/src/providers/routed-config.ts +15 -0
  164. package/src/providers/stale-model-roster-migration.ts +83 -15
  165. package/src/providers/workbuddy-credit-balance.ts +81 -0
  166. package/src/providers/workbuddy-models.ts +37 -0
  167. package/src/providers/workbuddy.ts +60 -0
  168. package/src/quota-pool/assemble.ts +326 -0
  169. package/src/quota-pool/contract.ts +58 -0
  170. package/src/quota-pool/identity.ts +203 -0
  171. package/src/remote/features.ts +74 -0
  172. package/src/remote/protocol.ts +2 -0
  173. package/src/remote/served-by.ts +57 -0
  174. package/src/responses/parser.ts +7 -0
  175. package/src/responses/reasoning-envelope.ts +12 -0
  176. package/src/responses/reasoning-replay-cache.ts +18 -14
  177. package/src/router.ts +2 -0
  178. package/src/routing/analytics.ts +2 -3
  179. package/src/routing/fallback-policy.ts +290 -0
  180. package/src/routing/fallback-state.ts +179 -0
  181. package/src/server/audio-client.ts +2 -1
  182. package/src/server/audio-dictation.ts +2 -1
  183. package/src/server/audio-upstream.ts +2 -1
  184. package/src/server/auth-cors.ts +7 -5
  185. package/src/server/background-lifecycle.ts +4 -0
  186. package/src/server/chat-completions.ts +22 -2
  187. package/src/server/chat-native-reasoning.ts +10 -3
  188. package/src/server/chat-native.ts +13 -2
  189. package/src/server/claude-messages.ts +3 -0
  190. package/src/server/gui-session.ts +2 -1
  191. package/src/server/index/link-listener.ts +11 -1
  192. package/src/server/index/link-management.ts +47 -0
  193. package/src/server/index/link-relay-sessions.ts +20 -7
  194. package/src/server/index/optional-listeners.ts +11 -2
  195. package/src/server/index/serve-options.ts +19 -2
  196. package/src/server/live.ts +5 -0
  197. package/src/server/local-management-read-client.ts +3 -1
  198. package/src/server/management/combo-routes.ts +10 -4
  199. package/src/server/management/context.ts +1 -1
  200. package/src/server/management/fallback-routes.ts +75 -0
  201. package/src/server/management/link-enrollment.ts +21 -3
  202. package/src/server/management/link-route-state.ts +2 -0
  203. package/src/server/management/link-routes.ts +16 -2
  204. package/src/server/management/provider-routes.ts +1 -1
  205. package/src/server/management/quota-pool-routes.ts +24 -0
  206. package/src/server/management/route-registry.ts +3 -0
  207. package/src/server/management-api.ts +16 -0
  208. package/src/server/management-auth.ts +2 -1
  209. package/src/server/port-reclaim.ts +51 -9
  210. package/src/server/request-log-conversation.ts +49 -0
  211. package/src/server/request-log.ts +2 -0
  212. package/src/server/responses/agent-task-recovery.ts +1 -1
  213. package/src/server/responses/anthropic-thinking-recovery.ts +1 -0
  214. package/src/server/responses/compact.ts +12 -4
  215. package/src/server/responses/core-auth.ts +11 -1
  216. package/src/server/responses/core-combo.ts +125 -32
  217. package/src/server/responses/core-lifetime.ts +2 -2
  218. package/src/server/responses/core-normalize.ts +6 -3
  219. package/src/server/responses/core-opaque-recovery.ts +1 -0
  220. package/src/server/responses/core-options.ts +5 -0
  221. package/src/server/responses/core-replay.ts +2 -1
  222. package/src/server/responses/encrypted-payload.ts +3 -3
  223. package/src/server/responses/jev-combo-choice.ts +39 -0
  224. package/src/server/responses/jev-session-pin.ts +203 -0
  225. package/src/server/responses/main-hard-lock-routing.ts +54 -2
  226. package/src/server/responses/passthrough-delivery.ts +4 -0
  227. package/src/server/responses/policy-fallback.ts +44 -1
  228. package/src/server/responses/request-prepare.ts +19 -6
  229. package/src/server/responses/request-transport.ts +25 -6
  230. package/src/server/responses/run-turn-execution.ts +2 -4
  231. package/src/server/responses/sidecar-execution.ts +2 -4
  232. package/src/server/responses-phase-inference.ts +267 -0
  233. package/src/server/restart-replacement.ts +50 -2
  234. package/src/server/sse-payload-rewrite.ts +14 -1
  235. package/src/server/ws-bridge.ts +5 -0
  236. package/src/service/cli.ts +25 -2
  237. package/src/service/definition.ts +20 -8
  238. package/src/service/diagnostics.ts +20 -6
  239. package/src/service/status-json.ts +116 -0
  240. package/src/types/config.ts +13 -0
  241. package/src/types/fallback.ts +38 -0
  242. package/src/types/provider.ts +9 -1
  243. package/src/types/request.ts +1 -0
  244. package/src/types/wire.ts +1 -0
  245. package/src/update/codex-cli-update-launch-policy.d.mts +9 -0
  246. package/src/update/codex-cli-update-launch-policy.mjs +30 -0
  247. package/src/usage/jev-stats.ts +54 -14
  248. package/gui/dist/assets/App-dnkJ85te.css +0 -1
  249. package/gui/dist/assets/index-TmaHa54u.js +0 -94
  250. package/gui/dist/assets/quota-summary-B16x5DZZ.js +0 -1
@@ -2,10 +2,11 @@ import { spawnSync, type SpawnSyncReturns } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
3
  import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
- import { join } from "node:path";
5
+ import { join, resolve } from "node:path";
6
6
 
7
7
  import { compareStrictSemver, parseStrictSemver } from "../lib/strict-semver";
8
8
  import { npmInvocation } from "../update/npm-invocation.mjs";
9
+ import { CODEX_CLI_TRANSPORT_ENV_KEYS } from "../update/codex-cli-update-launch-policy.mjs";
9
10
  import {
10
11
  inspectCodexCliInstall,
11
12
  type CodexCliInstallKind,
@@ -61,6 +62,7 @@ export type CodexCliUpdateRefusal =
61
62
  | "windows_inspection_deferred"
62
63
  | "not_managed"
63
64
  | "installed_version_unverified"
65
+ | "install_content_unverified"
64
66
  | "target_unresolved"
65
67
  | "already_current"
66
68
  | "target_not_newer"
@@ -100,6 +102,13 @@ export interface CodexCliUpdatePlan {
100
102
  readonly targetVersion: string | null;
101
103
  readonly targetIntegrity: string | null;
102
104
  readonly session: CodexCliUpdateSession;
105
+ /**
106
+ * The bounded content observation the installation report produced: digest of
107
+ * the manifest, declared entrypoint and resolved platform backing the plan's
108
+ * identity is pinned to. Not an execution guarantee — an apply phase must
109
+ * re-observe before it can rely on the same bytes.
110
+ */
111
+ readonly installContentDigest: string | null;
103
112
  /** Indicative install argv for the operator to read; the apply phase this plan precedes verifies a packed tarball against the bound sha512 rather than trusting a moving spec. */
104
113
  readonly command: readonly string[] | null;
105
114
  }
@@ -157,6 +166,7 @@ export function codexCliUpdatePlanId(bound: {
157
166
  readonly provenance: CodexCliInstallKind;
158
167
  readonly installedVersion: string;
159
168
  readonly installDigest: string | null;
169
+ readonly installContentDigest: string;
160
170
  readonly channel: CodexCliUpdateChannel;
161
171
  readonly targetVersion: string;
162
172
  readonly targetIntegrity: string;
@@ -170,6 +180,7 @@ export function codexCliUpdatePlanId(bound: {
170
180
  ["provenance", bound.provenance],
171
181
  ["installedVersion", bound.installedVersion],
172
182
  ["installDigest", bound.installDigest ?? ""],
183
+ ["installContentDigest", bound.installContentDigest],
173
184
  ["channel", bound.channel],
174
185
  ["targetVersion", bound.targetVersion],
175
186
  ["targetIntegrity", bound.targetIntegrity],
@@ -220,8 +231,8 @@ function npmTarget(args: readonly string[], env?: NodeJS.ProcessEnv): NpmTarget
220
231
  * shared modules from. None of these is input the updater child needs, so
221
232
  * ambient values stay out of the spawned npm. This is a denylist for
222
233
  * process-startup/output channels, not a claim that every ambient variable is
223
- * allowlisted. Ordinary OS environment, proxy settings and the supported
224
- * NODE_EXTRA_CA_CERTS extension remain available.
234
+ * allowlisted. When a proof-bound launcher snapshot is available, proxy and
235
+ * CA settings are replaced from that snapshot below.
225
236
  */
226
237
  const NPM_ENV_DROP_KEYS: ReadonlySet<string> = new Set([
227
238
  "node_options",
@@ -255,23 +266,24 @@ function codexCliUpdateNpmEnv(base: NodeJS.ProcessEnv = process.env): NodeJS.Pro
255
266
  }
256
267
 
257
268
  /**
258
- * Ambient environment with the executable-selection channels replaced by the proof-bound
259
- * launcher snapshot. HOME, proxy variables and NODE_EXTRA_CA_CERTS stay ambient because npm
260
- * itself needs them; PATH/PATHEXT are the channels a project dotenv could use to supply a
261
- * fake npm, so they come only from the captured snapshot. A snapshot without PATH means no
262
- * trusted PATH at all — an empty one fails the spawn closed rather than falling back.
269
+ * Ambient environment with executable-selection and transport-trust channels replaced by
270
+ * the proof-bound launcher snapshot. Bun may add project dotenv values after that snapshot,
271
+ * so PATH/PATHEXT, proxies and NODE_EXTRA_CA_CERTS must all come from the captured context.
272
+ * A snapshot without PATH means no trusted PATH at all — an empty one fails the spawn closed.
263
273
  */
264
274
  function codexCliUpdateBoundEnv(bound: NodeJS.ProcessEnv | undefined): NodeJS.ProcessEnv {
265
275
  const merged = codexCliUpdateNpmEnv();
266
276
  if (bound === undefined) return merged;
277
+ // Derived from the launcher's allowlist so capture, parsing and the npm child cannot drift.
278
+ const boundKeys = new Set<string>(["path", "pathext", ...CODEX_CLI_TRANSPORT_ENV_KEYS]);
267
279
  for (const key of Object.keys(merged)) {
268
280
  const k = key.toLowerCase();
269
- if (k === "path" || k === "pathext") delete merged[key];
281
+ if (boundKeys.has(k)) delete merged[key];
270
282
  }
271
283
  let hasTrustedPath = false;
272
284
  for (const [key, value] of Object.entries(bound)) {
273
285
  const k = key.toLowerCase();
274
- if ((k === "path" || k === "pathext") && value !== undefined) {
286
+ if (boundKeys.has(k) && value !== undefined) {
275
287
  merged[key] = value;
276
288
  if (k === "path") hasTrustedPath = true;
277
289
  }
@@ -303,7 +315,7 @@ interface NpmConfigIsolation {
303
315
  * the operator's real ancestors. The sentinel package.json anchors the walk here,
304
316
  * where no .npmrc exists. --userconfig/--globalconfig replace the two file configs,
305
317
  * and the env filter removes the env-var channel. What remains is exactly the pinned
306
- * --registry flag plus deliberately supported proxy/CA env.
318
+ * --registry flag plus proxy/CA values captured before project dotenv loading.
307
319
  */
308
320
  function createNpmConfigIsolation(dir?: string, boundEnv?: NodeJS.ProcessEnv): NpmConfigIsolation {
309
321
  const ownsRoot = dir === undefined;
@@ -315,7 +327,11 @@ function createNpmConfigIsolation(dir?: string, boundEnv?: NodeJS.ProcessEnv): N
315
327
  const globalNpmrc = join(root, "ocx-update-global.npmrc");
316
328
  writeFileSync(globalNpmrc, "");
317
329
  const cache = join(root, "npm-cache");
318
- return { dir: root, npmrc, globalNpmrc, cache, env: codexCliUpdateBoundEnv(boundEnv) };
330
+ const env = codexCliUpdateBoundEnv(boundEnv);
331
+ // npm enables Node's compile cache itself. Dropping the ambient output path
332
+ // alone recreates <tmpdir>/node-compile-cache outside this owned lifetime.
333
+ env.NODE_COMPILE_CACHE = resolve(root, "node-compile-cache");
334
+ return { dir: root, npmrc, globalNpmrc, cache, env };
319
335
  } catch (error) {
320
336
  // A mid-setup failure must not leak a directory this call created; a
321
337
  // caller-supplied directory stays the caller's responsibility.
@@ -356,7 +372,7 @@ function isolatedNpmTarget(args: readonly string[], isolation: NpmConfigIsolatio
356
372
  export function resolveCodexCliUpdateTarget(
357
373
  channel: CodexCliUpdateChannel,
358
374
  spawn: typeof spawnSync = spawnSync,
359
- /** Proof-bound launcher snapshot env; executable resolution reads only its PATH/PATHEXT. */
375
+ /** Proof-bound pre-dotenv PATH/PATHEXT and proxy/CA env; absent settings stay absent. */
360
376
  boundEnv?: NodeJS.ProcessEnv,
361
377
  ): CodexCliUpdateTarget {
362
378
  // The pinned registry and the isolation directory together are the boundary: the
@@ -453,6 +469,7 @@ function refusedPlan(
453
469
  targetVersion: target?.kind === "resolved" ? target.version : null,
454
470
  targetIntegrity: target?.kind === "resolved" ? target.integrity : null,
455
471
  session,
472
+ installContentDigest: report.installContentDigest,
456
473
  command: null,
457
474
  });
458
475
  }
@@ -473,7 +490,9 @@ export async function createCodexCliUpdatePlan(deps: CodexCliUpdatePlanDeps = {}
473
490
  const channel: CodexCliUpdateChannel = deps.channel ?? "latest";
474
491
  const platform = deps.platform ?? process.platform;
475
492
  const inspect = deps.inspect ?? inspectCodexCliInstall;
476
- const report = await inspect(deps.inspectionDeps ?? {});
493
+ // The planId binds the observed file set, so the plan is the one caller that
494
+ // pays the bounded content read; a bare ownership check leaves it unobserved.
495
+ const report = await inspect({ ...(deps.inspectionDeps ?? {}), observeInstallContent: true });
477
496
 
478
497
  // Phase 1 performs no candidate filesystem I/O at all on Windows, so there is no
479
498
  // ownership evidence to build a plan on. This stays dormant until the handle-bound
@@ -494,9 +513,15 @@ export async function createCodexCliUpdatePlan(deps: CodexCliUpdatePlanDeps = {}
494
513
  return refusedPlan("installed_version_unverified", report, channel, null, NOT_EVALUATED);
495
514
  }
496
515
 
516
+ // A managed install whose observed content could not be bound produces no
517
+ // usable plan id — spending a registry query on it can only widen the claim.
518
+ if (!report.installContentDigest) {
519
+ return refusedPlan("install_content_unverified", report, channel, null, NOT_EVALUATED);
520
+ }
521
+
497
522
  // The npm evidence channels are bound to the same environment snapshot the
498
- // ownership inspection ran against: PATH/PATHEXT from the proof-bound launcher
499
- // context, never the ambient ones a project dotenv could point at a fake npm.
523
+ // ownership inspection ran against: PATH/PATHEXT and proxy/CA from the proof-bound
524
+ // pre-dotenv launcher context, never ambient executable or transport overrides.
500
525
  // When the caller had no trusted snapshot, inspectionDeps.env is the fail-closed
501
526
  // { PATH: "" } form, so this resolution cannot silently fall back to ambient.
502
527
  const resolveTarget = deps.resolveTarget
@@ -543,6 +568,7 @@ export async function createCodexCliUpdatePlan(deps: CodexCliUpdatePlanDeps = {}
543
568
  provenance: report.provenance,
544
569
  installedVersion,
545
570
  installDigest: report.installDigest,
571
+ installContentDigest: report.installContentDigest,
546
572
  channel,
547
573
  targetVersion: target.version,
548
574
  targetIntegrity: target.integrity,
@@ -555,6 +581,7 @@ export async function createCodexCliUpdatePlan(deps: CodexCliUpdatePlanDeps = {}
555
581
  targetVersion: target.version,
556
582
  targetIntegrity: target.integrity,
557
583
  session: Object.freeze({ state: "none" as const, matches: 0 }),
584
+ installContentDigest: report.installContentDigest,
558
585
  command: codexCliUpdateCommand(target.version),
559
586
  });
560
587
  }
@@ -9,16 +9,17 @@
9
9
  *
10
10
  * The one thing an app-server restart cannot refresh is the picker: the renderer builds
11
11
  * the roster once when the shell launches, and `model_catalog_json` is startup-only. So
12
- * the shell has to restart only when it launched before the catalog it should show was
13
- * written. The catalog writer skips byte-identical rewrites to keep this mtime honest
14
- * (`src/codex/catalog/retained-sync.ts`), which is what makes the comparison usable.
12
+ * a running shell has to restart unless there is affirmative evidence that it loaded the
13
+ * current binding. The catalog writer skips byte-identical rewrites
14
+ * (`src/codex/catalog/retained-sync.ts`), so a file timestamp alone is not that evidence.
15
15
  *
16
16
  * The mtime only means something for the catalog `config.toml` actually binds. With no
17
17
  * `model_catalog_json` the shell shows Codex's own roster, which no local file dates, so
18
18
  * an unbound config is `unknown`. `config.toml`'s own mtime is no substitute: injection
19
19
  * rewrites it on every sync even when the bytes are identical. A binding changed by THIS
20
- * request's sync is caught by the caller comparing it across the sync; one changed by an
21
- * earlier sync over a byte-identical catalog file is not observable here.
20
+ * request's sync is caught by the caller comparing it across the sync. If the shell is
21
+ * newer than the catalog, however, its launch time cannot prove which binding it loaded:
22
+ * an earlier sync may have rebound an already-existing, byte-identical catalog.
22
23
  *
23
24
  * Anything we cannot read answers `unknown`, and the caller falls back to the full
24
25
  * desktop restart: a stale picker the operator asked us to fix is the failure this
@@ -38,8 +39,8 @@ export type DesktopPickerState = CodexDesktopPickerState;
38
39
  * Pure decision. `shellStartsMs === null` means the shells could not be listed; a null
39
40
  * entry means one shell's start time was unreadable.
40
41
  *
41
- * Strictly later, never equal: macOS `ps lstart` has one-second granularity, so a shell
42
- * that started in the same second as the catalog write may predate it.
42
+ * A shell no newer than the catalog is definitely stale. A newer shell is `unknown`, not
43
+ * current, because timestamps carry no evidence of which catalog binding it loaded.
43
44
  */
44
45
  export function classifyDesktopPicker(
45
46
  shellStartsMs: readonly (number | null)[] | null,
@@ -48,8 +49,9 @@ export function classifyDesktopPicker(
48
49
  if (shellStartsMs === null) return "unknown";
49
50
  if (shellStartsMs.length === 0) return "not_running";
50
51
  if (catalogMtimeMs === null) return "unknown";
52
+ if (shellStartsMs.some(start => start !== null && start <= catalogMtimeMs)) return "stale";
51
53
  if (shellStartsMs.some(start => start === null)) return "unknown";
52
- return shellStartsMs.every(start => start! > catalogMtimeMs) ? "current" : "stale";
54
+ return "unknown";
53
55
  }
54
56
 
55
57
  export interface DesktopPickerIo extends DesktopAppRestartIo {
@@ -40,7 +40,8 @@ export class UsageActivation {
40
40
  const beforeVerification=this.generation,binding=this.binding;
41
41
  let verified:string|null;
42
42
  try{verified=await this.verifyIdentity();}catch{verified=null;}
43
- if(verified!==binding||this.binding!==binding||this.generation!==beforeVerification)return{accepted:false,reason:'identity-not-verified',...this.snapshot()};
43
+ if(this.binding!==binding||this.generation!==beforeVerification)return{accepted:false,reason:'identity-not-verified',...this.snapshot()};
44
+ if(verified!==binding){this.invalidateIdentity();return{accepted:false,reason:'identity-not-verified',...this.snapshot()};}
44
45
  if(this.mode==='apply')return{accepted:false,reason:'activation-already-pending',...this.snapshot()};
45
46
  const age=this.latest?this.clock()-this.latest.at:Infinity;
46
47
  if(!this.latest||this.latest.kind!=='exhausted'||age<0||age>300000)return{accepted:false,reason:'no-fresh-eligible-exhaustion',...this.snapshot()};
@@ -72,7 +72,13 @@ export class UsageRelayController {
72
72
  || !Number.isSafeInteger(envelope.sequence) || Number(envelope.sequence) <= 0))) { stream?.exclude(); return null; }
73
73
  const original = envelope ? envelope.usage : parsed;
74
74
  if (!object(original) || original.account_id !== this.account.id || original.user_id !== this.account.userId
75
- || original.plan_type !== this.account.plan) { stream?.exclude(); return null; }
75
+ || original.plan_type !== this.account.plan) {
76
+ if (object(original) && [[original.account_id, this.account.id], [original.user_id, this.account.userId],
77
+ [original.plan_type, this.account.plan]].some(([actual, expected]) => typeof actual === 'string' && actual.length > 0 && actual !== expected)) {
78
+ this.activation.invalidateIdentity();
79
+ }
80
+ stream?.exclude(); return null;
81
+ }
76
82
  const rate = original.rate_limit;
77
83
  if (!object(rate) || typeof rate.allowed !== 'boolean' || typeof rate.limit_reached !== 'boolean') {
78
84
  stream?.exclude(); return null;
@@ -10,17 +10,24 @@ const ENTROPY = new TextEncoder().encode(PURPOSE);
10
10
  // Only the PowerShell fallback is bounded by a clock. A cold Windows PowerShell that must also load
11
11
  // System.Security has been measured past 10s on a loaded machine (Windows CI run 38591626343).
12
12
  const DPAPI_TIMEOUT_MS = 20_000;
13
+ // Cmdlet-free on purpose. The first cmdlet a PowerShell session runs (Add-Type, ConvertFrom-Json)
14
+ // sends it through command discovery and module auto-loading, which without a module-analysis cache
15
+ // for the profile scans the modules on PSModulePath. The test runner gives every run a fresh profile
16
+ // and CI images carry large module sets, so that scan, not DPAPI, is what cold runs waited on.
17
+ // Assembly loading, string splitting and base64 are plain .NET calls outside command discovery.
18
+ // The request is "<operation>\n<base64 data>" on stdin.
13
19
  const SCRIPT = [
14
20
  "$ErrorActionPreference='Stop'",
15
- "Add-Type -AssemblyName System.Security",
16
- "$r=[Console]::In.ReadToEnd()|ConvertFrom-Json",
17
- "$data=[Convert]::FromBase64String($r.data)",
21
+ "[void][Reflection.Assembly]::Load('System.Security, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a')",
22
+ "$r=[Console]::In.ReadToEnd().Split([char]10)",
23
+ "if($r.Length -ne 2){throw 'Invalid request'}",
24
+ "$data=[Convert]::FromBase64String($r[1])",
18
25
  `if($data.Length -eq 0 -or $data.Length -gt ${MAX_INPUT}){throw 'Invalid data size'}`,
19
26
  `$entropy=[Text.Encoding]::UTF8.GetBytes('${PURPOSE}')`,
20
27
  "$scope=[Security.Cryptography.DataProtectionScope]::CurrentUser",
21
- "if($r.operation -eq 'protect'){",
28
+ "if($r[0] -ceq 'protect'){",
22
29
  " $out=[Security.Cryptography.ProtectedData]::Protect($data,$entropy,$scope)",
23
- "}elseif($r.operation -eq 'unprotect'){",
30
+ "}elseif($r[0] -ceq 'unprotect'){",
24
31
  " $out=[Security.Cryptography.ProtectedData]::Unprotect($data,$entropy,$scope)",
25
32
  "}else{throw 'Invalid operation'}",
26
33
  "[Console]::Out.Write([Convert]::ToBase64String($out))",
@@ -126,7 +133,7 @@ function runPowerShellDpapi(operation: Operation, data: Uint8Array, timeoutMs =
126
133
  resolve(bytes);
127
134
  });
128
135
  child.stdin?.on("error", () => { /* execFile's completion reports the failed child. */ });
129
- child.stdin?.end(JSON.stringify({ operation, data: Buffer.from(data).toString("base64") }));
136
+ child.stdin?.end(`${operation}\n${Buffer.from(data).toString("base64")}`);
130
137
  });
131
138
  }
132
139
 
@@ -163,3 +170,8 @@ export function powerShellAuthorityKeyProtectionForTests(timeoutMs: number): Aut
163
170
  export function usesNativeDpapiForTests(): boolean {
164
171
  return process.platform === "win32" && loadNativeDpapi() !== null;
165
172
  }
173
+
174
+ /** Test-only: the PowerShell helper's script, so its cmdlet-free contract can be checked anywhere. */
175
+ export function powerShellDpapiScriptForTests(): string {
176
+ return SCRIPT;
177
+ }
@@ -125,6 +125,8 @@ export function noteCodexPoolRefreshFailure(
125
125
  reason: string,
126
126
  now = currentNow(),
127
127
  fence?: string,
128
+ // Resolved only when a line is actually written; the caller knows the account's durable label.
129
+ logLabel: (accountId: string) => string = fallbackCodexAccountLogLabel,
128
130
  ): { consecutiveFailures: number; cooldownUntil: number; openedWindow: boolean } {
129
131
  const existing = backoffByAccount.get(accountId);
130
132
  // A flight that started before the account's failures were cleared is speaking for a grant
@@ -153,7 +155,7 @@ export function noteCodexPoolRefreshFailure(
153
155
  const consecutiveFailures = (existing?.consecutiveFailures ?? 0) + 1;
154
156
  const cooldownUntil = now + delayFor(consecutiveFailures);
155
157
  backoffByAccount.set(accountId, { consecutiveFailures, cooldownUntil, reason });
156
- const label = fallbackCodexAccountLogLabel(accountId);
158
+ const label = logLabel(accountId);
157
159
  console.warn(
158
160
  `[codex-auth] Codex pool account ${label} credential refresh failed (${reason})`,
159
161
  );
@@ -92,6 +92,13 @@ export class CodexQuotaHistory {
92
92
  return { samples: structuredClone(bucket.samples.slice(-capped)), truncated: bucket.samples.length > capped };
93
93
  }
94
94
 
95
+ /** The newest sample's origin, without pruning or clearing: a passive read changes nothing. */
96
+ peekLatestSource(accountId: string, identity: string | undefined): QuotaHistorySample["source"] | undefined {
97
+ const bucket = this.accounts.get(accountId);
98
+ if (!identity || !bucket || bucket.identity !== identity) return undefined;
99
+ return bucket.samples.at(-1)?.source;
100
+ }
101
+
95
102
  clear(accountId?: string): number {
96
103
  if (accountId === undefined) {
97
104
  const count = this.accounts.size;
@@ -1099,3 +1099,13 @@ export function getAccountQuotaHistory(accountId: string, limit: number = QUOTA_
1099
1099
  return { observations: result.samples.map(({ credentialGeneration: _generation, ...sample }) => sample),
1100
1100
  truncated: result.truncated, retention: { maxObservations: QUOTA_HISTORY_LIMITS.perAccount, maxAgeDays: 30 } };
1101
1101
  }
1102
+
1103
+ /**
1104
+ * Where the newest stored reading for a pool account came from (a WHAM usage body or upstream
1105
+ * response headers), or undefined when no current history exists. Read-only: unlike
1106
+ * `getAccountQuotaHistory` it never prunes or clears a mismatched bucket.
1107
+ */
1108
+ export function latestCodexQuotaObservationSource(accountId: string): "wham" | "response-header" | undefined {
1109
+ hydrateAccountQuotasFromDisk();
1110
+ return quotaHistory.peekLatestSource(accountId, poolQuotaHistoryIdentity(accountId));
1111
+ }
@@ -38,6 +38,7 @@ import {
38
38
  isCanonicalOpenAiForwardProvider,
39
39
  } from "../providers/openai-tiers";
40
40
  import { routeModel, type RouteResult } from "../router";
41
+ import { guardFallbackHop, type FallbackAdmission } from "../routing/fallback-policy";
41
42
  import { sweepExpiredOnWrite } from "../lib/state-store-sweeper";
42
43
  import { codexAccountNamespaceForModel } from "./account-namespace-match";
43
44
  import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS, NATIVE_MAIN_DRAIN_SENTINEL_MODELS } from "./catalog/native-models";
@@ -629,7 +630,8 @@ export function applySubagentModelFallback(
629
630
  modelEligibleAccountIdsForModel?: SubagentModelEligibleAccountIds,
630
631
  resolvedFallbackChain?: readonly string[] | null,
631
632
  restrictedRouteEligible?: SubagentFallbackRouteEligibility,
632
- ): { from?: string; to?: string; skipped?: string[] } | null {
633
+ admission?: FallbackAdmission,
634
+ ): { from?: string; to?: string; skipped?: string[]; refusal?: Response } | null {
633
635
  if (!isThreadSpawnRequest(headers)) return null;
634
636
  const fallbackChain = resolvedFallbackChain === undefined
635
637
  ? resolveSubagentFallbackChain(parsed, config)
@@ -653,10 +655,31 @@ export function applySubagentModelFallback(
653
655
  ? { from: parsed.modelId, to: parsed.modelId, skipped: selection.skipped }
654
656
  : null;
655
657
  const from = parsed.modelId;
658
+ // OCX-11: the rewrite is a proactive hop away from an unavailable primary. Refused, the spawn
659
+ // keeps its model and the caller answers with the refusal instead of sending anything.
660
+ const hop = guardFallbackHop(config, {
661
+ site: "subagent",
662
+ route: from,
663
+ from: fallbackTargetOf(config, from),
664
+ to: fallbackTargetOf(config, selection.model),
665
+ trigger: "unavailable",
666
+ admission,
667
+ now,
668
+ });
669
+ if (!hop.ok) return { from, to: from, skipped: selection.skipped, refusal: hop.response };
670
+ // The rewrite is this site's admission: the spawn now carries the fallback model.
671
+ hop.commit();
656
672
  rewriteParsedModel(parsed, selection.model);
657
673
  return { from, to: selection.model, skipped: selection.skipped };
658
674
  }
659
675
 
676
+ function fallbackTargetOf(config: OcxConfig, model: string): { provider: string; model: string } {
677
+ const route = tryRouteFallbackModel(config, model);
678
+ if (route) return { provider: route.providerName, model: route.modelId };
679
+ const slash = model.indexOf("/");
680
+ return slash > 0 ? { provider: model.slice(0, slash), model: model.slice(slash + 1) } : { provider: "", model };
681
+ }
682
+
660
683
  /** Resolve the effective fallback chain once for one logical spawn request. */
661
684
  export function resolveSubagentFallbackChain(
662
685
  parsed: OcxParsedRequest,
@@ -19,6 +19,7 @@ import { isAbsolute, join, resolve } from "node:path";
19
19
 
20
20
  import { resolveTrustedWindowsPowerShellExe } from "../lib/windows-elevation";
21
21
  import { WINDOWS_PRINCIPAL_LOOKUP_TIMEOUT_MS } from "../lib/windows-user-principal";
22
+ import { resolveWindowsCurrentSid } from "./windows-current-sid";
22
23
  import { resolveWindowsLocalAppData } from "./windows-local-appdata";
23
24
 
24
25
  import type {
@@ -213,8 +214,8 @@ export function decodeWindowsIdentityPowerShellOutputForTests(output: Uint8Array
213
214
  * Both helper values -- the effective token's SID and its known-folder local AppData --
214
215
  * are fixed for the lifetime of a process: neither can change without a new logon
215
216
  * token, and the lookups deliberately ignore the environment, so nothing a caller
216
- * does between two calls can alter the answer. The native x64 known-folder path
217
- * avoids this helper entirely. Each fallback call otherwise spawns a fresh
217
+ * does between two calls can alter the answer. The native x64 SID and known-folder
218
+ * paths avoid this helper entirely. Each fallback call otherwise spawns a fresh
218
219
  * PowerShell, roughly 150ms for the SID and 310ms for the folder, and the
219
220
  * coordinator asks for both on every config write and lock acquisition. That cost
220
221
  * pushed real multi-process injection tests past their budget while proving
@@ -259,14 +260,42 @@ function powershellValue(expression: string): string {
259
260
  return value;
260
261
  }
261
262
 
262
- function resolveWindowsSid(): string {
263
- const sid = powershellValue(
264
- "[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value",
265
- );
263
+ const WINDOWS_SID_EXPRESSION = "[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value";
264
+
265
+ /**
266
+ * Windows x64 reads the token SID in-process (`windows-current-sid.ts`, #7544);
267
+ * the PowerShell helper runs only where `bun:ffi` cannot. Every failure either
268
+ * path reports — a helper timeout, a native API error — refuses.
269
+ */
270
+ function resolveWindowsSidWith(
271
+ powershellLookup: () => string,
272
+ nativeLookup?: (() => string) | null,
273
+ ): string {
274
+ let sid: string;
275
+ try {
276
+ sid = nativeLookup === undefined
277
+ ? resolveWindowsCurrentSid(powershellLookup)
278
+ : resolveWindowsCurrentSid(powershellLookup, nativeLookup);
279
+ } catch (cause) {
280
+ if (cause instanceof CodexUserIdentityRefusal) throw cause;
281
+ refuse("Windows effective-account lookup failed.", cause);
282
+ }
266
283
  if (!SID_PATTERN.test(sid)) refuse("Windows effective-account lookup returned an invalid SID.");
267
284
  return sid.toUpperCase();
268
285
  }
269
286
 
287
+ function resolveWindowsSid(): string {
288
+ return resolveWindowsSidWith(() => powershellValue(WINDOWS_SID_EXPRESSION));
289
+ }
290
+
291
+ /** Test-only: the SID resolution and refusal mapping with both lookups injected. */
292
+ export function resolveWindowsSidForTests(
293
+ powershellLookup: () => string,
294
+ nativeLookup: (() => string) | null,
295
+ ): string {
296
+ return resolveWindowsSidWith(powershellLookup, nativeLookup);
297
+ }
298
+
270
299
  function windowsLocalAppData(): string {
271
300
  try {
272
301
  return resolveWindowsLocalAppData(() => powershellValue(windowsLocalAppDataExpression()));
@@ -0,0 +1,162 @@
1
+ /**
2
+ * The effective process token's SID, read in-process on Windows x64.
3
+ *
4
+ * The PowerShell helper this replaces is correct but slow to start: a cold
5
+ * `powershell.exe` on a loaded runner spent more than the 30s lookup budget
6
+ * before evaluating one constant of the process token, and `ocx service install`
7
+ * refused (#7544). Widening that budget again would only move the line. The SID
8
+ * itself costs three advapi32 calls, so the x64 path asks the OS directly and the
9
+ * helper keeps running only where `bun:ffi` cannot (Windows ARM64).
10
+ *
11
+ * The answer is the PROCESS token's user. `WindowsIdentity.GetCurrent()` would
12
+ * prefer a thread impersonation token, but nothing in this process impersonates,
13
+ * so the two agree; the process token is also what Task Scheduler and the ACL
14
+ * code grant to.
15
+ *
16
+ * Fail-closed is preserved: when the native API is available and fails, the
17
+ * lookup throws and the caller refuses. It never falls back to the helper, whose
18
+ * environment the native path exists to avoid depending on.
19
+ */
20
+ import { dlopen, ptr, read, type Pointer } from "bun:ffi";
21
+ import { win32 } from "node:path";
22
+ import { resolveTrustedWindowsSystemDirectory } from "../lib/windows-elevation";
23
+
24
+ const TOKEN_QUERY = 0x0008;
25
+ const TOKEN_USER_INFORMATION_CLASS = 1;
26
+ /**
27
+ * TOKEN_USER is one SID_AND_ATTRIBUTES (an 8-byte SID pointer and a 4-byte
28
+ * attribute word, padded to 16) followed by the SID it points at. A SID is at
29
+ * most SECURITY_MAX_SID_SIZE (68) bytes, so 256 bytes always suffices and a
30
+ * larger reported length is treated as a malformed answer.
31
+ */
32
+ const TOKEN_USER_BUFFER_BYTES = 256;
33
+ const TOKEN_USER_HEADER_BYTES = 16;
34
+ /** "S-1-" plus at most 15 sub-authorities of up to 10 digits each, with room to spare. */
35
+ const MAX_SID_STRING_CODE_UNITS = 256;
36
+ const SID_PATTERN = /^S-1-(?:\d+-)+\d+$/i;
37
+
38
+ export interface WindowsTokenSidApi {
39
+ /** OpenProcessToken(GetCurrentProcess(), access, &token). Non-zero BOOL is success. */
40
+ openProcessToken(desiredAccess: number, token: BigUint64Array): number;
41
+ /** GetTokenInformation(token, class, buffer, buffer.length, &returnLength). */
42
+ getTokenInformation(
43
+ token: bigint,
44
+ informationClass: number,
45
+ buffer: Uint8Array,
46
+ returnLength: Uint32Array,
47
+ ): number;
48
+ /** ConvertSidToStringSidW(sid, &string). The string is LocalAlloc'd on success. */
49
+ convertSidToStringSid(sidAddress: number, output: BigUint64Array): number;
50
+ readCodeUnit(address: number, byteOffset: number): number;
51
+ localFree(address: number): void;
52
+ closeHandle(token: bigint): void;
53
+ }
54
+
55
+ function lookupError(detail: string): Error {
56
+ return new Error(`Windows effective-account token lookup ${detail}.`);
57
+ }
58
+
59
+ function readSidString(api: WindowsTokenSidApi, address: number): string {
60
+ const units: number[] = [];
61
+ for (let index = 0; index < MAX_SID_STRING_CODE_UNITS; index += 1) {
62
+ const unit = api.readCodeUnit(address, index * 2);
63
+ if (unit === 0) return String.fromCharCode(...units);
64
+ units.push(unit);
65
+ }
66
+ throw lookupError("returned an unterminated SID");
67
+ }
68
+
69
+ /** Same token user the PowerShell helper reports, without starting a process. */
70
+ export function readWindowsCurrentSid(api: WindowsTokenSidApi): string {
71
+ const tokenOutput = new BigUint64Array(1);
72
+ if (api.openProcessToken(TOKEN_QUERY, tokenOutput) === 0 || tokenOutput[0] === 0n) {
73
+ throw lookupError("could not open the process token");
74
+ }
75
+ const token = tokenOutput[0]!;
76
+ let stringAddress = 0;
77
+ try {
78
+ // Keep `buffer` referenced until the conversion returns: the SID pointer
79
+ // inside it points back into this same allocation.
80
+ const buffer = new Uint8Array(TOKEN_USER_BUFFER_BYTES);
81
+ const returnLength = new Uint32Array(1);
82
+ if (api.getTokenInformation(token, TOKEN_USER_INFORMATION_CLASS, buffer, returnLength) === 0) {
83
+ throw lookupError("could not read the token user");
84
+ }
85
+ const length = returnLength[0]!;
86
+ if (length <= TOKEN_USER_HEADER_BYTES || length > buffer.byteLength) {
87
+ throw lookupError("returned a malformed token user");
88
+ }
89
+ const sidAddress = Number(
90
+ new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength).getBigUint64(0, true),
91
+ );
92
+ if (!Number.isSafeInteger(sidAddress) || sidAddress <= 0) {
93
+ throw lookupError("returned a null SID");
94
+ }
95
+ const stringOutput = new BigUint64Array(1);
96
+ const converted = api.convertSidToStringSid(sidAddress, stringOutput);
97
+ const candidate = Number(stringOutput[0]);
98
+ if (converted === 0 || !Number.isSafeInteger(candidate) || candidate <= 0) {
99
+ throw lookupError("could not format the SID");
100
+ }
101
+ stringAddress = candidate;
102
+ const sid = readSidString(api, stringAddress);
103
+ if (!SID_PATTERN.test(sid)) throw lookupError("returned an invalid SID");
104
+ return sid.toUpperCase();
105
+ } finally {
106
+ if (stringAddress > 0) api.localFree(stringAddress);
107
+ api.closeHandle(token);
108
+ }
109
+ }
110
+
111
+ let nativeLookup: (() => string) | null | undefined;
112
+
113
+ function loadNativeLookup(): (() => string) | null {
114
+ if (nativeLookup !== undefined) return nativeLookup;
115
+ if (process.platform !== "win32" || process.arch !== "x64") return null;
116
+ try {
117
+ const systemDirectory = resolveTrustedWindowsSystemDirectory();
118
+ const advapi = dlopen(win32.join(systemDirectory, "advapi32.dll"), {
119
+ OpenProcessToken: { args: ["u64", "u32", "ptr"], returns: "i32" },
120
+ GetTokenInformation: { args: ["u64", "u32", "ptr", "u32", "ptr"], returns: "i32" },
121
+ ConvertSidToStringSidW: { args: ["ptr", "ptr"], returns: "i32" },
122
+ });
123
+ let kernel;
124
+ try {
125
+ kernel = dlopen(win32.join(systemDirectory, "kernel32.dll"), {
126
+ GetCurrentProcess: { args: [], returns: "u64" },
127
+ CloseHandle: { args: ["u64"], returns: "i32" },
128
+ LocalFree: { args: ["ptr"], returns: "ptr" },
129
+ });
130
+ } catch (error) {
131
+ advapi.close();
132
+ throw error;
133
+ }
134
+ const api: WindowsTokenSidApi = {
135
+ openProcessToken: (access, token) => advapi.symbols.OpenProcessToken(
136
+ kernel.symbols.GetCurrentProcess(), access, ptr(token),
137
+ ),
138
+ getTokenInformation: (token, informationClass, buffer, returnLength) =>
139
+ advapi.symbols.GetTokenInformation(
140
+ token, informationClass, ptr(buffer), buffer.byteLength, ptr(returnLength),
141
+ ),
142
+ convertSidToStringSid: (sidAddress, output) => advapi.symbols.ConvertSidToStringSidW(
143
+ sidAddress as Pointer, ptr(output),
144
+ ),
145
+ readCodeUnit: (address, offset) => read.u16(address as Pointer, offset),
146
+ localFree: address => { kernel.symbols.LocalFree(address as Pointer); },
147
+ closeHandle: token => { kernel.symbols.CloseHandle(token); },
148
+ };
149
+ nativeLookup = () => readWindowsCurrentSid(api);
150
+ } catch {
151
+ nativeLookup = null;
152
+ }
153
+ return nativeLookup;
154
+ }
155
+
156
+ /** Unsupported FFI keeps the bounded trusted helper; an OS API failure still refuses. */
157
+ export function resolveWindowsCurrentSid(
158
+ powershellLookup: () => string,
159
+ lookup: (() => string) | null = loadNativeLookup(),
160
+ ): string {
161
+ return lookup ? lookup() : powershellLookup();
162
+ }
@@ -23,6 +23,7 @@ export {
23
23
  advanceComboAfterFailure,
24
24
  clearComboSelectionState,
25
25
  comboExclusionReasons,
26
+ comboTargetSkipTrigger,
26
27
  NoAvailableComboTargetsError,
27
28
  noteComboFailure,
28
29
  noteComboSuccess,