@bitkyc08/opencodex 2.58.0 → 2.60.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-BTuCbqQd.css +1 -0
- package/gui/dist/assets/index-DoBVdPHP.js +134 -0
- 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 +4 -1
- package/src/adapters/anthropic-image-codec.ts +16 -2
- package/src/adapters/anthropic-image-normalize.ts +49 -2
- package/src/adapters/anthropic.ts +4 -1
- package/src/adapters/base.ts +34 -1
- package/src/adapters/coding-agent/turn.ts +22 -2
- package/src/adapters/command-code.ts +50 -3
- package/src/adapters/cursor/catalog.ts +11 -0
- package/src/adapters/cursor/checkpoint-store.ts +3 -0
- package/src/adapters/cursor/discovery.ts +11 -8
- 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 +26 -9
- package/src/adapters/cursor/message-mapper.ts +3 -2
- package/src/adapters/cursor/protobuf-request.ts +8 -5
- package/src/adapters/cursor/request-builder.ts +21 -4
- package/src/adapters/cursor/thread-continuity.ts +105 -31
- package/src/adapters/cursor/tool-guidance.ts +5 -4
- package/src/adapters/cursor/transport.ts +19 -0
- package/src/adapters/cursor.ts +45 -2
- package/src/adapters/devin/cloud-direct/chat.ts +14 -3
- package/src/adapters/devin/cloud-direct/index.ts +7 -0
- package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +140 -0
- package/src/adapters/devin.ts +125 -26
- package/src/adapters/google-antigravity-replay.ts +1 -1
- package/src/adapters/google-antigravity-wire.ts +55 -4
- package/src/adapters/google-http.ts +57 -11
- package/src/adapters/google-tool-schema.ts +595 -31
- package/src/adapters/google-wire-compiler.ts +93 -10
- package/src/adapters/google-wire-shape.ts +461 -0
- package/src/adapters/google.ts +60 -10
- package/src/adapters/openai-chat/response-events.ts +61 -0
- package/src/adapters/openai-chat-images.ts +3 -1
- package/src/adapters/openai-chat.ts +10 -11
- package/src/adapters/openai-responses/image-gen.ts +8 -6
- package/src/adapters/openai-responses/passthrough.ts +25 -4
- package/src/adapters/openai-responses/reasoning.ts +7 -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/opencode-go-additional-tools.ts +12 -2
- package/src/adapters/responses-tool-schema.ts +76 -46
- package/src/adapters/run-turn-queue.ts +17 -4
- package/src/bridge/response-json.ts +1 -1
- package/src/bridge/sse.ts +28 -35
- package/src/claude/context-windows.ts +22 -0
- package/src/claude/outbound.ts +35 -4
- package/src/cli/account-api.ts +4 -3
- package/src/cli/account-extended.ts +26 -6
- 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/dispatch.ts +57 -3
- package/src/cli/doctor.ts +218 -4
- package/src/cli/help.ts +4 -1
- package/src/cli/hub.ts +3 -2
- package/src/cli/index.ts +95 -22
- package/src/cli/models-runtime.ts +33 -4
- package/src/cli/opencode.ts +2 -2
- package/src/cli/provider.ts +13 -1
- 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 +2 -0
- package/src/client/machine-api.ts +6 -5
- package/src/client/machine-listener.ts +16 -3
- package/src/client/runtime.ts +26 -2
- package/src/clients/config-export/constants.ts +2 -3
- package/src/clients/config-export.ts +5 -5
- package/src/codex/account-store.ts +146 -5
- package/src/codex/auth-api/account-list.ts +19 -11
- package/src/codex/auth-api/pool-quota-probe.ts +44 -10
- package/src/codex/auth-api/routes.ts +17 -2
- package/src/codex/auth-context.ts +16 -12
- 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 +22 -2
- package/src/codex/catalog/model-hints.ts +66 -33
- package/src/codex/catalog/parsing.ts +90 -5
- package/src/codex/catalog/provider-models.ts +19 -2
- package/src/codex/catalog/reserve-warn.ts +96 -0
- package/src/codex/catalog/retained-sync.ts +41 -26
- package/src/codex/catalog/routed-gather.ts +61 -3
- package/src/codex/cli-installation-identity.ts +210 -0
- package/src/codex/cli-installation-targets.ts +158 -0
- package/src/codex/context-compat.ts +5 -2
- package/src/codex/convergence.ts +5 -0
- package/src/codex/desired-state.ts +4 -1
- package/src/codex/history-job.ts +6 -6
- package/src/codex/history-provider.ts +24 -167
- package/src/codex/history-rollout-read.ts +174 -0
- package/src/codex/history-state-open.ts +105 -0
- package/src/codex/inject/config-toml.ts +44 -2
- package/src/codex/inject.ts +3 -2
- package/src/codex/internal/catalog-writer.ts +33 -1
- package/src/codex/lineage.ts +83 -32
- package/src/codex/loopback-target.ts +31 -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-cache.ts +47 -0
- package/src/codex/model-entitlements.ts +80 -2
- package/src/codex/observed-model-denials.ts +230 -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 +289 -16
- 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 +20 -16
- package/src/codex/shim.ts +1 -1
- package/src/codex/subagent-model-fallback.ts +114 -2
- package/src/codex/windows-installation-files.ts +224 -0
- package/src/combos/failover.ts +125 -5
- package/src/config/admitted-identity.ts +222 -0
- package/src/config/diagnostics.ts +43 -1
- package/src/config/feature-flags.ts +5 -0
- package/src/config/load-degrade.ts +33 -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 +31 -7
- package/src/config/schema/compaction-triggers.ts +11 -0
- package/src/config/schema/config-schema.ts +25 -0
- package/src/config/schema/leaf-validators.ts +76 -17
- package/src/config.ts +2 -2
- package/src/generated/compatibility-version.json +410 -258
- package/src/generated/model-metadata.ts +1 -1
- package/src/grok/reset-coupons.ts +38 -19
- package/src/images/loop.ts +6 -1
- package/src/integrations/aside-profile-context.ts +37 -3
- package/src/integrations/aside-profile-journal.ts +68 -3
- package/src/integrations/aside-profiles.ts +128 -3
- package/src/integrations/mutation-plan.ts +815 -0
- package/src/integrations/writer.ts +85 -99
- package/src/lab/live/transport.ts +4 -0
- package/src/lab/live/types.ts +5 -0
- package/src/lab/subject/behavior-fingerprint.ts +1 -1
- package/src/lib/admin-secrets.ts +9 -1
- package/src/lib/bounded-body.ts +4 -2
- package/src/lib/debug-log-buffer.ts +6 -1
- package/src/lib/debug.ts +23 -0
- package/src/lib/destination-policy.ts +48 -6
- package/src/lib/errors.ts +82 -15
- package/src/lib/http-response-semantics.ts +57 -0
- package/src/lib/lab-live-pinned-sender.ts +26 -12
- package/src/lib/local-destinations.ts +32 -5
- package/src/lib/pinned-http.ts +142 -2
- package/src/lib/plain-data.ts +103 -0
- package/src/lib/process-control.ts +13 -5
- package/src/lib/provider-outbound.ts +53 -5
- 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 +741 -0
- package/src/lib/spend-ledger-owner.ts +364 -0
- package/src/lib/spend-reservation-ledger.ts +332 -35
- package/src/lib/windows-system-proxy.ts +16 -11
- package/src/lib/workflow-budget.ts +145 -8
- package/src/oauth/account-quota-rank.ts +72 -15
- package/src/oauth/callback-server.ts +4 -3
- package/src/oauth/generic-account-failover.ts +41 -27
- package/src/oauth/health.ts +12 -1
- package/src/oauth/index.ts +3 -107
- package/src/oauth/login-flow-state.ts +127 -0
- 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 +34 -17
- package/src/providers/devin-cli-authmode-migration.ts +14 -10
- package/src/providers/devin-provider-merge-migration.ts +33 -12
- package/src/providers/free-directory.ts +20 -2
- package/src/providers/key-failover.ts +327 -25
- package/src/providers/model-rename-migration.ts +56 -1
- package/src/providers/model-rename-startup.ts +7 -5
- package/src/providers/openai-sidecar.ts +4 -0
- package/src/providers/openai-virtual-models.ts +42 -2
- package/src/providers/opencode-go-transport.ts +14 -5
- package/src/providers/quota/antigravity.ts +22 -2
- package/src/providers/quota/report-cache.ts +3 -0
- package/src/providers/quota/vendor-probes-key.ts +1 -1
- package/src/providers/registry/entries-core.ts +39 -17
- package/src/providers/registry/entries-extended.ts +131 -1
- package/src/providers/registry/model-ids.ts +168 -0
- package/src/providers/registry/model-seeds.ts +87 -21
- package/src/providers/registry/types.ts +2 -0
- package/src/providers/resolved-model-policy-merge.ts +167 -0
- package/src/providers/resolved-model-policy.ts +406 -0
- package/src/providers/stale-vision-classification-migration.ts +137 -0
- package/src/responses/apply-patch-envelope.ts +32 -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/freeform-wrapper-scan.ts +279 -0
- package/src/responses/hosted-tool-policy.ts +85 -2
- package/src/responses/legacy-dotted-tool-name-repair.ts +134 -0
- package/src/responses/progressive-freeform-input.ts +130 -0
- package/src/responses/reasoning-envelope.ts +30 -0
- package/src/responses/schema.ts +9 -2
- package/src/responses/state.ts +5 -12
- package/src/responses/tool-name-aliases.ts +15 -1
- package/src/router.ts +91 -115
- package/src/routing/compatibility/behavior.ts +9 -0
- package/src/routing/compatibility/subject.ts +16 -1
- package/src/server/adapter-resolve.ts +9 -0
- package/src/server/auth-cors.ts +29 -0
- package/src/server/chat-completions.ts +13 -5
- package/src/server/chat-native-sse.ts +26 -9
- package/src/server/chat-native.ts +10 -4
- package/src/server/claude-messages.ts +30 -5
- package/src/server/effort-row.ts +11 -3
- package/src/server/grok-responses-control-frame.ts +160 -1
- package/src/server/gui-static.ts +36 -2
- package/src/server/inbound-body-admission.ts +187 -0
- package/src/server/index/serve-options.ts +86 -29
- package/src/server/index/spend-ledger-lifecycle.ts +66 -0
- package/src/server/index/websocket-handler.ts +6 -1
- package/src/server/index.ts +24 -25
- package/src/server/management/api-access.ts +3 -4
- package/src/server/management/aside-profile-routes.ts +266 -7
- package/src/server/management/config-routes.ts +48 -7
- package/src/server/management/context.ts +3 -0
- package/src/server/management/integration-routes.ts +287 -5
- package/src/server/management/metrics-routes.ts +20 -0
- package/src/server/management/model-rows.ts +224 -12
- package/src/server/management/provider-capability-config.ts +35 -7
- package/src/server/management/provider-routes.ts +70 -18
- package/src/server/management/route-registry.ts +14 -0
- package/src/server/management/shared.ts +10 -3
- package/src/server/management/system-restart.ts +7 -2
- package/src/server/management/system-routes.ts +2 -0
- package/src/server/management/usage-aggregate-cache.ts +4 -0
- package/src/server/management-api.ts +2 -0
- package/src/server/management-auth.ts +15 -1
- package/src/server/proxy-liveness.ts +97 -2
- package/src/server/readiness.ts +29 -10
- package/src/server/relay-eager.ts +24 -2
- package/src/server/relay.ts +136 -34
- package/src/server/request-log.ts +80 -3
- package/src/server/request-metrics.ts +236 -0
- package/src/server/responses/adapter-continuation.ts +74 -30
- package/src/server/responses/adapter-delivery.ts +39 -8
- package/src/server/responses/adapter-dispatch.ts +63 -30
- package/src/server/responses/compact.ts +89 -18
- package/src/server/responses/compaction-routing.ts +111 -0
- package/src/server/responses/core-codex-account.ts +90 -24
- package/src/server/responses/core-combo.ts +7 -7
- package/src/server/responses/core-normalize.ts +16 -15
- package/src/server/responses/core-opaque-recovery.ts +1 -0
- package/src/server/responses/core-options.ts +4 -0
- package/src/server/responses/encrypted-payload.ts +20 -2
- package/src/server/responses/fetch-helpers.ts +68 -2
- package/src/server/responses/passthrough-delivery.ts +41 -15
- package/src/server/responses/passthrough-dispatch.ts +145 -53
- package/src/server/responses/passthrough-execution.ts +11 -1
- package/src/server/responses/policy-fallback.ts +5 -13
- package/src/server/responses/request-prepare.ts +96 -17
- package/src/server/responses/request-send-budget.ts +89 -8
- package/src/server/responses/request-sidecar-auth.ts +17 -9
- package/src/server/responses/request-spend.ts +38 -9
- package/src/server/responses/request-transport.ts +15 -12
- package/src/server/responses/run-turn-execution.ts +45 -8
- package/src/server/responses/sidecar-execution.ts +19 -2
- package/src/server/responses/ws-upstream.ts +16 -28
- package/src/server/responses-custom-tool-repair.ts +29 -56
- package/src/server/responses-undeclared-tool-guard.ts +31 -1
- package/src/server/sse-frame-buffer.ts +12 -10
- package/src/server/sse-payload-rewrite.ts +37 -10
- 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/service/cli.ts +16 -6
- package/src/service/guards.ts +10 -0
- package/src/service/health.ts +43 -0
- package/src/service/state.ts +7 -2
- package/src/tray/windows-tray.ps1 +155 -3
- package/src/types/accounts.ts +4 -0
- package/src/types/config.ts +111 -6
- package/src/types/provider.ts +37 -0
- package/src/types/request.ts +9 -1
- package/src/types/tools.ts +14 -0
- package/src/types/wire.ts +9 -1
- package/src/types.ts +1 -0
- package/src/usage/expected-prices.ts +28 -0
- package/src/usage/log.ts +87 -4
- package/src/vision/eligibility.ts +88 -9
- package/src/vision/plan.ts +34 -10
- package/src/web-search/executor.ts +41 -2
- package/src/web-search/loop.ts +6 -1
- package/src/web-search/passthrough-bridge.ts +39 -5
- package/gui/dist/assets/index-BbrHOIY0.js +0 -128
- package/gui/dist/assets/index-C5-RdDmD.css +0 -1
|
@@ -24,11 +24,10 @@
|
|
|
24
24
|
* released. Minting a new root id mints no new budget because the identity and pool scopes
|
|
25
25
|
* still hold the spend.
|
|
26
26
|
*
|
|
27
|
-
* SUPPORTED TOPOLOGY:
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* needs a
|
|
31
|
-
* than implied.
|
|
27
|
+
* SUPPORTED TOPOLOGY: one live writer owns one OPENCODEX_HOME journal. Every server and
|
|
28
|
+
* direct shared-ledger caller must hold the state-directory SQLite lease before replay,
|
|
29
|
+
* append or compaction. Independent homes remain independent; multi-host shared storage
|
|
30
|
+
* still needs a distributed transaction boundary and is outside this local lease.
|
|
32
31
|
*
|
|
33
32
|
* Five properties this file owes its callers. Each one was absent in the first draft, and a
|
|
34
33
|
* budget that can be bypassed is worse than no budget because it looks like protection:
|
|
@@ -54,15 +53,24 @@
|
|
|
54
53
|
* that are re-applied to an EXISTING file rather than trusted from its creation.
|
|
55
54
|
*/
|
|
56
55
|
|
|
57
|
-
import { appendFileSync, chmodSync,
|
|
56
|
+
import { appendFileSync, chmodSync, closeSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
58
57
|
import { createHash, randomBytes } from "node:crypto";
|
|
59
58
|
import { dirname, join } from "node:path";
|
|
60
59
|
// Definition-site import, not the ../config barrel -- same reasoning as
|
|
61
60
|
// src/quota/reset-seen-store.ts: the barrel pulls ~154 modules into a hot path.
|
|
62
61
|
import { getConfigDir } from "../config/paths";
|
|
62
|
+
// Type-only, so it is erased before this module has a runtime import graph at all. The
|
|
63
|
+
// config SHAPE is what this file needs; the config loader is what the note above keeps out.
|
|
64
|
+
import type { OcxSpendConfig, OcxSpendScopeConfig } from "../types/config";
|
|
63
65
|
import { assertNotRealHomeUnderTest } from "./test-home-guard";
|
|
64
66
|
// Windows chmod does not remove inherited ACEs; this is the repository's icacls path.
|
|
65
67
|
import { hardenSecretPath } from "./windows-secret-acl";
|
|
68
|
+
import { assertSpendLedgerOwnerHeld, assertStorageOwned, bindSpendLedgerOwnerHome, mintSpendLedgerStorage, onSpendLedgerOwnerReleased, resetSpendLedgerOwnerBindingForTest, SpendLedgerOwnerError, spendLedgerOwnerSnapshot, spendLedgerStoragePath, type SpendLedgerStorage } from "./spend-ledger-owner";
|
|
69
|
+
|
|
70
|
+
// The singleton belongs to the state directory it was built for. Releasing ownership hands that
|
|
71
|
+
// directory to whoever comes next, so the in-memory copy goes with it and the next construction
|
|
72
|
+
// replays the journal.
|
|
73
|
+
onSpendLedgerOwnerReleased(() => { sharedLedger = undefined; });
|
|
66
74
|
|
|
67
75
|
export const SPEND_LEDGER_JOURNAL_FILENAME = "spend-ledger.jsonl";
|
|
68
76
|
/**
|
|
@@ -147,6 +155,24 @@ export interface SpendReservationRequest {
|
|
|
147
155
|
/** Enforceable output ceiling -- max_output_tokens or the model's documented cap. */
|
|
148
156
|
readonly outputCeilingTokens: number;
|
|
149
157
|
readonly at?: number;
|
|
158
|
+
/**
|
|
159
|
+
* This send has ALREADY left for upstream and is being recorded rather than admitted.
|
|
160
|
+
*
|
|
161
|
+
* Some transports report their physical sends after the fact -- the passthrough ladder
|
|
162
|
+
* reports through `onSendsConsumed`, and an adapter's inner retries are counted when they
|
|
163
|
+
* finish. For those, a ceiling cannot refuse anything: the tokens are spent. Refusing to
|
|
164
|
+
* BOOK them is the worse answer, and it is not hypothetical -- it is a fixpoint. The send
|
|
165
|
+
* that would cross the ceiling gets dropped from the total, the total stays just under the
|
|
166
|
+
* limit forever, the scope never reads as exhausted, and the ceiling never fires again for
|
|
167
|
+
* any request. So a recorded send skips the limit check and takes the scope over its
|
|
168
|
+
* ceiling, which is what makes the NEXT request refusable.
|
|
169
|
+
*
|
|
170
|
+
* It skips the durability refusal for the same reason: a journal that could not be written
|
|
171
|
+
* is a reason to report degradation, never a reason to forget spend that really happened.
|
|
172
|
+
* Identity, capacity and journal-integrity denials still apply -- those say the ledger
|
|
173
|
+
* cannot account for the send at all, which no flag here can change.
|
|
174
|
+
*/
|
|
175
|
+
readonly alreadySent?: boolean;
|
|
150
176
|
}
|
|
151
177
|
|
|
152
178
|
/**
|
|
@@ -370,7 +396,70 @@ function hardenLedgerFile(path: string, options: { readonly force?: boolean } =
|
|
|
370
396
|
} catch { /* best-effort: a non-owner cannot chmod */ }
|
|
371
397
|
}
|
|
372
398
|
|
|
373
|
-
|
|
399
|
+
/**
|
|
400
|
+
* Fault injection for the journal's own filesystem steps. Internal test contract, not config.
|
|
401
|
+
*
|
|
402
|
+
* The compaction cleanup only runs when a step after the exclusive create fails, and there is no
|
|
403
|
+
* portable way to make a validate, harden or rename fail on demand. Without a seam the cleanup
|
|
404
|
+
* would ship asserted by reading alone, which is how a failure path stays broken.
|
|
405
|
+
*/
|
|
406
|
+
export type SpendJournalFaultStep = "stat" | "create" | "write" | "validate" | "harden" | "rename";
|
|
407
|
+
|
|
408
|
+
let journalFaultForTests: ((step: SpendJournalFaultStep, temp: string) => void) | undefined;
|
|
409
|
+
|
|
410
|
+
export function setSpendJournalFaultForTests(
|
|
411
|
+
fault: ((step: SpendJournalFaultStep, temp: string) => void) | undefined,
|
|
412
|
+
): void {
|
|
413
|
+
journalFaultForTests = fault;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Does a directory entry exist here, whatever it points at?
|
|
418
|
+
*
|
|
419
|
+
* `existsSync` follows the link, so a symlink whose target is absent reads as "no file" and an
|
|
420
|
+
* append then creates that target somewhere else entirely. The entry itself is what decides
|
|
421
|
+
* whether the safety check runs.
|
|
422
|
+
*/
|
|
423
|
+
function ledgerEntryExists(path: string): boolean {
|
|
424
|
+
try {
|
|
425
|
+
journalFaultForTests?.("stat", path);
|
|
426
|
+
lstatSync(path);
|
|
427
|
+
return true;
|
|
428
|
+
} catch (error) {
|
|
429
|
+
// Only a genuinely absent entry is absent. Treating every failure as "no file" meant a
|
|
430
|
+
// permission denial or an I/O error skipped assertSafeLedgerFile entirely and let the
|
|
431
|
+
// append proceed against whatever is actually there, which is the case that check exists
|
|
432
|
+
// for. An entry we cannot inspect is a refusal, not an empty slot.
|
|
433
|
+
//
|
|
434
|
+
// Raised as the module's typed error rather than the raw fs error: the path and errno of a
|
|
435
|
+
// state file are not something a client should be handed.
|
|
436
|
+
if ((error as NodeJS.ErrnoException | null)?.code === "ENOENT") return false;
|
|
437
|
+
throw new SpendLedgerOwnerError(
|
|
438
|
+
"SPEND_LEDGER_OWNER_UNAVAILABLE",
|
|
439
|
+
"Spend-ledger storage could not be inspected safely.",
|
|
440
|
+
{ cause: error },
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function assertSafeLedgerFile(path: string): void {
|
|
446
|
+
const stat = lstatSync(path);
|
|
447
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1
|
|
448
|
+
|| (process.platform !== "win32" && stat.uid !== process.getuid!())) {
|
|
449
|
+
throw new SpendLedgerOwnerError(
|
|
450
|
+
"SPEND_LEDGER_OWNER_UNAVAILABLE",
|
|
451
|
+
"Spend-ledger storage could not be opened safely.",
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* The production journal. Its location comes from the owned state directory and every touch
|
|
458
|
+
* proves that ownership, so there is no entrypoint here that writes a caller-chosen path.
|
|
459
|
+
*/
|
|
460
|
+
export function createOwnedFileSpendJournal(storage: SpendLedgerStorage): SpendJournal {
|
|
461
|
+
assertStorageOwned(storage);
|
|
462
|
+
const path = spendLedgerStoragePath(storage);
|
|
374
463
|
const ensureDir = (): string => {
|
|
375
464
|
const dir = dirname(path);
|
|
376
465
|
// The guard runs before any mutation so a rejected write leaves nothing behind.
|
|
@@ -380,26 +469,66 @@ export function createFileSpendJournal(path: string): SpendJournal {
|
|
|
380
469
|
};
|
|
381
470
|
return {
|
|
382
471
|
read(): string[] {
|
|
383
|
-
|
|
472
|
+
assertStorageOwned(storage);
|
|
473
|
+
if (!ledgerEntryExists(path)) return [];
|
|
474
|
+
assertSafeLedgerFile(path);
|
|
384
475
|
// Replay is once per process and is the moment a journal inherited from an older build
|
|
385
476
|
// or a restored backup first passes through here.
|
|
386
477
|
hardenLedgerFile(path, { force: true });
|
|
387
478
|
return readFileSync(path, "utf8").split("\n").filter((line) => line.length > 0);
|
|
388
479
|
},
|
|
389
480
|
append(line: string): void {
|
|
481
|
+
assertStorageOwned(storage);
|
|
390
482
|
ensureDir();
|
|
391
|
-
const created = !
|
|
483
|
+
const created = !ledgerEntryExists(path);
|
|
484
|
+
if (!created) assertSafeLedgerFile(path);
|
|
392
485
|
appendFileSync(path, line + "\n", { encoding: "utf8", mode: 0o600 });
|
|
486
|
+
assertSafeLedgerFile(path);
|
|
393
487
|
hardenLedgerFile(path, { force: created });
|
|
394
488
|
},
|
|
395
489
|
rewrite(lines: string[]): void {
|
|
490
|
+
assertStorageOwned(storage);
|
|
396
491
|
ensureDir();
|
|
397
492
|
// Same directory, so the rename is atomic on the same filesystem: a crash mid-compaction
|
|
398
493
|
// leaves either the old journal or the new one, never a half-written ledger.
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
494
|
+
if (ledgerEntryExists(path)) assertSafeLedgerFile(path);
|
|
495
|
+
const temp = `${path}.compact-${process.pid}-${randomBytes(6).toString("hex")}`;
|
|
496
|
+
// The creation is INSIDE the cleanup, not before it. The name carries random bytes, so a
|
|
497
|
+
// failure anywhere after the entry exists used to leave a uniquely named file and the next
|
|
498
|
+
// attempt made another: repeated failures accumulated instead of overwriting one fixed
|
|
499
|
+
// name. Only an entry this call created is removed, so a name that turned out to belong to
|
|
500
|
+
// something else is left alone, and the original journal and the primary error survive.
|
|
501
|
+
let fd: number | undefined;
|
|
502
|
+
let created = false;
|
|
503
|
+
let renamed = false;
|
|
504
|
+
try {
|
|
505
|
+
// Exclusive create FIRST, so "this entry is ours" is a fact rather than a guess about
|
|
506
|
+
// which error a combined write threw. EEXIST leaves created false and the name is left
|
|
507
|
+
// alone; every failure after this point is cleaned because the entry is provably ours,
|
|
508
|
+
// including a write that stopped partway through.
|
|
509
|
+
journalFaultForTests?.("create", temp);
|
|
510
|
+
fd = openSync(temp, "wx", 0o600);
|
|
511
|
+
created = true;
|
|
512
|
+
journalFaultForTests?.("write", temp);
|
|
513
|
+
writeFileSync(fd, lines.map((line) => line + "\n").join(""), { encoding: "utf8" });
|
|
514
|
+
closeSync(fd);
|
|
515
|
+
fd = undefined;
|
|
516
|
+
journalFaultForTests?.("validate", temp);
|
|
517
|
+
assertSafeLedgerFile(temp);
|
|
518
|
+
journalFaultForTests?.("harden", temp);
|
|
519
|
+
hardenLedgerFile(temp, { force: true });
|
|
520
|
+
journalFaultForTests?.("rename", temp);
|
|
521
|
+
renameSync(temp, path);
|
|
522
|
+
renamed = true;
|
|
523
|
+
} finally {
|
|
524
|
+
if (fd !== undefined) {
|
|
525
|
+
try { closeSync(fd); } catch { /* the compaction failure is the one to report */ }
|
|
526
|
+
}
|
|
527
|
+
if (created && !renamed) {
|
|
528
|
+
try { unlinkSync(temp); } catch { /* same */ }
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
assertSafeLedgerFile(path);
|
|
403
532
|
hardenLedgerFile(path, { force: true });
|
|
404
533
|
},
|
|
405
534
|
};
|
|
@@ -412,17 +541,26 @@ export function createFileSpendJournal(path: string): SpendJournal {
|
|
|
412
541
|
* recorded spend, which would hand every scope a fresh allowance -- so it is a file, not a
|
|
413
542
|
* per-process value.
|
|
414
543
|
*/
|
|
415
|
-
export function loadOrCreateSpendLedgerSalt(
|
|
416
|
-
|
|
544
|
+
export function loadOrCreateSpendLedgerSalt(storage: SpendLedgerStorage): string {
|
|
545
|
+
assertStorageOwned(storage);
|
|
546
|
+
const path = spendLedgerStoragePath(storage);
|
|
547
|
+
if (ledgerEntryExists(path)) {
|
|
548
|
+
assertSafeLedgerFile(path);
|
|
417
549
|
hardenLedgerFile(path, { force: true });
|
|
418
550
|
const existing = readFileSync(path, "utf8").trim();
|
|
419
551
|
if (/^[0-9a-f]{32,}$/.test(existing)) return existing;
|
|
552
|
+
throw new SpendLedgerOwnerError(
|
|
553
|
+
"SPEND_LEDGER_OWNER_UNAVAILABLE",
|
|
554
|
+
"Spend-ledger storage could not be opened safely.",
|
|
555
|
+
);
|
|
420
556
|
}
|
|
421
557
|
const dir = dirname(path);
|
|
558
|
+
assertStorageOwned(storage);
|
|
422
559
|
assertNotRealHomeUnderTest(dir);
|
|
423
560
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
424
561
|
const salt = randomBytes(32).toString("hex");
|
|
425
|
-
writeFileSync(path, salt + "\n", { encoding: "utf8", mode: 0o600 });
|
|
562
|
+
writeFileSync(path, salt + "\n", { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
563
|
+
assertSafeLedgerFile(path);
|
|
426
564
|
hardenLedgerFile(path, { force: true });
|
|
427
565
|
return salt;
|
|
428
566
|
}
|
|
@@ -468,6 +606,20 @@ export interface SpendReservationLedger {
|
|
|
468
606
|
prune(now?: number): void;
|
|
469
607
|
/** Whether this send id is already known, and therefore refused. */
|
|
470
608
|
knows(sendId: string): boolean;
|
|
609
|
+
/**
|
|
610
|
+
* Replace the live policy.
|
|
611
|
+
*
|
|
612
|
+
* Every figure already accounted survives: raising, lowering or clearing a ceiling changes
|
|
613
|
+
* what is REFUSED from here on and never what was spent. Rebuilding the ledger instead
|
|
614
|
+
* would replay the journal into a second set of maps while the first still holds this
|
|
615
|
+
* process's open reservations, and the two would then disagree about what is in flight.
|
|
616
|
+
*/
|
|
617
|
+
reconfigure(next: SpendReservationPolicy): void;
|
|
618
|
+
/**
|
|
619
|
+
* The policy in force. A live read, not a copy: a caller that formats a refusal has to name
|
|
620
|
+
* the ceiling this ledger would enforce on the NEXT request, not the one it was built with.
|
|
621
|
+
*/
|
|
622
|
+
readonly policy: SpendReservationPolicy;
|
|
471
623
|
/** Journal writes that failed; a nonzero count means durability is degraded. */
|
|
472
624
|
readonly persistFailures: number;
|
|
473
625
|
/**
|
|
@@ -494,14 +646,25 @@ export function createSpendReservationLedger(options: {
|
|
|
494
646
|
* no file anyone could correlate.
|
|
495
647
|
*/
|
|
496
648
|
readonly salt?: string;
|
|
649
|
+
/**
|
|
650
|
+
* Shared production ledgers prove their exact ownership before reading or changing
|
|
651
|
+
* accounting. Identity, not just the directory name: a handle kept across a release and a
|
|
652
|
+
* reacquire describes a journal another writer may have changed in between.
|
|
653
|
+
*/
|
|
654
|
+
readonly assertOwnedAccounting?: () => void;
|
|
497
655
|
} = {}): SpendReservationLedger {
|
|
498
|
-
|
|
656
|
+
// Mutable because the ceilings are operator configuration, and configuration is reloadable.
|
|
657
|
+
// The three bounds below are read through functions for the same reason: a value captured
|
|
658
|
+
// at construction would answer for the policy this ledger was BUILT with, and an operator
|
|
659
|
+
// who raised a bound would keep the old one until the process restarted.
|
|
660
|
+
let policy = options.policy ?? DEFAULT_SPEND_RESERVATION_POLICY;
|
|
499
661
|
const journal = options.journal;
|
|
500
662
|
const now = options.now ?? (() => Date.now());
|
|
501
663
|
const salt = options.salt ?? "";
|
|
502
|
-
const
|
|
503
|
-
const
|
|
504
|
-
const
|
|
664
|
+
const assertOwnedAccounting = options.assertOwnedAccounting;
|
|
665
|
+
const maxTrackedScopes = (): number => policy.maxTrackedScopes ?? DEFAULT_MAX_TRACKED_SCOPES;
|
|
666
|
+
const maxTrackedSends = (): number => policy.maxTrackedSends ?? DEFAULT_MAX_TRACKED_SENDS;
|
|
667
|
+
const compactAfterRecords = (): number => policy.compactAfterRecords ?? DEFAULT_COMPACT_AFTER_RECORDS;
|
|
505
668
|
const scopes = new Map<string, ScopeState>();
|
|
506
669
|
const reservations = new Map<string, Reservation>();
|
|
507
670
|
let persistFailures = 0;
|
|
@@ -553,7 +716,8 @@ export function createSpendReservationLedger(options: {
|
|
|
553
716
|
journal.append(JSON.stringify(record));
|
|
554
717
|
recordsOnDisk += 1;
|
|
555
718
|
return true;
|
|
556
|
-
} catch {
|
|
719
|
+
} catch (error) {
|
|
720
|
+
if (error instanceof SpendLedgerOwnerError) throw error;
|
|
557
721
|
// In-memory state still bounds this process; the counter is how a caller learns the
|
|
558
722
|
// restart guarantee degraded instead of discovering it after the fact.
|
|
559
723
|
persistFailures += 1;
|
|
@@ -753,7 +917,7 @@ export function createSpendReservationLedger(options: {
|
|
|
753
917
|
*/
|
|
754
918
|
const compact = (at: number): void => {
|
|
755
919
|
const rewrite = journal?.rewrite;
|
|
756
|
-
if (!journal || !rewrite || recordsOnDisk < compactAfterRecords) return;
|
|
920
|
+
if (!journal || !rewrite || recordsOnDisk < compactAfterRecords()) return;
|
|
757
921
|
const checkpoint: JournalRecord = {
|
|
758
922
|
v: 1,
|
|
759
923
|
kind: "checkpoint",
|
|
@@ -780,7 +944,8 @@ export function createSpendReservationLedger(options: {
|
|
|
780
944
|
try {
|
|
781
945
|
rewrite.call(journal, [JSON.stringify(checkpoint)]);
|
|
782
946
|
recordsOnDisk = 1;
|
|
783
|
-
} catch {
|
|
947
|
+
} catch (error) {
|
|
948
|
+
if (error instanceof SpendLedgerOwnerError) throw error;
|
|
784
949
|
// Compaction is maintenance, not accounting: a failed rewrite leaves the previous
|
|
785
950
|
// journal intact and every figure in it still replayable.
|
|
786
951
|
persistFailures += 1;
|
|
@@ -791,12 +956,12 @@ export function createSpendReservationLedger(options: {
|
|
|
791
956
|
const makeRoom = (refs: readonly ScopeRef[], at: number): SpendDenial | undefined => {
|
|
792
957
|
evictSends(at, false);
|
|
793
958
|
evictScopes(at, false);
|
|
794
|
-
while (reservations.size >= maxTrackedSends) {
|
|
959
|
+
while (reservations.size >= maxTrackedSends()) {
|
|
795
960
|
if (evictSends(at, true) === 0) return { reason: "tracking-capacity-exhausted" };
|
|
796
961
|
}
|
|
797
962
|
let fresh = 0;
|
|
798
963
|
for (const ref of refs) if (!scopes.has(scopeKey(ref.scope, ref.alias))) fresh += 1;
|
|
799
|
-
while (scopes.size + fresh > maxTrackedScopes) {
|
|
964
|
+
while (scopes.size + fresh > maxTrackedScopes()) {
|
|
800
965
|
if (evictScopes(at, true) === 0) {
|
|
801
966
|
return { reason: "tracking-capacity-exhausted", scope: refs[0]?.scope };
|
|
802
967
|
}
|
|
@@ -805,11 +970,15 @@ export function createSpendReservationLedger(options: {
|
|
|
805
970
|
};
|
|
806
971
|
|
|
807
972
|
return {
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
get
|
|
973
|
+
// Every figure this ledger reports describes a journal it must still own. Reporting one
|
|
974
|
+
// after ownership ended is the same error as writing then, with a quieter symptom.
|
|
975
|
+
get persistFailures() { assertOwnedAccounting?.(); return persistFailures; },
|
|
976
|
+
get corruptRecords() { assertOwnedAccounting?.(); return corruptRecords; },
|
|
977
|
+
get degraded() { assertOwnedAccounting?.(); return persistFailures > 0 || corruptRecords > 0; },
|
|
978
|
+
get policy() { assertOwnedAccounting?.(); return policy; },
|
|
811
979
|
|
|
812
980
|
reserve(request: SpendReservationRequest): SpendReservationDecision {
|
|
981
|
+
assertOwnedAccounting?.();
|
|
813
982
|
const tokens = sanitizeTokens(request.inputTokens) + sanitizeTokens(request.outputCeilingTokens);
|
|
814
983
|
const at = request.at ?? now();
|
|
815
984
|
const send = aliasFor("send", request.sendId);
|
|
@@ -834,7 +1003,9 @@ export function createSpendReservationLedger(options: {
|
|
|
834
1003
|
// reservation booked on the scopes that would have passed. Reading state without
|
|
835
1004
|
// creating it matters here -- a denied request must not leave a tracked scope behind.
|
|
836
1005
|
for (const ref of refs) {
|
|
837
|
-
|
|
1006
|
+
// A recorded send has no limit to fail: it already happened, and the point of booking
|
|
1007
|
+
// it is to let the total go OVER the ceiling so the next request can be refused.
|
|
1008
|
+
const limit = request.alreadySent === true ? undefined : limitFor(ref.scope);
|
|
838
1009
|
if (limit === undefined) continue;
|
|
839
1010
|
const state = scopes.get(scopeKey(ref.scope, ref.alias));
|
|
840
1011
|
const projected = (state ? state.settled + state.reserved + state.unresolved : 0) + tokens;
|
|
@@ -853,7 +1024,7 @@ export function createSpendReservationLedger(options: {
|
|
|
853
1024
|
// limit a failed write refuses the request rather than admitting one that a restart
|
|
854
1025
|
// would forget -- which is exactly the disk-full and permission case durability is for.
|
|
855
1026
|
const durable = append({ v: 1, kind: "reserve", send, targets: refs, tokens, at });
|
|
856
|
-
if (!durable && enforced) {
|
|
1027
|
+
if (!durable && enforced && request.alreadySent !== true) {
|
|
857
1028
|
return { reserved: false, denial: { reason: "reserve-not-durable", sendId: request.sendId } };
|
|
858
1029
|
}
|
|
859
1030
|
applyReserve(send, refs, tokens, at);
|
|
@@ -862,6 +1033,7 @@ export function createSpendReservationLedger(options: {
|
|
|
862
1033
|
},
|
|
863
1034
|
|
|
864
1035
|
markDispatched(sendId: string): boolean {
|
|
1036
|
+
assertOwnedAccounting?.();
|
|
865
1037
|
const send = aliasFor("send", sendId);
|
|
866
1038
|
const reservation = reservations.get(send);
|
|
867
1039
|
if (!reservation || reservation.status !== "open") return false;
|
|
@@ -872,6 +1044,7 @@ export function createSpendReservationLedger(options: {
|
|
|
872
1044
|
},
|
|
873
1045
|
|
|
874
1046
|
abandon(sendId: string): boolean {
|
|
1047
|
+
assertOwnedAccounting?.();
|
|
875
1048
|
const send = aliasFor("send", sendId);
|
|
876
1049
|
const reservation = reservations.get(send);
|
|
877
1050
|
// Only an UNDISPATCHED reservation may be released for free. Once bytes have left for
|
|
@@ -884,6 +1057,7 @@ export function createSpendReservationLedger(options: {
|
|
|
884
1057
|
},
|
|
885
1058
|
|
|
886
1059
|
settle(sendId: string, usage: SpendUsage): boolean {
|
|
1060
|
+
assertOwnedAccounting?.();
|
|
887
1061
|
const send = aliasFor("send", sendId);
|
|
888
1062
|
const reservation = reservations.get(send);
|
|
889
1063
|
if (!reservation || !isLive(reservation.status)) return false;
|
|
@@ -895,6 +1069,7 @@ export function createSpendReservationLedger(options: {
|
|
|
895
1069
|
},
|
|
896
1070
|
|
|
897
1071
|
markLost(sendId: string): boolean {
|
|
1072
|
+
assertOwnedAccounting?.();
|
|
898
1073
|
const send = aliasFor("send", sendId);
|
|
899
1074
|
const reservation = reservations.get(send);
|
|
900
1075
|
if (!reservation || !isLive(reservation.status)) return false;
|
|
@@ -905,10 +1080,14 @@ export function createSpendReservationLedger(options: {
|
|
|
905
1080
|
},
|
|
906
1081
|
|
|
907
1082
|
knows(sendId: string): boolean {
|
|
1083
|
+
assertOwnedAccounting?.();
|
|
908
1084
|
return reservations.has(aliasFor("send", sendId));
|
|
909
1085
|
},
|
|
910
1086
|
|
|
911
1087
|
snapshot(scope: SpendScope, scopeId: string): ScopeSpendSnapshot | undefined {
|
|
1088
|
+
// Reading accounting from a handle whose ownership has ended is as wrong as writing it:
|
|
1089
|
+
// the figures describe a journal this process no longer owns.
|
|
1090
|
+
assertOwnedAccounting?.();
|
|
912
1091
|
const state = scopes.get(scopeKey(scope, aliasFor(scope, scopeId)));
|
|
913
1092
|
if (!state) return undefined;
|
|
914
1093
|
return {
|
|
@@ -920,39 +1099,157 @@ export function createSpendReservationLedger(options: {
|
|
|
920
1099
|
},
|
|
921
1100
|
|
|
922
1101
|
exhausted(scope: SpendScope, scopeId: string): boolean {
|
|
1102
|
+
assertOwnedAccounting?.();
|
|
923
1103
|
const state = scopes.get(scopeKey(scope, aliasFor(scope, scopeId)));
|
|
924
1104
|
return state !== undefined && isExhausted(scope, state);
|
|
925
1105
|
},
|
|
926
1106
|
|
|
927
1107
|
prune(at: number = now()): void {
|
|
1108
|
+
assertOwnedAccounting?.();
|
|
928
1109
|
// Removal requires BOTH inactive and not exhausted inside the window. An
|
|
929
1110
|
// exhausted-but-idle scope that was dropped would be recreated fresh under the
|
|
930
1111
|
// same id -- the exact laundering the ceiling exists to stop.
|
|
931
1112
|
evictSends(at, false);
|
|
932
1113
|
evictScopes(at, false);
|
|
933
1114
|
},
|
|
1115
|
+
|
|
1116
|
+
reconfigure(next: SpendReservationPolicy): void {
|
|
1117
|
+
assertOwnedAccounting?.();
|
|
1118
|
+
policy = next;
|
|
1119
|
+
},
|
|
934
1120
|
};
|
|
935
1121
|
}
|
|
936
1122
|
|
|
937
1123
|
let sharedLedger: SpendReservationLedger | undefined;
|
|
1124
|
+
/**
|
|
1125
|
+
* The operator policy in effect. Held beside the ledger rather than inside it because the
|
|
1126
|
+
* ledger is built lazily: a configured ceiling has to be remembered from startup until the
|
|
1127
|
+
* first request that actually reserves, and an install that configures nothing must still
|
|
1128
|
+
* open no journal.
|
|
1129
|
+
*/
|
|
1130
|
+
let sharedPolicy: SpendReservationPolicy = DEFAULT_SPEND_RESERVATION_POLICY;
|
|
1131
|
+
|
|
1132
|
+
/** Whether any scope carries a ceiling -- that is, whether anything at all can be refused. */
|
|
1133
|
+
export function spendCeilingsConfigured(policy: SpendReservationPolicy = sharedPolicy): boolean {
|
|
1134
|
+
return policy.root.maxTokens !== undefined
|
|
1135
|
+
|| policy.identity.maxTokens !== undefined
|
|
1136
|
+
|| policy.pool.maxTokens !== undefined;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
/** The policy the process-wide ledger enforces right now. */
|
|
1140
|
+
export function sharedSpendPolicy(): SpendReservationPolicy {
|
|
1141
|
+
return sharedPolicy;
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
const spendScopeLimitFromConfig = (scope: OcxSpendScopeConfig | undefined): SpendScopeLimit =>
|
|
1145
|
+
scope?.maxTokens !== undefined && Number.isFinite(scope.maxTokens) && scope.maxTokens > 0
|
|
1146
|
+
? { maxTokens: Math.trunc(scope.maxTokens) }
|
|
1147
|
+
: {};
|
|
1148
|
+
|
|
1149
|
+
/**
|
|
1150
|
+
* The ledger policy an operator's `spend` section asks for.
|
|
1151
|
+
*
|
|
1152
|
+
* An absent section, an empty one, and one whose every ceiling is absent all produce the
|
|
1153
|
+
* unconfigured default: observe-only accounting that refuses nothing. That equivalence is the
|
|
1154
|
+
* load-bearing part. This ledger is on and journaling by default, so shipping a default
|
|
1155
|
+
* ceiling would start refusing real traffic on the first upgrade that ran this code, against
|
|
1156
|
+
* a number nobody chose. There is deliberately no default figure here at all.
|
|
1157
|
+
*/
|
|
1158
|
+
export function spendPolicyFromConfig(spend: OcxSpendConfig | undefined): SpendReservationPolicy {
|
|
1159
|
+
const retentionDays = spend?.retentionDays;
|
|
1160
|
+
return {
|
|
1161
|
+
root: spendScopeLimitFromConfig(spend?.root),
|
|
1162
|
+
identity: spendScopeLimitFromConfig(spend?.identity),
|
|
1163
|
+
pool: spendScopeLimitFromConfig(spend?.pool),
|
|
1164
|
+
retentionMs: retentionDays !== undefined && Number.isFinite(retentionDays) && retentionDays > 0
|
|
1165
|
+
? Math.trunc(retentionDays) * 24 * 60 * 60_000
|
|
1166
|
+
: DEFAULT_SPEND_RESERVATION_POLICY.retentionMs,
|
|
1167
|
+
};
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
/**
|
|
1171
|
+
* Apply an operator policy to the process-wide ledger.
|
|
1172
|
+
*
|
|
1173
|
+
* Startup calls this with the loaded config, and a reload may call it again: the ledger keeps
|
|
1174
|
+
* every figure it has already accounted, so changing a ceiling changes what is refused from
|
|
1175
|
+
* here on and never what was spent. It does not CREATE the ledger -- an install that
|
|
1176
|
+
* configures no ceiling must not open a journal merely because the server started.
|
|
1177
|
+
*/
|
|
1178
|
+
export function configureSharedSpendLedger(policy: SpendReservationPolicy): void {
|
|
1179
|
+
// Recording a policy value touches no journal, so it needs no ownership. Changing a ledger
|
|
1180
|
+
// that already exists does, because that ledger is a live view of an owned directory.
|
|
1181
|
+
if (sharedLedger) {
|
|
1182
|
+
assertSpendLedgerOwnerHeld();
|
|
1183
|
+
bindSpendLedgerOwnerHome();
|
|
1184
|
+
}
|
|
1185
|
+
sharedPolicy = policy;
|
|
1186
|
+
sharedLedger?.reconfigure(policy);
|
|
1187
|
+
}
|
|
938
1188
|
|
|
939
1189
|
/**
|
|
940
1190
|
* Process-wide ledger backed by the journal under OPENCODEX_HOME. Created lazily so
|
|
941
1191
|
* importing the module -- or running a request path that never reserves -- touches no
|
|
942
|
-
* disk.
|
|
1192
|
+
* disk. One directory at a time, not one directory for the life of the process: the singleton
|
|
1193
|
+
* is discarded when its ownership ends, so a later directory replays its own journal rather
|
|
1194
|
+
* than inheriting figures from the previous one.
|
|
943
1195
|
*/
|
|
944
1196
|
export function sharedSpendLedger(): SpendReservationLedger {
|
|
1197
|
+
assertSpendLedgerOwnerHeld();
|
|
1198
|
+
bindSpendLedgerOwnerHome();
|
|
945
1199
|
if (!sharedLedger) {
|
|
946
|
-
|
|
1200
|
+
// Minted by the owner module from the directory it actually owns, and carrying the exact
|
|
1201
|
+
// ownership they were minted under. Nothing here chooses a path or supplies its own guard.
|
|
1202
|
+
const journalStorage = mintSpendLedgerStorage(SPEND_LEDGER_JOURNAL_FILENAME);
|
|
1203
|
+
const saltStorage = mintSpendLedgerStorage(SPEND_LEDGER_SALT_FILENAME);
|
|
1204
|
+
const journalPath = spendLedgerStoragePath(journalStorage);
|
|
1205
|
+
const saltPath = spendLedgerStoragePath(saltStorage);
|
|
1206
|
+
const assertOwnedAccounting = (): void => {
|
|
1207
|
+
assertStorageOwned(journalStorage);
|
|
1208
|
+
if (ledgerEntryExists(journalPath)) assertSafeLedgerFile(journalPath);
|
|
1209
|
+
if (ledgerEntryExists(saltPath)) assertSafeLedgerFile(saltPath);
|
|
1210
|
+
};
|
|
947
1211
|
sharedLedger = createSpendReservationLedger({
|
|
948
|
-
journal:
|
|
949
|
-
salt: loadOrCreateSpendLedgerSalt(
|
|
1212
|
+
journal: createOwnedFileSpendJournal(journalStorage),
|
|
1213
|
+
salt: loadOrCreateSpendLedgerSalt(saltStorage),
|
|
1214
|
+
policy: sharedPolicy,
|
|
1215
|
+
assertOwnedAccounting,
|
|
950
1216
|
});
|
|
951
1217
|
}
|
|
952
1218
|
return sharedLedger;
|
|
953
1219
|
}
|
|
954
1220
|
|
|
955
|
-
|
|
1221
|
+
const MAX_DIAGNOSTIC_ERROR_COUNT = 1_000_000;
|
|
1222
|
+
|
|
1223
|
+
/** Scalar-only and side-effect-free: reading diagnostics never constructs or replays. */
|
|
1224
|
+
export function spendLedgerDiagnosticsSnapshot(): {
|
|
1225
|
+
readonly ownership: "held" | "unheld";
|
|
1226
|
+
readonly initialized: boolean;
|
|
1227
|
+
readonly configured: boolean;
|
|
1228
|
+
readonly degraded: boolean;
|
|
1229
|
+
readonly persistFailures: number;
|
|
1230
|
+
readonly corruptRecords: number;
|
|
1231
|
+
} {
|
|
1232
|
+
const ledger = sharedLedger;
|
|
1233
|
+
const bounded = (value: number): number => Math.min(MAX_DIAGNOSTIC_ERROR_COUNT, Math.max(0, value));
|
|
1234
|
+
return {
|
|
1235
|
+
...spendLedgerOwnerSnapshot(),
|
|
1236
|
+
initialized: ledger !== undefined,
|
|
1237
|
+
configured: spendCeilingsConfigured(sharedPolicy),
|
|
1238
|
+
degraded: ledger?.degraded ?? false,
|
|
1239
|
+
persistFailures: bounded(ledger?.persistFailures ?? 0),
|
|
1240
|
+
corruptRecords: bounded(ledger?.corruptRecords ?? 0),
|
|
1241
|
+
};
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
/**
|
|
1245
|
+
* Test seam for discarding the singleton outright.
|
|
1246
|
+
*
|
|
1247
|
+
* Production discards it too, but only with the ownership it belongs to, which is what
|
|
1248
|
+
* `onSpendLedgerOwnerReleased` does. Neither path resets a spent budget: the journal is the
|
|
1249
|
+
* durable record and the next construction replays it.
|
|
1250
|
+
*/
|
|
956
1251
|
export function resetSharedSpendLedgerForTest(): void {
|
|
957
1252
|
sharedLedger = undefined;
|
|
1253
|
+
sharedPolicy = DEFAULT_SPEND_RESERVATION_POLICY;
|
|
1254
|
+
resetSpendLedgerOwnerBindingForTest();
|
|
958
1255
|
}
|
|
@@ -16,7 +16,7 @@ import { decodeWindowsTextBytes } from "./windows-text";
|
|
|
16
16
|
const INTERNET_SETTINGS_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings";
|
|
17
17
|
|
|
18
18
|
export type WindowsSystemProxyResult =
|
|
19
|
-
| { kind: "proxy";
|
|
19
|
+
| { kind: "proxy"; httpUrl?: string; httpsUrl?: string }
|
|
20
20
|
| { kind: "disabled" }
|
|
21
21
|
| { kind: "socks-only" }
|
|
22
22
|
| { kind: "unsupported" }
|
|
@@ -63,35 +63,40 @@ export function readWindowsProxyRegistry(): WindowsProxyRegistryValues | null {
|
|
|
63
63
|
|
|
64
64
|
/**
|
|
65
65
|
* `ProxyServer` is either a bare `host:port` (applies to every scheme) or a semicolon list of
|
|
66
|
-
* `scheme=host:port` entries.
|
|
67
|
-
* mirrored into
|
|
66
|
+
* `scheme=host:port` entries. Bare values apply to both HTTP and HTTPS destinations; per-scheme
|
|
67
|
+
* values retain their WinINET scope. A SOCKS-only value cannot be mirrored into HTTP(S)_PROXY.
|
|
68
68
|
*/
|
|
69
|
-
export function parseWindowsProxyServer(value: string): { kind: "proxy"
|
|
69
|
+
export function parseWindowsProxyServer(value: string): Extract<WindowsSystemProxyResult, { kind: "proxy" | "socks-only" | "disabled" }> {
|
|
70
70
|
const trimmed = value.trim();
|
|
71
71
|
if (!trimmed) return { kind: "disabled" };
|
|
72
|
-
if (!trimmed.includes("="))
|
|
72
|
+
if (!trimmed.includes("=")) {
|
|
73
|
+
const url = normalize(trimmed);
|
|
74
|
+
return url ? { kind: "proxy", httpUrl: url, httpsUrl: url } : { kind: "disabled" };
|
|
75
|
+
}
|
|
73
76
|
const entries = new Map<string, string>();
|
|
74
77
|
for (const part of trimmed.split(";")) {
|
|
75
78
|
const eq = part.indexOf("=");
|
|
76
79
|
if (eq <= 0) continue;
|
|
77
80
|
entries.set(part.slice(0, eq).trim().toLowerCase(), part.slice(eq + 1).trim());
|
|
78
81
|
}
|
|
79
|
-
const
|
|
80
|
-
|
|
82
|
+
const httpUrl = normalize(entries.get("http") ?? "");
|
|
83
|
+
const httpsUrl = normalize(entries.get("https") ?? "");
|
|
84
|
+
if (httpUrl || httpsUrl) return { kind: "proxy", ...(httpUrl && { httpUrl }), ...(httpsUrl && { httpsUrl }) };
|
|
81
85
|
if (entries.has("socks")) return { kind: "socks-only" };
|
|
82
86
|
return { kind: "disabled" };
|
|
83
87
|
}
|
|
84
88
|
|
|
85
|
-
function normalize(hostPort: string):
|
|
89
|
+
function normalize(hostPort: string): string | undefined {
|
|
90
|
+
if (!hostPort) return undefined;
|
|
86
91
|
const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(hostPort) ? hostPort : `http://${hostPort}`;
|
|
87
92
|
try {
|
|
88
93
|
const url = new URL(withScheme);
|
|
89
|
-
if (!url.hostname || (url.protocol !== "http:" && url.protocol !== "https:")) return
|
|
94
|
+
if (!url.hostname || (url.protocol !== "http:" && url.protocol !== "https:")) return undefined;
|
|
90
95
|
// Keep userinfo: a credentialed proxy is valid in HTTP_PROXY. Only the log strips it.
|
|
91
96
|
const auth = url.username ? `${url.username}${url.password ? `:${url.password}` : ""}@` : "";
|
|
92
|
-
return
|
|
97
|
+
return `${url.protocol}//${auth}${url.host}`;
|
|
93
98
|
} catch {
|
|
94
|
-
return
|
|
99
|
+
return undefined;
|
|
95
100
|
}
|
|
96
101
|
}
|
|
97
102
|
|