@try-works/dsh-model-as-tool 0.1.2 → 0.3.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/dsh-tool-mode-credentials.mjs +20 -0
- package/bin/dsh-tool-mode.mjs +202 -0
- package/catalog/desert-ant-models.json +693 -693
- package/client/client.model-tools.js +592 -0
- package/client/index.js +1679 -0
- package/lib/adapters/index.d.ts.map +1 -1
- package/lib/adapters/index.js +4 -4
- package/lib/bindings.d.ts.map +1 -1
- package/lib/bindings.js +26 -25
- package/lib/capability.d.ts.map +1 -1
- package/lib/capability.js +17 -0
- package/lib/capture-path.d.ts +97 -0
- package/lib/capture-path.d.ts.map +1 -0
- package/lib/capture-path.js +152 -0
- package/lib/diagnostics.d.ts +166 -0
- package/lib/diagnostics.d.ts.map +1 -0
- package/lib/diagnostics.js +135 -0
- package/lib/extension-dependencies.d.ts +197 -0
- package/lib/extension-dependencies.d.ts.map +1 -0
- package/lib/extension-dependencies.js +369 -0
- package/lib/extension-dispatch.d.ts +117 -0
- package/lib/extension-dispatch.d.ts.map +1 -0
- package/lib/extension-dispatch.js +124 -0
- package/lib/extension-registrar.d.ts +100 -0
- package/lib/extension-registrar.d.ts.map +1 -0
- package/lib/extension-registrar.js +134 -0
- package/lib/extensions-api.d.ts +217 -0
- package/lib/extensions-api.d.ts.map +1 -0
- package/lib/extensions-api.js +17 -0
- package/lib/extensions-remote.d.ts +54 -0
- package/lib/extensions-remote.d.ts.map +1 -0
- package/lib/extensions-remote.js +66 -0
- package/lib/extensions-service.d.ts +334 -0
- package/lib/extensions-service.d.ts.map +1 -0
- package/lib/extensions-service.js +1351 -0
- package/lib/harness-llm.d.ts +138 -0
- package/lib/harness-llm.d.ts.map +1 -0
- package/lib/harness-llm.js +218 -0
- package/lib/harness-serving.d.ts +95 -0
- package/lib/harness-serving.d.ts.map +1 -0
- package/lib/harness-serving.js +157 -0
- package/lib/headless.d.ts +97 -0
- package/lib/headless.d.ts.map +1 -0
- package/lib/headless.js +349 -0
- package/lib/http-acquisition.d.ts +27 -0
- package/lib/http-acquisition.d.ts.map +1 -0
- package/lib/http-acquisition.js +136 -0
- package/lib/index.d.ts +250 -7
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +2625 -70
- package/lib/invocation-accounting.d.ts +169 -0
- package/lib/invocation-accounting.d.ts.map +1 -0
- package/lib/invocation-accounting.js +148 -0
- package/lib/legacy-migration.d.ts +95 -0
- package/lib/legacy-migration.d.ts.map +1 -0
- package/lib/legacy-migration.js +429 -0
- package/lib/managed-workers.d.ts +151 -0
- package/lib/managed-workers.d.ts.map +1 -0
- package/lib/managed-workers.js +249 -0
- package/lib/mcp/client-grant.d.ts +74 -0
- package/lib/mcp/client-grant.d.ts.map +1 -0
- package/lib/mcp/client-grant.js +60 -0
- package/lib/mcp/credential-cli.d.ts +38 -0
- package/lib/mcp/credential-cli.d.ts.map +1 -0
- package/lib/mcp/credential-cli.js +148 -0
- package/lib/mcp/credential-store.d.ts +143 -0
- package/lib/mcp/credential-store.d.ts.map +1 -0
- package/lib/mcp/credential-store.js +454 -0
- package/lib/mcp/host-binding.d.ts +25 -0
- package/lib/mcp/host-binding.d.ts.map +1 -1
- package/lib/mcp/host-binding.js +107 -14
- package/lib/mcp/http.d.ts +68 -18
- package/lib/mcp/http.d.ts.map +1 -1
- package/lib/mcp/http.js +390 -62
- package/lib/mcp/oauth.d.ts +149 -0
- package/lib/mcp/oauth.d.ts.map +1 -0
- package/lib/mcp/oauth.js +396 -0
- package/lib/mcp/protocol.d.ts +46 -7
- package/lib/mcp/protocol.d.ts.map +1 -1
- package/lib/mcp/protocol.js +63 -16
- package/lib/mcp/rate-limit.d.ts +68 -0
- package/lib/mcp/rate-limit.d.ts.map +1 -0
- package/lib/mcp/rate-limit.js +114 -0
- package/lib/mcp/sdk-v2.d.ts +17 -0
- package/lib/mcp/sdk-v2.d.ts.map +1 -0
- package/lib/mcp/sdk-v2.js +181 -0
- package/lib/mcp/server.d.ts +11 -1
- package/lib/mcp/server.d.ts.map +1 -1
- package/lib/mcp/server.js +80 -13
- package/lib/mcp/stdio.d.ts.map +1 -1
- package/lib/mcp/stdio.js +9 -1
- package/lib/mcp/subscriptions.d.ts +43 -1
- package/lib/mcp/subscriptions.d.ts.map +1 -1
- package/lib/mcp/subscriptions.js +90 -9
- package/lib/model-qualification.d.ts +192 -0
- package/lib/model-qualification.d.ts.map +1 -0
- package/lib/model-qualification.js +178 -0
- package/lib/planning.d.ts +246 -0
- package/lib/planning.d.ts.map +1 -0
- package/lib/planning.js +457 -0
- package/lib/prepared-assets.d.ts +178 -0
- package/lib/prepared-assets.d.ts.map +1 -0
- package/lib/prepared-assets.js +286 -0
- package/lib/provider-bridge.d.ts +37 -0
- package/lib/provider-bridge.d.ts.map +1 -1
- package/lib/provider-bridge.js +189 -12
- package/lib/ptc-authority.d.ts +140 -0
- package/lib/ptc-authority.d.ts.map +1 -0
- package/lib/ptc-authority.js +216 -0
- package/lib/ptc-runtime.d.ts +156 -0
- package/lib/ptc-runtime.d.ts.map +1 -0
- package/lib/ptc-runtime.js +191 -0
- package/lib/publication.d.ts +2 -1
- package/lib/publication.d.ts.map +1 -1
- package/lib/publication.js +13 -5
- package/lib/receipt.d.ts.map +1 -1
- package/lib/receipt.js +8 -0
- package/lib/title-proposal.d.ts +194 -0
- package/lib/title-proposal.d.ts.map +1 -0
- package/lib/title-proposal.js +244 -0
- package/lib/tool-mode-run.d.ts +235 -0
- package/lib/tool-mode-run.d.ts.map +1 -0
- package/lib/tool-mode-run.js +283 -0
- package/lib/types.d.ts +3 -1
- package/lib/types.d.ts.map +1 -1
- package/lib/update-surface.d.ts +79 -0
- package/lib/update-surface.d.ts.map +1 -0
- package/lib/update-surface.js +224 -0
- package/node_modules/@try-works/tool-mode-contracts/lib/contracts/lang-detect.d.ts +16 -0
- package/node_modules/@try-works/tool-mode-contracts/lib/contracts/lang-detect.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-contracts/lib/contracts/lang-detect.js +85 -0
- package/node_modules/@try-works/tool-mode-contracts/lib/contracts/standard.d.ts +38 -0
- package/node_modules/@try-works/tool-mode-contracts/lib/contracts/standard.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-contracts/lib/contracts/standard.js +445 -0
- package/node_modules/@try-works/tool-mode-contracts/lib/contracts/stt-transcribe.d.ts +20 -0
- package/node_modules/@try-works/tool-mode-contracts/lib/contracts/stt-transcribe.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-contracts/lib/contracts/stt-transcribe.js +112 -0
- package/node_modules/@try-works/tool-mode-contracts/lib/contracts/text-embed.d.ts +14 -0
- package/node_modules/@try-works/tool-mode-contracts/lib/contracts/text-embed.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-contracts/lib/contracts/text-embed.js +63 -0
- package/node_modules/@try-works/tool-mode-contracts/lib/contracts/text-rerank.d.ts +14 -0
- package/node_modules/@try-works/tool-mode-contracts/lib/contracts/text-rerank.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-contracts/lib/contracts/text-rerank.js +76 -0
- package/node_modules/@try-works/tool-mode-contracts/lib/dsh.d.ts +82 -0
- package/node_modules/@try-works/tool-mode-contracts/lib/dsh.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-contracts/lib/dsh.js +274 -0
- package/node_modules/@try-works/tool-mode-contracts/lib/index.d.ts +26 -0
- package/node_modules/@try-works/tool-mode-contracts/lib/index.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-contracts/lib/index.js +48 -0
- package/node_modules/@try-works/tool-mode-contracts/lib/meta.d.ts +104 -0
- package/node_modules/@try-works/tool-mode-contracts/lib/meta.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-contracts/lib/meta.js +55 -0
- package/node_modules/@try-works/tool-mode-contracts/lib/registry.d.ts +107 -0
- package/node_modules/@try-works/tool-mode-contracts/lib/registry.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-contracts/lib/registry.js +246 -0
- package/node_modules/@try-works/tool-mode-contracts/package.json +34 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/bin/dsh-tool-mode.mjs +20 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/cli.d.ts +80 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/cli.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/cli.js +533 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/declarative-recipes.d.ts +83 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/declarative-recipes.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/declarative-recipes.js +125 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/discovery.d.ts +70 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/discovery.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/discovery.js +201 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/errors.d.ts +57 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/errors.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/errors.js +83 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/index.d.ts +31 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/index.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/index.js +31 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/archive.d.ts +86 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/archive.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/archive.js +185 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/dependencies.d.ts +126 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/dependencies.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/dependencies.js +297 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/lifecycle.d.ts +216 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/lifecycle.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/lifecycle.js +800 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/migration.d.ts +210 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/migration.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/migration.js +368 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/runtime-link.d.ts +63 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/runtime-link.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/runtime-link.js +149 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/source-directory.d.ts +35 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/source-directory.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/source-directory.js +179 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/staged.d.ts +179 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/staged.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/staged.js +235 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/store.d.ts +367 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/store.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/store.js +807 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/update-flow.d.ts +185 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/update-flow.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/update-flow.js +504 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/zip-source.d.ts +17 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/zip-source.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/zip-source.js +80 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/load.d.ts +52 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/load.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/load.js +102 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/manifest.d.ts +171 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/manifest.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/manifest.js +271 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/runtime.d.ts +242 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/runtime.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/runtime.js +58 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/state.d.ts +118 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/state.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/state.js +113 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/worker/worker-child.d.ts +2 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/worker/worker-child.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/worker/worker-child.js +351 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/worker.d.ts +150 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/worker.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/worker.js +670 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/package.json +53 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/schema/extension-manifest-1.1.0.schema.json +253 -0
- package/node_modules/@try-works/tool-mode-recipes/bin/tool-mode-recipe.mjs +15 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/broker.d.ts +427 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/broker.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/broker.js +592 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/cli.d.ts +60 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/cli.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/cli.js +936 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/dsh-adapter.d.ts +237 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/dsh-adapter.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/dsh-adapter.js +200 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/fixture-provider.d.ts +85 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/fixture-provider.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/fixture-provider.js +176 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/images.d.ts +105 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/images.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/images.js +129 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/index.d.ts +25 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/index.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/index.js +25 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/recipe-format.d.ts +325 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/recipe-format.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/recipe-format.js +1310 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/templates.d.ts +51 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/templates.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/templates.js +310 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/titles.d.ts +227 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/titles.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/titles.js +407 -0
- package/node_modules/@try-works/tool-mode-recipes/package.json +56 -0
- package/node_modules/@try-works/tool-mode-recipes/recipes/describe-image.json +38 -0
- package/node_modules/@try-works/tool-mode-recipes/recipes/text-title.json +45 -0
- package/node_modules/@try-works/tool-mode-recipes/schema/harness-model-recipe.schema.json +273 -0
- package/package.json +50 -17
- package/remote/spec.mjs +439 -0
- package/remote/typert.host.mjs +14 -0
- package/remote/typert.remote-client.mjs +16 -0
- package/lib/adapters/tongue.d.ts +0 -31
- package/lib/adapters/tongue.d.ts.map +0 -1
- package/lib/adapters/tongue.js +0 -93
- package/lib/adapters/voz.d.ts +0 -28
- package/lib/adapters/voz.d.ts.map +0 -1
- package/lib/adapters/voz.js +0 -102
- /package/{lib → node_modules/@try-works/tool-mode-extension-sdk/lib}/licensing.d.ts +0 -0
- /package/{lib → node_modules/@try-works/tool-mode-extension-sdk/lib}/licensing.d.ts.map +0 -0
- /package/{lib → node_modules/@try-works/tool-mode-extension-sdk/lib}/licensing.js +0 -0
|
@@ -0,0 +1,807 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The operator-owned extension store (EXT-33).
|
|
3
|
+
*
|
|
4
|
+
* Three properties this module exists to guarantee:
|
|
5
|
+
*
|
|
6
|
+
* 1. **Identity survives restart.** Every installation is recorded in
|
|
7
|
+
* `extensions.lock.json` with its resolved source and a CONTENT digest, so the
|
|
8
|
+
* host can tell on a later boot whether the bytes it is about to activate are
|
|
9
|
+
* still the bytes it installed.
|
|
10
|
+
* 2. **Corruption fails closed.** A state file that cannot be read yields NO
|
|
11
|
+
* active extensions and a diagnostic. The tempting alternative — falling back
|
|
12
|
+
* to "everything installed is active" — is precisely the failure EXT-33 names,
|
|
13
|
+
* because a corrupted file would silently enable every package on the machine.
|
|
14
|
+
* 3. **A write that did not land is never reported as landed.** Every write goes
|
|
15
|
+
* through a temporary file and a rename, and a failure throws rather than
|
|
16
|
+
* leaving the caller believing a preference was stored.
|
|
17
|
+
*
|
|
18
|
+
* @module @try-works/tool-mode-extension-sdk/install/store
|
|
19
|
+
*/
|
|
20
|
+
import { createHash } from 'node:crypto';
|
|
21
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, } from 'node:fs';
|
|
22
|
+
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
23
|
+
import { ManagementError } from "./../errors.js";
|
|
24
|
+
import { STATE_SCHEMA_VERSION, STATE_VERSION, applyLegacyChoices, parseLegacyModelState, } from "./migration.js";
|
|
25
|
+
/** Reentrant acquisition depth per resolved store root, process-local. */
|
|
26
|
+
const exclusiveDepth = new Map();
|
|
27
|
+
/** The lock document name, as EXT-33 specifies. */
|
|
28
|
+
export const LOCK_FILE = 'extensions.lock.json';
|
|
29
|
+
const emptyLock = () => ({ lockVersion: 1, extensions: [] });
|
|
30
|
+
/**
|
|
31
|
+
* Digest the files inside an installed package.
|
|
32
|
+
*
|
|
33
|
+
* Identity is `relative path` plus size plus a per-file digest, over a sorted
|
|
34
|
+
* walk, so a renamed file, an edited file, an added file and a removed file all
|
|
35
|
+
* change the result. The install receipt is excluded: it is store metadata, not
|
|
36
|
+
* package content, and including it would make the digest depend on itself.
|
|
37
|
+
* @param directory - the installed package directory.
|
|
38
|
+
* @returns the content digest, lowercase hex.
|
|
39
|
+
*/
|
|
40
|
+
export function digestInstalledContent(directory) {
|
|
41
|
+
const hash = createHash('sha256');
|
|
42
|
+
const files = [];
|
|
43
|
+
const walk = (current) => {
|
|
44
|
+
for (const entry of readdirSync(current, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
45
|
+
const absolute = join(current, entry.name);
|
|
46
|
+
if (entry.isDirectory())
|
|
47
|
+
walk(absolute);
|
|
48
|
+
else if (entry.isFile())
|
|
49
|
+
files.push(absolute);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
walk(directory);
|
|
53
|
+
for (const file of files.sort()) {
|
|
54
|
+
const name = relative(directory, file).split(sep).join('/');
|
|
55
|
+
if (name === '.tool-mode-install.json')
|
|
56
|
+
continue;
|
|
57
|
+
const bytes = readFileSync(file);
|
|
58
|
+
hash.update(name);
|
|
59
|
+
hash.update('\0');
|
|
60
|
+
hash.update(String(bytes.byteLength));
|
|
61
|
+
hash.update('\0');
|
|
62
|
+
hash.update(createHash('sha256').update(bytes).digest());
|
|
63
|
+
hash.update('\0');
|
|
64
|
+
}
|
|
65
|
+
return hash.digest('hex');
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Whether a process with this pid exists. `process.kill(pid, 0)` is the probe.
|
|
69
|
+
*
|
|
70
|
+
* Exported because surface ownership is recorded as a PID: telling a GONE owner (a
|
|
71
|
+
* crashed or restarted host, whose in-memory handles died with it) from a LIVE one
|
|
72
|
+
* (another host still serving) is the same question the store lock asks.
|
|
73
|
+
* @param pid - the process id to probe.
|
|
74
|
+
* @returns whether the process exists.
|
|
75
|
+
*/
|
|
76
|
+
export function isProcessAlive(pid) {
|
|
77
|
+
try {
|
|
78
|
+
process.kill(pid, 0);
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
// EPERM means the process EXISTS but belongs to another user, which still
|
|
83
|
+
// counts as alive: treating it as dead would let two writers collide.
|
|
84
|
+
return error.code === 'EPERM';
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/** Write a JSON document atomically, or throw. */
|
|
88
|
+
function writeJsonAtomic(path, value) {
|
|
89
|
+
mkdirSync(join(path, '..'), { recursive: true });
|
|
90
|
+
const temporary = `${path}.tmp`;
|
|
91
|
+
writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
92
|
+
try {
|
|
93
|
+
renameSync(temporary, path);
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
rmSync(temporary, { force: true });
|
|
97
|
+
throw new ManagementError('EXTENSION_FAULT', 'install', `the state file could not be written: ${error instanceof Error ? error.message : String(error)}`, 'Check that the extension store is writable by the running account, then retry.');
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/** Read a JSON document, or report why it could not be trusted. */
|
|
101
|
+
function readJsonBounded(path, maxBytes = 4 * 1024 * 1024) {
|
|
102
|
+
let size;
|
|
103
|
+
try {
|
|
104
|
+
size = statSync(path).size;
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// Absent is not corrupt: a first run has no state yet.
|
|
108
|
+
return {};
|
|
109
|
+
}
|
|
110
|
+
if (size > maxBytes)
|
|
111
|
+
return { diagnostic: `the state file is ${size} bytes, over the ${maxBytes}-byte limit` };
|
|
112
|
+
let text;
|
|
113
|
+
try {
|
|
114
|
+
text = readFileSync(path, 'utf8');
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
return { diagnostic: `the state file could not be read: ${error instanceof Error ? error.message : String(error)}` };
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
return { value: JSON.parse(text) };
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
return { diagnostic: `the state file is not valid JSON: ${error instanceof Error ? error.message : String(error)}` };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/** The operator-owned extension store. */
|
|
127
|
+
export class ExtensionStore {
|
|
128
|
+
/** Absolute path of the store root. */
|
|
129
|
+
rootDir;
|
|
130
|
+
constructor(rootDir) {
|
|
131
|
+
this.rootDir = resolve(rootDir);
|
|
132
|
+
}
|
|
133
|
+
/** Path of the lock document. */
|
|
134
|
+
get lockPath() {
|
|
135
|
+
return join(this.rootDir, LOCK_FILE);
|
|
136
|
+
}
|
|
137
|
+
/** Path of one profile's state file. */
|
|
138
|
+
profileStatePath(profile) {
|
|
139
|
+
// A profile name reaches a path, so it is validated rather than escaped: a
|
|
140
|
+
// name containing a separator would otherwise address another profile.
|
|
141
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(profile)) {
|
|
142
|
+
throw new ManagementError('INVALID_EXTENSION', 'install', `the profile name ${JSON.stringify(profile)} is not usable`, 'Use letters, digits, dot, underscore or dash.');
|
|
143
|
+
}
|
|
144
|
+
return join(this.rootDir, 'profiles', profile, 'state.json');
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Take exclusive ownership of the store for offline edits (EXT-31).
|
|
148
|
+
*
|
|
149
|
+
* EXT-31 requires offline edits to hold exclusive store ownership, because an
|
|
150
|
+
* offline writer and a running host editing the same store would each be working
|
|
151
|
+
* from a state the other cannot see. The lock records the owning process so a
|
|
152
|
+
* stale lock from a crashed run can be reported as stale rather than blocking
|
|
153
|
+
* forever.
|
|
154
|
+
* @returns a release function.
|
|
155
|
+
* @throws ManagementError with `EXTENSION_IN_USE` when another process holds it.
|
|
156
|
+
*/
|
|
157
|
+
acquireExclusive() {
|
|
158
|
+
const path = join(this.rootDir, 'store.lock');
|
|
159
|
+
mkdirSync(this.rootDir, { recursive: true });
|
|
160
|
+
const existing = readJsonBounded(path, 64 * 1024);
|
|
161
|
+
if (existing.value !== undefined) {
|
|
162
|
+
const holder = existing.value;
|
|
163
|
+
if (typeof holder.pid === 'number' && holder.pid !== process.pid && isProcessAlive(holder.pid)) {
|
|
164
|
+
throw new ManagementError('EXTENSION_IN_USE', 'install', `the extension store is owned by process ${holder.pid}${holder.since === undefined ? '' : ` since ${holder.since}`}`, 'Stop the running host, or wait for it to release the store, before editing offline.');
|
|
165
|
+
}
|
|
166
|
+
// A lock whose owner is gone is stale: refusing would block forever on a
|
|
167
|
+
// crashed run, which is worse than proceeding under a fresh lock.
|
|
168
|
+
}
|
|
169
|
+
writeJsonAtomic(path, { pid: process.pid, since: new Date().toISOString() });
|
|
170
|
+
let released = false;
|
|
171
|
+
return () => {
|
|
172
|
+
if (released)
|
|
173
|
+
return;
|
|
174
|
+
released = true;
|
|
175
|
+
const current = readJsonBounded(path, 64 * 1024).value;
|
|
176
|
+
// Only remove our OWN lock: a lock taken by someone else after ours was
|
|
177
|
+
// replaced must not be deleted by our release.
|
|
178
|
+
if (current?.pid === process.pid)
|
|
179
|
+
rmSync(path, { force: true });
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Run a state-mutating operation under the store lock (EXT-31).
|
|
184
|
+
*
|
|
185
|
+
* The HOST LIFECYCLE takes this around every state write, and the offline CLI
|
|
186
|
+
* takes it around its edits; each side refuses while the other's process is
|
|
187
|
+
* alive. Reentrancy is keyed by the RESOLVED STORE ROOT, not the instance:
|
|
188
|
+
* the CLI creates fresh ExtensionStore objects around one directory, so
|
|
189
|
+
* per-instance counting would let an inner acquisition overwrite the outer's
|
|
190
|
+
* lock record (the round-49 review's hole 3). The depth is recorded only
|
|
191
|
+
* AFTER the file lock is taken, so a refusal throws WITHOUT poisoning the
|
|
192
|
+
* count (hole 1), and the lock file is removed only when the depth returns to
|
|
193
|
+
* zero (hole 2: a concurrent section still holds it).
|
|
194
|
+
* @param work - the mutating work.
|
|
195
|
+
* @returns the work's result.
|
|
196
|
+
*/
|
|
197
|
+
async exclusive(work) {
|
|
198
|
+
// Keyed by the CANONICAL path, not the lexical one: drive-letter case and
|
|
199
|
+
// junctions make two spellings of one directory, and two keys for one store
|
|
200
|
+
// would re-open the cross-instance overwrite this whole map exists to close
|
|
201
|
+
// (the round-50 review's key-canonicalization finding). The directory may
|
|
202
|
+
// not exist yet on a first-ever call, so it is created first; afterwards
|
|
203
|
+
// realpathSync is safe. When canonicalization itself fails (a UNC/DFS edge),
|
|
204
|
+
// the lexical resolved path is the fallback key: a divergent spelling is
|
|
205
|
+
// better than a raw fs throw on a store the operator legitimately opened.
|
|
206
|
+
mkdirSync(this.rootDir, { recursive: true });
|
|
207
|
+
let canonical;
|
|
208
|
+
try {
|
|
209
|
+
canonical = realpathSync.native(resolve(this.rootDir));
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
canonical = resolve(this.rootDir);
|
|
213
|
+
}
|
|
214
|
+
const key = canonical;
|
|
215
|
+
const depth = exclusiveDepth.get(key) ?? 0;
|
|
216
|
+
if (depth === 0) {
|
|
217
|
+
// May throw EXTENSION_IN_USE; the depth is NOT yet recorded, so a refusal
|
|
218
|
+
// leaves this process able to acquire later.
|
|
219
|
+
this.acquireExclusive();
|
|
220
|
+
exclusiveDepth.set(key, 1);
|
|
221
|
+
// EXT-29: retry owned deletions recorded by tombstones, bounded and
|
|
222
|
+
// best-effort — the exclusive section is the one moment no other writer
|
|
223
|
+
// can observe a half-deleted directory, and a failure here must never
|
|
224
|
+
// block the operation the lock was taken for.
|
|
225
|
+
try {
|
|
226
|
+
this.retryCleanupPending();
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
// Best-effort: the tombstones stay for the next exclusive section.
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
else {
|
|
233
|
+
exclusiveDepth.set(key, depth + 1);
|
|
234
|
+
}
|
|
235
|
+
try {
|
|
236
|
+
return await work();
|
|
237
|
+
}
|
|
238
|
+
finally {
|
|
239
|
+
const remaining = (exclusiveDepth.get(key) ?? 1) - 1;
|
|
240
|
+
if (remaining === 0) {
|
|
241
|
+
exclusiveDepth.delete(key);
|
|
242
|
+
// Remove our OWN lock record only: a record written by another process
|
|
243
|
+
// (or by a later acquisition) must survive.
|
|
244
|
+
const path = join(this.rootDir, 'store.lock');
|
|
245
|
+
const current = readJsonBounded(path, 64 * 1024).value;
|
|
246
|
+
if (current?.pid === process.pid)
|
|
247
|
+
rmSync(path, { force: true });
|
|
248
|
+
}
|
|
249
|
+
else {
|
|
250
|
+
exclusiveDepth.set(key, remaining);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Read the lock document.
|
|
256
|
+
*
|
|
257
|
+
* A corrupt lock fails closed with a diagnostic and NO records, rather than
|
|
258
|
+
* reporting the store as empty-without-explanation or trusting a partial read.
|
|
259
|
+
* @returns the lock and a diagnostic when it could not be trusted.
|
|
260
|
+
*/
|
|
261
|
+
readLock() {
|
|
262
|
+
const { value, diagnostic } = readJsonBounded(this.lockPath);
|
|
263
|
+
if (diagnostic !== undefined)
|
|
264
|
+
return { value: emptyLock(), corrupt: true, diagnostic };
|
|
265
|
+
if (value === undefined)
|
|
266
|
+
return { value: emptyLock(), corrupt: false };
|
|
267
|
+
if (typeof value !== 'object' || value === null || !Array.isArray(value.extensions)) {
|
|
268
|
+
return { value: emptyLock(), corrupt: true, diagnostic: 'the lock document has no extensions array' };
|
|
269
|
+
}
|
|
270
|
+
return { value: value, corrupt: false };
|
|
271
|
+
}
|
|
272
|
+
/** List installed extensions. */
|
|
273
|
+
listInstalled() {
|
|
274
|
+
const lock = this.readLock();
|
|
275
|
+
return { value: lock.value.extensions, corrupt: lock.corrupt, ...lock.diagnostic === undefined ? {} : { diagnostic: lock.diagnostic } };
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Record an installation.
|
|
279
|
+
*
|
|
280
|
+
* Throws when the write fails, so a caller never treats a lost record as a
|
|
281
|
+
* completed install.
|
|
282
|
+
* @param record - the installation to record.
|
|
283
|
+
* @throws ManagementError when the same identity and version is already recorded.
|
|
284
|
+
*/
|
|
285
|
+
recordInstall(record) {
|
|
286
|
+
const lock = this.readLock();
|
|
287
|
+
if (lock.corrupt) {
|
|
288
|
+
// Appending to a document we could not read would discard whatever it held.
|
|
289
|
+
throw new ManagementError('EXTENSION_FAULT', 'install', `the store lock is unreadable, so a new record cannot be added safely: ${lock.diagnostic ?? 'unknown reason'}`, 'Inspect the lock document and repair or remove it deliberately; do not append to it blindly.');
|
|
290
|
+
}
|
|
291
|
+
const existing = lock.value.extensions.find(candidate => candidate.extensionId === record.extensionId && candidate.version === record.version);
|
|
292
|
+
if (existing !== undefined) {
|
|
293
|
+
throw new ManagementError('UPGRADE_FAILED', 'install', `${record.extensionId}@${record.version} is already recorded as installed`, 'Uninstall it first, or install a different version.');
|
|
294
|
+
}
|
|
295
|
+
writeJsonAtomic(this.lockPath, {
|
|
296
|
+
lockVersion: 1,
|
|
297
|
+
extensions: [...lock.value.extensions, record],
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Remove an installation record.
|
|
302
|
+
*
|
|
303
|
+
* Removing a record does NOT delete the installed bytes: deletion is a separate,
|
|
304
|
+
* operator-driven step with its own failure modes (EXT-29), and conflating the
|
|
305
|
+
* two would make a failed deletion look like a failed uninstall.
|
|
306
|
+
* @param extensionId - the extension to forget.
|
|
307
|
+
*/
|
|
308
|
+
removeRecord(extensionId) {
|
|
309
|
+
const lock = this.readLock();
|
|
310
|
+
if (lock.corrupt) {
|
|
311
|
+
throw new ManagementError('EXTENSION_FAULT', 'uninstall', `the store lock is unreadable, so a record cannot be removed safely: ${lock.diagnostic ?? 'unknown reason'}`, 'Inspect the lock document and repair it deliberately.');
|
|
312
|
+
}
|
|
313
|
+
writeJsonAtomic(this.lockPath, {
|
|
314
|
+
lockVersion: 1,
|
|
315
|
+
extensions: lock.value.extensions.filter(record => record.extensionId !== extensionId),
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Check whether an installation still matches what was recorded (EXT-33).
|
|
320
|
+
*
|
|
321
|
+
* Run before activation: a package edited on disk after installation is a
|
|
322
|
+
* different package, and activating it would execute bytes nobody approved.
|
|
323
|
+
* @param record - the recorded installation.
|
|
324
|
+
* @returns whether the content is unchanged, plus the current digest.
|
|
325
|
+
*/
|
|
326
|
+
verifyUnchanged(record) {
|
|
327
|
+
if (!existsSync(record.installPath)) {
|
|
328
|
+
return { unchanged: false, diagnostic: 'the installation directory is missing' };
|
|
329
|
+
}
|
|
330
|
+
const contentDigest = digestInstalledContent(record.installPath);
|
|
331
|
+
if (contentDigest !== record.contentDigest) {
|
|
332
|
+
return {
|
|
333
|
+
unchanged: false,
|
|
334
|
+
contentDigest,
|
|
335
|
+
diagnostic: 'the installed content differs from the recorded digest; the package changed on disk after installation',
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
return { unchanged: true, contentDigest };
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Read one profile's activation state.
|
|
342
|
+
*
|
|
343
|
+
* A corrupt file yields NO states and a diagnostic. Returning the installed set
|
|
344
|
+
* instead would be the failure EXT-33 names: corruption would enable everything.
|
|
345
|
+
*
|
|
346
|
+
* Versioning (BASE-09): a document with a HIGHER `stateVersion` was written by
|
|
347
|
+
* a newer SDK and is refused, not read half-understood. A document with NO
|
|
348
|
+
* `stateVersion` is either a legacy model-id era document (enabled/disabled
|
|
349
|
+
* arrays, migrated via {@link ExtensionStore.migrateLegacyProfileState}) or
|
|
350
|
+
* something this store never wrote — both fail closed with a diagnostic.
|
|
351
|
+
* @param profile - the profile name.
|
|
352
|
+
* @returns the states and any diagnostic.
|
|
353
|
+
*/
|
|
354
|
+
readProfileState(profile) {
|
|
355
|
+
const { value, diagnostic } = readJsonBounded(this.profileStatePath(profile));
|
|
356
|
+
if (diagnostic !== undefined)
|
|
357
|
+
return { value: [], corrupt: true, diagnostic };
|
|
358
|
+
if (value === undefined)
|
|
359
|
+
return { value: [], corrupt: false };
|
|
360
|
+
if (typeof value !== 'object' || value === null) {
|
|
361
|
+
return { value: [], corrupt: true, diagnostic: 'the profile state is not a document' };
|
|
362
|
+
}
|
|
363
|
+
const file = value;
|
|
364
|
+
const stateVersion = file.stateVersion;
|
|
365
|
+
if (stateVersion === undefined) {
|
|
366
|
+
if (Array.isArray(file.enabled) || Array.isArray(file.disabled)) {
|
|
367
|
+
return {
|
|
368
|
+
value: [], corrupt: true,
|
|
369
|
+
diagnostic: 'the profile state is a legacy model-id era document (enabled/disabled arrays); migrate it with migrateLegacyProfileState',
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
if (Array.isArray(file.extensions)) {
|
|
373
|
+
return {
|
|
374
|
+
value: [], corrupt: true,
|
|
375
|
+
diagnostic: 'the profile state has no stateVersion key; a document this store never wrote cannot be trusted',
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
return { value: [], corrupt: true, diagnostic: 'the profile state has no stateVersion key' };
|
|
379
|
+
}
|
|
380
|
+
// The gate is EQUALITY, not a lower bound (BASE-09): this SDK reads exactly
|
|
381
|
+
// stateVersion 1. A document stamped 0, negative, fractional, or a
|
|
382
|
+
// non-number is not this era's document and must not read as current — a
|
|
383
|
+
// half-understood read is how a wrong era's choices get applied.
|
|
384
|
+
if (stateVersion !== STATE_VERSION) {
|
|
385
|
+
return {
|
|
386
|
+
value: [], corrupt: true,
|
|
387
|
+
diagnostic: `the profile state was written by a different SDK (stateVersion ${JSON.stringify(stateVersion)}); this SDK reads stateVersion ${STATE_VERSION}`,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
if (file.schemaVersion !== undefined && typeof file.schemaVersion !== 'string') {
|
|
391
|
+
return { value: [], corrupt: true, diagnostic: 'the profile state schemaVersion is not a string' };
|
|
392
|
+
}
|
|
393
|
+
if (!Array.isArray(file.extensions)) {
|
|
394
|
+
return { value: [], corrupt: true, diagnostic: 'the profile state has no extensions array' };
|
|
395
|
+
}
|
|
396
|
+
// Every entry must be structurally usable; one malformed entry invalidates the
|
|
397
|
+
// whole file rather than silently dropping just that package.
|
|
398
|
+
for (const state of file.extensions) {
|
|
399
|
+
if (typeof state !== 'object' || state === null
|
|
400
|
+
|| typeof state.extensionId !== 'string'
|
|
401
|
+
|| typeof state.desiredActivation !== 'string'
|
|
402
|
+
|| typeof state.observedActivation !== 'string') {
|
|
403
|
+
return { value: [], corrupt: true, diagnostic: 'a profile state entry is malformed' };
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return { value: file.extensions, corrupt: false };
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Persist one profile's activation state.
|
|
410
|
+
*
|
|
411
|
+
* The document carries both BASE-09 version keys: `stateVersion` (the
|
|
412
|
+
* migration era) and `schemaVersion` (the schema dialect), so a later reader
|
|
413
|
+
* can tell an up-to-date document from a legacy one without guessing.
|
|
414
|
+
*
|
|
415
|
+
* MIGRATED-ASSET DURABILITY (BASE-09): when the caller does not pass
|
|
416
|
+
* `migratedAssets`, the mappings already recorded in the file are PRESERVED
|
|
417
|
+
* verbatim instead of dropped. Every lifecycle write and CLI edit calls this
|
|
418
|
+
* method without the parameter, so requiring every caller to carry the
|
|
419
|
+
* mappings through would mean the first forgetful writer silently loses the
|
|
420
|
+
* migration's prepared-asset record; preserving by default makes the store
|
|
421
|
+
* itself the keeper of them. Pass an empty array to clear them deliberately.
|
|
422
|
+
* @param profile - the profile name.
|
|
423
|
+
* @param extensions - the complete state set for that profile.
|
|
424
|
+
* @param migratedAssets - durable legacy prepared-asset mappings. Omitted:
|
|
425
|
+
* preserve the existing ones. `[]`: clear them.
|
|
426
|
+
* @throws ManagementError when the write fails.
|
|
427
|
+
*/
|
|
428
|
+
writeProfileState(profile, extensions, migratedAssets, activeVersions, previousVersions) {
|
|
429
|
+
let effective = migratedAssets;
|
|
430
|
+
if (effective === undefined) {
|
|
431
|
+
// A current-version file's mappings survive; an absent or unreadable file
|
|
432
|
+
// has none to preserve, and this write replaces the file anyway.
|
|
433
|
+
const existing = this.readMigratedAssets(profile);
|
|
434
|
+
if (!existing.corrupt)
|
|
435
|
+
effective = existing.value;
|
|
436
|
+
}
|
|
437
|
+
writeJsonAtomic(this.profileStatePath(profile), {
|
|
438
|
+
stateVersion: STATE_VERSION,
|
|
439
|
+
schemaVersion: STATE_SCHEMA_VERSION,
|
|
440
|
+
profile,
|
|
441
|
+
extensions,
|
|
442
|
+
...effective === undefined || effective.length === 0 ? {} : { migratedAssets: effective },
|
|
443
|
+
...activeVersions === undefined ? {} : { activeVersions },
|
|
444
|
+
...previousVersions === undefined ? {} : { previousVersions },
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Read the EXT-30 active-version pointers from one profile's state document.
|
|
449
|
+
*
|
|
450
|
+
* The maps are OPTIONAL document keys written only by the update flow: an
|
|
451
|
+
* older document simply has no pointers, and a corrupt map fails closed
|
|
452
|
+
* rather than being half-read. The read is separate from
|
|
453
|
+
* {@link ExtensionStore.readProfileState} so the existing per-extension state
|
|
454
|
+
* contract is untouched.
|
|
455
|
+
* @param profile - the profile name.
|
|
456
|
+
* @returns the pointer maps and any diagnostic.
|
|
457
|
+
*/
|
|
458
|
+
readProfilePointers(profile) {
|
|
459
|
+
const { value, diagnostic } = readJsonBounded(this.profileStatePath(profile));
|
|
460
|
+
if (diagnostic !== undefined)
|
|
461
|
+
return { value: { activeVersions: {}, previousVersions: {} }, corrupt: true, diagnostic };
|
|
462
|
+
if (value === undefined)
|
|
463
|
+
return { value: { activeVersions: {}, previousVersions: {} }, corrupt: false };
|
|
464
|
+
if (typeof value !== 'object' || value === null) {
|
|
465
|
+
return { value: { activeVersions: {}, previousVersions: {} }, corrupt: true, diagnostic: 'the profile state is not a document' };
|
|
466
|
+
}
|
|
467
|
+
const file = value;
|
|
468
|
+
const active = file.activeVersions;
|
|
469
|
+
const previous = file.previousVersions;
|
|
470
|
+
for (const [label, candidate] of [['activeVersions', active], ['previousVersions', previous]]) {
|
|
471
|
+
if (candidate === undefined)
|
|
472
|
+
continue;
|
|
473
|
+
if (typeof candidate !== 'object' || candidate === null || Array.isArray(candidate)) {
|
|
474
|
+
return { value: { activeVersions: {}, previousVersions: {} }, corrupt: true, diagnostic: `the profile state ${label} is not an object` };
|
|
475
|
+
}
|
|
476
|
+
for (const [id, version] of Object.entries(candidate)) {
|
|
477
|
+
if (typeof version !== 'string') {
|
|
478
|
+
return { value: { activeVersions: {}, previousVersions: {} }, corrupt: true, diagnostic: `the profile state ${label} entry ${JSON.stringify(id)} is not a version string` };
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
return {
|
|
483
|
+
value: {
|
|
484
|
+
activeVersions: active ?? {},
|
|
485
|
+
previousVersions: previous ?? {},
|
|
486
|
+
},
|
|
487
|
+
corrupt: false,
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
/** The approval-record path for one staged version (EXT-30, lifetime audit). */
|
|
491
|
+
approvalPath(extensionId, version) {
|
|
492
|
+
return join(this.rootDir, 'approvals', `${extensionId}--${version}.json`);
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* Read the approval record for one staged version.
|
|
496
|
+
*
|
|
497
|
+
* A corrupt record fails closed with the diagnostic: an update whose approval
|
|
498
|
+
* cannot be read must NOT proceed as if it were approved.
|
|
499
|
+
* @param extensionId - the extension.
|
|
500
|
+
* @param version - the staged version.
|
|
501
|
+
* @returns the record (or undefined when none was written) and any diagnostic.
|
|
502
|
+
*/
|
|
503
|
+
readApproval(extensionId, version) {
|
|
504
|
+
const { value, diagnostic } = readJsonBounded(this.approvalPath(extensionId, version));
|
|
505
|
+
if (diagnostic !== undefined)
|
|
506
|
+
return { value: undefined, corrupt: true, diagnostic };
|
|
507
|
+
if (value === undefined)
|
|
508
|
+
return { value: undefined, corrupt: false };
|
|
509
|
+
if (typeof value !== 'object' || value === null
|
|
510
|
+
|| typeof value.extensionId !== 'string'
|
|
511
|
+
|| typeof value.version !== 'string'
|
|
512
|
+
|| (value.approved !== true
|
|
513
|
+
&& value.approved !== false)) {
|
|
514
|
+
return { value: undefined, corrupt: true, diagnostic: 'the approval record is malformed' };
|
|
515
|
+
}
|
|
516
|
+
return { value: value, corrupt: false };
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Record ONE approval decision for one staged version (EXT-30).
|
|
520
|
+
*
|
|
521
|
+
* The record is retained for the store's LIFETIME (audit trail, no expiry)
|
|
522
|
+
* and is WRITE-ONCE per staged version: a later decision never silently
|
|
523
|
+
* overwrites an earlier one. A denied record stays as the audit entry.
|
|
524
|
+
* @param extensionId - the extension.
|
|
525
|
+
* @param version - the staged version.
|
|
526
|
+
* @param decision - the operator's decision.
|
|
527
|
+
* @throws ManagementError when an earlier decision exists.
|
|
528
|
+
*/
|
|
529
|
+
recordApproval(extensionId, version, decision) {
|
|
530
|
+
const existing = this.readApproval(extensionId, version);
|
|
531
|
+
if (existing.corrupt) {
|
|
532
|
+
throw new ManagementError('EXTENSION_FAULT', 'install', `the approval record for ${extensionId}@${version} is unreadable: ${existing.diagnostic ?? 'unknown reason'}`, 'Inspect the record deliberately; a decision is never overwritten through a corrupt one.');
|
|
533
|
+
}
|
|
534
|
+
if (existing.value !== undefined) {
|
|
535
|
+
throw new ManagementError('UPGRADE_FAILED', 'install', `an approval decision already exists for ${extensionId}@${version}; decisions are write-once`, 'Stage a new version for a new decision, or inspect the existing record.');
|
|
536
|
+
}
|
|
537
|
+
mkdirSync(dirname(this.approvalPath(extensionId, version)), { recursive: true });
|
|
538
|
+
writeJsonAtomic(this.approvalPath(extensionId, version), {
|
|
539
|
+
extensionId,
|
|
540
|
+
version,
|
|
541
|
+
approved: decision.approved,
|
|
542
|
+
...decision.approved ? {} : { deniedReason: decision.reason },
|
|
543
|
+
decidedAt: new Date().toISOString(),
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Remove ONE version's installation record (EXT-30 admission rollback).
|
|
548
|
+
*
|
|
549
|
+
* The per-version removal exists for the update flow's failure cleanup: when
|
|
550
|
+
* a commit fails after the new version was admitted, the new version's record
|
|
551
|
+
* is removed while every OTHER version's record survives.
|
|
552
|
+
* @param extensionId - the extension.
|
|
553
|
+
* @param version - the version to forget.
|
|
554
|
+
*/
|
|
555
|
+
removeVersionRecord(extensionId, version) {
|
|
556
|
+
const lock = this.readLock();
|
|
557
|
+
if (lock.corrupt) {
|
|
558
|
+
throw new ManagementError('EXTENSION_FAULT', 'install', `the store lock is unreadable, so a version record cannot be removed safely: ${lock.diagnostic ?? 'unknown reason'}`, 'Inspect the lock document and repair it deliberately.');
|
|
559
|
+
}
|
|
560
|
+
writeJsonAtomic(this.lockPath, {
|
|
561
|
+
lockVersion: 1,
|
|
562
|
+
extensions: lock.value.extensions.filter(record => !(record.extensionId === extensionId && record.version === version)),
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
/** The cleanup-pending tombstone directory. */
|
|
566
|
+
cleanupPendingDir() {
|
|
567
|
+
return join(this.rootDir, 'cleanup-pending');
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* Record an owned deletion that could not complete (EXT-29).
|
|
571
|
+
*
|
|
572
|
+
* A tombstone keeps the CLEANUP_PENDING truth durable: the retry loop
|
|
573
|
+
* ({@link ExtensionStore.retryCleanupPending}) re-attempts the deletion on
|
|
574
|
+
* later runs, bounded, and the directory is never silently forgotten.
|
|
575
|
+
* @param targetPath - the path the deletion could not remove.
|
|
576
|
+
* @param reason - why the deletion failed.
|
|
577
|
+
*/
|
|
578
|
+
writeCleanupTombstone(targetPath, reason) {
|
|
579
|
+
const dir = this.cleanupPendingDir();
|
|
580
|
+
mkdirSync(dir, { recursive: true });
|
|
581
|
+
const token = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
582
|
+
writeJsonAtomic(join(dir, `${token}.json`), {
|
|
583
|
+
path: targetPath,
|
|
584
|
+
attempts: 0,
|
|
585
|
+
since: new Date().toISOString(),
|
|
586
|
+
reason,
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
/**
|
|
590
|
+
* Retry owned deletions recorded by tombstones (EXT-29), bounded.
|
|
591
|
+
*
|
|
592
|
+
* Every tombstone under the attempts cap gets ONE retry attempt per call;
|
|
593
|
+
* a still-failing deletion increments the counter and keeps the truthful
|
|
594
|
+
* CLEANUP_PENDING record. A path that no longer exists counts as deleted.
|
|
595
|
+
* @param maxAttempts - the attempts cap (default 5).
|
|
596
|
+
* @returns the per-run counts.
|
|
597
|
+
*/
|
|
598
|
+
retryCleanupPending(maxAttempts = 5) {
|
|
599
|
+
const dir = this.cleanupPendingDir();
|
|
600
|
+
if (!existsSync(dir))
|
|
601
|
+
return { retried: 0, deleted: 0, kept: 0 };
|
|
602
|
+
let retried = 0;
|
|
603
|
+
let deleted = 0;
|
|
604
|
+
let kept = 0;
|
|
605
|
+
for (const entry of readdirSync(dir)) {
|
|
606
|
+
if (!entry.endsWith('.json'))
|
|
607
|
+
continue;
|
|
608
|
+
const tombstonePath = join(dir, entry);
|
|
609
|
+
const { value } = readJsonBounded(tombstonePath, 64 * 1024);
|
|
610
|
+
if (value === undefined || typeof value !== 'object') {
|
|
611
|
+
// An unreadable tombstone is kept as evidence rather than deleted.
|
|
612
|
+
kept += 1;
|
|
613
|
+
continue;
|
|
614
|
+
}
|
|
615
|
+
const tombstone = value;
|
|
616
|
+
if (typeof tombstone.path !== 'string') {
|
|
617
|
+
kept += 1;
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
const attempts = typeof tombstone.attempts === 'number' ? tombstone.attempts : 0;
|
|
621
|
+
if (attempts >= maxAttempts) {
|
|
622
|
+
kept += 1;
|
|
623
|
+
continue;
|
|
624
|
+
}
|
|
625
|
+
retried += 1;
|
|
626
|
+
try {
|
|
627
|
+
rmSync(tombstone.path, { recursive: true, force: true });
|
|
628
|
+
rmSync(tombstonePath, { force: true });
|
|
629
|
+
deleted += 1;
|
|
630
|
+
}
|
|
631
|
+
catch {
|
|
632
|
+
writeJsonAtomic(tombstonePath, {
|
|
633
|
+
path: tombstone.path,
|
|
634
|
+
attempts: attempts + 1,
|
|
635
|
+
since: typeof tombstone.since === 'string' ? tombstone.since : new Date().toISOString(),
|
|
636
|
+
reason: typeof tombstone.reason === 'string' ? tombstone.reason : 'the owned deletion failed again',
|
|
637
|
+
});
|
|
638
|
+
kept += 1;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
// An empty tombstone directory carries no pending state; removing it keeps
|
|
642
|
+
// the store's truth surface clean. A directory with KEPT tombstones stays —
|
|
643
|
+
// its records are the truthful CLEANUP_PENDING state.
|
|
644
|
+
if (kept === 0) {
|
|
645
|
+
try {
|
|
646
|
+
rmSync(dir, { recursive: true, force: true });
|
|
647
|
+
}
|
|
648
|
+
catch {
|
|
649
|
+
// Best-effort: an empty directory that cannot be removed changes nothing.
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
return { retried, deleted, kept };
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* Read the durable legacy prepared-asset mappings a migration recorded.
|
|
656
|
+
*
|
|
657
|
+
* Kept on the store rather than folded into `readProfileState` because the
|
|
658
|
+
* state array is the lifecycle's working surface: changing its shape for the
|
|
659
|
+
* asset record would make every writer a silent dropper of the record, which
|
|
660
|
+
* is exactly the failure BASE-09's preservation clause names. The version
|
|
661
|
+
* gate matches {@link ExtensionStore.readProfileState}.
|
|
662
|
+
* @param profile - the profile name.
|
|
663
|
+
* @returns the recorded mappings and any diagnostic.
|
|
664
|
+
*/
|
|
665
|
+
readMigratedAssets(profile) {
|
|
666
|
+
const { value, diagnostic } = readJsonBounded(this.profileStatePath(profile));
|
|
667
|
+
if (diagnostic !== undefined)
|
|
668
|
+
return { value: [], corrupt: true, diagnostic };
|
|
669
|
+
if (value === undefined)
|
|
670
|
+
return { value: [], corrupt: false };
|
|
671
|
+
if (typeof value !== 'object' || value === null) {
|
|
672
|
+
return { value: [], corrupt: true, diagnostic: 'the profile state is not a document' };
|
|
673
|
+
}
|
|
674
|
+
const file = value;
|
|
675
|
+
const stateVersion = file.stateVersion;
|
|
676
|
+
if (stateVersion === undefined) {
|
|
677
|
+
return { value: [], corrupt: true, diagnostic: 'the profile state has no stateVersion key' };
|
|
678
|
+
}
|
|
679
|
+
if (stateVersion !== STATE_VERSION) {
|
|
680
|
+
return {
|
|
681
|
+
value: [], corrupt: true,
|
|
682
|
+
diagnostic: `the profile state was written by a different SDK (stateVersion ${JSON.stringify(stateVersion)}); this SDK reads stateVersion ${STATE_VERSION}`,
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
if (file.migratedAssets === undefined)
|
|
686
|
+
return { value: [], corrupt: false };
|
|
687
|
+
if (!Array.isArray(file.migratedAssets)) {
|
|
688
|
+
return { value: [], corrupt: true, diagnostic: 'the profile state migratedAssets is not an array' };
|
|
689
|
+
}
|
|
690
|
+
return { value: file.migratedAssets, corrupt: false };
|
|
691
|
+
}
|
|
692
|
+
/**
|
|
693
|
+
* Migrate a legacy model-id era document into this profile (BASE-09, BASE-10).
|
|
694
|
+
*
|
|
695
|
+
* The old world's `<DSH_HOME>/model-as-tool/state.json` held unversioned
|
|
696
|
+
* `enabled`/`disabled` MODEL-ID arrays. The host reads that document and hands
|
|
697
|
+
* its content here; this method runs it through the explicit migration table
|
|
698
|
+
* and writes the versioned profile state atomically under the store lock.
|
|
699
|
+
*
|
|
700
|
+
* Guarantees, per BASE-10:
|
|
701
|
+
* - Disabled choices are preserved as tool-level disables; nothing is
|
|
702
|
+
* installed, activated or prepared by the migration.
|
|
703
|
+
* - Legacy enables are STAGED in the report, never granted.
|
|
704
|
+
* - Unresolved mappings are reported, never silently enabled.
|
|
705
|
+
* - A profile that already holds current-version state is left untouched
|
|
706
|
+
* (double migration is idempotent); a document this SDK cannot interpret is
|
|
707
|
+
* refused rather than overwritten.
|
|
708
|
+
* @param profile - the profile to migrate into.
|
|
709
|
+
* @param legacyJson - the legacy document's text. Omit it to migrate a
|
|
710
|
+
* legacy-shaped document found at the profile state path itself.
|
|
711
|
+
* @param legacySource - description of the origin, for the report.
|
|
712
|
+
* @returns the migration report.
|
|
713
|
+
* @throws ManagementError when the input is not a usable legacy document or
|
|
714
|
+
* the profile already holds a document this SDK refuses to overwrite.
|
|
715
|
+
*/
|
|
716
|
+
async migrateLegacyProfileState(profile, legacyJson, legacySource = 'legacy model-id era state') {
|
|
717
|
+
// Validates the profile name before any file is touched.
|
|
718
|
+
this.profileStatePath(profile);
|
|
719
|
+
let legacy;
|
|
720
|
+
if (legacyJson !== undefined) {
|
|
721
|
+
let parsed;
|
|
722
|
+
try {
|
|
723
|
+
parsed = JSON.parse(legacyJson);
|
|
724
|
+
}
|
|
725
|
+
catch (error) {
|
|
726
|
+
throw new ManagementError('EXTENSION_FAULT', 'update', `${legacySource} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`, 'Inspect the legacy state document and repair it; the extension store was not modified.');
|
|
727
|
+
}
|
|
728
|
+
const result = parseLegacyModelState(parsed, legacySource);
|
|
729
|
+
if (result.state === undefined) {
|
|
730
|
+
throw new ManagementError('EXTENSION_FAULT', 'update', result.diagnostic ?? 'the legacy state document is not usable', 'Inspect the legacy state document and repair it; the extension store was not modified.');
|
|
731
|
+
}
|
|
732
|
+
legacy = result.state;
|
|
733
|
+
}
|
|
734
|
+
// The store lock wraps the read-check-write: an offline CLI must not
|
|
735
|
+
// interleave its edits between the migration's read and its write (EXT-31).
|
|
736
|
+
return this.exclusive(async () => {
|
|
737
|
+
const { value, diagnostic } = readJsonBounded(this.profileStatePath(profile));
|
|
738
|
+
if (diagnostic !== undefined) {
|
|
739
|
+
throw new ManagementError('EXTENSION_FAULT', 'update', `the profile state could not be read, so a migration cannot proceed safely: ${diagnostic}`, 'Inspect the profile state document and repair or remove it deliberately; migration refuses to overwrite an unreadable document.');
|
|
740
|
+
}
|
|
741
|
+
if (value !== undefined) {
|
|
742
|
+
const version = value.stateVersion;
|
|
743
|
+
if (typeof version === 'number' && version === STATE_VERSION) {
|
|
744
|
+
// Double migration is a no-op: the choices are already versioned.
|
|
745
|
+
return {
|
|
746
|
+
profile,
|
|
747
|
+
legacySource,
|
|
748
|
+
fromStateVersion: null,
|
|
749
|
+
toStateVersion: STATE_VERSION,
|
|
750
|
+
toSchemaVersion: STATE_SCHEMA_VERSION,
|
|
751
|
+
alreadyCurrent: true,
|
|
752
|
+
wroteState: false,
|
|
753
|
+
entries: [],
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
if (typeof version === 'number' && version !== STATE_VERSION) {
|
|
757
|
+
// Equality, not a lower bound (BASE-09): a document stamped with any
|
|
758
|
+
// other number — newer, 0, negative, fractional — is not this era's
|
|
759
|
+
// document. Refusing to overwrite it is the fail-closed half of the
|
|
760
|
+
// same rule readProfileState applies.
|
|
761
|
+
throw new ManagementError('EXTENSION_FAULT', 'update', `the profile state was written by a different SDK (stateVersion ${version}); this SDK writes stateVersion ${STATE_VERSION}`, 'Install a matching or newer SDK; the migration refuses to rewrite state it did not write.');
|
|
762
|
+
}
|
|
763
|
+
if (legacy === undefined) {
|
|
764
|
+
// The profile path itself may hold the legacy document (an SDK whose
|
|
765
|
+
// store directory previously received the old world's file).
|
|
766
|
+
const result = parseLegacyModelState(value, this.profileStatePath(profile));
|
|
767
|
+
if (result.state !== undefined) {
|
|
768
|
+
legacy = result.state;
|
|
769
|
+
legacySource = this.profileStatePath(profile);
|
|
770
|
+
}
|
|
771
|
+
else {
|
|
772
|
+
throw new ManagementError('EXTENSION_FAULT', 'update', `the profile already holds a document this SDK cannot interpret: ${result.diagnostic ?? 'unknown shape'}`, 'Inspect the profile state document and repair or remove it deliberately; the migration refuses to overwrite an unreadable document.');
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
else {
|
|
776
|
+
throw new ManagementError('EXTENSION_FAULT', 'update', 'the profile already holds an unversioned document; pass its content as the legacy input instead of a different document', 'Inspect the profile state document and repair or remove it deliberately; the migration refuses to overwrite an unreadable document.');
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
if (legacy === undefined) {
|
|
780
|
+
return {
|
|
781
|
+
profile,
|
|
782
|
+
legacySource,
|
|
783
|
+
fromStateVersion: null,
|
|
784
|
+
toStateVersion: STATE_VERSION,
|
|
785
|
+
toSchemaVersion: STATE_SCHEMA_VERSION,
|
|
786
|
+
alreadyCurrent: false,
|
|
787
|
+
wroteState: false,
|
|
788
|
+
skippedReason: 'no legacy document was provided and none was found at the profile state path',
|
|
789
|
+
entries: [],
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
const applied = applyLegacyChoices(legacy);
|
|
793
|
+
this.writeProfileState(profile, applied.extensions, applied.migratedAssets);
|
|
794
|
+
return {
|
|
795
|
+
profile,
|
|
796
|
+
legacySource,
|
|
797
|
+
fromStateVersion: null,
|
|
798
|
+
toStateVersion: STATE_VERSION,
|
|
799
|
+
toSchemaVersion: STATE_SCHEMA_VERSION,
|
|
800
|
+
alreadyCurrent: false,
|
|
801
|
+
wroteState: true,
|
|
802
|
+
entries: applied.entries,
|
|
803
|
+
};
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
//# sourceMappingURL=store.js.map
|