@bitkyc08/opencodex 2.57.0 → 2.59.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/README.md +28 -10
- package/gui/dist/assets/index-C5IebErG.js +136 -0
- package/gui/dist/assets/{index-C5-RdDmD.css → index-OESInAjC.css} +1 -1
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/crusoe.svg +1 -0
- package/gui/dist/provider-icons/opper.svg +3 -0
- package/package.json +2 -2
- package/src/adapters/base.ts +11 -1
- package/src/adapters/codebuddy/scaffold-guard.ts +5 -4
- package/src/adapters/command-code.ts +13 -4
- package/src/adapters/cursor/catalog.ts +11 -0
- package/src/adapters/cursor/cursor-errors.ts +15 -0
- package/src/adapters/cursor/discovery.ts +65 -1
- package/src/adapters/cursor/effort-map.ts +16 -2
- package/src/adapters/cursor/envelope-echo.ts +55 -2
- package/src/adapters/cursor/live-transport.ts +5 -1
- package/src/adapters/cursor/message-mapper.ts +3 -2
- package/src/adapters/cursor/protobuf-events.ts +110 -11
- package/src/adapters/cursor/protobuf-request.ts +27 -6
- package/src/adapters/cursor/request-builder.ts +14 -3
- package/src/adapters/cursor/text-toolcall.ts +230 -0
- package/src/adapters/cursor/thread-continuity.ts +141 -0
- package/src/adapters/cursor/tool-guidance.ts +5 -4
- package/src/adapters/cursor/types.ts +5 -0
- package/src/adapters/cursor.ts +97 -6
- package/src/adapters/devin/cloud-direct/chat.ts +11 -2
- package/src/adapters/devin/cloud-direct/index.ts +7 -0
- package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +103 -0
- package/src/adapters/devin.ts +75 -13
- package/src/adapters/google-antigravity-wire.ts +29 -2
- package/src/adapters/google-http.ts +45 -13
- package/src/adapters/google.ts +23 -4
- package/src/adapters/mimo-free.ts +32 -17
- package/src/adapters/ollama-native.ts +42 -8
- package/src/adapters/openai-chat/response-events.ts +61 -0
- package/src/adapters/openai-chat.ts +5 -10
- package/src/adapters/openai-responses/passthrough.ts +40 -5
- package/src/adapters/openai-responses/request-strips.ts +43 -0
- package/src/adapters/openai-responses/tool-output-recovery.ts +75 -0
- package/src/adapters/openai-responses/tool-schema.ts +19 -7
- package/src/adapters/physical-send.ts +50 -0
- package/src/adapters/responses-tool-schema.ts +76 -46
- package/src/adapters/run-turn-queue.ts +17 -4
- package/src/bridge/response-json.ts +2 -2
- package/src/bridge/sse.ts +166 -25
- package/src/claude/context-windows.ts +22 -0
- package/src/claude/outbound.ts +46 -5
- package/src/cli/account-api.ts +4 -3
- package/src/cli/account-extended.ts +22 -2
- package/src/cli/account-orca-import.ts +63 -0
- package/src/cli/account.ts +32 -4
- package/src/cli/capabilities.ts +40 -0
- package/src/cli/claude.ts +29 -1
- package/src/cli/codex-cli-update.ts +97 -2
- package/src/cli/config-command.ts +35 -18
- package/src/cli/dispatch.ts +71 -4
- package/src/cli/doctor.ts +197 -2
- package/src/cli/help.ts +4 -1
- package/src/cli/index.ts +132 -22
- package/src/cli/models-runtime.ts +33 -4
- package/src/cli/registry.ts +11 -1
- package/src/cli/runtime-api.ts +44 -0
- package/src/cli/start-args.ts +94 -0
- package/src/cli/system-command.ts +72 -1
- package/src/cli/uninstall-client-state.ts +12 -0
- package/src/client/machine-api.ts +4 -3
- package/src/client/machine-listener.ts +14 -1
- package/src/clients/config-export/constants.ts +2 -3
- package/src/clients/config-export.ts +5 -5
- package/src/codex/account-store.ts +81 -5
- package/src/codex/auth-api/pool-quota-probe.ts +14 -3
- package/src/codex/auth-api/routes.ts +17 -2
- package/src/codex/auth-context.ts +58 -20
- package/src/codex/catalog/build-entries.ts +25 -4
- package/src/codex/catalog/derive-entry.ts +8 -1
- package/src/codex/catalog/effort.ts +10 -6
- package/src/codex/catalog/gather-capture.ts +1 -0
- package/src/codex/catalog/model-hints.ts +37 -5
- package/src/codex/catalog/parsing.ts +83 -5
- package/src/codex/catalog/reserve-warn.ts +96 -0
- package/src/codex/catalog/retained-sync.ts +19 -0
- package/src/codex/catalog/routed-gather.ts +42 -3
- package/src/codex/cli-installation-identity.ts +210 -0
- package/src/codex/cli-installation-targets.ts +158 -0
- package/src/codex/convergence.ts +5 -0
- package/src/codex/desktop-switches.ts +145 -0
- package/src/codex/history-job.ts +5 -1
- package/src/codex/history-provider.ts +37 -5
- package/src/codex/history-state-open.ts +105 -0
- package/src/codex/history-worker.ts +14 -1
- package/src/codex/inject/config-toml.ts +44 -2
- package/src/codex/inject/remove.ts +145 -7
- package/src/codex/inject/restore.ts +204 -32
- package/src/codex/inject.ts +6 -9
- package/src/codex/lineage.ts +83 -32
- package/src/codex/loopback-target.ts +40 -0
- package/src/codex/main-account-hard-lock.ts +2 -1
- package/src/codex/main-account.ts +10 -3
- package/src/codex/main-device-reauth.ts +17 -9
- package/src/codex/model-entitlements.ts +60 -1
- package/src/codex/native-profile-startup.ts +64 -20
- package/src/codex/observed-model-denials.ts +137 -0
- package/src/codex/orca-auth-source.ts +94 -0
- package/src/codex/orca-import.ts +219 -0
- package/src/codex/prompt-text-probe.ts +282 -12
- package/src/codex/quota-401-recovery.ts +12 -0
- package/src/codex/quota-types.ts +65 -0
- package/src/codex/quota.ts +24 -19
- package/src/codex/routing/cooldown-math.ts +8 -47
- package/src/codex/routing/pin-drain.ts +57 -0
- package/src/codex/routing.ts +13 -15
- package/src/codex/subagent-model-fallback.ts +94 -0
- package/src/codex/windows-installation-files.ts +224 -0
- package/src/combos/failover.ts +122 -5
- package/src/config/atomic-write.ts +83 -8
- package/src/config/diagnostics.ts +21 -0
- package/src/config/load-degrade.ts +15 -0
- package/src/config/pending-teardown.ts +8 -0
- package/src/config/process-state.ts +36 -3
- package/src/config/provider-relative-send-path.ts +16 -0
- package/src/config/proxy-env.ts +23 -5
- package/src/config/schema/config-schema.ts +23 -0
- package/src/config/schema/leaf-validators.ts +65 -17
- package/src/generated/compatibility-version.json +337 -201
- package/src/generated/model-metadata.ts +1 -1
- package/src/lib/bounded-body.ts +4 -2
- package/src/lib/bounded-subprocess.ts +62 -10
- package/src/lib/destination-policy.ts +48 -6
- package/src/lib/errors.ts +3 -15
- package/src/lib/local-destinations.ts +32 -5
- package/src/lib/provider-outbound.ts +3 -3
- package/src/lib/proxy-env.ts +70 -3
- package/src/lib/request-execution-budget.ts +11 -3
- package/src/lib/response-body-inactivity.ts +193 -0
- package/src/lib/retry-delay.ts +69 -0
- package/src/lib/socks5-fetch.ts +631 -0
- package/src/lib/spend-reservation-ledger.ts +115 -9
- package/src/lib/windows-secret-acl.ts +151 -15
- package/src/lib/windows-user-principal.ts +5 -1
- package/src/lib/workflow-budget.ts +145 -8
- package/src/oauth/account-quota-rank.ts +72 -15
- package/src/oauth/generic-account-failover.ts +40 -27
- package/src/oauth/orcarouter.ts +15 -2
- package/src/oauth/store.ts +8 -0
- package/src/providers/codex-capacity.ts +9 -0
- package/src/providers/derive.ts +6 -0
- package/src/providers/devin-provider-merge-migration.ts +33 -12
- package/src/providers/free-directory.ts +20 -2
- package/src/providers/key-failover.ts +261 -7
- package/src/providers/model-discovery.ts +19 -7
- package/src/providers/model-rename-migration.ts +1 -0
- package/src/providers/openai-sidecar.ts +4 -0
- package/src/providers/opencode-go-transport.ts +14 -5
- package/src/providers/quota/report-cache.ts +3 -0
- package/src/providers/registry/entries-core.ts +11 -0
- package/src/providers/registry/entries-extended.ts +146 -28
- package/src/providers/registry/model-seeds.ts +136 -29
- package/src/providers/registry/types.ts +9 -0
- package/src/responses/apply-patch-envelope.ts +44 -11
- package/src/responses/bridge-search-replay-cache.ts +152 -0
- package/src/responses/code-mode-helper-compat.ts +26 -16
- package/src/responses/custom-tool-compat.ts +1 -1
- package/src/responses/hosted-tool-policy.ts +85 -2
- package/src/responses/schema.ts +9 -2
- package/src/responses/spill-store.ts +17 -0
- package/src/responses/state/body-policy.ts +25 -0
- package/src/responses/state/spill-queue.ts +8 -6
- package/src/responses/state.ts +3 -22
- package/src/router.ts +4 -0
- package/src/server/auth-cors.ts +27 -0
- package/src/server/chat-completions.ts +9 -4
- package/src/server/chat-native-sse.ts +26 -9
- package/src/server/chat-native.ts +10 -4
- package/src/server/claude-messages.ts +24 -2
- package/src/server/gui-static.ts +36 -2
- package/src/server/inbound-body-admission.ts +187 -0
- package/src/server/index/websocket-handler.ts +48 -1
- package/src/server/index.ts +15 -19
- package/src/server/management/api-access.ts +3 -4
- package/src/server/management/config-routes.ts +57 -10
- package/src/server/management/provider-capability-config.ts +35 -7
- package/src/server/management/provider-routes.ts +70 -18
- package/src/server/models-capabilities.ts +24 -3
- package/src/server/proxy-liveness.ts +97 -2
- package/src/server/relay.ts +17 -24
- package/src/server/request-log.ts +25 -1
- package/src/server/responses/adapter-continuation.ts +71 -27
- package/src/server/responses/adapter-delivery.ts +39 -8
- package/src/server/responses/adapter-dispatch.ts +52 -24
- package/src/server/responses/codex-ws-exchange.ts +65 -4
- package/src/server/responses/combo-stream-preflight.ts +68 -5
- package/src/server/responses/compact.ts +60 -11
- package/src/server/responses/core-codex-account.ts +83 -22
- package/src/server/responses/core-combo.ts +26 -0
- package/src/server/responses/core-normalize.ts +12 -5
- package/src/server/responses/core-options.ts +3 -0
- package/src/server/responses/fetch-helpers.ts +72 -3
- package/src/server/responses/native-injection-protocol.ts +42 -0
- package/src/server/responses/native-injection-replay.ts +105 -0
- package/src/server/responses/native-injection.ts +242 -0
- package/src/server/responses/native-response-control.ts +56 -0
- package/src/server/responses/native-response-json.ts +14 -0
- package/src/server/responses/native-response-output.ts +37 -0
- package/src/server/responses/native-steering-log.ts +44 -0
- package/src/server/responses/native-steering-policy.ts +49 -0
- package/src/server/responses/native-steering-replay.ts +126 -0
- package/src/server/responses/native-steering-settings.ts +76 -0
- package/src/server/responses/native-steering.ts +400 -0
- package/src/server/responses/native-tool-results.ts +130 -0
- package/src/server/responses/passthrough-delivery.ts +21 -1
- package/src/server/responses/passthrough-dispatch.ts +146 -49
- package/src/server/responses/passthrough-execution.ts +11 -1
- package/src/server/responses/request-prepare.ts +70 -0
- package/src/server/responses/request-send-budget.ts +84 -7
- package/src/server/responses/request-sidecar-auth.ts +16 -8
- package/src/server/responses/request-spend.ts +38 -9
- package/src/server/responses/request-transport.ts +13 -10
- package/src/server/responses/run-turn-execution.ts +20 -5
- package/src/server/responses/sidecar-execution.ts +2 -0
- package/src/server/responses/ws-upstream.ts +23 -2
- package/src/server/responses-custom-tool-repair.ts +2 -2
- package/src/server/sse-frame-buffer.ts +12 -10
- package/src/server/sse-payload-rewrite.ts +36 -9
- package/src/server/stop-teardown.ts +8 -1
- package/src/server/system-env-shell.ts +5 -1
- package/src/server/system-env.ts +7 -1
- package/src/server/workflow-refusal.ts +56 -2
- package/src/server/ws-bridge.ts +16 -1
- package/src/service/cli.ts +29 -7
- package/src/service/guards.ts +10 -0
- package/src/service/health.ts +43 -0
- package/src/service/state.ts +7 -2
- package/src/types/accounts.ts +4 -0
- package/src/types/config.ts +104 -3
- package/src/types/provider.ts +32 -0
- package/src/types/request.ts +7 -1
- package/src/types/wire.ts +9 -1
- package/src/usage/expected-prices.ts +28 -0
- package/src/usage/log.ts +87 -4
- package/src/web-search/passthrough-bridge.ts +39 -5
- package/gui/dist/assets/index-Cz7CLdif.js +0 -128
|
@@ -60,6 +60,9 @@ import { dirname, join } from "node:path";
|
|
|
60
60
|
// Definition-site import, not the ../config barrel -- same reasoning as
|
|
61
61
|
// src/quota/reset-seen-store.ts: the barrel pulls ~154 modules into a hot path.
|
|
62
62
|
import { getConfigDir } from "../config/paths";
|
|
63
|
+
// Type-only, so it is erased before this module has a runtime import graph at all. The
|
|
64
|
+
// config SHAPE is what this file needs; the config loader is what the note above keeps out.
|
|
65
|
+
import type { OcxSpendConfig, OcxSpendScopeConfig } from "../types/config";
|
|
63
66
|
import { assertNotRealHomeUnderTest } from "./test-home-guard";
|
|
64
67
|
// Windows chmod does not remove inherited ACEs; this is the repository's icacls path.
|
|
65
68
|
import { hardenSecretPath } from "./windows-secret-acl";
|
|
@@ -147,6 +150,24 @@ export interface SpendReservationRequest {
|
|
|
147
150
|
/** Enforceable output ceiling -- max_output_tokens or the model's documented cap. */
|
|
148
151
|
readonly outputCeilingTokens: number;
|
|
149
152
|
readonly at?: number;
|
|
153
|
+
/**
|
|
154
|
+
* This send has ALREADY left for upstream and is being recorded rather than admitted.
|
|
155
|
+
*
|
|
156
|
+
* Some transports report their physical sends after the fact -- the passthrough ladder
|
|
157
|
+
* reports through `onSendsConsumed`, and an adapter's inner retries are counted when they
|
|
158
|
+
* finish. For those, a ceiling cannot refuse anything: the tokens are spent. Refusing to
|
|
159
|
+
* BOOK them is the worse answer, and it is not hypothetical -- it is a fixpoint. The send
|
|
160
|
+
* that would cross the ceiling gets dropped from the total, the total stays just under the
|
|
161
|
+
* limit forever, the scope never reads as exhausted, and the ceiling never fires again for
|
|
162
|
+
* any request. So a recorded send skips the limit check and takes the scope over its
|
|
163
|
+
* ceiling, which is what makes the NEXT request refusable.
|
|
164
|
+
*
|
|
165
|
+
* It skips the durability refusal for the same reason: a journal that could not be written
|
|
166
|
+
* is a reason to report degradation, never a reason to forget spend that really happened.
|
|
167
|
+
* Identity, capacity and journal-integrity denials still apply -- those say the ledger
|
|
168
|
+
* cannot account for the send at all, which no flag here can change.
|
|
169
|
+
*/
|
|
170
|
+
readonly alreadySent?: boolean;
|
|
150
171
|
}
|
|
151
172
|
|
|
152
173
|
/**
|
|
@@ -468,6 +489,20 @@ export interface SpendReservationLedger {
|
|
|
468
489
|
prune(now?: number): void;
|
|
469
490
|
/** Whether this send id is already known, and therefore refused. */
|
|
470
491
|
knows(sendId: string): boolean;
|
|
492
|
+
/**
|
|
493
|
+
* Replace the live policy.
|
|
494
|
+
*
|
|
495
|
+
* Every figure already accounted survives: raising, lowering or clearing a ceiling changes
|
|
496
|
+
* what is REFUSED from here on and never what was spent. Rebuilding the ledger instead
|
|
497
|
+
* would replay the journal into a second set of maps while the first still holds this
|
|
498
|
+
* process's open reservations, and the two would then disagree about what is in flight.
|
|
499
|
+
*/
|
|
500
|
+
reconfigure(next: SpendReservationPolicy): void;
|
|
501
|
+
/**
|
|
502
|
+
* The policy in force. A live read, not a copy: a caller that formats a refusal has to name
|
|
503
|
+
* the ceiling this ledger would enforce on the NEXT request, not the one it was built with.
|
|
504
|
+
*/
|
|
505
|
+
readonly policy: SpendReservationPolicy;
|
|
471
506
|
/** Journal writes that failed; a nonzero count means durability is degraded. */
|
|
472
507
|
readonly persistFailures: number;
|
|
473
508
|
/**
|
|
@@ -495,13 +530,17 @@ export function createSpendReservationLedger(options: {
|
|
|
495
530
|
*/
|
|
496
531
|
readonly salt?: string;
|
|
497
532
|
} = {}): SpendReservationLedger {
|
|
498
|
-
|
|
533
|
+
// Mutable because the ceilings are operator configuration, and configuration is reloadable.
|
|
534
|
+
// The three bounds below are read through functions for the same reason: a value captured
|
|
535
|
+
// at construction would answer for the policy this ledger was BUILT with, and an operator
|
|
536
|
+
// who raised a bound would keep the old one until the process restarted.
|
|
537
|
+
let policy = options.policy ?? DEFAULT_SPEND_RESERVATION_POLICY;
|
|
499
538
|
const journal = options.journal;
|
|
500
539
|
const now = options.now ?? (() => Date.now());
|
|
501
540
|
const salt = options.salt ?? "";
|
|
502
|
-
const maxTrackedScopes = policy.maxTrackedScopes ?? DEFAULT_MAX_TRACKED_SCOPES;
|
|
503
|
-
const maxTrackedSends = policy.maxTrackedSends ?? DEFAULT_MAX_TRACKED_SENDS;
|
|
504
|
-
const compactAfterRecords = policy.compactAfterRecords ?? DEFAULT_COMPACT_AFTER_RECORDS;
|
|
541
|
+
const maxTrackedScopes = (): number => policy.maxTrackedScopes ?? DEFAULT_MAX_TRACKED_SCOPES;
|
|
542
|
+
const maxTrackedSends = (): number => policy.maxTrackedSends ?? DEFAULT_MAX_TRACKED_SENDS;
|
|
543
|
+
const compactAfterRecords = (): number => policy.compactAfterRecords ?? DEFAULT_COMPACT_AFTER_RECORDS;
|
|
505
544
|
const scopes = new Map<string, ScopeState>();
|
|
506
545
|
const reservations = new Map<string, Reservation>();
|
|
507
546
|
let persistFailures = 0;
|
|
@@ -753,7 +792,7 @@ export function createSpendReservationLedger(options: {
|
|
|
753
792
|
*/
|
|
754
793
|
const compact = (at: number): void => {
|
|
755
794
|
const rewrite = journal?.rewrite;
|
|
756
|
-
if (!journal || !rewrite || recordsOnDisk < compactAfterRecords) return;
|
|
795
|
+
if (!journal || !rewrite || recordsOnDisk < compactAfterRecords()) return;
|
|
757
796
|
const checkpoint: JournalRecord = {
|
|
758
797
|
v: 1,
|
|
759
798
|
kind: "checkpoint",
|
|
@@ -791,12 +830,12 @@ export function createSpendReservationLedger(options: {
|
|
|
791
830
|
const makeRoom = (refs: readonly ScopeRef[], at: number): SpendDenial | undefined => {
|
|
792
831
|
evictSends(at, false);
|
|
793
832
|
evictScopes(at, false);
|
|
794
|
-
while (reservations.size >= maxTrackedSends) {
|
|
833
|
+
while (reservations.size >= maxTrackedSends()) {
|
|
795
834
|
if (evictSends(at, true) === 0) return { reason: "tracking-capacity-exhausted" };
|
|
796
835
|
}
|
|
797
836
|
let fresh = 0;
|
|
798
837
|
for (const ref of refs) if (!scopes.has(scopeKey(ref.scope, ref.alias))) fresh += 1;
|
|
799
|
-
while (scopes.size + fresh > maxTrackedScopes) {
|
|
838
|
+
while (scopes.size + fresh > maxTrackedScopes()) {
|
|
800
839
|
if (evictScopes(at, true) === 0) {
|
|
801
840
|
return { reason: "tracking-capacity-exhausted", scope: refs[0]?.scope };
|
|
802
841
|
}
|
|
@@ -808,6 +847,7 @@ export function createSpendReservationLedger(options: {
|
|
|
808
847
|
get persistFailures() { return persistFailures; },
|
|
809
848
|
get corruptRecords() { return corruptRecords; },
|
|
810
849
|
get degraded() { return persistFailures > 0 || corruptRecords > 0; },
|
|
850
|
+
get policy() { return policy; },
|
|
811
851
|
|
|
812
852
|
reserve(request: SpendReservationRequest): SpendReservationDecision {
|
|
813
853
|
const tokens = sanitizeTokens(request.inputTokens) + sanitizeTokens(request.outputCeilingTokens);
|
|
@@ -834,7 +874,9 @@ export function createSpendReservationLedger(options: {
|
|
|
834
874
|
// reservation booked on the scopes that would have passed. Reading state without
|
|
835
875
|
// creating it matters here -- a denied request must not leave a tracked scope behind.
|
|
836
876
|
for (const ref of refs) {
|
|
837
|
-
|
|
877
|
+
// A recorded send has no limit to fail: it already happened, and the point of booking
|
|
878
|
+
// it is to let the total go OVER the ceiling so the next request can be refused.
|
|
879
|
+
const limit = request.alreadySent === true ? undefined : limitFor(ref.scope);
|
|
838
880
|
if (limit === undefined) continue;
|
|
839
881
|
const state = scopes.get(scopeKey(ref.scope, ref.alias));
|
|
840
882
|
const projected = (state ? state.settled + state.reserved + state.unresolved : 0) + tokens;
|
|
@@ -853,7 +895,7 @@ export function createSpendReservationLedger(options: {
|
|
|
853
895
|
// limit a failed write refuses the request rather than admitting one that a restart
|
|
854
896
|
// would forget -- which is exactly the disk-full and permission case durability is for.
|
|
855
897
|
const durable = append({ v: 1, kind: "reserve", send, targets: refs, tokens, at });
|
|
856
|
-
if (!durable && enforced) {
|
|
898
|
+
if (!durable && enforced && request.alreadySent !== true) {
|
|
857
899
|
return { reserved: false, denial: { reason: "reserve-not-durable", sendId: request.sendId } };
|
|
858
900
|
}
|
|
859
901
|
applyReserve(send, refs, tokens, at);
|
|
@@ -931,10 +973,72 @@ export function createSpendReservationLedger(options: {
|
|
|
931
973
|
evictSends(at, false);
|
|
932
974
|
evictScopes(at, false);
|
|
933
975
|
},
|
|
976
|
+
|
|
977
|
+
reconfigure(next: SpendReservationPolicy): void {
|
|
978
|
+
policy = next;
|
|
979
|
+
},
|
|
934
980
|
};
|
|
935
981
|
}
|
|
936
982
|
|
|
937
983
|
let sharedLedger: SpendReservationLedger | undefined;
|
|
984
|
+
/**
|
|
985
|
+
* The operator policy in effect. Held beside the ledger rather than inside it because the
|
|
986
|
+
* ledger is built lazily: a configured ceiling has to be remembered from startup until the
|
|
987
|
+
* first request that actually reserves, and an install that configures nothing must still
|
|
988
|
+
* open no journal.
|
|
989
|
+
*/
|
|
990
|
+
let sharedPolicy: SpendReservationPolicy = DEFAULT_SPEND_RESERVATION_POLICY;
|
|
991
|
+
|
|
992
|
+
/** Whether any scope carries a ceiling -- that is, whether anything at all can be refused. */
|
|
993
|
+
export function spendCeilingsConfigured(policy: SpendReservationPolicy = sharedPolicy): boolean {
|
|
994
|
+
return policy.root.maxTokens !== undefined
|
|
995
|
+
|| policy.identity.maxTokens !== undefined
|
|
996
|
+
|| policy.pool.maxTokens !== undefined;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
/** The policy the process-wide ledger enforces right now. */
|
|
1000
|
+
export function sharedSpendPolicy(): SpendReservationPolicy {
|
|
1001
|
+
return sharedPolicy;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
const spendScopeLimitFromConfig = (scope: OcxSpendScopeConfig | undefined): SpendScopeLimit =>
|
|
1005
|
+
scope?.maxTokens !== undefined && Number.isFinite(scope.maxTokens) && scope.maxTokens > 0
|
|
1006
|
+
? { maxTokens: Math.trunc(scope.maxTokens) }
|
|
1007
|
+
: {};
|
|
1008
|
+
|
|
1009
|
+
/**
|
|
1010
|
+
* The ledger policy an operator's `spend` section asks for.
|
|
1011
|
+
*
|
|
1012
|
+
* An absent section, an empty one, and one whose every ceiling is absent all produce the
|
|
1013
|
+
* unconfigured default: observe-only accounting that refuses nothing. That equivalence is the
|
|
1014
|
+
* load-bearing part. This ledger is on and journaling by default, so shipping a default
|
|
1015
|
+
* ceiling would start refusing real traffic on the first upgrade that ran this code, against
|
|
1016
|
+
* a number nobody chose. There is deliberately no default figure here at all.
|
|
1017
|
+
*/
|
|
1018
|
+
export function spendPolicyFromConfig(spend: OcxSpendConfig | undefined): SpendReservationPolicy {
|
|
1019
|
+
const retentionDays = spend?.retentionDays;
|
|
1020
|
+
return {
|
|
1021
|
+
root: spendScopeLimitFromConfig(spend?.root),
|
|
1022
|
+
identity: spendScopeLimitFromConfig(spend?.identity),
|
|
1023
|
+
pool: spendScopeLimitFromConfig(spend?.pool),
|
|
1024
|
+
retentionMs: retentionDays !== undefined && Number.isFinite(retentionDays) && retentionDays > 0
|
|
1025
|
+
? Math.trunc(retentionDays) * 24 * 60 * 60_000
|
|
1026
|
+
: DEFAULT_SPEND_RESERVATION_POLICY.retentionMs,
|
|
1027
|
+
};
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/**
|
|
1031
|
+
* Apply an operator policy to the process-wide ledger.
|
|
1032
|
+
*
|
|
1033
|
+
* Startup calls this with the loaded config, and a reload may call it again: the ledger keeps
|
|
1034
|
+
* every figure it has already accounted, so changing a ceiling changes what is refused from
|
|
1035
|
+
* here on and never what was spent. It does not CREATE the ledger -- an install that
|
|
1036
|
+
* configures no ceiling must not open a journal merely because the server started.
|
|
1037
|
+
*/
|
|
1038
|
+
export function configureSharedSpendLedger(policy: SpendReservationPolicy): void {
|
|
1039
|
+
sharedPolicy = policy;
|
|
1040
|
+
sharedLedger?.reconfigure(policy);
|
|
1041
|
+
}
|
|
938
1042
|
|
|
939
1043
|
/**
|
|
940
1044
|
* Process-wide ledger backed by the journal under OPENCODEX_HOME. Created lazily so
|
|
@@ -947,6 +1051,7 @@ export function sharedSpendLedger(): SpendReservationLedger {
|
|
|
947
1051
|
sharedLedger = createSpendReservationLedger({
|
|
948
1052
|
journal: createFileSpendJournal(join(home, SPEND_LEDGER_JOURNAL_FILENAME)),
|
|
949
1053
|
salt: loadOrCreateSpendLedgerSalt(join(home, SPEND_LEDGER_SALT_FILENAME)),
|
|
1054
|
+
policy: sharedPolicy,
|
|
950
1055
|
});
|
|
951
1056
|
}
|
|
952
1057
|
return sharedLedger;
|
|
@@ -955,4 +1060,5 @@ export function sharedSpendLedger(): SpendReservationLedger {
|
|
|
955
1060
|
/** Test seam. Production never discards the ledger: that would reset a spent budget. */
|
|
956
1061
|
export function resetSharedSpendLedgerForTest(): void {
|
|
957
1062
|
sharedLedger = undefined;
|
|
1063
|
+
sharedPolicy = DEFAULT_SPEND_RESERVATION_POLICY;
|
|
958
1064
|
}
|
|
@@ -30,8 +30,13 @@
|
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
32
|
import { existsSync, statSync } from "node:fs";
|
|
33
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
33
34
|
import { env, platform } from "node:process";
|
|
34
|
-
import {
|
|
35
|
+
import {
|
|
36
|
+
SUBPROCESS_KILL_GRACE_MS,
|
|
37
|
+
waitForSubprocessExit,
|
|
38
|
+
type SubprocessDeadlineScheduler,
|
|
39
|
+
} from "./bounded-subprocess";
|
|
35
40
|
import { resolveTrustedWindowsIcaclsExe } from "./windows-elevation";
|
|
36
41
|
import {
|
|
37
42
|
cachedCurrentWindowsIdentity,
|
|
@@ -48,17 +53,32 @@ const hardenedPaths = new Map<string, HardenedIdentity>();
|
|
|
48
53
|
* that attempt was consumed. Ordinary callers never consume it.
|
|
49
54
|
*/
|
|
50
55
|
const timedOutPaths = new Map<string, boolean>();
|
|
56
|
+
/** Compatibility slack before the outer belt releases a caller whose killed child has not reaped. */
|
|
57
|
+
const ASYNC_ICACLS_BELT_MARGIN_MS = 250;
|
|
58
|
+
const pendingAsyncIcaclsReaps = new Map<string, Set<Promise<void>>>();
|
|
59
|
+
|
|
60
|
+
const scheduleAsyncIcaclsBelt: SubprocessDeadlineScheduler = (callback, milliseconds) => {
|
|
61
|
+
const timer = setTimeout(callback, milliseconds);
|
|
62
|
+
return () => clearTimeout(timer);
|
|
63
|
+
};
|
|
64
|
+
let asyncIcaclsBeltScheduler: SubprocessDeadlineScheduler = scheduleAsyncIcaclsBelt;
|
|
51
65
|
|
|
52
66
|
/**
|
|
53
|
-
* The memo value: `object
|
|
54
|
-
* to.
|
|
67
|
+
* The memo value: the `object` plus `freshness` of a file a harden was actually
|
|
68
|
+
* attributed to.
|
|
55
69
|
*
|
|
56
70
|
* There is deliberately no null member. An observation that cannot be read is
|
|
57
71
|
* not stored at all — the entry is deleted — because a "recorded as unverifiable"
|
|
58
72
|
* value was dead code the moment attribution became a before/after comparison,
|
|
59
73
|
* and a branch nothing can reach is a branch no test can defend.
|
|
74
|
+
*
|
|
75
|
+
* It is the observation itself rather than a joined string so that the two
|
|
76
|
+
* questions stay separately askable after storage. `reattributeHardenedSecretPath`
|
|
77
|
+
* has to compare the object while deliberately ignoring the freshness, and
|
|
78
|
+
* recovering one half out of `dev:ino:ctimeNs` by counting colons would make that
|
|
79
|
+
* comparison depend on a format nothing declares.
|
|
60
80
|
*/
|
|
61
|
-
type HardenedIdentity =
|
|
81
|
+
type HardenedIdentity = PathObservation;
|
|
62
82
|
|
|
63
83
|
/**
|
|
64
84
|
* What a stat can tell us about WHICH OBJECT is at a path.
|
|
@@ -128,8 +148,8 @@ function observe(targetPath: string): PathObservation | null {
|
|
|
128
148
|
}
|
|
129
149
|
}
|
|
130
150
|
|
|
131
|
-
function
|
|
132
|
-
return
|
|
151
|
+
function sameObservation(a: PathObservation, b: PathObservation): boolean {
|
|
152
|
+
return a.object === b.object && a.freshness === b.freshness;
|
|
133
153
|
}
|
|
134
154
|
|
|
135
155
|
/**
|
|
@@ -155,7 +175,7 @@ function memoSatisfied(cache: Map<string, HardenedIdentity>, targetPath: string)
|
|
|
155
175
|
// without any ACL work. That needs exact-identity ABA to bite — outside the
|
|
156
176
|
// proof bound this unit claims — but "the consequence is out of scope" is not a
|
|
157
177
|
// reason to keep an entry we have just proven does not describe what is there.
|
|
158
|
-
if (current === null ||
|
|
178
|
+
if (current === null || !sameObservation(current, remembered)) {
|
|
159
179
|
cache.delete(targetPath);
|
|
160
180
|
return false;
|
|
161
181
|
}
|
|
@@ -203,7 +223,7 @@ function recordHarden(
|
|
|
203
223
|
cache.delete(targetPath);
|
|
204
224
|
return false;
|
|
205
225
|
}
|
|
206
|
-
cache.set(targetPath,
|
|
226
|
+
cache.set(targetPath, after);
|
|
207
227
|
return true;
|
|
208
228
|
}
|
|
209
229
|
|
|
@@ -339,7 +359,7 @@ function defaultIcaclsRunner(args: string[], timeoutMs: number): IcaclsResult {
|
|
|
339
359
|
/**
|
|
340
360
|
* Async icacls runner (#612): yields the event loop while waiting for the child.
|
|
341
361
|
* Async Subprocess has no exitedDueToTimeout, so the shared settlement helper
|
|
342
|
-
* classifies the deadline and
|
|
362
|
+
* classifies the deadline and keeps waiting for a killed child to actually exit.
|
|
343
363
|
*/
|
|
344
364
|
async function defaultAsyncIcaclsRunner(args: string[], timeoutMs: number): Promise<IcaclsResult> {
|
|
345
365
|
const proc = trySpawnIcacls(args);
|
|
@@ -359,21 +379,78 @@ async function defaultAsyncIcaclsRunner(args: string[], timeoutMs: number): Prom
|
|
|
359
379
|
function awaitAsyncIcaclsRunner(args: string[], timeoutMs: number): Promise<IcaclsResult> {
|
|
360
380
|
return new Promise(resolve => {
|
|
361
381
|
let settled = false;
|
|
362
|
-
let
|
|
382
|
+
let cancelBelt: (() => void) | undefined;
|
|
363
383
|
const finish = (result: IcaclsResult): void => {
|
|
364
384
|
if (settled) return;
|
|
365
385
|
settled = true;
|
|
366
|
-
|
|
386
|
+
cancelBelt?.();
|
|
367
387
|
resolve(result);
|
|
368
388
|
};
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
389
|
+
const runner = asyncIcaclsRunner(args, timeoutMs).then(
|
|
390
|
+
result => { finish(result); },
|
|
391
|
+
() => { finish(spawnFailedResult()); },
|
|
392
|
+
);
|
|
393
|
+
// The belt has to outlast the runner it is guarding, or it is not a belt -- it is the
|
|
394
|
+
// deadline. The runner may now legitimately outlive it while a killed child is reaped. The
|
|
395
|
+
// caller is still released, but the target is registered so removal can wait for the distinct
|
|
396
|
+
// handle-release question instead of treating flight settlement as proof that the child died.
|
|
397
|
+
cancelBelt = asyncIcaclsBeltScheduler(
|
|
398
|
+
() => {
|
|
399
|
+
const targetPath = args[0];
|
|
400
|
+
if (targetPath) registerPendingAsyncIcaclsReap(targetPath, runner);
|
|
401
|
+
finish({ success: false, exitCode: null, timedOut: true, stdout: "" });
|
|
402
|
+
},
|
|
403
|
+
Math.max(1, timeoutMs) + SUBPROCESS_KILL_GRACE_MS + ASYNC_ICACLS_BELT_MARGIN_MS,
|
|
372
404
|
);
|
|
373
|
-
void asyncIcaclsRunner(args, timeoutMs).then(finish, () => finish(spawnFailedResult()));
|
|
374
405
|
});
|
|
375
406
|
}
|
|
376
407
|
|
|
408
|
+
function registerPendingAsyncIcaclsReap(targetPath: string, reap: Promise<void>): void {
|
|
409
|
+
let pending = pendingAsyncIcaclsReaps.get(targetPath);
|
|
410
|
+
if (!pending) {
|
|
411
|
+
pending = new Set();
|
|
412
|
+
pendingAsyncIcaclsReaps.set(targetPath, pending);
|
|
413
|
+
}
|
|
414
|
+
pending.add(reap);
|
|
415
|
+
void reap.finally(() => {
|
|
416
|
+
pending!.delete(reap);
|
|
417
|
+
if (pending!.size === 0) pendingAsyncIcaclsReaps.delete(targetPath);
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function pathIsAtOrBelow(targetPath: string, rootPath: string): boolean {
|
|
422
|
+
const relativePath = relative(resolve(rootPath), resolve(targetPath));
|
|
423
|
+
return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath));
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** True while an async icacls runner still owns this exact path after its caller's belt fired. */
|
|
427
|
+
export function windowsSecretAclReapPendingForPath(targetPath: string): boolean {
|
|
428
|
+
return (pendingAsyncIcaclsReaps.get(targetPath)?.size ?? 0) > 0;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** Non-blocking removal guard for callers that must refuse rather than wait for a stuck child. */
|
|
432
|
+
export function windowsSecretAclReapPendingAtOrBelow(rootPath: string): boolean {
|
|
433
|
+
return [...pendingAsyncIcaclsReaps.keys()]
|
|
434
|
+
.some(targetPath => pathIsAtOrBelow(targetPath, rootPath));
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Removal barrier for a file or tree that may still be held by a timed-out icacls child.
|
|
439
|
+
*
|
|
440
|
+
* This wait is deliberately separate from ordinary startup and shutdown: a genuinely stuck child
|
|
441
|
+
* must not defeat the caller-facing belt. Code that chooses to remove the target has the stricter
|
|
442
|
+
* contract and must not proceed until every registered runner at or below it has actually reaped.
|
|
443
|
+
*/
|
|
444
|
+
export async function flushWindowsSecretAclReapsBeforeRemoval(rootPath: string): Promise<void> {
|
|
445
|
+
while (true) {
|
|
446
|
+
const pending = [...pendingAsyncIcaclsReaps]
|
|
447
|
+
.filter(([targetPath]) => pathIsAtOrBelow(targetPath, rootPath))
|
|
448
|
+
.flatMap(([, reaps]) => [...reaps]);
|
|
449
|
+
if (pending.length === 0) return;
|
|
450
|
+
await Promise.all(pending);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
377
454
|
let icaclsRunner: IcaclsRunner = defaultIcaclsRunner;
|
|
378
455
|
let asyncIcaclsRunner: AsyncIcaclsRunner = defaultAsyncIcaclsRunner;
|
|
379
456
|
let platformOverride: string | null = null;
|
|
@@ -389,6 +466,13 @@ export function setAsyncIcaclsRunnerForTests(runner: AsyncIcaclsRunner | null):
|
|
|
389
466
|
asyncIcaclsRunner = runner ?? defaultAsyncIcaclsRunner;
|
|
390
467
|
}
|
|
391
468
|
|
|
469
|
+
/** Test seam: fire the outer caller-facing belt without sleeping. */
|
|
470
|
+
export function setAsyncIcaclsBeltSchedulerForTests(
|
|
471
|
+
scheduler: SubprocessDeadlineScheduler | null,
|
|
472
|
+
): void {
|
|
473
|
+
asyncIcaclsBeltScheduler = scheduler ?? scheduleAsyncIcaclsBelt;
|
|
474
|
+
}
|
|
475
|
+
|
|
392
476
|
/**
|
|
393
477
|
* Test seam: force the platform gate (e.g. "win32") so CI on POSIX reaches the runner.
|
|
394
478
|
*
|
|
@@ -423,6 +507,58 @@ export function forgetHardenedSecretPath(targetPath: string): void {
|
|
|
423
507
|
hardenedPaths.delete(targetPath);
|
|
424
508
|
}
|
|
425
509
|
|
|
510
|
+
/**
|
|
511
|
+
* Re-attribute an existing file memo to the SAME object after the caller wrote
|
|
512
|
+
* content to it through a descriptor whose identity it verified.
|
|
513
|
+
*
|
|
514
|
+
* This exists because `freshness` is `ctimeNs`, and on Windows libuv reports
|
|
515
|
+
* `st_ctim` from the NTFS ChangeTime, which moves when file DATA is written. An
|
|
516
|
+
* atomic writer therefore invalidated its own memo between the harden that
|
|
517
|
+
* protects the empty temp and the harden before the rename, and paid a second
|
|
518
|
+
* full `/grant:r` + `/inheritance:r` + `/remove:g` sequence to reapply the ACL
|
|
519
|
+
* that was already on the file. Every secret write on Windows paid it twice.
|
|
520
|
+
*
|
|
521
|
+
* Only the freshness moves, and only for an unchanged object: a different object
|
|
522
|
+
* retires the entry instead. A caller must have proven, immediately beforehand,
|
|
523
|
+
* that `targetPath` resolves to the object its own descriptor refers to.
|
|
524
|
+
*
|
|
525
|
+
* The cost of this is worth stating exactly, because `PathObservation` documents
|
|
526
|
+
* that freshness also moves when PERMISSIONS change, and this call cannot tell
|
|
527
|
+
* the two apart. So a DACL change landing between the harden and this call is
|
|
528
|
+
* absorbed instead of forcing a re-harden. That window is the caller's own
|
|
529
|
+
* content write; every permission change after this call still moves ctime
|
|
530
|
+
* again and still misses the memo, so the detection this memo provides is
|
|
531
|
+
* relocated, not removed.
|
|
532
|
+
*
|
|
533
|
+
* What makes the absorbed window acceptable is who can be in it. Once the harden
|
|
534
|
+
* has run, the DACL is an explicit owner-only ACE with inheritance removed, so
|
|
535
|
+
* no other principal can open the file for `WRITE_DAC` at all. The one principal
|
|
536
|
+
* who can still rewrite that DACL is one holding a handle opened BEFORE the
|
|
537
|
+
* harden, and Windows keeps the access granted to an open handle: that principal
|
|
538
|
+
* can equally rewrite the DACL after any later harden, and after the rename, on
|
|
539
|
+
* the same object. A second mutation pass never bounded that capability — it
|
|
540
|
+
* stripped an ACE the holder could immediately re-add — so declining to repeat
|
|
541
|
+
* it removes no guarantee anyone had.
|
|
542
|
+
*
|
|
543
|
+
* Refusal is cheap and safe in either direction: an unmoved memo simply means the
|
|
544
|
+
* caller's next harden runs in full.
|
|
545
|
+
*
|
|
546
|
+
* Returns whether the memo now describes what is at the path.
|
|
547
|
+
*/
|
|
548
|
+
export function reattributeHardenedSecretPath(targetPath: string): boolean {
|
|
549
|
+
const remembered = hardenedPaths.get(targetPath);
|
|
550
|
+
if (remembered === undefined) return false;
|
|
551
|
+
const current = observe(targetPath);
|
|
552
|
+
// Unreadable, or a different object: this is exactly the case the memo must
|
|
553
|
+
// not cover. Retire it so the next harden is a real one.
|
|
554
|
+
if (current === null || current.object !== remembered.object) {
|
|
555
|
+
hardenedPaths.delete(targetPath);
|
|
556
|
+
return false;
|
|
557
|
+
}
|
|
558
|
+
hardenedPaths.set(targetPath, current);
|
|
559
|
+
return true;
|
|
560
|
+
}
|
|
561
|
+
|
|
426
562
|
/**
|
|
427
563
|
* Ephemeral-path lifecycle release: clears the success memo AND any timeout
|
|
428
564
|
* memo keyed by THIS TEMP path in both namespaces. Call only after the temp is
|
|
@@ -160,7 +160,11 @@ async function defaultAsyncWindowsPrincipalRunner(
|
|
|
160
160
|
stderr: "ignore",
|
|
161
161
|
windowsHide: true,
|
|
162
162
|
});
|
|
163
|
-
|
|
163
|
+
// No kill grace. The grace exists so a dying child releases a path someone is about to
|
|
164
|
+
// remove; this lookup holds no such path, and it runs during `ocx start`, where the composed
|
|
165
|
+
// acceptance cases already measure real startups at up to 38.8s against a bounded watchdog.
|
|
166
|
+
// Paying two extra seconds per timed-out resolution there buys nothing and costs margin.
|
|
167
|
+
const { exitCode, timedOut } = await waitForSubprocessExit(proc, timeoutMs, 0);
|
|
164
168
|
// `.bytes()` rather than `.text()`, for the same reason as the sync runner above.
|
|
165
169
|
const stdout: string | Uint8Array = !timedOut && proc.stdout
|
|
166
170
|
? await new Response(proc.stdout).bytes().catch(() => new Uint8Array())
|