@gajae-code/coding-agent 0.15.6 → 0.16.1
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/CHANGELOG.md +99 -0
- package/dist/types/async/job-manager.d.ts +32 -1
- package/dist/types/browser-backend.d.ts +12 -0
- package/dist/types/cli/args.d.ts +2 -0
- package/dist/types/cli/root-flags.d.ts +14 -0
- package/dist/types/cli/update-cli.d.ts +14 -0
- package/dist/types/cli.d.ts +7 -0
- package/dist/types/commands/launch.d.ts +10 -0
- package/dist/types/commands/sdk.d.ts +2 -0
- package/dist/types/config/autorouting-tier-map.d.ts +24 -0
- package/dist/types/config/billing-path.d.ts +25 -0
- package/dist/types/config/config-schema-version.d.ts +13 -1
- package/dist/types/config/file-lock.d.ts +5 -0
- package/dist/types/config/model-registry.d.ts +22 -0
- package/dist/types/config/settings-schema.d.ts +27 -8
- package/dist/types/config/settings.d.ts +2 -0
- package/dist/types/coordinator-mcp/projection-scan.d.ts +8 -0
- package/dist/types/crash/record-loader.d.ts +15 -0
- package/dist/types/extensibility/extensions/types.d.ts +8 -3
- package/dist/types/gjc-runtime/launch-worktree-reservation.d.ts +21 -0
- package/dist/types/gjc-runtime/launch-worktree.d.ts +25 -1
- package/dist/types/gjc-runtime/managed-owner-supervisor.d.ts +2 -0
- package/dist/types/gjc-runtime/session-state-lock.d.ts +22 -0
- package/dist/types/gjc-runtime/session-state-sidecar.d.ts +66 -1
- package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +1 -0
- package/dist/types/gjc-runtime/tmux-sessions.d.ts +27 -0
- package/dist/types/main.d.ts +1 -0
- package/dist/types/master-mode/context.d.ts +21 -0
- package/dist/types/master-mode/first-request.d.ts +34 -0
- package/dist/types/master-mode/snapshot.d.ts +32 -0
- package/dist/types/modes/acp/acp-agent.d.ts +21 -21
- package/dist/types/modes/components/frictionless-onboarding-selector.d.ts +10 -0
- package/dist/types/modes/components/model-selector.d.ts +4 -0
- package/dist/types/modes/components/oauth-selector.d.ts +1 -0
- package/dist/types/modes/components/session-selector.d.ts +1 -1
- package/dist/types/modes/components/sessions-dashboard.d.ts +2 -1
- package/dist/types/modes/components/status-line/model-name.d.ts +18 -0
- package/dist/types/modes/components/status-line/priority-row.d.ts +34 -0
- package/dist/types/modes/components/status-line/segments.d.ts +10 -0
- package/dist/types/modes/interactive-mode.d.ts +16 -2
- package/dist/types/modes/print-mode.d.ts +17 -0
- package/dist/types/modes/theme/theme.d.ts +2 -1
- package/dist/types/modes/types.d.ts +2 -0
- package/dist/types/modes/ui-language.d.ts +7 -1
- package/dist/types/sdk/broker/broker.d.ts +79 -2
- package/dist/types/sdk/broker/endpoint-authority.d.ts +24 -0
- package/dist/types/sdk/broker/ensure.d.ts +3 -0
- package/dist/types/sdk/broker/index.d.ts +3 -0
- package/dist/types/sdk/broker/lifecycle-ledger.d.ts +6 -0
- package/dist/types/sdk/broker/lifecycle.d.ts +40 -0
- package/dist/types/sdk/broker/master-capability.d.ts +10 -0
- package/dist/types/sdk/broker/session-index.d.ts +40 -11
- package/dist/types/sdk/broker/session-scope.d.ts +72 -0
- package/dist/types/sdk/broker/spawn-authority.d.ts +201 -0
- package/dist/types/sdk/broker/spawn-substrate.d.ts +30 -0
- package/dist/types/sdk/broker/startup-budget.d.ts +52 -0
- package/dist/types/sdk/broker/state-version.d.ts +21 -1
- package/dist/types/sdk/broker/worktree-occupancy.d.ts +28 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +3 -0
- package/dist/types/sdk/bus/discord-daemon.d.ts +1 -1
- package/dist/types/sdk/bus/index.d.ts +9 -1
- package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +1 -1
- package/dist/types/sdk/bus/prompt-reconciliation.d.ts +1 -1
- package/dist/types/sdk/bus/slack-conversation.d.ts +1 -1
- package/dist/types/sdk/bus/slack-daemon.d.ts +1 -1
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
- package/dist/types/sdk/cli/index.d.ts +1 -0
- package/dist/types/sdk/cli/master-cli.d.ts +63 -0
- package/dist/types/sdk/cli/rows.d.ts +12 -5
- package/dist/types/sdk/cli/session-cli.d.ts +20 -3
- package/dist/types/sdk/client/client.d.ts +3 -2
- package/dist/types/sdk/host/control/dispatch.d.ts +10 -0
- package/dist/types/sdk/host/control/index.d.ts +1 -1
- package/dist/types/sdk/host/control/operations.d.ts +2 -0
- package/dist/types/sdk/host/control/runtime-gate.d.ts +21 -7
- package/dist/types/sdk/host/events.d.ts +1 -0
- package/dist/types/sdk/host/host.d.ts +10 -0
- package/dist/types/sdk/host/query/cursor.d.ts +4 -2
- package/dist/types/sdk/host/query/handlers.d.ts +1 -0
- package/dist/types/sdk/host/query/revision-store.d.ts +1 -0
- package/dist/types/sdk/host/session-runtime.d.ts +70 -17
- package/dist/types/sdk/lifecycle/broker-client.d.ts +6 -0
- package/dist/types/sdk/lifecycle/service.d.ts +15 -3
- package/dist/types/sdk/lifecycle-session.d.ts +3 -1
- package/dist/types/sdk/prompt-deadline-manager.d.ts +2 -5
- package/dist/types/sdk/receipt-state.d.ts +2 -0
- package/dist/types/sdk/router/session-router.d.ts +6 -0
- package/dist/types/sdk/session.d.ts +16 -0
- package/dist/types/sdk/startup-capability.d.ts +2 -1
- package/dist/types/sdk/turn-result.d.ts +6 -0
- package/dist/types/session/agent-session.d.ts +52 -7
- package/dist/types/session/credential-store-errors.d.ts +1 -0
- package/dist/types/session/fold-coordinator.d.ts +29 -2
- package/dist/types/session/session-manager.d.ts +79 -2
- package/dist/types/session/yield-queue.d.ts +5 -4
- package/dist/types/setup/frictionless-onboarding.d.ts +1 -0
- package/dist/types/tools/ask.d.ts +1 -0
- package/dist/types/tools/bash.d.ts +16 -0
- package/dist/types/tools/browser/launch.d.ts +10 -0
- package/dist/types/tools/browser/registry.d.ts +8 -0
- package/dist/types/tools/gh.d.ts +8 -0
- package/dist/types/tools/index.d.ts +10 -10
- package/dist/types/tools/job.d.ts +3 -1
- package/dist/types/utils/external-editor.d.ts +7 -0
- package/examples/extensions/README.md +1 -1
- package/examples/extensions/pirate.ts +7 -4
- package/package.json +7 -7
- package/scripts/generate-sdk-operation-inventory.ts +13 -1
- package/scripts/verify-gjc-sdk-canonicalization.ts +35 -1
- package/src/async/job-manager.ts +107 -9
- package/src/browser-backend.ts +29 -0
- package/src/cli/accounts-cli.ts +9 -5
- package/src/cli/args.ts +17 -0
- package/src/cli/fast-help.ts +1 -0
- package/src/cli/plugin-cli.ts +29 -0
- package/src/cli/root-flags.ts +5 -0
- package/src/cli/update-cli.ts +140 -23
- package/src/commands/launch.ts +86 -18
- package/src/commands/sdk.ts +135 -28
- package/src/config/autorouting-tier-map.ts +12 -0
- package/src/config/billing-path.ts +42 -0
- package/src/config/config-schema-version.ts +18 -1
- package/src/config/file-lock.ts +158 -83
- package/src/config/model-preset-registry.ts +178 -23
- package/src/config/model-profiles.ts +5 -5
- package/src/config/model-registry.ts +291 -42
- package/src/config/model-resolver.ts +5 -0
- package/src/config/settings-schema.ts +27 -8
- package/src/config/settings.ts +51 -15
- package/src/coordinator-mcp/projection-scan.ts +27 -6
- package/src/coordinator-mcp/question-state.ts +9 -11
- package/src/coordinator-mcp/server.ts +115 -21
- package/src/crash/index-store.ts +2 -0
- package/src/crash/record-loader.ts +10 -0
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -1
- package/src/extensibility/extensions/runner.ts +27 -0
- package/src/extensibility/extensions/types.ts +8 -3
- package/src/extensibility/gjc-plugins/installer.ts +51 -11
- package/src/extensibility/runtime-skill-discovery.ts +110 -3
- package/src/extensibility/skills.ts +22 -12
- package/src/gjc-runtime/launch-worktree-reservation.ts +242 -0
- package/src/gjc-runtime/launch-worktree.ts +285 -4
- package/src/gjc-runtime/managed-owner-supervisor.ts +63 -51
- package/src/gjc-runtime/session-state-lock.ts +213 -14
- package/src/gjc-runtime/session-state-sidecar.ts +938 -39
- package/src/gjc-runtime/tmux-owner-isolation-cli.ts +4 -7
- package/src/gjc-runtime/tmux-owner-isolation.ts +18 -3
- package/src/gjc-runtime/tmux-sessions.ts +252 -13
- package/src/hooks/skill-state.ts +6 -0
- package/src/internal-urls/docs-index.generated.ts +15 -15
- package/src/internal-urls/issue-pr-protocol.ts +4 -2
- package/src/main.ts +61 -3
- package/src/master-mode/context.ts +50 -0
- package/src/master-mode/first-request.ts +65 -0
- package/src/master-mode/snapshot.ts +205 -0
- package/src/modes/acp/acp-agent.ts +610 -161
- package/src/modes/components/footer.ts +4 -2
- package/src/modes/components/frictionless-onboarding-selector.ts +48 -2
- package/src/modes/components/model-selector.ts +76 -8
- package/src/modes/components/oauth-selector.ts +6 -1
- package/src/modes/components/session-selector.ts +23 -15
- package/src/modes/components/sessions-dashboard.ts +8 -5
- package/src/modes/components/settings-selector.ts +1 -0
- package/src/modes/components/status-line/model-name.ts +53 -0
- package/src/modes/components/status-line/priority-row.ts +107 -0
- package/src/modes/components/status-line/segments.ts +23 -9
- package/src/modes/components/tool-status-header.ts +151 -15
- package/src/modes/controllers/command-controller.ts +1 -1
- package/src/modes/controllers/event-controller.ts +30 -4
- package/src/modes/controllers/extension-ui-controller.ts +13 -13
- package/src/modes/controllers/input-controller.ts +30 -2
- package/src/modes/controllers/selector-controller.ts +200 -98
- package/src/modes/interactive-mode.ts +36 -12
- package/src/modes/print-mode.ts +64 -0
- package/src/modes/prompt-action-autocomplete.ts +30 -4
- package/src/modes/runtime-init.ts +12 -9
- package/src/modes/theme/theme.ts +5 -0
- package/src/modes/types.ts +2 -0
- package/src/modes/ui-language.ts +49 -2
- package/src/modes/utils/ui-helpers.ts +22 -3
- package/src/prompts/agent-fragments/browser-aside.md +22 -0
- package/src/prompts/system/master-mode.md +28 -0
- package/src/prompts/tools/ask.md +1 -1
- package/src/prompts/tools/bash.md +1 -0
- package/src/prompts/tools/job.md +1 -1
- package/src/sdk/acp/adapter.ts +5 -0
- package/src/sdk/broker/broker.ts +1646 -27
- package/src/sdk/broker/endpoint-authority.ts +86 -0
- package/src/sdk/broker/ensure.ts +81 -3
- package/src/sdk/broker/index.ts +3 -0
- package/src/sdk/broker/lifecycle-ledger.ts +6 -0
- package/src/sdk/broker/lifecycle.ts +440 -75
- package/src/sdk/broker/master-capability.ts +232 -0
- package/src/sdk/broker/session-index.ts +121 -17
- package/src/sdk/broker/session-scope.ts +272 -0
- package/src/sdk/broker/spawn-authority.ts +1031 -0
- package/src/sdk/broker/spawn-substrate.ts +591 -0
- package/src/sdk/broker/startup-budget.ts +144 -2
- package/src/sdk/broker/state-version.ts +43 -14
- package/src/sdk/broker/transport.ts +1 -0
- package/src/sdk/broker/worktree-occupancy.ts +90 -0
- package/src/sdk/bus/chat-daemon-control.ts +5 -2
- package/src/sdk/bus/chat-daemon-runtime.ts +136 -18
- package/src/sdk/bus/discord-daemon.ts +50 -10
- package/src/sdk/bus/index.ts +84 -7
- package/src/sdk/bus/kind-aware-reconciliation.ts +296 -48
- package/src/sdk/bus/lifecycle-commands.ts +4 -0
- package/src/sdk/bus/notification-service.ts +22 -5
- package/src/sdk/bus/prompt-reconciliation.ts +7 -2
- package/src/sdk/bus/reconciliation-store.ts +55 -31
- package/src/sdk/bus/slack-conversation.ts +1 -1
- package/src/sdk/bus/slack-daemon.ts +72 -38
- package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
- package/src/sdk/cli/index.ts +1 -0
- package/src/sdk/cli/master-cli.ts +259 -0
- package/src/sdk/cli/rows.ts +41 -15
- package/src/sdk/cli/session-cli.ts +297 -26
- package/src/sdk/client/client.ts +14 -8
- package/src/sdk/host/control/dispatch.ts +101 -40
- package/src/sdk/host/control/index.ts +2 -0
- package/src/sdk/host/control/operations.ts +2 -0
- package/src/sdk/host/control/runtime-gate.ts +203 -11
- package/src/sdk/host/events.ts +1 -0
- package/src/sdk/host/host.ts +29 -2
- package/src/sdk/host/query/cursor.ts +52 -7
- package/src/sdk/host/query/handlers.ts +33 -12
- package/src/sdk/host/query/revision-store.ts +12 -0
- package/src/sdk/host/session-runtime.test.ts +673 -14
- package/src/sdk/host/session-runtime.ts +674 -103
- package/src/sdk/host/surface-policy.ts +3 -3
- package/src/sdk/lifecycle/broker-client.ts +28 -0
- package/src/sdk/lifecycle/service.ts +279 -20
- package/src/sdk/lifecycle-session.ts +17 -4
- package/src/sdk/prompt-deadline-manager.ts +2 -1
- package/src/sdk/protocol/operation-inventory.generated.json +105 -3
- package/src/sdk/protocol/operation-registry.ts +14 -4
- package/src/sdk/receipt-state.ts +10 -0
- package/src/sdk/router/session-router.ts +219 -118
- package/src/sdk/session.ts +416 -195
- package/src/sdk/startup-capability.ts +1 -0
- package/src/sdk/turn-result.ts +22 -0
- package/src/session/agent-session.ts +1628 -318
- package/src/session/blob-store.ts +15 -5
- package/src/session/credential-store-errors.ts +2 -0
- package/src/session/fold-coordinator.ts +40 -3
- package/src/session/internal/managed-session-storage.ts +153 -78
- package/src/session/session-manager.ts +850 -97
- package/src/session/yield-queue.ts +24 -10
- package/src/setup/frictionless-onboarding.ts +2 -0
- package/src/slash-commands/builtin-registry.ts +41 -0
- package/src/task/executor.ts +9 -4
- package/src/telemetry/events.ts +1138 -12
- package/src/tools/ask-contract.test.ts +64 -0
- package/src/tools/ask-contract.ts +36 -1
- package/src/tools/ask.ts +10 -8
- package/src/tools/bash-interactive.ts +11 -2
- package/src/tools/bash.ts +412 -75
- package/src/tools/browser/launch.ts +37 -0
- package/src/tools/browser/registry.ts +99 -9
- package/src/tools/cron.ts +6 -4
- package/src/tools/descriptors.test.ts +15 -0
- package/src/tools/descriptors.ts +5 -1
- package/src/tools/gh.ts +104 -48
- package/src/tools/github-cache.ts +2 -2
- package/src/tools/index.ts +10 -6
- package/src/tools/job.ts +4 -1
- package/src/tools/monitor.ts +12 -9
- package/src/tools/resolve.ts +30 -10
- package/src/tools/tool-catalog.generated.ts +2 -2
- package/src/utils/external-editor.ts +72 -1
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Billing path for a resolved binding's credential source — audit-reversible.
|
|
3
|
+
* Derived from (model.api, credential-provenance) where the binding actually
|
|
4
|
+
* resolves credentials, not from a provider-name allowlist.
|
|
5
|
+
*/
|
|
6
|
+
import type { Api } from "@gajae-code/ai/core";
|
|
7
|
+
import type { EffectiveProviderAuth } from "./provider-selection-policy";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Label shown in the selector row + active-model line. Only the category is
|
|
11
|
+
* disclosed (never the key material or project id).
|
|
12
|
+
*/
|
|
13
|
+
export type BillingPathKind = "metered-api" | "cloud-project" | "bundled";
|
|
14
|
+
|
|
15
|
+
export interface BillingPath {
|
|
16
|
+
kind: BillingPathKind;
|
|
17
|
+
/** Stable per-credential-source key for one-time notice dismissal. */
|
|
18
|
+
dismissKey: string;
|
|
19
|
+
label: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Derive from the concrete binding (api + credential provenance).
|
|
24
|
+
* - oauth/keyless/unknown => bundled (plan/proxied/kNoAuth-like)
|
|
25
|
+
* - key + cloud-project api (google-vertex / bedrock) => cloud-project
|
|
26
|
+
* - key + any other api (including custom OpenAI-compatible) => metered-api
|
|
27
|
+
*/
|
|
28
|
+
export function deriveBillingPath(
|
|
29
|
+
provider: string,
|
|
30
|
+
api: Api | string | undefined,
|
|
31
|
+
effectiveAuth: EffectiveProviderAuth,
|
|
32
|
+
): BillingPath | undefined {
|
|
33
|
+
if (effectiveAuth === "oauth" || effectiveAuth === "keyless" || effectiveAuth === "unknown") return undefined;
|
|
34
|
+
if (effectiveAuth !== "key") return undefined;
|
|
35
|
+
const apiKind = typeof api === "string" ? api : undefined;
|
|
36
|
+
const cloudApis = new Set(["google-vertex", "bedrock-converse-stream"]);
|
|
37
|
+
const isCloud = apiKind ? cloudApis.has(apiKind) : false;
|
|
38
|
+
const kind: BillingPathKind = isCloud ? "cloud-project" : "metered-api";
|
|
39
|
+
const label = kind === "cloud-project" ? "cloud-project billing" : "metered API";
|
|
40
|
+
const dismissKey = `${kind}:${provider.trim().toLowerCase()}`;
|
|
41
|
+
return { kind, dismissKey, label };
|
|
42
|
+
}
|
|
@@ -7,4 +7,21 @@
|
|
|
7
7
|
* this dependency-light module so the resolver can import it without pulling in
|
|
8
8
|
* the TUI/AI-heavy settings schema.
|
|
9
9
|
*/
|
|
10
|
-
export const CONFIG_SCHEMA_VERSION =
|
|
10
|
+
export const CONFIG_SCHEMA_VERSION = 2;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Normalize a durable `configSchemaVersion` marker into a comparable version.
|
|
14
|
+
*
|
|
15
|
+
* Every version decision (future-schema detection, migration-pending state, and
|
|
16
|
+
* the ordered migration registry) must read the SAME normalized value: a
|
|
17
|
+
* malformed marker read as a future schema by one guard and as unmigrated by
|
|
18
|
+
* the next would skip legacy migrations while still re-running v0 transforms.
|
|
19
|
+
* A finite malformed value floors to the nearest completed version; a
|
|
20
|
+
* non-finite or non-numeric one carries no version information and normalizes
|
|
21
|
+
* to 0 (fully unmigrated). `undefined` stays `undefined` (no marker written).
|
|
22
|
+
*/
|
|
23
|
+
export function normalizeConfigSchemaVersion(value: unknown): number | undefined {
|
|
24
|
+
if (value === undefined) return undefined;
|
|
25
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return 0;
|
|
26
|
+
return Math.max(0, Math.floor(value));
|
|
27
|
+
}
|
package/src/config/file-lock.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import * as crypto from "node:crypto";
|
|
2
|
-
import type { BigIntStats
|
|
2
|
+
import type { BigIntStats } from "node:fs";
|
|
3
3
|
import * as fs from "node:fs/promises";
|
|
4
4
|
import * as path from "node:path";
|
|
5
|
-
import type { NativeDirectoryTreeResult, NativeExactUnlinkResult } from "@gajae-code/natives";
|
|
6
|
-
import { renameNoReplacePathAsync } from "@gajae-code/natives";
|
|
5
|
+
import type { NativeDirectoryTreeResult, NativeExactUnlinkResult, NativeNoReplaceResult } from "@gajae-code/natives";
|
|
6
|
+
import { renameDirectoryNoReplacePathAsync, renameNoReplacePathAsync } from "@gajae-code/natives";
|
|
7
7
|
import { isEnoent } from "@gajae-code/utils/fs-error";
|
|
8
8
|
import { nativeProcessBindings } from "@gajae-code/utils/native-process";
|
|
9
9
|
|
|
@@ -49,6 +49,10 @@ type LockInfo = FileLockOwnerToken;
|
|
|
49
49
|
|
|
50
50
|
export const FileLockTestHooks: {
|
|
51
51
|
afterParentMkdir?: (lockPath: string) => void | Promise<void>;
|
|
52
|
+
nativePublicationBindings?: () => {
|
|
53
|
+
renameNoReplacePathAsync: typeof renameNoReplacePathAsync;
|
|
54
|
+
renameDirectoryNoReplacePathAsync: typeof renameDirectoryNoReplacePathAsync;
|
|
55
|
+
};
|
|
52
56
|
nativeQuarantineBindings?: () => NativeFileLockBindings;
|
|
53
57
|
} = {};
|
|
54
58
|
|
|
@@ -423,6 +427,118 @@ async function ensureLockParent(directory: string): Promise<void> {
|
|
|
423
427
|
}
|
|
424
428
|
}
|
|
425
429
|
|
|
430
|
+
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
|
431
|
+
return (
|
|
432
|
+
typeof value === "object" &&
|
|
433
|
+
value !== null &&
|
|
434
|
+
!Array.isArray(value) &&
|
|
435
|
+
Object.getPrototypeOf(value) === Object.prototype
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function isValidNativeNoReplaceResult(value: unknown): value is NativeNoReplaceResult {
|
|
440
|
+
if (!isPlainRecord(value)) return false;
|
|
441
|
+
const expectedKeys = [
|
|
442
|
+
"ok",
|
|
443
|
+
"code",
|
|
444
|
+
"mutationState",
|
|
445
|
+
"durabilityState",
|
|
446
|
+
"reason",
|
|
447
|
+
"primitive",
|
|
448
|
+
"phase",
|
|
449
|
+
"diagnostic",
|
|
450
|
+
];
|
|
451
|
+
if (Object.keys(value).some(key => !expectedKeys.includes(key))) return false;
|
|
452
|
+
if (
|
|
453
|
+
typeof value.ok !== "boolean" ||
|
|
454
|
+
(value.code !== undefined && (typeof value.code !== "string" || !/^[a-z0-9_]{1,64}$/.test(value.code))) ||
|
|
455
|
+
!(["not_committed", "committed", "unknown"] as const).includes(value.mutationState as never) ||
|
|
456
|
+
!(["not_attempted", "proven", "not_provable"] as const).includes(value.durabilityState as never) ||
|
|
457
|
+
!(
|
|
458
|
+
[
|
|
459
|
+
"none",
|
|
460
|
+
"destination_exists",
|
|
461
|
+
"atomic_unavailable",
|
|
462
|
+
"cross_device",
|
|
463
|
+
"permission_denied",
|
|
464
|
+
"io_failure",
|
|
465
|
+
"invalid_request",
|
|
466
|
+
"interrupted",
|
|
467
|
+
"identity_violation",
|
|
468
|
+
"durability_not_provable",
|
|
469
|
+
"unknown",
|
|
470
|
+
] as const
|
|
471
|
+
).includes(value.reason as never) ||
|
|
472
|
+
!(
|
|
473
|
+
[
|
|
474
|
+
"renameat2_noreplace",
|
|
475
|
+
"linkat_noreplace",
|
|
476
|
+
"mkdirat_renameat_noreplace",
|
|
477
|
+
"renameatx_np_excl",
|
|
478
|
+
"windows_rename_noreplace",
|
|
479
|
+
"unsupported",
|
|
480
|
+
"unknown",
|
|
481
|
+
] as const
|
|
482
|
+
).includes(value.primitive as never) ||
|
|
483
|
+
!(
|
|
484
|
+
[
|
|
485
|
+
"preflight",
|
|
486
|
+
"file_sync",
|
|
487
|
+
"rename",
|
|
488
|
+
"source_unlink",
|
|
489
|
+
"source_parent_sync",
|
|
490
|
+
"destination_parent_sync",
|
|
491
|
+
"terminal_identity",
|
|
492
|
+
"complete",
|
|
493
|
+
"unknown",
|
|
494
|
+
] as const
|
|
495
|
+
).includes(value.phase as never)
|
|
496
|
+
)
|
|
497
|
+
return false;
|
|
498
|
+
if (!isPlainRecord(value.diagnostic)) return false;
|
|
499
|
+
if (
|
|
500
|
+
Object.keys(value.diagnostic).some(
|
|
501
|
+
key => !["schemaVersion", "collectionState", "osCode", "syncFailures"].includes(key),
|
|
502
|
+
) ||
|
|
503
|
+
value.diagnostic.schemaVersion !== 1 ||
|
|
504
|
+
!(["complete", "partial", "unavailable"] as const).includes(value.diagnostic.collectionState as never) ||
|
|
505
|
+
(value.diagnostic.osCode !== undefined &&
|
|
506
|
+
(typeof value.diagnostic.osCode !== "number" || !Number.isInteger(value.diagnostic.osCode))) ||
|
|
507
|
+
(value.diagnostic.syncFailures !== undefined && !Array.isArray(value.diagnostic.syncFailures))
|
|
508
|
+
)
|
|
509
|
+
return false;
|
|
510
|
+
if (value.ok)
|
|
511
|
+
return (
|
|
512
|
+
value.mutationState === "committed" &&
|
|
513
|
+
value.reason === "none" &&
|
|
514
|
+
value.phase === "complete" &&
|
|
515
|
+
(value.durabilityState === "not_attempted" || value.durabilityState === "proven")
|
|
516
|
+
);
|
|
517
|
+
if (value.mutationState === "not_committed") return value.durabilityState === "not_attempted";
|
|
518
|
+
return value.mutationState === "unknown" && value.durabilityState === "not_provable";
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Only a complete native envelope proving that no namespace mutation happened
|
|
523
|
+
* may authorize the directory fallback. A legacy or malformed result is
|
|
524
|
+
* treated as an unknown publication outcome and never followed by another
|
|
525
|
+
* mutating primitive.
|
|
526
|
+
*/
|
|
527
|
+
function isPreMutationUnsupportedRenameResult(value: unknown): value is NativeNoReplaceResult {
|
|
528
|
+
if (!isValidNativeNoReplaceResult(value)) return false;
|
|
529
|
+
if (
|
|
530
|
+
value.ok !== false ||
|
|
531
|
+
(value.code !== "invalid_request" && value.code !== "atomic_unavailable") ||
|
|
532
|
+
value.mutationState !== "not_committed" ||
|
|
533
|
+
value.durabilityState !== "not_attempted" ||
|
|
534
|
+
value.reason !== value.code ||
|
|
535
|
+
(value.primitive !== "renameat2_noreplace" && value.primitive !== "renameatx_np_excl") ||
|
|
536
|
+
(value.phase !== "preflight" && value.phase !== "rename")
|
|
537
|
+
)
|
|
538
|
+
return false;
|
|
539
|
+
return true;
|
|
540
|
+
}
|
|
541
|
+
|
|
426
542
|
async function localLockKey(lockPath: string): Promise<string> {
|
|
427
543
|
try {
|
|
428
544
|
return normalizeLockKey(await fs.realpath(lockPath));
|
|
@@ -452,17 +568,9 @@ function ownerIncarnationChanged(owner: FileLockOwnerToken, startTimeCache?: Map
|
|
|
452
568
|
/** Outcome of a guarded lock-dir removal attempt (`removeFileLockDirForGc`). */
|
|
453
569
|
export type FileLockGcRemoval = "removed" | "owner_changed" | "missing" | "cleanup_failed";
|
|
454
570
|
|
|
455
|
-
interface LockDirStatToken {
|
|
456
|
-
dev: number;
|
|
457
|
-
ino: number;
|
|
458
|
-
mtimeMs: number;
|
|
459
|
-
ctimeMs: number;
|
|
460
|
-
}
|
|
461
|
-
|
|
462
571
|
type LockStaleSnapshot =
|
|
463
572
|
| { stale: false }
|
|
464
|
-
| { stale: true; owner: FileLockOwnerToken; identity: GenericFileLockDirIdentity }
|
|
465
|
-
| { stale: true; owner: null; stat: LockDirStatToken };
|
|
573
|
+
| { stale: true; owner: FileLockOwnerToken; identity: GenericFileLockDirIdentity };
|
|
466
574
|
|
|
467
575
|
/** Identity evidence carried by the generic stale verdict into a later removal. */
|
|
468
576
|
export interface GenericFileLockDirIdentity {
|
|
@@ -634,22 +742,9 @@ function ownerLiveness(pid: number): OwnerLiveness {
|
|
|
634
742
|
}
|
|
635
743
|
}
|
|
636
744
|
|
|
637
|
-
function statToken(stats: Stats): LockDirStatToken {
|
|
638
|
-
return {
|
|
639
|
-
dev: stats.dev,
|
|
640
|
-
ino: stats.ino,
|
|
641
|
-
mtimeMs: stats.mtimeMs,
|
|
642
|
-
ctimeMs: stats.ctimeMs,
|
|
643
|
-
};
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
function sameStatToken(a: LockDirStatToken, b: LockDirStatToken): boolean {
|
|
647
|
-
return a.dev === b.dev && a.ino === b.ino && a.mtimeMs === b.mtimeMs && a.ctimeMs === b.ctimeMs;
|
|
648
|
-
}
|
|
649
|
-
|
|
650
745
|
async function staleLockSnapshot(
|
|
651
746
|
lockPath: string,
|
|
652
|
-
|
|
747
|
+
_staleMs: number,
|
|
653
748
|
ownerHostId?: string,
|
|
654
749
|
previousOwnerHostIds: readonly string[] = [],
|
|
655
750
|
startTimeCache?: Map<string, string | null>,
|
|
@@ -673,26 +768,12 @@ async function staleLockSnapshot(
|
|
|
673
768
|
if (isTransientReleaseError(error)) return { stale: false };
|
|
674
769
|
throw error;
|
|
675
770
|
}
|
|
676
|
-
if (!info && ownerHostId !== undefined) return { stale: false };
|
|
677
771
|
if (!info) {
|
|
678
|
-
// A
|
|
679
|
-
//
|
|
680
|
-
//
|
|
681
|
-
// elapsed mtime
|
|
682
|
-
|
|
683
|
-
await fs.lstat(path.join(lockPath, "info"));
|
|
684
|
-
return { stale: false };
|
|
685
|
-
} catch (error) {
|
|
686
|
-
if (!isEnoent(error)) throw error;
|
|
687
|
-
}
|
|
688
|
-
try {
|
|
689
|
-
const stats = await fs.stat(lockPath);
|
|
690
|
-
if (Date.now() - stats.mtimeMs <= staleMs) return { stale: false };
|
|
691
|
-
return { stale: true, owner: null, stat: statToken(stats) };
|
|
692
|
-
} catch (err) {
|
|
693
|
-
if (isEnoent(err)) return { stale: false };
|
|
694
|
-
throw err;
|
|
695
|
-
}
|
|
772
|
+
// A directory without a valid owner record is either a contender between
|
|
773
|
+
// native mkdirat ownership and metadata publication, or malformed/foreign
|
|
774
|
+
// state. Neither case carries enough identity evidence for stale removal;
|
|
775
|
+
// elapsed mtime must never make this empty namespace reclaimable.
|
|
776
|
+
return { stale: false };
|
|
696
777
|
}
|
|
697
778
|
|
|
698
779
|
// A host-qualified lock may only be reclaimed after proving that its owner is
|
|
@@ -741,38 +822,7 @@ async function staleLockSnapshot(
|
|
|
741
822
|
|
|
742
823
|
async function removeStaleLockForAcquire(lockPath: string, snapshot: LockStaleSnapshot): Promise<boolean> {
|
|
743
824
|
if (!snapshot.stale) return false;
|
|
744
|
-
|
|
745
|
-
return (await removeFileLockDirForGc(lockPath, snapshot.owner, snapshot.identity)) === "removed";
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
let currentInfo: LockInfo | null;
|
|
749
|
-
try {
|
|
750
|
-
currentInfo = await readLockInfo(lockPath);
|
|
751
|
-
} catch (error) {
|
|
752
|
-
if (isTransientReleaseError(error)) return false;
|
|
753
|
-
throw error;
|
|
754
|
-
}
|
|
755
|
-
if (currentInfo) return false;
|
|
756
|
-
try {
|
|
757
|
-
const currentStats = await fs.lstat(lockPath);
|
|
758
|
-
if (currentStats.isSymbolicLink() || !currentStats.isDirectory()) return false;
|
|
759
|
-
if (!sameStatToken(statToken(currentStats), snapshot.stat)) return false;
|
|
760
|
-
const nativeCapturePath = await canonicalLockPathPreservingFinal(lockPath);
|
|
761
|
-
const captured = nativeFileLockBindings().snapshotDirectoryTree(nativeCapturePath);
|
|
762
|
-
if (captured.code === "sharing_violation") throwTransientNativeResult(captured.code);
|
|
763
|
-
if (!captured.ok || !captured.snapshot) return false;
|
|
764
|
-
if (
|
|
765
|
-
captured.snapshot.rootDev !== String(currentStats.dev) ||
|
|
766
|
-
captured.snapshot.rootIno !== String(currentStats.ino)
|
|
767
|
-
)
|
|
768
|
-
return false;
|
|
769
|
-
const removed = nativeFileLockBindings().exactRemoveDirectoryTree(nativeCapturePath, captured.snapshot);
|
|
770
|
-
return removed.ok || removed.code === "not_found";
|
|
771
|
-
} catch (err) {
|
|
772
|
-
if (isEnoent(err)) return false;
|
|
773
|
-
if (isTransientReleaseError(err)) return false;
|
|
774
|
-
throw err;
|
|
775
|
-
}
|
|
825
|
+
return (await removeFileLockDirForGc(lockPath, snapshot.owner, snapshot.identity)) === "removed";
|
|
776
826
|
}
|
|
777
827
|
|
|
778
828
|
/**
|
|
@@ -812,7 +862,7 @@ export async function genericFileLockDirStaleVerdict(
|
|
|
812
862
|
ownerHostId?: string,
|
|
813
863
|
): Promise<GenericFileLockDirStaleVerdict> {
|
|
814
864
|
const verdict = await staleLockSnapshot(lockDir, staleMs, ownerHostId);
|
|
815
|
-
if (!verdict.stale
|
|
865
|
+
if (!verdict.stale) return { stale: false };
|
|
816
866
|
return {
|
|
817
867
|
stale: true,
|
|
818
868
|
identity: verdict.identity,
|
|
@@ -841,11 +891,32 @@ async function tryAcquireLock(
|
|
|
841
891
|
// already-created staging entry and its parent before publication so aliases
|
|
842
892
|
// retain the same lock identity as the ordinary path.
|
|
843
893
|
const canonicalParent = await fs.realpath(path.dirname(lockPath));
|
|
844
|
-
const
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
894
|
+
const destinationPath = path.join(canonicalParent, path.basename(lockPath));
|
|
895
|
+
// Resolve only the stable parent: resolving the mutable staging final
|
|
896
|
+
// component would follow an attacker-replaced symlink before native no-follow
|
|
897
|
+
// validation gets a chance to reject it.
|
|
898
|
+
const canonicalPendingPath = path.join(canonicalParent, path.basename(pendingPath));
|
|
899
|
+
const publication = FileLockTestHooks.nativePublicationBindings?.() ?? {
|
|
900
|
+
renameNoReplacePathAsync,
|
|
901
|
+
renameDirectoryNoReplacePathAsync,
|
|
902
|
+
};
|
|
903
|
+
const published = await publication.renameNoReplacePathAsync(canonicalPendingPath, destinationPath);
|
|
904
|
+
let publishedSuccessfully = published.ok;
|
|
905
|
+
if (!published.ok && isPreMutationUnsupportedRenameResult(published)) {
|
|
906
|
+
const fallback = await publication.renameDirectoryNoReplacePathAsync(canonicalPendingPath, destinationPath);
|
|
907
|
+
if (fallback.ok) {
|
|
908
|
+
publishedSuccessfully = true;
|
|
909
|
+
} else if (fallback.reason === "destination_exists") {
|
|
910
|
+
return null;
|
|
911
|
+
} else {
|
|
912
|
+
const failure = new Error(
|
|
913
|
+
`Failed to publish file lock: ${fallback.code ?? fallback.reason ?? "unknown"}.`,
|
|
914
|
+
) as NodeJS.ErrnoException;
|
|
915
|
+
if (fallback.code) failure.code = fallback.code;
|
|
916
|
+
throw failure;
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
if (!publishedSuccessfully) {
|
|
849
920
|
if (published.reason === "destination_exists") return null;
|
|
850
921
|
const failure = new Error(
|
|
851
922
|
`Failed to publish file lock: ${published.code ?? published.reason ?? "unknown"}.`,
|
|
@@ -1103,6 +1174,10 @@ async function lockHolderDescription(lockPath: string): Promise<string> {
|
|
|
1103
1174
|
}
|
|
1104
1175
|
|
|
1105
1176
|
async function acquireLock(filePath: string, options: FileLockOptions = {}): Promise<() => Promise<void>> {
|
|
1177
|
+
const requestedFilePath: unknown = filePath;
|
|
1178
|
+
if (typeof requestedFilePath !== "string" || requestedFilePath.length === 0 || !path.isAbsolute(requestedFilePath))
|
|
1179
|
+
throw new TypeError("filePath must be a non-empty absolute path");
|
|
1180
|
+
if (requestedFilePath.includes("\0")) throw new TypeError("filePath must not contain NUL bytes");
|
|
1106
1181
|
if (options.ownerHostId !== undefined && !options.ownerHostId) throw new Error("ownerHostId must be non-empty");
|
|
1107
1182
|
if (options.previousOwnerHostIds?.some(hostId => !hostId))
|
|
1108
1183
|
throw new Error("previousOwnerHostIds must contain only non-empty identities");
|
|
@@ -47,6 +47,7 @@ const MODEL_PRESET_REGISTRY_MAX_STATE_BYTES = 32 * 1024 * 1024;
|
|
|
47
47
|
const MODEL_PRESET_REGISTRY_MAX_HISTORY = 4;
|
|
48
48
|
const MODEL_PRESET_REGISTRY_MAX_RETENTION_ANCESTRY = 64;
|
|
49
49
|
const MODEL_PRESET_REGISTRY_MAX_ERROR_BYTES = 1024;
|
|
50
|
+
const MODEL_PRESET_REGISTRY_IDENTITY_SKELETON_CACHE_MAX = 200_000;
|
|
50
51
|
const MODEL_PRESET_REGISTRY_FETCH_TIMEOUT_MS = 8_000;
|
|
51
52
|
const REVISION_PATTERN = /^[0-9]{8}$/;
|
|
52
53
|
const SHA256_PATTERN = /^[a-f0-9]{64}$/;
|
|
@@ -592,10 +593,23 @@ function assertSafeRegistryDocument(value: unknown, location = "$"): void {
|
|
|
592
593
|
}
|
|
593
594
|
}
|
|
594
595
|
|
|
596
|
+
/**
|
|
597
|
+
* Confusable folding is a pure string function evaluated once per identity per
|
|
598
|
+
* document, and startup validates the same catalog identities repeatedly. The
|
|
599
|
+
* bounded memo keeps the fold cost proportional to the distinct identity count
|
|
600
|
+
* instead of the number of validation passes.
|
|
601
|
+
*/
|
|
602
|
+
const identitySkeletonCache = new Map<string, string>();
|
|
603
|
+
|
|
595
604
|
function identitySkeleton(value: string): string {
|
|
596
|
-
|
|
605
|
+
const cached = identitySkeletonCache.get(value);
|
|
606
|
+
if (cached !== undefined) return cached;
|
|
607
|
+
const skeleton = [...value.normalize("NFKD").replace(/\p{M}/gu, "").toLowerCase()]
|
|
597
608
|
.map(character => IDENTITY_HOMOGLYPHS.get(character) ?? character)
|
|
598
609
|
.join("");
|
|
610
|
+
if (identitySkeletonCache.size >= MODEL_PRESET_REGISTRY_IDENTITY_SKELETON_CACHE_MAX) identitySkeletonCache.clear();
|
|
611
|
+
identitySkeletonCache.set(value, skeleton);
|
|
612
|
+
return skeleton;
|
|
599
613
|
}
|
|
600
614
|
|
|
601
615
|
function assertUniqueRegistryNames(values: readonly string[], label: string): void {
|
|
@@ -824,23 +838,21 @@ function assertProfilePresetReferences(
|
|
|
824
838
|
}
|
|
825
839
|
}
|
|
826
840
|
|
|
827
|
-
function
|
|
841
|
+
function validateGenerationUncached(
|
|
828
842
|
generation: AcceptedGeneration,
|
|
829
843
|
trustedKeys: ReadonlyMap<string, ModelPresetRegistryTrustedKey>,
|
|
830
|
-
allowRevoked
|
|
831
|
-
):
|
|
832
|
-
const manifest =
|
|
844
|
+
allowRevoked: boolean,
|
|
845
|
+
): void {
|
|
846
|
+
const { manifest, snapshot, profiles, presets } = generation;
|
|
833
847
|
assertSafeRegistryDocument(manifest);
|
|
834
848
|
verifyManifest(manifest, trustedKeys, allowRevoked);
|
|
835
849
|
assertManifestBindings(manifest);
|
|
836
|
-
const snapshotBytes = new TextEncoder().encode(canonicalModelPresetRegistryJson(
|
|
850
|
+
const snapshotBytes = new TextEncoder().encode(canonicalModelPresetRegistryJson(snapshot));
|
|
837
851
|
assertContentDescriptor(snapshotBytes, manifest.signed.snapshot, "Cached registry snapshot");
|
|
838
|
-
const snapshot = ModelPresetRegistrySnapshotSchema.parse(generation.snapshot);
|
|
839
852
|
assertSafeRegistryDocument(snapshot);
|
|
840
853
|
assertSnapshotBindings(manifest, snapshot);
|
|
841
|
-
const profileBytes = new TextEncoder().encode(canonicalModelPresetRegistryJson(
|
|
854
|
+
const profileBytes = new TextEncoder().encode(canonicalModelPresetRegistryJson(profiles));
|
|
842
855
|
assertContentDescriptor(profileBytes, manifest.signed.contents.profiles, "Cached registry profiles");
|
|
843
|
-
const profiles = ModelPresetRegistryProfilesSchema.parse(generation.profiles);
|
|
844
856
|
assertSafeRegistryDocument(profiles);
|
|
845
857
|
assertRegistryIdentityPolicy(profiles);
|
|
846
858
|
if (
|
|
@@ -848,9 +860,8 @@ function validateGeneration(
|
|
|
848
860
|
profiles.profiles.length !== manifest.signed.contents.profiles.count
|
|
849
861
|
)
|
|
850
862
|
throw new Error("Cached registry profile identity is invalid.");
|
|
851
|
-
const presetBytes = new TextEncoder().encode(canonicalModelPresetRegistryJson(
|
|
863
|
+
const presetBytes = new TextEncoder().encode(canonicalModelPresetRegistryJson(presets));
|
|
852
864
|
assertContentDescriptor(presetBytes, manifest.signed.contents.presets, "Cached registry presets");
|
|
853
|
-
const presets = ModelPresetRegistryPresetsSchema.parse(generation.presets);
|
|
854
865
|
assertSafeRegistryDocument(presets);
|
|
855
866
|
assertRegistryIdentityPolicy(presets);
|
|
856
867
|
if (
|
|
@@ -859,24 +870,73 @@ function validateGeneration(
|
|
|
859
870
|
)
|
|
860
871
|
throw new Error("Cached registry preset identity is invalid.");
|
|
861
872
|
assertProfilePresetReferences(profiles, presets);
|
|
862
|
-
|
|
863
|
-
|
|
873
|
+
if (sha256(canonicalModelPresetRegistryJson(manifest)) !== generation.manifestSha256)
|
|
874
|
+
throw new Error("Cached registry manifest digest is invalid.");
|
|
864
875
|
assertSafeRegistryDocument(generation.retainedProfiles);
|
|
865
876
|
assertSafeRegistryDocument(generation.retainedPresets);
|
|
866
877
|
assertSafeRegistryDocument(generation.retainedDynamicProviders);
|
|
867
|
-
const effectiveProfiles =
|
|
878
|
+
const effectiveProfiles: ModelPresetRegistryProfiles = {
|
|
868
879
|
...profiles,
|
|
869
880
|
dynamicProviders: [...generation.retainedDynamicProviders, ...profiles.dynamicProviders],
|
|
870
881
|
profiles: [...generation.retainedProfiles, ...profiles.profiles],
|
|
871
|
-
}
|
|
872
|
-
const effectivePresets =
|
|
882
|
+
};
|
|
883
|
+
const effectivePresets: ModelPresetRegistryPresets = {
|
|
873
884
|
...presets,
|
|
874
885
|
presets: [...generation.retainedPresets, ...presets.presets],
|
|
875
|
-
}
|
|
886
|
+
};
|
|
876
887
|
assertRegistryIdentityPolicy(effectiveProfiles);
|
|
877
888
|
assertRegistryIdentityPolicy(effectivePresets);
|
|
878
889
|
assertProfilePresetReferences(effectiveProfiles, effectivePresets);
|
|
879
|
-
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* Signature and binding verification is a pure function of the generation, the
|
|
894
|
+
* trusted key set, and the revocation allowance, and one accepted-registry read
|
|
895
|
+
* verifies the same generation objects from several recovery angles. The memo
|
|
896
|
+
* records both outcomes per (generation, keys, allowance) so a startup read
|
|
897
|
+
* verifies each cached generation exactly once instead of once per angle.
|
|
898
|
+
*/
|
|
899
|
+
type GenerationValidationOutcome = { ok: true } | { ok: false; error: unknown };
|
|
900
|
+
|
|
901
|
+
const generationValidationCache = new WeakMap<
|
|
902
|
+
AcceptedGeneration,
|
|
903
|
+
WeakMap<ReadonlyMap<string, ModelPresetRegistryTrustedKey>, Map<boolean, GenerationValidationOutcome>>
|
|
904
|
+
>();
|
|
905
|
+
|
|
906
|
+
/**
|
|
907
|
+
* Every caller supplies a generation whose documents were already schema-parsed
|
|
908
|
+
* (cache reads through `parseState`, refresh through `parseCanonicalDocument`),
|
|
909
|
+
* so this asserts the cryptographic and structural bindings and returns the
|
|
910
|
+
* input rather than re-deriving an identical parse.
|
|
911
|
+
*/
|
|
912
|
+
function validateGeneration(
|
|
913
|
+
generation: AcceptedGeneration,
|
|
914
|
+
trustedKeys: ReadonlyMap<string, ModelPresetRegistryTrustedKey>,
|
|
915
|
+
allowRevoked = false,
|
|
916
|
+
): AcceptedGeneration {
|
|
917
|
+
let byKeys = generationValidationCache.get(generation);
|
|
918
|
+
if (!byKeys) {
|
|
919
|
+
byKeys = new WeakMap();
|
|
920
|
+
generationValidationCache.set(generation, byKeys);
|
|
921
|
+
}
|
|
922
|
+
let byAllowance = byKeys.get(trustedKeys);
|
|
923
|
+
if (!byAllowance) {
|
|
924
|
+
byAllowance = new Map();
|
|
925
|
+
byKeys.set(trustedKeys, byAllowance);
|
|
926
|
+
}
|
|
927
|
+
const memoized = byAllowance.get(allowRevoked);
|
|
928
|
+
if (memoized) {
|
|
929
|
+
if (!memoized.ok) throw memoized.error;
|
|
930
|
+
return generation;
|
|
931
|
+
}
|
|
932
|
+
try {
|
|
933
|
+
validateGenerationUncached(generation, trustedKeys, allowRevoked);
|
|
934
|
+
} catch (error) {
|
|
935
|
+
byAllowance.set(allowRevoked, { ok: false, error });
|
|
936
|
+
throw error;
|
|
937
|
+
}
|
|
938
|
+
byAllowance.set(allowRevoked, { ok: true });
|
|
939
|
+
return generation;
|
|
880
940
|
}
|
|
881
941
|
|
|
882
942
|
function readJsonSync(file: string): unknown | undefined {
|
|
@@ -1167,11 +1227,7 @@ function recoverStateCopiesSync(
|
|
|
1167
1227
|
(!primaryEvidence.allGenerationsVerified || primaryEvidence.duplicateRevision)
|
|
1168
1228
|
)
|
|
1169
1229
|
if (!options.allowInvalidPrimaryFallback || !backupEvidence?.fullyVerified) {
|
|
1170
|
-
|
|
1171
|
-
validateStateGenerations(primaryEvidence.state, trustedKeys);
|
|
1172
|
-
} catch (error) {
|
|
1173
|
-
throw error;
|
|
1174
|
-
}
|
|
1230
|
+
validateStateGenerations(primaryEvidence.state, trustedKeys);
|
|
1175
1231
|
}
|
|
1176
1232
|
if (
|
|
1177
1233
|
backupEvidence &&
|
|
@@ -1492,9 +1548,92 @@ function acceptedRegistryFromState(
|
|
|
1492
1548
|
};
|
|
1493
1549
|
}
|
|
1494
1550
|
|
|
1551
|
+
/**
|
|
1552
|
+
* Accepted-registry reads are pure over the on-disk state, backup, and control
|
|
1553
|
+
* files, and a single launch resolves the registry from several subsystems.
|
|
1554
|
+
* The memo keys on the content digest of those inputs, so any byte change from
|
|
1555
|
+
* any writer (refresh, rollback, pin, tampering) is re-verified from scratch
|
|
1556
|
+
* while an unchanged launch pays the verification cost once. Digesting the
|
|
1557
|
+
* inputs is bounded by their size cap and stays far below the signature,
|
|
1558
|
+
* schema, and canonical-form work it replaces.
|
|
1559
|
+
*/
|
|
1560
|
+
const acceptedRegistryReadCache = new Map<string, { fingerprint: string; value: AcceptedModelPresetRegistry }>();
|
|
1561
|
+
|
|
1562
|
+
function cloneAcceptedModelPresetRegistry(value: AcceptedModelPresetRegistry): AcceptedModelPresetRegistry {
|
|
1563
|
+
return {
|
|
1564
|
+
...value,
|
|
1565
|
+
profiles: new Map(
|
|
1566
|
+
[...value.profiles].map(([id, profile]) => [id, structuredClone(profile)] as [string, ModelProfileDefinition]),
|
|
1567
|
+
),
|
|
1568
|
+
presets: structuredClone(value.presets),
|
|
1569
|
+
dynamicProviders: [...value.dynamicProviders],
|
|
1570
|
+
retainedProfiles: [...value.retainedProfiles],
|
|
1571
|
+
retainedPresets: [...value.retainedPresets],
|
|
1572
|
+
};
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
function registryFileFingerprint(file: string): string {
|
|
1576
|
+
try {
|
|
1577
|
+
return sha256(fsSync.readFileSync(file));
|
|
1578
|
+
} catch {
|
|
1579
|
+
return "absent";
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
function acceptedRegistryFingerprint(
|
|
1584
|
+
agentDir: string,
|
|
1585
|
+
dependencies: Omit<ModelPresetRegistryDependencies, "agentDir">,
|
|
1586
|
+
): string {
|
|
1587
|
+
const paths = registryPaths(agentDir);
|
|
1588
|
+
return [
|
|
1589
|
+
refreshDependencyId(effectiveTrustedKeys(dependencies, agentDir) as object),
|
|
1590
|
+
...[paths.state, paths.backup, paths.control].map(registryFileFingerprint),
|
|
1591
|
+
].join("|");
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
async function registryFileFingerprintAsync(file: string): Promise<string> {
|
|
1595
|
+
try {
|
|
1596
|
+
return sha256(new Uint8Array(await Bun.file(file).arrayBuffer()));
|
|
1597
|
+
} catch {
|
|
1598
|
+
return "absent";
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
async function acceptedRegistryFingerprintAsync(
|
|
1603
|
+
agentDir: string,
|
|
1604
|
+
dependencies: Omit<ModelPresetRegistryDependencies, "agentDir">,
|
|
1605
|
+
): Promise<string> {
|
|
1606
|
+
const paths = registryPaths(agentDir);
|
|
1607
|
+
const files = await Promise.all(
|
|
1608
|
+
[paths.state, paths.backup, paths.control].map(file => registryFileFingerprintAsync(file)),
|
|
1609
|
+
);
|
|
1610
|
+
return [refreshDependencyId(effectiveTrustedKeys(dependencies, agentDir) as object), ...files].join("|");
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
/** Only the dependency-free startup read is memoized; injected dependencies always recompute. */
|
|
1614
|
+
function acceptedRegistryReadIsCacheable(dependencies: Omit<ModelPresetRegistryDependencies, "agentDir">): boolean {
|
|
1615
|
+
return Object.keys(dependencies).length === 0;
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1495
1618
|
export function loadAcceptedModelPresetRegistry(
|
|
1496
1619
|
agentDir = getAgentDir(),
|
|
1497
1620
|
dependencies: Omit<ModelPresetRegistryDependencies, "agentDir"> = {},
|
|
1621
|
+
): AcceptedModelPresetRegistry {
|
|
1622
|
+
if (!acceptedRegistryReadIsCacheable(dependencies) || environmentDisabled())
|
|
1623
|
+
return loadAcceptedModelPresetRegistryUncached(agentDir, dependencies);
|
|
1624
|
+
const fingerprint = acceptedRegistryFingerprint(agentDir, dependencies);
|
|
1625
|
+
const cached = acceptedRegistryReadCache.get(agentDir);
|
|
1626
|
+
if (cached && cached.fingerprint === fingerprint) return cloneAcceptedModelPresetRegistry(cached.value);
|
|
1627
|
+
const value = loadAcceptedModelPresetRegistryUncached(agentDir, dependencies);
|
|
1628
|
+
const verifiedFingerprint = acceptedRegistryFingerprint(agentDir, dependencies);
|
|
1629
|
+
if (verifiedFingerprint !== fingerprint) return loadAcceptedModelPresetRegistryUncached(agentDir, dependencies);
|
|
1630
|
+
acceptedRegistryReadCache.set(agentDir, { fingerprint, value });
|
|
1631
|
+
return cloneAcceptedModelPresetRegistry(value);
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
function loadAcceptedModelPresetRegistryUncached(
|
|
1635
|
+
agentDir: string,
|
|
1636
|
+
dependencies: Omit<ModelPresetRegistryDependencies, "agentDir">,
|
|
1498
1637
|
): AcceptedModelPresetRegistry {
|
|
1499
1638
|
if (environmentDisabled())
|
|
1500
1639
|
return {
|
|
@@ -1555,6 +1694,22 @@ export function loadAcceptedModelPresetRegistry(
|
|
|
1555
1694
|
export async function loadAcceptedModelPresetRegistryAsync(
|
|
1556
1695
|
agentDir = getAgentDir(),
|
|
1557
1696
|
dependencies: Omit<ModelPresetRegistryDependencies, "agentDir"> = {},
|
|
1697
|
+
): Promise<AcceptedModelPresetRegistry> {
|
|
1698
|
+
if (!acceptedRegistryReadIsCacheable(dependencies) || environmentDisabled())
|
|
1699
|
+
return loadAcceptedModelPresetRegistryAsyncUncached(agentDir, dependencies);
|
|
1700
|
+
const fingerprint = await acceptedRegistryFingerprintAsync(agentDir, dependencies);
|
|
1701
|
+
const cached = acceptedRegistryReadCache.get(agentDir);
|
|
1702
|
+
if (cached && cached.fingerprint === fingerprint) return cloneAcceptedModelPresetRegistry(cached.value);
|
|
1703
|
+
const value = await loadAcceptedModelPresetRegistryAsyncUncached(agentDir, dependencies);
|
|
1704
|
+
const verifiedFingerprint = await acceptedRegistryFingerprintAsync(agentDir, dependencies);
|
|
1705
|
+
if (verifiedFingerprint !== fingerprint) return loadAcceptedModelPresetRegistryAsyncUncached(agentDir, dependencies);
|
|
1706
|
+
acceptedRegistryReadCache.set(agentDir, { fingerprint, value });
|
|
1707
|
+
return cloneAcceptedModelPresetRegistry(value);
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
async function loadAcceptedModelPresetRegistryAsyncUncached(
|
|
1711
|
+
agentDir: string,
|
|
1712
|
+
dependencies: Omit<ModelPresetRegistryDependencies, "agentDir">,
|
|
1558
1713
|
): Promise<AcceptedModelPresetRegistry> {
|
|
1559
1714
|
if (environmentDisabled())
|
|
1560
1715
|
return {
|