@bitkyc08/opencodex 2.59.0 → 2.61.0-preview.20260922
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/AGENTS_INSTALL.md +64 -0
- package/README.md +28 -1
- package/bin/ocx.mjs +382 -209
- package/gui/dist/assets/App-E64Rzjap.js +50 -0
- package/gui/dist/assets/Tray-_nfzD8k4.js +1 -0
- package/gui/dist/assets/index-DpdfZWMK.js +86 -0
- package/gui/dist/assets/index-_bpvxJu0.css +1 -0
- package/gui/dist/assets/usage-companion-chart-DtoK7T6h.js +1 -0
- package/gui/dist/favicon.png +0 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/stepfun-color.svg +1 -0
- package/package.json +8 -1
- package/src/adapters/anthropic-image-codec.ts +16 -2
- package/src/adapters/anthropic-image-normalize.ts +49 -2
- package/src/adapters/anthropic.ts +20 -1
- package/src/adapters/base.ts +23 -0
- package/src/adapters/coding-agent/protocol.ts +36 -6
- package/src/adapters/coding-agent/turn.ts +32 -4
- package/src/adapters/command-code.ts +52 -4
- package/src/adapters/cursor/catalog.ts +51 -7
- package/src/adapters/cursor/checkpoint-store.ts +3 -0
- package/src/adapters/cursor/discovery.ts +11 -8
- package/src/adapters/cursor/live-transport.ts +26 -9
- package/src/adapters/cursor/protobuf-request.ts +6 -3
- package/src/adapters/cursor/request-builder.ts +20 -4
- package/src/adapters/cursor/transport.ts +19 -0
- package/src/adapters/cursor.ts +14 -3
- package/src/adapters/declaration-carrier.ts +45 -0
- package/src/adapters/devin/cloud-direct/chat.ts +3 -1
- package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +42 -5
- package/src/adapters/devin.ts +125 -36
- package/src/adapters/google-antigravity-replay.ts +1 -1
- package/src/adapters/google-antigravity-wire.ts +34 -7
- package/src/adapters/google-errors.ts +7 -1
- package/src/adapters/google-http.ts +49 -10
- 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 +66 -11
- package/src/adapters/image.ts +4 -1
- package/src/adapters/input-media-guard.ts +21 -9
- package/src/adapters/kiro/usage.ts +3 -2
- package/src/adapters/kiro-tool-fallback.ts +1 -1
- package/src/adapters/ollama-native.ts +6 -0
- package/src/adapters/openai-chat/developer-role.ts +61 -0
- package/src/adapters/openai-chat/messages.ts +46 -27
- package/src/adapters/openai-chat/parallel-tool-calls.ts +32 -0
- package/src/adapters/openai-chat/passthrough.ts +33 -9
- package/src/adapters/openai-chat/reasoning-wire.ts +89 -0
- package/src/adapters/openai-chat-images.ts +3 -1
- package/src/adapters/openai-chat.ts +23 -58
- package/src/adapters/openai-responses/image-gen.ts +8 -6
- package/src/adapters/openai-responses/passthrough.ts +17 -3
- package/src/adapters/openai-responses/reasoning.ts +7 -0
- package/src/adapters/opencode-go-additional-tools.ts +12 -2
- package/src/adapters/registry.ts +3 -2
- package/src/adapters/run-turn-queue.ts +178 -29
- package/src/adapters/xai-web-search.ts +16 -1
- package/src/bridge/errors.ts +8 -2
- package/src/bridge/response-json.ts +9 -1
- package/src/bridge/sse.ts +13 -151
- package/src/chat/inbound.ts +141 -5
- package/src/claude/desktop-3p.ts +7 -1
- package/src/claude/desktop-first-party.ts +183 -0
- package/src/claude/desktop-gateway-state.ts +41 -0
- package/src/claude/inbound-content-options.ts +6 -0
- package/src/claude/inbound.ts +32 -6
- package/src/claude/intercept/connect-proxy.ts +179 -0
- package/src/claude/intercept/listener.ts +122 -0
- package/src/claude/intercept/local-ca.ts +298 -0
- package/src/claude/intercept/runtime.ts +98 -0
- package/src/claude/intercept/settings.ts +189 -0
- package/src/cli/access.ts +87 -0
- package/src/cli/account-auth.ts +19 -0
- package/src/cli/account-extended.ts +4 -4
- package/src/cli/capabilities.ts +31 -0
- package/src/cli/claude-desktop.ts +206 -16
- package/src/cli/codex-shim-autorestore.ts +3 -0
- package/src/cli/companion.ts +56 -0
- package/src/cli/dispatch.ts +46 -7
- package/src/cli/doctor.ts +28 -9
- package/src/cli/ensure-desired-integrations.ts +43 -5
- package/src/cli/help.ts +7 -9
- package/src/cli/hub.ts +3 -2
- package/src/cli/index.ts +203 -59
- package/src/cli/init.ts +8 -0
- package/src/cli/integrations.ts +7 -1
- package/src/cli/opencode.ts +2 -2
- package/src/cli/provider.ts +13 -1
- package/src/cli/registry.ts +41 -2
- package/src/cli/resolve.ts +230 -0
- package/src/cli/root.ts +24 -1
- package/src/cli/start-ownership-publication.ts +56 -0
- package/src/cli/status-probes.ts +2 -18
- package/src/cli/status.ts +62 -0
- package/src/cli/stop-report.ts +143 -0
- package/src/cli/uninstall-plan.ts +9 -0
- package/src/client/machine-api.ts +2 -2
- package/src/client/machine-listener.ts +4 -7
- package/src/client/runtime.ts +26 -2
- package/src/clients/aside-profiles.ts +4 -0
- package/src/clients/config-export/zcode-store.ts +157 -0
- package/src/clients/config-export.ts +36 -0
- package/src/codex/account-store.ts +65 -0
- package/src/codex/app-server-processes.ts +72 -40
- package/src/codex/auth-api/account-list.ts +19 -11
- package/src/codex/auth-api/login-flow.ts +6 -1
- package/src/codex/auth-api/pool-quota-probe.ts +30 -7
- package/src/codex/autostart-health.ts +28 -0
- package/src/codex/catalog/build-entries.ts +2 -2
- package/src/codex/catalog/effort.ts +3 -3
- package/src/codex/catalog/gather-capture.ts +21 -2
- package/src/codex/catalog/model-hints.ts +29 -28
- package/src/codex/catalog/parsing.ts +7 -0
- package/src/codex/catalog/provider-models.ts +43 -17
- package/src/codex/catalog/retained-sync.ts +24 -28
- package/src/codex/catalog/routed-gather.ts +19 -0
- package/src/codex/context-compat.ts +5 -2
- package/src/codex/convergence.ts +2 -2
- package/src/codex/desired-state.ts +4 -1
- package/src/codex/history-job.ts +6 -6
- package/src/codex/history-provider.ts +31 -166
- package/src/codex/history-rollout-read.ts +174 -0
- package/src/codex/inject/config-toml.ts +41 -6
- package/src/codex/inject/paginated-openai-compat.ts +90 -0
- package/src/codex/inject.ts +18 -15
- package/src/codex/injected-marker.ts +18 -0
- package/src/codex/internal/catalog-writer.ts +33 -1
- package/src/codex/main-account.ts +6 -0
- package/src/codex/model-cache.ts +99 -6
- package/src/codex/model-entitlement-admission.ts +59 -0
- package/src/codex/model-entitlements.ts +116 -54
- package/src/codex/native-main-admission.ts +83 -0
- package/src/codex/observed-model-denials.ts +101 -8
- package/src/codex/prompt-text-probe.ts +9 -6
- package/src/codex/routing/health-store.ts +39 -0
- package/src/codex/routing/selection.ts +37 -1
- package/src/codex/routing.ts +12 -42
- package/src/codex/shim-templates.ts +29 -3
- package/src/codex/shim.ts +1 -1
- package/src/codex/subagent-model-fallback.ts +22 -4
- package/src/combos/failover.ts +3 -0
- package/src/companion/settings.ts +132 -0
- package/src/config/admitted-identity.ts +222 -0
- package/src/config/atomic-write.ts +117 -5
- package/src/config/diagnostics.ts +22 -1
- package/src/config/feature-flags.ts +5 -0
- package/src/config/load-degrade.ts +52 -7
- package/src/config/process-state.ts +1 -1
- package/src/config/proxy-env.ts +8 -2
- package/src/config/schema/compaction-triggers.ts +11 -0
- package/src/config/schema/config-schema.ts +31 -1
- package/src/config/schema/leaf-validators.ts +59 -0
- package/src/config.ts +3 -3
- package/src/generated/compatibility-version.json +604 -280
- 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/config-io.ts +44 -10
- package/src/integrations/merge.ts +120 -13
- package/src/integrations/mutation-plan.ts +921 -0
- package/src/integrations/registry.ts +38 -0
- package/src/integrations/state.ts +78 -45
- package/src/integrations/target.ts +208 -0
- package/src/integrations/writer.ts +134 -110
- package/src/lab/conformance/fixture-provider.ts +5 -0
- 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/browser-launch-notice.ts +59 -0
- package/src/lib/bun-runtime.ts +6 -2
- package/src/lib/debug-log-buffer.ts +6 -1
- package/src/lib/debug.ts +63 -0
- package/src/lib/errors.ts +79 -0
- package/src/lib/http-response-semantics.ts +57 -0
- package/src/lib/lab-live-pinned-sender.ts +26 -12
- package/src/lib/open-url.ts +51 -7
- package/src/lib/package-tree-integrity.ts +2 -1
- package/src/lib/package-version.ts +8 -0
- 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-egress.ts +310 -0
- package/src/lib/provider-outbound.ts +109 -16
- package/src/lib/proxy-env.ts +82 -7
- package/src/lib/request-execution-budget.ts +72 -0
- package/src/lib/request-failure-attribution.ts +183 -0
- package/src/lib/request-failure-model.ts +236 -0
- package/src/lib/request-resend-gate.ts +138 -0
- package/src/lib/socks5-fetch.ts +136 -26
- package/src/lib/spend-ledger-owner.ts +364 -0
- package/src/lib/spend-reservation-ledger.ts +218 -27
- package/src/lib/standalone.ts +16 -0
- package/src/lib/upstream-retry.ts +167 -16
- package/src/lib/windows-system-proxy.ts +16 -11
- package/src/lib/winsw.ts +2 -2
- package/src/oauth/callback-server.ts +4 -3
- package/src/oauth/generic-account-failover.ts +1 -0
- package/src/oauth/health.ts +12 -1
- package/src/oauth/index.ts +27 -108
- package/src/oauth/login-cli.ts +80 -29
- package/src/oauth/login-flow-state.ts +127 -0
- package/src/providers/api-key-resolve.ts +133 -0
- package/src/providers/api-key-selection.ts +5 -1
- package/src/providers/derive.ts +34 -17
- package/src/providers/devin-cli-authmode-migration.ts +14 -10
- package/src/providers/key-failover.ts +97 -19
- package/src/providers/key-store.ts +34 -110
- package/src/providers/model-rename-fields.ts +147 -0
- package/src/providers/model-rename-migration.ts +179 -38
- package/src/providers/model-rename-startup.ts +7 -5
- package/src/providers/openai-virtual-models.ts +42 -2
- package/src/providers/quota/antigravity.ts +22 -2
- package/src/providers/quota/vendor-probes-key.ts +38 -23
- package/src/providers/reasoning-metadata.ts +43 -18
- package/src/providers/registry/entries-core.ts +47 -20
- package/src/providers/registry/entries-extended.ts +63 -4
- package/src/providers/registry/model-ids.ts +168 -0
- package/src/providers/registry/model-seeds.ts +56 -10
- 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/providers/xai-transport.ts +12 -1
- package/src/reasoning-effort.ts +8 -0
- package/src/responses/apply-patch-envelope.ts +0 -12
- package/src/responses/freeform-wrapper-scan.ts +279 -0
- package/src/responses/function-call-compat.ts +38 -1
- package/src/responses/inline-document.ts +65 -0
- package/src/responses/input-media.ts +42 -8
- package/src/responses/legacy-dotted-tool-name-repair.ts +134 -0
- package/src/responses/muse-tool-name-alias.ts +19 -0
- package/src/responses/parser-content.ts +8 -2
- package/src/responses/parser-tools.ts +3 -0
- package/src/responses/parser.ts +3 -1
- package/src/responses/progressive-freeform-input.ts +130 -0
- package/src/responses/reasoning-envelope.ts +30 -0
- package/src/responses/schema.ts +3 -0
- package/src/responses/state.ts +5 -12
- package/src/responses/tool-name-aliases.ts +15 -1
- package/src/router.ts +108 -117
- 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/admission-model-scope.ts +219 -0
- package/src/server/audio-live.ts +9 -3
- package/src/server/audio-upstream.ts +18 -0
- package/src/server/auth-cors.ts +29 -0
- package/src/server/chat-completions.ts +60 -4
- package/src/server/chat-native.ts +19 -4
- package/src/server/claude-messages.ts +61 -20
- package/src/server/effort-row.ts +11 -3
- package/src/server/grok-responses-control-frame.ts +160 -1
- package/src/server/grok-responses-snapshot-repair.ts +113 -11
- package/src/server/gui-freshness.ts +103 -0
- package/src/server/gui-static.ts +7 -9
- package/src/server/images.ts +59 -6
- package/src/server/index/claude-intercept-lifecycle.ts +49 -0
- package/src/server/index/serve-options.ts +142 -39
- package/src/server/index/spend-ledger-lifecycle.ts +92 -0
- package/src/server/index/startup-warnings.ts +24 -0
- package/src/server/index/websocket-handler.ts +6 -1
- package/src/server/index.ts +34 -38
- package/src/server/lifecycle.ts +4 -4
- package/src/server/live-call-bindings.ts +6 -0
- package/src/server/live.ts +88 -3
- package/src/server/management/agent-settings-routes.ts +121 -36
- package/src/server/management/aside-profile-routes.ts +266 -7
- package/src/server/management/companion-routes.ts +77 -0
- package/src/server/management/config-routes.ts +18 -2
- package/src/server/management/context.ts +3 -0
- package/src/server/management/integration-routes.ts +287 -5
- package/src/server/management/logs-usage-routes.ts +19 -0
- package/src/server/management/metrics-routes.ts +20 -0
- package/src/server/management/model-rows.ts +224 -12
- package/src/server/management/native-integration-routes.ts +103 -6
- package/src/server/management/oauth-account-routes.ts +45 -7
- package/src/server/management/route-registry.ts +20 -0
- package/src/server/management/shared.ts +28 -4
- 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/usage-timeline-routes.ts +44 -0
- package/src/server/management-api.ts +10 -9
- package/src/server/management-auth.ts +15 -1
- package/src/server/proxy-liveness.ts +75 -0
- package/src/server/readiness.ts +29 -10
- package/src/server/relay-eager.ts +24 -2
- package/src/server/relay.ts +138 -12
- package/src/server/request-log-failure-attribution.ts +99 -0
- package/src/server/request-log.ts +169 -2
- package/src/server/request-metrics.ts +298 -0
- package/src/server/responses/adapter-continuation.ts +3 -3
- package/src/server/responses/adapter-dispatch.ts +11 -6
- package/src/server/responses/codex-ws-wire.ts +34 -8
- package/src/server/responses/combo-stream-preflight.ts +168 -6
- package/src/server/responses/compact.ts +43 -10
- package/src/server/responses/compaction-routing.ts +111 -0
- package/src/server/responses/core-codex-account.ts +8 -3
- package/src/server/responses/core-combo.ts +7 -7
- package/src/server/responses/core-normalize.ts +6 -12
- package/src/server/responses/core-opaque-recovery.ts +91 -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 +124 -8
- package/src/server/responses/input-admission.ts +10 -0
- package/src/server/responses/passthrough-delivery.ts +45 -15
- package/src/server/responses/passthrough-dispatch.ts +215 -44
- package/src/server/responses/passthrough-error.ts +27 -8
- package/src/server/responses/policy-fallback.ts +5 -13
- package/src/server/responses/request-prepare.ts +109 -18
- package/src/server/responses/request-send-budget.ts +17 -1
- package/src/server/responses/request-sidecar-auth.ts +1 -1
- package/src/server/responses/request-transport.ts +26 -6
- package/src/server/responses/reset-replay.ts +108 -0
- package/src/server/responses/run-turn-execution.ts +25 -3
- package/src/server/responses/sidecar-execution.ts +17 -2
- package/src/server/responses/ws-upstream.ts +14 -27
- package/src/server/responses-custom-tool-repair.ts +27 -54
- package/src/server/responses-request-tool-scope.ts +214 -0
- package/src/server/responses-undeclared-tool-guard.ts +35 -2
- package/src/server/search.ts +25 -1
- package/src/server/sse-payload-rewrite.ts +1 -1
- package/src/server/usage-ledger-retention.ts +73 -0
- package/src/service/cli.ts +48 -2
- package/src/service/health.ts +3 -2
- package/src/service/install-state-contract.d.mts +27 -0
- package/src/service/install-state-contract.mjs +34 -0
- package/src/service/launchd.ts +1 -1
- package/src/service/orchestration.ts +2 -4
- package/src/service/ownership-compatibility.ts +164 -0
- package/src/service/ownership-mutation-lease.d.mts +32 -0
- package/src/service/ownership-mutation-lease.mjs +211 -0
- package/src/service/repair.ts +45 -1
- package/src/service/state-lock.ts +269 -0
- package/src/service/state-record.d.mts +36 -0
- package/src/service/state-record.mjs +138 -0
- package/src/service/state.ts +582 -68
- package/src/service/windows-taskxml.ts +11 -10
- package/src/service.ts +7 -3
- package/src/tray/windows-tray.ps1 +156 -4
- package/src/types/config.ts +48 -3
- package/src/types/provider.ts +91 -0
- package/src/types/request.ts +30 -2
- package/src/types/tools.ts +33 -0
- package/src/types.ts +4 -0
- package/src/update/index.ts +207 -63
- package/src/update/job.ts +9 -5
- package/src/update/ownership-transaction.ts +47 -0
- package/src/update/restart-ownership.ts +54 -0
- package/src/update/runtime-ownership.d.mts +40 -0
- package/src/update/runtime-ownership.mjs +122 -0
- package/src/usage/attempt-delivery.ts +198 -0
- package/src/usage/cache-diagnostic.ts +305 -0
- package/src/usage/failure-fingerprint.ts +118 -0
- package/src/usage/failure-projection-cache.ts +174 -0
- package/src/usage/failure-projection.ts +174 -0
- package/src/usage/ledger-retention.ts +165 -0
- package/src/usage/log.ts +126 -79
- package/src/usage/request-outcome.ts +150 -0
- package/src/usage/retention-contract.ts +28 -0
- package/src/usage/summary.ts +2 -2
- package/src/usage/telemetry-contract.ts +237 -0
- package/src/usage/timeline.ts +236 -0
- package/src/vision/eligibility.ts +88 -9
- package/src/vision/plan.ts +34 -10
- package/src/web-search/alpha-search.ts +21 -1
- package/src/web-search/executor.ts +41 -2
- package/src/web-search/loop.ts +6 -1
- package/gui/dist/assets/index-C5IebErG.js +0 -136
- package/gui/dist/assets/index-OESInAjC.css +0 -1
package/src/lib/socks5-fetch.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import net, { type Socket } from "node:net";
|
|
2
2
|
import tls, { type TLSSocket } from "node:tls";
|
|
3
|
+
import { classifyContentCoding, isNullBodyStatus } from "./http-response-semantics";
|
|
3
4
|
|
|
4
5
|
const DEFAULT_SOCKS5_PORT = 1080;
|
|
5
6
|
const SOCKS5_CONNECT_TIMEOUT_MS = 30_000;
|
|
@@ -510,7 +511,9 @@ function responseBody(
|
|
|
510
511
|
|
|
511
512
|
/** Statuses and methods that carry no response body, whatever the headers say about one. */
|
|
512
513
|
function bodylessResponse(method: string, status: number): boolean {
|
|
513
|
-
|
|
514
|
+
// 205 belongs here for the same reason 204 does, and leaving it out was not a judgement call:
|
|
515
|
+
// a peer that resets the caller's view sends no body, so the Response constructor rejects one.
|
|
516
|
+
return method === "HEAD" || isNullBodyStatus(status) || (status >= 100 && status < 200);
|
|
514
517
|
}
|
|
515
518
|
|
|
516
519
|
/**
|
|
@@ -528,11 +531,121 @@ function bodylessResponse(method: string, status: number): boolean {
|
|
|
528
531
|
* the coded path would fail responses that succeed uncompressed.
|
|
529
532
|
*/
|
|
530
533
|
function contentCodingFormat(headers: Headers): "gzip" | "deflate" | undefined {
|
|
531
|
-
const coding = (headers
|
|
532
|
-
if (coding === "
|
|
533
|
-
if (coding === "
|
|
534
|
-
|
|
535
|
-
|
|
534
|
+
const coding = classifyContentCoding(headers);
|
|
535
|
+
if (coding.kind === "identity") return undefined;
|
|
536
|
+
if (coding.kind === "decodable") return coding.format;
|
|
537
|
+
throw new Socks5FetchError("SOCKS5 upstream returned an unsupported content-encoding: " + coding.coding);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** Read response heads until the final one, consuming the interim informational answers. */
|
|
541
|
+
async function finalResponseHead(
|
|
542
|
+
reader: SocketReader,
|
|
543
|
+
signal: AbortSignal,
|
|
544
|
+
): Promise<{ status: number; statusText: string; headers: Headers }> {
|
|
545
|
+
let head = parseResponseHead(await reader.readUntil(HEADER_END, MAX_RESPONSE_HEADER_BYTES, signal));
|
|
546
|
+
while (head.status >= 100 && head.status < 200 && head.status !== 101) {
|
|
547
|
+
head = parseResponseHead(await reader.readUntil(HEADER_END, MAX_RESPONSE_HEADER_BYTES, signal));
|
|
548
|
+
}
|
|
549
|
+
return head;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* A promise that rejects with the caller's abort reason, and a way to stop listening.
|
|
554
|
+
*
|
|
555
|
+
* Destroying the socket is not enough to end a pending `bodyReader.read()`: that promise belongs
|
|
556
|
+
* to the caller's body stream, which knows nothing about this socket. Racing it against this one
|
|
557
|
+
* is what turns an abort into a settled fetch instead of a permanently pending one.
|
|
558
|
+
*/
|
|
559
|
+
function abortRejection(signal: AbortSignal): { promise: Promise<never>; dispose: () => void } {
|
|
560
|
+
let onAbort = (): void => { /* replaced below */ };
|
|
561
|
+
const promise = new Promise<never>((_resolve, reject) => {
|
|
562
|
+
// Reject with the caller's reason exactly, including `null` and `false`. A real
|
|
563
|
+
// `AbortSignal` always has one — `abort()` with no argument supplies an AbortError — so
|
|
564
|
+
// the fallback covers only a hand-built signal that exposes none.
|
|
565
|
+
onAbort = () => reject(signal.reason === undefined ? new Error("The operation was aborted") : signal.reason);
|
|
566
|
+
if (signal.aborted) onAbort();
|
|
567
|
+
else signal.addEventListener("abort", onAbort, { once: true });
|
|
568
|
+
});
|
|
569
|
+
// The race below may settle on another branch first; this rejection must not be reported as
|
|
570
|
+
// unhandled when nobody is left waiting for it.
|
|
571
|
+
promise.catch(() => { /* surfaced by whoever loses the race */ });
|
|
572
|
+
return { promise, dispose: () => signal.removeEventListener("abort", onAbort) };
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* The two races below have different possible outcomes, so they have different result types.
|
|
577
|
+
* One union covering both would let the compiler accept reading `value` off a drain result,
|
|
578
|
+
* which is the kind of mistake a type is supposed to catch rather than describe.
|
|
579
|
+
*/
|
|
580
|
+
type Answered = { kind: "answered" };
|
|
581
|
+
type BodyStep = { kind: "chunk"; value: Uint8Array } | { kind: "end" } | Answered;
|
|
582
|
+
type DrainStep = { kind: "drained" } | Answered;
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Send the request body, settling on caller abort or on an answer that arrives first.
|
|
586
|
+
*
|
|
587
|
+
* A peer is entitled to answer a request it has not finished receiving — a 413 or a 401 lands
|
|
588
|
+
* while a large body is still going out — and a body stream is entitled to stall. Waiting for
|
|
589
|
+
* the upload to finish before looking at the socket made those two facts combine into a fetch
|
|
590
|
+
* that never settles, with the answer already sitting in the receive buffer.
|
|
591
|
+
*
|
|
592
|
+
* `answered` covers the head settling either way: a final response means the peer has stopped
|
|
593
|
+
* reading, and a terminal socket failure means nothing more can be written. Which of the two it
|
|
594
|
+
* was belongs to whoever awaits the head, so this only needs to know that it happened.
|
|
595
|
+
*/
|
|
596
|
+
async function uploadRequestBody(
|
|
597
|
+
socket: Socket,
|
|
598
|
+
request: Request,
|
|
599
|
+
chunked: boolean,
|
|
600
|
+
head: Promise<unknown>,
|
|
601
|
+
): Promise<void> {
|
|
602
|
+
const body = request.body;
|
|
603
|
+
if (!body) return;
|
|
604
|
+
const signal = request.signal;
|
|
605
|
+
const bodyReader = body.getReader();
|
|
606
|
+
const abort = abortRejection(signal);
|
|
607
|
+
const answered: Promise<Answered> = head.then(
|
|
608
|
+
(): Answered => ({ kind: "answered" }),
|
|
609
|
+
(): Answered => ({ kind: "answered" }),
|
|
610
|
+
);
|
|
611
|
+
let completed = false;
|
|
612
|
+
try {
|
|
613
|
+
for (;;) {
|
|
614
|
+
const read: Promise<BodyStep> = bodyReader.read().then(
|
|
615
|
+
(result): BodyStep => (result.done ? { kind: "end" } : { kind: "chunk", value: result.value }),
|
|
616
|
+
);
|
|
617
|
+
// Releasing the lock below rejects a read still waiting on a source that stopped
|
|
618
|
+
// producing. That rejection is this function's own doing, not a failure to report.
|
|
619
|
+
read.catch(() => { /* settled by releasing the reader */ });
|
|
620
|
+
const step = await Promise.race([read, answered, abort.promise]);
|
|
621
|
+
if (step.kind === "answered") break;
|
|
622
|
+
if (step.kind === "end") {
|
|
623
|
+
completed = true;
|
|
624
|
+
break;
|
|
625
|
+
}
|
|
626
|
+
const payload = chunked
|
|
627
|
+
? Buffer.concat([Buffer.from(`${step.value.byteLength.toString(16)}\r\n`), Buffer.from(step.value), CRLF])
|
|
628
|
+
: step.value;
|
|
629
|
+
if (socket.write(payload)) continue;
|
|
630
|
+
const drain: Promise<DrainStep> = waitForDrain(socket, signal).then((): DrainStep => ({ kind: "drained" }));
|
|
631
|
+
if ((await Promise.race([drain, answered, abort.promise])).kind === "answered") break;
|
|
632
|
+
}
|
|
633
|
+
// A terminating chunk written after the peer has already answered goes into a conversation
|
|
634
|
+
// that is over, and a peer that has moved on may read it as the head of the next request.
|
|
635
|
+
if (completed && chunked && !socket.write("0\r\n\r\n")) {
|
|
636
|
+
const drain: Promise<DrainStep> = waitForDrain(socket, signal).then((): DrainStep => ({ kind: "drained" }));
|
|
637
|
+
await Promise.race([drain, answered, abort.promise]);
|
|
638
|
+
}
|
|
639
|
+
} finally {
|
|
640
|
+
abort.dispose();
|
|
641
|
+
if (!completed) {
|
|
642
|
+
// Cancelling runs the caller's own cancel algorithm, which is free to never settle, so it
|
|
643
|
+
// is started rather than awaited. Releasing the lock is what actually frees a read still
|
|
644
|
+
// waiting on a body that stopped producing.
|
|
645
|
+
void bodyReader.cancel().catch(() => { /* the caller's stream owns this outcome */ });
|
|
646
|
+
}
|
|
647
|
+
try { bodyReader.releaseLock(); } catch { /* already released */ }
|
|
648
|
+
}
|
|
536
649
|
}
|
|
537
650
|
|
|
538
651
|
export async function socks5Fetch(
|
|
@@ -570,26 +683,16 @@ export async function socks5Fetch(
|
|
|
570
683
|
const headers = requestHeaders(request, target);
|
|
571
684
|
const head = `${request.method} ${target.pathname}${target.search} HTTP/1.1\r\n${headers.text}\r\n`;
|
|
572
685
|
socket.write(head);
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
}
|
|
584
|
-
if (headers.chunked && !socket.write("0\r\n\r\n")) await waitForDrain(socket, request.signal);
|
|
585
|
-
} finally {
|
|
586
|
-
bodyReader.releaseLock();
|
|
587
|
-
}
|
|
588
|
-
}
|
|
589
|
-
let responseHead = parseResponseHead(await reader.readUntil(HEADER_END, MAX_RESPONSE_HEADER_BYTES, request.signal));
|
|
590
|
-
while (responseHead.status >= 100 && responseHead.status < 200 && responseHead.status !== 101) {
|
|
591
|
-
responseHead = parseResponseHead(await reader.readUntil(HEADER_END, MAX_RESPONSE_HEADER_BYTES, request.signal));
|
|
592
|
-
}
|
|
686
|
+
// Start reading the answer before the upload finishes, and keep one consumer of the socket
|
|
687
|
+
// reader for the whole exchange. The upload races this promise so an early final response or
|
|
688
|
+
// a terminal socket failure ends it, instead of leaving it waiting on a body chunk that is
|
|
689
|
+
// never coming while the answer sits in the receive buffer.
|
|
690
|
+
const pendingHead = finalResponseHead(reader, request.signal);
|
|
691
|
+
// The upload observes this promise through a handler of its own; this one keeps a rejection
|
|
692
|
+
// from being reported as unhandled in the window before the await below.
|
|
693
|
+
pendingHead.catch(() => { /* rethrown by the await below */ });
|
|
694
|
+
await uploadRequestBody(socket, request, headers.chunked, pendingHead);
|
|
695
|
+
const responseHead = await pendingHead;
|
|
593
696
|
// A bodyless response has no coded bytes to undo, so its `content-encoding` describes the
|
|
594
697
|
// representation it would have sent and must neither be decoded nor refused — a HEAD whose
|
|
595
698
|
// peer advertises brotli is a correct answer, not an unreadable body. For everything else,
|
|
@@ -626,6 +729,13 @@ export async function socks5Fetch(
|
|
|
626
729
|
request.signal.removeEventListener("abort", onAbort);
|
|
627
730
|
reader?.dispose();
|
|
628
731
|
socket.destroy();
|
|
732
|
+
// An aborted caller gets its own reason back. Several waiters inside this transport can win
|
|
733
|
+
// the race that settles an abort, and they do not agree on what to do with a reason they
|
|
734
|
+
// consider absent: the socket reader substitutes an Error for a null reason, others coerce
|
|
735
|
+
// anything that is not an Error. Without this, the same `abort(null)` surfaces as an Error
|
|
736
|
+
// or as null depending on scheduling. `undefined` is left alone so a hand-built signal
|
|
737
|
+
// still reaches the thrown cause below.
|
|
738
|
+
if (request.signal.aborted && request.signal.reason !== undefined) throw request.signal.reason;
|
|
629
739
|
throw error;
|
|
630
740
|
}
|
|
631
741
|
}
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
/** Process-lifetime single-writer ownership for the shared spend journal. */
|
|
2
|
+
import { Database } from "bun:sqlite";
|
|
3
|
+
import { chmodSync, closeSync, lstatSync, mkdirSync, openSync, realpathSync } from "node:fs";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import { getConfigDir } from "../config/paths";
|
|
6
|
+
import { recordOwnedConfigPath } from "./config-ownership";
|
|
7
|
+
import { assertNotRealHomeUnderTest } from "./test-home-guard";
|
|
8
|
+
import { hardenSecretDir, hardenSecretPath } from "./windows-secret-acl";
|
|
9
|
+
|
|
10
|
+
export const SPEND_LEDGER_OWNER_FILENAME = "spend-ledger-owner.sqlite";
|
|
11
|
+
export const SPEND_LEDGER_RESTART_PARENT_ENV = "OCX_SPEND_LEDGER_RESTART_PARENT_PID";
|
|
12
|
+
export const SPEND_LEDGER_RESTART_WAIT_MS = 5_000;
|
|
13
|
+
const OWNER_SIDECARS = ["-journal", "-wal", "-shm"] as const;
|
|
14
|
+
|
|
15
|
+
export type SpendLedgerOwnerErrorCode =
|
|
16
|
+
| "SPEND_LEDGER_OWNER_BUSY"
|
|
17
|
+
| "SPEND_LEDGER_OWNER_UNAVAILABLE"
|
|
18
|
+
| "SPEND_LEDGER_OWNER_HOME_CONFLICT"
|
|
19
|
+
| "SPEND_LEDGER_OWNER_NOT_HELD";
|
|
20
|
+
|
|
21
|
+
export class SpendLedgerOwnerError extends Error {
|
|
22
|
+
constructor(readonly code: SpendLedgerOwnerErrorCode, message: string, options?: ErrorOptions) {
|
|
23
|
+
super(message, options);
|
|
24
|
+
this.name = "SpendLedgerOwnerError";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface SpendLedgerOwnerLease {
|
|
29
|
+
release(): void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface ActiveOwner {
|
|
33
|
+
readonly home: string;
|
|
34
|
+
readonly database: Database;
|
|
35
|
+
references: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let activeOwner: ActiveOwner | null = null;
|
|
39
|
+
let boundLedgerHome: string | null = null;
|
|
40
|
+
/**
|
|
41
|
+
* Identity of the current ownership, not just of the home it covers.
|
|
42
|
+
*
|
|
43
|
+
* A home name alone cannot tell "still the lease I was built under" from "the same directory,
|
|
44
|
+
* owned again since". A ledger that only checked the home therefore came back to life across a
|
|
45
|
+
* release and a reacquire, writing totals it had accumulated before another writer owned the
|
|
46
|
+
* journal. Every acquisition that actually takes the lock mints a new value.
|
|
47
|
+
*/
|
|
48
|
+
let ownerGeneration = 0;
|
|
49
|
+
const releaseHooks: Array<() => void> = [];
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Run when the last lease on a state directory goes away.
|
|
53
|
+
*
|
|
54
|
+
* The ledger singleton is bound to the home it was built for, so it has to go when ownership
|
|
55
|
+
* does; otherwise a process that starts a second server against a different state directory
|
|
56
|
+
* either writes the old home's journal or is refused for a conflict it no longer has. The
|
|
57
|
+
* journal on disk is the durable record and construction replays it, so nothing is lost.
|
|
58
|
+
*/
|
|
59
|
+
export function onSpendLedgerOwnerReleased(hook: () => void): void {
|
|
60
|
+
releaseHooks.push(hook);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function errorCode(error: unknown): unknown {
|
|
64
|
+
return error !== null && typeof error === "object" && "code" in error ? error.code : undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function assertPrivateFile(path: string): void {
|
|
68
|
+
const stat = lstatSync(path);
|
|
69
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) {
|
|
70
|
+
throw new SpendLedgerOwnerError(
|
|
71
|
+
"SPEND_LEDGER_OWNER_UNAVAILABLE",
|
|
72
|
+
"Spend-ledger ownership could not be established safely.",
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
if (process.platform !== "win32" && stat.uid !== process.getuid!()) {
|
|
76
|
+
throw new SpendLedgerOwnerError(
|
|
77
|
+
"SPEND_LEDGER_OWNER_UNAVAILABLE",
|
|
78
|
+
"Spend-ledger ownership could not be established safely.",
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function prepareOwnerPath(configDir: string): { home: string; path: string } {
|
|
84
|
+
const requested = resolve(configDir);
|
|
85
|
+
// First statement before mutation: tests must never acquire against the real user home.
|
|
86
|
+
assertNotRealHomeUnderTest(requested);
|
|
87
|
+
mkdirSync(requested, { recursive: true, mode: 0o700 });
|
|
88
|
+
const home = realpathSync.native(requested);
|
|
89
|
+
if (process.platform !== "win32") chmodSync(home, 0o700);
|
|
90
|
+
hardenSecretDir(home, { required: true });
|
|
91
|
+
const path = join(home, SPEND_LEDGER_OWNER_FILENAME);
|
|
92
|
+
// Registered while the directory is still EMPTY. Config ownership refuses to claim a
|
|
93
|
+
// directory that already has contents, so creating the database first meant a fresh home
|
|
94
|
+
// never got an ownership marker at all: the manifest was never written, and the database and
|
|
95
|
+
// its sidecars were left behind by a later uninstall because nothing recorded them.
|
|
96
|
+
recordOwnedConfigPath(home, path);
|
|
97
|
+
for (const suffix of OWNER_SIDECARS) recordOwnedConfigPath(home, `${path}${suffix}`);
|
|
98
|
+
try { closeSync(openSync(path, "wx", 0o600)); }
|
|
99
|
+
catch (error) { if (errorCode(error) !== "EEXIST") throw error; }
|
|
100
|
+
assertPrivateFile(path);
|
|
101
|
+
if (process.platform !== "win32") chmodSync(path, 0o600);
|
|
102
|
+
hardenSecretPath(path, { required: true });
|
|
103
|
+
assertPrivateFile(path);
|
|
104
|
+
return { home: process.platform === "win32" ? home.toLowerCase() : home, path };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isBusy(error: unknown): boolean {
|
|
108
|
+
const code = errorCode(error);
|
|
109
|
+
const message = error instanceof Error ? error.message : "";
|
|
110
|
+
return code === "SQLITE_BUSY" || code === "SQLITE_LOCKED"
|
|
111
|
+
|| /database (?:is|table is) locked/i.test(message);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function stateDirectoryIdentity(configDir: string): string {
|
|
115
|
+
const requested = resolve(configDir);
|
|
116
|
+
assertNotRealHomeUnderTest(requested);
|
|
117
|
+
let canonical = requested;
|
|
118
|
+
try { canonical = realpathSync.native(requested); } catch { /* acquisition owns creation */ }
|
|
119
|
+
return process.platform === "win32" ? canonical.toLowerCase() : canonical;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function restartHandoffWaitMs(): number {
|
|
123
|
+
const markedParent = process.env[SPEND_LEDGER_RESTART_PARENT_ENV];
|
|
124
|
+
delete process.env[SPEND_LEDGER_RESTART_PARENT_ENV];
|
|
125
|
+
return markedParent === String(process.ppid) ? SPEND_LEDGER_RESTART_WAIT_MS : 0;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Mark only a parent-exit restart child for bounded lease acquisition. */
|
|
129
|
+
export function spendLedgerRestartEnvironment(
|
|
130
|
+
source: NodeJS.ProcessEnv,
|
|
131
|
+
parentPid?: number,
|
|
132
|
+
): NodeJS.ProcessEnv {
|
|
133
|
+
const env = { ...source };
|
|
134
|
+
if (parentPid === undefined) delete env[SPEND_LEDGER_RESTART_PARENT_ENV];
|
|
135
|
+
else env[SPEND_LEDGER_RESTART_PARENT_ENV] = String(parentPid);
|
|
136
|
+
return env;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Hold one SQLite write transaction until the final in-process reference releases it.
|
|
141
|
+
* SQLite and the OS release a crashed process; no PID, timestamp, TTL or lock-file unlink
|
|
142
|
+
* can evict a live owner or mistake a reused process identity for this lease.
|
|
143
|
+
*/
|
|
144
|
+
export function acquireSpendLedgerOwner(configDir = getConfigDir()): SpendLedgerOwnerLease {
|
|
145
|
+
const requestedHome = stateDirectoryIdentity(configDir);
|
|
146
|
+
const busyTimeout = restartHandoffWaitMs();
|
|
147
|
+
if (boundLedgerHome !== null && boundLedgerHome !== requestedHome) {
|
|
148
|
+
throw new SpendLedgerOwnerError(
|
|
149
|
+
"SPEND_LEDGER_OWNER_HOME_CONFLICT",
|
|
150
|
+
"This process already owns the spend ledger for a different state directory.",
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
if (activeOwner) {
|
|
154
|
+
if (activeOwner.home !== requestedHome) {
|
|
155
|
+
throw new SpendLedgerOwnerError(
|
|
156
|
+
"SPEND_LEDGER_OWNER_HOME_CONFLICT",
|
|
157
|
+
"This process already owns the spend ledger for a different state directory.",
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
activeOwner.references += 1;
|
|
161
|
+
return leaseFor(activeOwner);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let prepared: { home: string; path: string };
|
|
165
|
+
try {
|
|
166
|
+
prepared = prepareOwnerPath(configDir);
|
|
167
|
+
} catch (cause) {
|
|
168
|
+
if (cause instanceof SpendLedgerOwnerError) throw cause;
|
|
169
|
+
throw new SpendLedgerOwnerError(
|
|
170
|
+
"SPEND_LEDGER_OWNER_UNAVAILABLE",
|
|
171
|
+
"Spend-ledger ownership could not be established safely.",
|
|
172
|
+
{ cause },
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
let database: Database | undefined;
|
|
177
|
+
try {
|
|
178
|
+
database = new Database(prepared.path, { create: true });
|
|
179
|
+
database.exec(`PRAGMA locking_mode = NORMAL; PRAGMA busy_timeout = ${busyTimeout}; BEGIN IMMEDIATE`);
|
|
180
|
+
} catch (cause) {
|
|
181
|
+
try { database?.close(); } catch { /* preserve acquisition failure */ }
|
|
182
|
+
if (isBusy(cause)) {
|
|
183
|
+
throw new SpendLedgerOwnerError(
|
|
184
|
+
"SPEND_LEDGER_OWNER_BUSY",
|
|
185
|
+
"Another OpenCodex process already owns this spend ledger. Use a separate OPENCODEX_HOME for an independent instance.",
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
throw new SpendLedgerOwnerError(
|
|
189
|
+
"SPEND_LEDGER_OWNER_UNAVAILABLE",
|
|
190
|
+
"Spend-ledger ownership could not be established safely.",
|
|
191
|
+
{ cause },
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
activeOwner = { home: prepared.home, database, references: 1 };
|
|
196
|
+
ownerGeneration += 1;
|
|
197
|
+
return leaseFor(activeOwner);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function leaseFor(owner: ActiveOwner): SpendLedgerOwnerLease {
|
|
201
|
+
let released = false;
|
|
202
|
+
return Object.freeze({
|
|
203
|
+
release(): void {
|
|
204
|
+
if (released) return;
|
|
205
|
+
released = true;
|
|
206
|
+
if (activeOwner !== owner || owner.references < 1) return;
|
|
207
|
+
owner.references -= 1;
|
|
208
|
+
if (owner.references > 0) return;
|
|
209
|
+
activeOwner = null;
|
|
210
|
+
boundLedgerHome = null;
|
|
211
|
+
for (const hook of releaseHooks) {
|
|
212
|
+
try { hook(); } catch { /* a discard hook must not mask a release failure */ }
|
|
213
|
+
}
|
|
214
|
+
let failure: unknown;
|
|
215
|
+
try { owner.database.exec("ROLLBACK"); } catch (error) { failure = error; }
|
|
216
|
+
try { owner.database.close(); } catch (error) { failure ??= error; }
|
|
217
|
+
if (failure !== undefined) {
|
|
218
|
+
throw new SpendLedgerOwnerError(
|
|
219
|
+
"SPEND_LEDGER_OWNER_UNAVAILABLE",
|
|
220
|
+
"Spend-ledger ownership could not be released cleanly.",
|
|
221
|
+
{ cause: failure },
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** The ownership a ledger was built under, to be handed back on every later use. */
|
|
229
|
+
export function currentSpendLedgerOwnerGeneration(): number {
|
|
230
|
+
return ownerGeneration;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Permission to touch one file under the owned state directory, for as long as this exact
|
|
235
|
+
* ownership lasts.
|
|
236
|
+
*
|
|
237
|
+
* A callback supplied by the caller cannot be this proof: the caller can pass one that does
|
|
238
|
+
* nothing, which is how a "required guard" still allowed an unowned write. A marker carried ON
|
|
239
|
+
* the object is not proof either, because an object spread copies it: `{ ...minted, path:
|
|
240
|
+
* elsewhere, assert() {} }` would look minted while pointing somewhere else. So the token
|
|
241
|
+
* carries nothing. The path and the ownership it was minted under live in a table only this
|
|
242
|
+
* module can read, keyed by the token's identity, and a copy is simply not that key.
|
|
243
|
+
*/
|
|
244
|
+
export interface SpendLedgerStorage {
|
|
245
|
+
readonly __spendLedgerStorage: unique symbol;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const mintedStorage = new WeakMap<object, { readonly path: string; readonly generation: number; readonly home: string }>();
|
|
249
|
+
|
|
250
|
+
function trustedStorage(storage: SpendLedgerStorage): { readonly path: string; readonly generation: number; readonly home: string } {
|
|
251
|
+
const entry = typeof storage === "object" && storage !== null ? mintedStorage.get(storage) : undefined;
|
|
252
|
+
if (!entry) {
|
|
253
|
+
throw new SpendLedgerOwnerError(
|
|
254
|
+
"SPEND_LEDGER_OWNER_NOT_HELD",
|
|
255
|
+
"Spend-ledger ownership is required before the shared ledger can be used.",
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
return entry;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Mint storage permission for one file name directly under the owned state directory.
|
|
263
|
+
*
|
|
264
|
+
* The name is a file name, never a path: joining a caller-supplied path would let the caller
|
|
265
|
+
* choose the destination, which is the thing ownership is supposed to decide.
|
|
266
|
+
*/
|
|
267
|
+
export function mintSpendLedgerStorage(fileName: string): SpendLedgerStorage {
|
|
268
|
+
if (fileName.includes("/") || fileName.includes("\\") || fileName === "." || fileName === "..") {
|
|
269
|
+
throw new SpendLedgerOwnerError(
|
|
270
|
+
"SPEND_LEDGER_OWNER_UNAVAILABLE",
|
|
271
|
+
"Spend-ledger storage could not be established safely.",
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
assertSpendLedgerOwnerHeld();
|
|
275
|
+
const owner = activeOwner;
|
|
276
|
+
if (!owner) {
|
|
277
|
+
throw new SpendLedgerOwnerError(
|
|
278
|
+
"SPEND_LEDGER_OWNER_NOT_HELD",
|
|
279
|
+
"Spend-ledger ownership is required before the shared ledger can be used.",
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
const generation = ownerGeneration;
|
|
283
|
+
const home = owner.home;
|
|
284
|
+
// The token is deliberately empty: everything trustworthy about it is held here, under its
|
|
285
|
+
// identity, where a spread cannot reach.
|
|
286
|
+
const token = Object.freeze({}) as unknown as SpendLedgerStorage;
|
|
287
|
+
mintedStorage.set(token, { path: join(home, fileName), generation, home });
|
|
288
|
+
return token;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** The file this token stands for, as this module recorded it at mint time. */
|
|
292
|
+
export function spendLedgerStoragePath(storage: SpendLedgerStorage): string {
|
|
293
|
+
return trustedStorage(storage).path;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Refuse unless this exact token was minted here and its ownership is still current.
|
|
298
|
+
*
|
|
299
|
+
* Both halves matter: identity rules out a copy or a look-alike, and the generation rules out a
|
|
300
|
+
* token minted before the lock was dropped and taken again.
|
|
301
|
+
*/
|
|
302
|
+
export function assertStorageOwned(storage: SpendLedgerStorage): void {
|
|
303
|
+
const entry = trustedStorage(storage);
|
|
304
|
+
assertSpendLedgerOwnerGeneration(entry.generation, entry.home);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Refuse a handle built under an ownership that has since ended.
|
|
309
|
+
*
|
|
310
|
+
* The state directory may be the same one; what matters is that the lock was dropped and taken
|
|
311
|
+
* again in between, because another process could have written the journal while it was free.
|
|
312
|
+
*/
|
|
313
|
+
export function assertSpendLedgerOwnerGeneration(generation: number, configDir = getConfigDir()): void {
|
|
314
|
+
assertSpendLedgerOwnerHeld(configDir);
|
|
315
|
+
if (generation !== ownerGeneration) {
|
|
316
|
+
throw new SpendLedgerOwnerError(
|
|
317
|
+
"SPEND_LEDGER_OWNER_NOT_HELD",
|
|
318
|
+
"Spend-ledger ownership is required before the shared ledger can be used.",
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** The singleton journal may be touched only while its matching state directory is owned. */
|
|
324
|
+
export function assertSpendLedgerOwnerHeld(configDir = getConfigDir()): void {
|
|
325
|
+
let home: string;
|
|
326
|
+
try {
|
|
327
|
+
const canonical = realpathSync.native(resolve(configDir));
|
|
328
|
+
home = process.platform === "win32" ? canonical.toLowerCase() : canonical;
|
|
329
|
+
} catch (cause) {
|
|
330
|
+
throw new SpendLedgerOwnerError(
|
|
331
|
+
"SPEND_LEDGER_OWNER_NOT_HELD",
|
|
332
|
+
"Spend-ledger ownership is required before the shared ledger can be used.",
|
|
333
|
+
{ cause },
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
if (!activeOwner || activeOwner.home !== home || activeOwner.references < 1) {
|
|
337
|
+
throw new SpendLedgerOwnerError(
|
|
338
|
+
"SPEND_LEDGER_OWNER_NOT_HELD",
|
|
339
|
+
"Spend-ledger ownership is required before the shared ledger can be used.",
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Permanently bind the live process-wide singleton to its first constructed home. */
|
|
345
|
+
export function bindSpendLedgerOwnerHome(configDir = getConfigDir()): void {
|
|
346
|
+
assertSpendLedgerOwnerHeld(configDir);
|
|
347
|
+
const home = stateDirectoryIdentity(configDir);
|
|
348
|
+
if (boundLedgerHome !== null && boundLedgerHome !== home) {
|
|
349
|
+
throw new SpendLedgerOwnerError(
|
|
350
|
+
"SPEND_LEDGER_OWNER_HOME_CONFLICT",
|
|
351
|
+
"This process already owns the spend ledger for a different state directory.",
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
boundLedgerHome = home;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export function spendLedgerOwnerSnapshot(): { readonly ownership: "held" | "unheld" } {
|
|
358
|
+
return { ownership: activeOwner ? "held" : "unheld" };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Test seam paired with discarding the process-wide ledger singleton. */
|
|
362
|
+
export function resetSpendLedgerOwnerBindingForTest(): void {
|
|
363
|
+
boundLedgerHome = null;
|
|
364
|
+
}
|