@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
|
@@ -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.
|
|
224
|
-
*
|
|
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
|
|
259
|
-
* launcher snapshot.
|
|
260
|
-
*
|
|
261
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
499
|
-
// context, never
|
|
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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* (`src/codex/catalog/retained-sync.ts`),
|
|
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
|
|
21
|
-
*
|
|
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
|
-
*
|
|
42
|
-
*
|
|
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
|
|
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(
|
|
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) {
|
|
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
|
-
"
|
|
16
|
-
"$r=[Console]::In.ReadToEnd()
|
|
17
|
-
"
|
|
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
|
|
28
|
+
"if($r[0] -ceq 'protect'){",
|
|
22
29
|
" $out=[Security.Cryptography.ProtectedData]::Protect($data,$entropy,$scope)",
|
|
23
|
-
"}elseif($r
|
|
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(
|
|
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 =
|
|
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;
|
package/src/codex/quota.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
217
|
-
*
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
+
}
|