@try-works/dsh-model-as-tool 0.1.3 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +614 -0
- package/client/index.js +1717 -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 +325 -0
- package/lib/extension-dependencies.d.ts.map +1 -0
- package/lib/extension-dependencies.js +858 -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 +221 -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 +367 -0
- package/lib/extensions-service.d.ts.map +1 -0
- package/lib/extensions-service.js +1527 -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 +271 -7
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +2644 -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/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/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.map +1 -1
- package/node_modules/@try-works/tool-mode-contracts/lib/dsh.js +69 -55
- package/node_modules/@try-works/tool-mode-contracts/lib/index.d.ts +4 -1
- package/node_modules/@try-works/tool-mode-contracts/lib/index.d.ts.map +1 -1
- package/node_modules/@try-works/tool-mode-contracts/lib/index.js +26 -2
- package/node_modules/@try-works/tool-mode-contracts/lib/registry.d.ts +28 -2
- package/node_modules/@try-works/tool-mode-contracts/lib/registry.d.ts.map +1 -1
- package/node_modules/@try-works/tool-mode-contracts/lib/registry.js +93 -10
- package/node_modules/@try-works/tool-mode-contracts/package.json +0 -1
- 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 +32 -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 +32 -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 +191 -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/tar-gzip-source.d.ts +44 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/tar-gzip-source.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/tar-gzip-source.js +243 -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 +43 -15
- package/remote/spec.mjs +462 -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,1527 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The HOST half of the extension-management surface (Settings → Model as Tool).
|
|
3
|
+
*
|
|
4
|
+
* This module implements {@link ModelToolsRemote} from `extensions-api.ts` and is
|
|
5
|
+
* deliberately WIRE-AGNOSTIC: it imports `node:*` and the published SDK, never DSH,
|
|
6
|
+
* never the client page, and never the generated Typert artifact. That boundary is
|
|
7
|
+
* what lets the same implementation be exercised by unit tests without a Context,
|
|
8
|
+
* by the plugin at `apply`, and by whatever transports the browser half uses.
|
|
9
|
+
*
|
|
10
|
+
* The governing rule is TRUTHFULNESS over convenience, because every field here is
|
|
11
|
+
* shown to an operator who is about to change what code their host runs:
|
|
12
|
+
*
|
|
13
|
+
* - `store` is `null` when no extension store is configured, rather than an empty
|
|
14
|
+
* string that a caller could mistake for a mounted store.
|
|
15
|
+
* - `writable` is derived from a real access check on the store path and from the
|
|
16
|
+
* store lock being readable, not assumed from "the config mentions a directory".
|
|
17
|
+
* - `installed` means a record exists in the store lock; `active` means the
|
|
18
|
+
* persisted observation was made by THIS process; `integrity` is `unknown` when
|
|
19
|
+
* the installed bytes could not be digested, never `unchanged` by default.
|
|
20
|
+
* - `reason` names the FIRST condition that prevents install/activation, so a UI
|
|
21
|
+
* can show why an extension is not usable instead of guessing.
|
|
22
|
+
*
|
|
23
|
+
* INSTALLATION IS NOT ACTIVATION (EXT-07): `install` returns `active: false` and
|
|
24
|
+
* never calls the lifecycle. `enable`/`disable` are the only activation paths.
|
|
25
|
+
*
|
|
26
|
+
* FIRST-PARTY DEPENDENCIES (EXT-09): a source-directory install also supplies the
|
|
27
|
+
* SDK's dependency graph (`extension-dependencies.ts`), which resolves every
|
|
28
|
+
* declared runtime dependency from the host's own installed tree and refuses the
|
|
29
|
+
* install by name when one is missing. With the operator's preinstalled-content
|
|
30
|
+
* gate on, the resolved directory is copied by the SDK and recorded with
|
|
31
|
+
* `provenance: 'preinstalled'`; with it off (the default), the dependency is served
|
|
32
|
+
* as verified archive bytes and EXT-10's lifecycle-script refusal applies.
|
|
33
|
+
*
|
|
34
|
+
* PERSISTENCE, STATED HONESTLY:
|
|
35
|
+
*
|
|
36
|
+
* - Activation intent is durable through the SDK: `ExtensionLifecycle.activate`
|
|
37
|
+
* writes `desiredActivation: 'active'` and `deactivate` writes `'inactive'` into
|
|
38
|
+
* the profile state document, so a restart follows the operator's last toggle
|
|
39
|
+
* even when nothing else persists.
|
|
40
|
+
* - The operator's `disabledExtensions` list is a CONFIG value (Settings form). This
|
|
41
|
+
* service cannot write plugin config from the host: no config writer is injected.
|
|
42
|
+
* It therefore keeps this process's toggles as an in-memory override, seeded from
|
|
43
|
+
* `disabledExtensions`, and exposes `persistDisabledExtensions` as the seam a host
|
|
44
|
+
* with a config writer supplies. Without that seam, a toggle is truthfully
|
|
45
|
+
* reported as affecting this process only, and a `disable` survives restart via
|
|
46
|
+
* the SDK's durable `desiredActivation: 'inactive'` while an `enable` of an id
|
|
47
|
+
* still listed in config will be filtered by the restore gate on the next load.
|
|
48
|
+
*
|
|
49
|
+
* @module @try-works/dsh-model-as-tool/extensions-service
|
|
50
|
+
*/
|
|
51
|
+
import { accessSync, constants, existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
52
|
+
import { dirname, join, resolve } from 'node:path';
|
|
53
|
+
import { createTarGzipArchiveSource, digestInstalledContent, discoverExtension, ExtensionStore, ManagementError, stagedInstall, stagedInstallFromDirectory, } from '@try-works/tool-mode-extension-sdk';
|
|
54
|
+
import { createFirstPartyDependencyGraph, createRegistryDependencyGraph, createRegistrySession, DEFAULT_REGISTRY_URL, MAX_DEPENDENCY_ARCHIVE_BYTES, RegistryError, resolveFirstPartyDependency, } from "./extension-dependencies.js";
|
|
55
|
+
import { createHttpSource } from "./http-acquisition.js";
|
|
56
|
+
/**
|
|
57
|
+
* The readiness values that permit activation (the SDK's callable set).
|
|
58
|
+
*
|
|
59
|
+
* `prepared-cold` is callable: the model loads on first use. Every other value is
|
|
60
|
+
* a concrete blocker this service names in a row reason and in the enable refusal.
|
|
61
|
+
*/
|
|
62
|
+
const CALLABLE_READINESS = new Set([
|
|
63
|
+
'prepared-cold',
|
|
64
|
+
'loading',
|
|
65
|
+
'ready',
|
|
66
|
+
'busy',
|
|
67
|
+
]);
|
|
68
|
+
/**
|
|
69
|
+
* The order an unmet requirement is surfaced in (row reason and enable preflight).
|
|
70
|
+
*
|
|
71
|
+
* Integrity and platform first (nothing the operator can do in the page will clear
|
|
72
|
+
* them), then what the operator CAN act on: dependencies, the harness model, the
|
|
73
|
+
* executable approval, model assets, activation cleanup; store/source last because
|
|
74
|
+
* an installed row already satisfied them.
|
|
75
|
+
*/
|
|
76
|
+
const REQUIREMENT_PRIORITY = [
|
|
77
|
+
'integrity',
|
|
78
|
+
'platform',
|
|
79
|
+
'dependencies',
|
|
80
|
+
'harness-model',
|
|
81
|
+
'executable',
|
|
82
|
+
'assets',
|
|
83
|
+
'activation',
|
|
84
|
+
'store',
|
|
85
|
+
'source',
|
|
86
|
+
];
|
|
87
|
+
/**
|
|
88
|
+
* Whether one UNMET requirement actually prevents this row from reaching active.
|
|
89
|
+
*
|
|
90
|
+
* A not-yet-installed row is blocked only by facts that would stop the INSTALL or
|
|
91
|
+
* would make the eventual activation impossible (store/source, dependencies,
|
|
92
|
+
* platform, harness model). Readiness, executable approval, integrity and
|
|
93
|
+
* activation state describe installed bytes, so they are not meaningful before an
|
|
94
|
+
* install and do not by themselves make the Install button impossible.
|
|
95
|
+
* @param installed - whether the store holds an installed copy.
|
|
96
|
+
* @param requirement - the requirement to classify.
|
|
97
|
+
* @returns whether the unmet requirement blocks the row.
|
|
98
|
+
*/
|
|
99
|
+
function blocksRow(installed, requirement) {
|
|
100
|
+
if (requirement.satisfied)
|
|
101
|
+
return false;
|
|
102
|
+
if (installed)
|
|
103
|
+
return true;
|
|
104
|
+
return requirement.kind === 'store'
|
|
105
|
+
|| requirement.kind === 'source'
|
|
106
|
+
|| requirement.kind === 'dependencies'
|
|
107
|
+
|| requirement.kind === 'platform'
|
|
108
|
+
|| requirement.kind === 'harness-model';
|
|
109
|
+
}
|
|
110
|
+
/** Build one requirement value. */
|
|
111
|
+
function requirement(kind, satisfied, reason, action = 'none') {
|
|
112
|
+
return { kind, satisfied, reason, action };
|
|
113
|
+
}
|
|
114
|
+
/** The highest-priority UNMET requirement that blocks this row, or undefined. */
|
|
115
|
+
function firstBlockingRequirement(requirements, installed) {
|
|
116
|
+
for (const kind of REQUIREMENT_PRIORITY) {
|
|
117
|
+
const found = requirements.find(candidate => candidate.kind === kind);
|
|
118
|
+
if (found !== undefined && blocksRow(installed, found))
|
|
119
|
+
return found;
|
|
120
|
+
}
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* A named refusal from the extension-management surface.
|
|
125
|
+
*
|
|
126
|
+
* The `code` is stable and machine-readable; the message is operator-facing and
|
|
127
|
+
* names the concrete condition (path, SDK code, or identity) rather than a generic
|
|
128
|
+
* "failed", because the Settings page shows it verbatim.
|
|
129
|
+
*/
|
|
130
|
+
export class ExtensionsServiceError extends Error {
|
|
131
|
+
name = 'ExtensionsServiceError';
|
|
132
|
+
code;
|
|
133
|
+
constructor(code, message, options) {
|
|
134
|
+
super(message, options);
|
|
135
|
+
this.code = code;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* The directory a first-party checkout for one package identity occupies.
|
|
140
|
+
*
|
|
141
|
+
* A source directory holds PACKAGE DIRECTORIES, not scoped directories: the
|
|
142
|
+
* workspace layout is `packages/extension-laya`, whose package name is
|
|
143
|
+
* `@try-works/extension-laya`. The scope is therefore dropped and the final path
|
|
144
|
+
* segment is used, which is the only mapping that requires no registry lookup.
|
|
145
|
+
* @param extensionId - the extension identity, which equals its package name.
|
|
146
|
+
* @returns the directory name under the configured source directory.
|
|
147
|
+
*/
|
|
148
|
+
export function packageDirFor(extensionId) {
|
|
149
|
+
const slash = extensionId.lastIndexOf('/');
|
|
150
|
+
return slash === -1 ? extensionId : extensionId.slice(slash + 1);
|
|
151
|
+
}
|
|
152
|
+
/** A package identity: one optional scope segment, then one name segment. */
|
|
153
|
+
const PACKAGE_ID = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/i;
|
|
154
|
+
/**
|
|
155
|
+
* Refuse an identity that cannot safely become a path component.
|
|
156
|
+
*
|
|
157
|
+
* `packageDirFor` is joined onto the source directory, so an id such as `..` or
|
|
158
|
+
* one carrying a separator would address a directory outside the configured root.
|
|
159
|
+
* @param extensionId - the requested id.
|
|
160
|
+
* @throws ExtensionsServiceError `extension-id-invalid`.
|
|
161
|
+
*/
|
|
162
|
+
function assertUsableId(extensionId) {
|
|
163
|
+
if (!PACKAGE_ID.test(extensionId)) {
|
|
164
|
+
throw new ExtensionsServiceError('extension-id-invalid', `${JSON.stringify(extensionId)} is not a package identity; expected a name like @scope/name`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/** A message from an arbitrary thrown value. */
|
|
168
|
+
function messageOf(error) {
|
|
169
|
+
return error instanceof Error ? error.message : String(error);
|
|
170
|
+
}
|
|
171
|
+
/** Facts for a row with no readable package directory. */
|
|
172
|
+
const EMPTY_FACTS = {
|
|
173
|
+
packageName: null,
|
|
174
|
+
packageVersion: null,
|
|
175
|
+
packageDir: null,
|
|
176
|
+
tools: [],
|
|
177
|
+
models: [],
|
|
178
|
+
executionKind: undefined,
|
|
179
|
+
platforms: [],
|
|
180
|
+
dependencies: [],
|
|
181
|
+
problem: null,
|
|
182
|
+
};
|
|
183
|
+
/** Read a package.json's `dependencies` as name→range pairs; empty when unreadable. */
|
|
184
|
+
function readDeclaredDependencies(packageDir) {
|
|
185
|
+
if (packageDir === null)
|
|
186
|
+
return [];
|
|
187
|
+
try {
|
|
188
|
+
const parsed = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8'));
|
|
189
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
190
|
+
return [];
|
|
191
|
+
// SAFETY: the value came from JSON.parse and the object guard above; only
|
|
192
|
+
// string-valued dependency specs are kept, so an unexpected shape yields an
|
|
193
|
+
// empty declaration rather than a trusted one.
|
|
194
|
+
const dependencies = parsed.dependencies;
|
|
195
|
+
if (typeof dependencies !== 'object' || dependencies === null || Array.isArray(dependencies))
|
|
196
|
+
return [];
|
|
197
|
+
const declared = [];
|
|
198
|
+
// SAFETY: the dependency container was checked to be a non-array object; only
|
|
199
|
+
// string entries become declared dependencies, and non-strings are ignored.
|
|
200
|
+
for (const [name, range] of Object.entries(dependencies)) {
|
|
201
|
+
if (typeof range === 'string' && range.length > 0)
|
|
202
|
+
declared.push({ name, range });
|
|
203
|
+
}
|
|
204
|
+
return declared;
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
return [];
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Read one package directory's declared facts without importing or executing it.
|
|
212
|
+
* @param packageDir - the installed copy or first-party checkout.
|
|
213
|
+
* @returns the facts, with `problem` set (and manifest facts empty) when discovery fails.
|
|
214
|
+
*/
|
|
215
|
+
function readExtensionFacts(packageDir) {
|
|
216
|
+
const dependencies = readDeclaredDependencies(packageDir);
|
|
217
|
+
try {
|
|
218
|
+
const discovery = discoverExtension(packageDir);
|
|
219
|
+
const manifest = discovery.manifest;
|
|
220
|
+
return {
|
|
221
|
+
packageName: discovery.packageName,
|
|
222
|
+
packageVersion: discovery.packageVersion,
|
|
223
|
+
packageDir,
|
|
224
|
+
tools: manifest.tools.map(tool => tool.name),
|
|
225
|
+
models: manifest.models.map(model => model.id),
|
|
226
|
+
executionKind: manifest.execution.kind,
|
|
227
|
+
platforms: manifest.models.flatMap(model => model.platforms),
|
|
228
|
+
dependencies,
|
|
229
|
+
problem: null,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
return {
|
|
234
|
+
...EMPTY_FACTS,
|
|
235
|
+
packageName: null,
|
|
236
|
+
packageVersion: null,
|
|
237
|
+
packageDir,
|
|
238
|
+
dependencies,
|
|
239
|
+
problem: `the package at ${packageDir} is not a usable extension: ${messageOf(error)}`,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
/** A short human label for one declared platform. */
|
|
244
|
+
function platformLabel(platform) {
|
|
245
|
+
return `${platform.os}-${platform.arch}${platform.accelerator === undefined ? '' : `-${platform.accelerator}`}`;
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Whether the host's own platform matches any declared deployment platform.
|
|
249
|
+
* @param platforms - the manifest's declared platforms.
|
|
250
|
+
* @returns whether this host can run the declared deployment.
|
|
251
|
+
*/
|
|
252
|
+
function hostPlatformMatches(platforms) {
|
|
253
|
+
return platforms.some(platform => platform.os === process.platform && platform.arch === process.arch);
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Every declared deployment whose recorded readiness does not permit activation.
|
|
257
|
+
*
|
|
258
|
+
* A deployment with NO recorded readiness is reported as `needs-preparation`
|
|
259
|
+
* because that is the constant the SDK's lifecycle projects for an undeclared
|
|
260
|
+
* state (`#readinessOf` defaults every declared model to `needs-preparation`);
|
|
261
|
+
* reporting anything else would invent a fact the host does not hold.
|
|
262
|
+
* @param readiness - the persisted readiness map (may be empty).
|
|
263
|
+
* @param declaredModels - the manifest's model deployment ids.
|
|
264
|
+
* @returns one operator-facing line per blocker, most actionable phrasing first.
|
|
265
|
+
*/
|
|
266
|
+
function preparationBlockers(readiness, declaredModels) {
|
|
267
|
+
const blockers = [];
|
|
268
|
+
for (const modelId of declaredModels) {
|
|
269
|
+
const value = readiness[modelId] ?? 'needs-preparation';
|
|
270
|
+
if (CALLABLE_READINESS.has(value))
|
|
271
|
+
continue;
|
|
272
|
+
if (value === 'needs-preparation') {
|
|
273
|
+
blockers.push(`${modelId} needs its model assets prepared — run prepare for this extension`);
|
|
274
|
+
}
|
|
275
|
+
else if (value === 'preparing') {
|
|
276
|
+
blockers.push(`${modelId} is still preparing its model assets`);
|
|
277
|
+
}
|
|
278
|
+
else if (value === 'unavailable') {
|
|
279
|
+
blockers.push(`${modelId} is unavailable on this host — its runtime or assets are missing`);
|
|
280
|
+
}
|
|
281
|
+
else if (value === 'failed') {
|
|
282
|
+
blockers.push(`${modelId} failed inspection on this host — repair or reinstall its assets, then prepare again`);
|
|
283
|
+
}
|
|
284
|
+
else {
|
|
285
|
+
blockers.push(`${modelId} is ${value}, which does not permit activation`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return blockers;
|
|
289
|
+
}
|
|
290
|
+
/** Project the SDK/coordinator readiness map onto the wire readiness vocabulary. */
|
|
291
|
+
function readinessView(readiness, declaredModels) {
|
|
292
|
+
const view = {};
|
|
293
|
+
for (const modelId of declaredModels) {
|
|
294
|
+
const value = readiness[modelId];
|
|
295
|
+
// SAFETY: a readiness value read from the SDK's state is one of the SDK's
|
|
296
|
+
// ModelReadiness literals, which is the same union as ExtensionModelReadiness;
|
|
297
|
+
// an absent entry is projected as its documented default.
|
|
298
|
+
view[modelId] = value === undefined ? 'needs-preparation' : value;
|
|
299
|
+
}
|
|
300
|
+
return view;
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Project the coordinator's plan onto the wire disclosure.
|
|
304
|
+
*
|
|
305
|
+
* The digest and identity fields the provider needs stay host-side; the page gets
|
|
306
|
+
* the bytes, sources, executable flags, totals, terms and revision an operator
|
|
307
|
+
* approves (the same fields the management channel discloses).
|
|
308
|
+
* @param plan - the resolved preparation plan.
|
|
309
|
+
* @returns the wire disclosure.
|
|
310
|
+
*/
|
|
311
|
+
function preparationPlanView(plan) {
|
|
312
|
+
return {
|
|
313
|
+
modelId: plan.identity.modelId,
|
|
314
|
+
modelRevision: plan.identity.modelRevision,
|
|
315
|
+
assets: plan.assets.map(asset => ({
|
|
316
|
+
assetId: asset.assetId,
|
|
317
|
+
bytes: asset.bytes,
|
|
318
|
+
source: asset.source,
|
|
319
|
+
executable: asset.executable,
|
|
320
|
+
})),
|
|
321
|
+
totalBytes: plan.totalBytes,
|
|
322
|
+
storageBytes: plan.storageBytes,
|
|
323
|
+
requiresExecutableApproval: plan.requiresExecutableApproval,
|
|
324
|
+
license: { name: plan.license.name, url: plan.license.url },
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Whether this process is likely able to create or write inside a directory.
|
|
329
|
+
*
|
|
330
|
+
* `access(W_OK)` is the strongest check available without performing a write, and
|
|
331
|
+
* it can still report success on a Windows ACL that later denies one. That gap is
|
|
332
|
+
* the reason every action names its write failure instead of trusting `writable`.
|
|
333
|
+
* @param directory - the store directory (it may not exist yet).
|
|
334
|
+
* @returns whether the nearest existing ancestor is a writable directory.
|
|
335
|
+
*/
|
|
336
|
+
function directoryWritable(directory) {
|
|
337
|
+
try {
|
|
338
|
+
let candidate = resolve(directory);
|
|
339
|
+
while (!existsSync(candidate)) {
|
|
340
|
+
const parent = dirname(candidate);
|
|
341
|
+
if (parent === candidate)
|
|
342
|
+
return false;
|
|
343
|
+
candidate = parent;
|
|
344
|
+
}
|
|
345
|
+
if (!statSync(candidate).isDirectory())
|
|
346
|
+
return false;
|
|
347
|
+
accessSync(candidate, constants.W_OK);
|
|
348
|
+
return true;
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
return false;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* The SDK lifecycle state for one profile, indexed by extension id.
|
|
356
|
+
* @param store - the extension store.
|
|
357
|
+
* @param profile - the lifecycle profile.
|
|
358
|
+
* @returns the states and whether the document could be trusted.
|
|
359
|
+
*/
|
|
360
|
+
function readProfileStates(store, profile) {
|
|
361
|
+
const result = store.readProfileState(profile);
|
|
362
|
+
const byId = new Map();
|
|
363
|
+
for (const state of result.value)
|
|
364
|
+
byId.set(state.extensionId, state);
|
|
365
|
+
return { byId, corrupt: result.corrupt, diagnostic: result.diagnostic ?? null };
|
|
366
|
+
}
|
|
367
|
+
/** Read a candidate package.json name for a directory whose discovery failed. */
|
|
368
|
+
function declaredPackageName(directory) {
|
|
369
|
+
try {
|
|
370
|
+
const parsed = JSON.parse(readFileSync(join(directory, 'package.json'), 'utf8'));
|
|
371
|
+
if (parsed === null)
|
|
372
|
+
return undefined;
|
|
373
|
+
// SAFETY: the value was parsed from the package's own package.json, and a
|
|
374
|
+
// non-string `name` is rejected below rather than trusted. The null guard above
|
|
375
|
+
// is what keeps the property read total for this boundary.
|
|
376
|
+
const name = parsed.name;
|
|
377
|
+
return typeof name === 'string' && PACKAGE_ID.test(name) ? name : undefined;
|
|
378
|
+
}
|
|
379
|
+
catch {
|
|
380
|
+
return undefined;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Discover every first-party package under a source directory.
|
|
385
|
+
*
|
|
386
|
+
* A directory whose package.json is unreadable is not listed at all: without an
|
|
387
|
+
* identity there is nothing truthful to name. A directory whose package.json names
|
|
388
|
+
* a package but whose manifest is unusable is listed WITH the reason, so the
|
|
389
|
+
* operator can see the broken checkout instead of an unexplained absence.
|
|
390
|
+
* @param sourceDir - the configured source directory.
|
|
391
|
+
* @returns packages in directory-name order.
|
|
392
|
+
*/
|
|
393
|
+
function listSourcePackages(sourceDir) {
|
|
394
|
+
let entries;
|
|
395
|
+
try {
|
|
396
|
+
entries = readdirSync(sourceDir, { withFileTypes: true });
|
|
397
|
+
}
|
|
398
|
+
catch {
|
|
399
|
+
return [];
|
|
400
|
+
}
|
|
401
|
+
const packages = [];
|
|
402
|
+
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
403
|
+
if (!entry.isDirectory())
|
|
404
|
+
continue;
|
|
405
|
+
const directory = join(sourceDir, entry.name);
|
|
406
|
+
const facts = readExtensionFacts(directory);
|
|
407
|
+
if (facts.packageName !== null && facts.problem === null) {
|
|
408
|
+
packages.push({
|
|
409
|
+
id: facts.packageName,
|
|
410
|
+
version: facts.packageVersion,
|
|
411
|
+
tools: facts.tools,
|
|
412
|
+
facts,
|
|
413
|
+
problem: null,
|
|
414
|
+
});
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
const id = facts.packageName ?? declaredPackageName(directory);
|
|
418
|
+
if (id === undefined)
|
|
419
|
+
continue;
|
|
420
|
+
packages.push({
|
|
421
|
+
id,
|
|
422
|
+
version: facts.packageVersion,
|
|
423
|
+
tools: facts.tools,
|
|
424
|
+
facts: facts.problem === null
|
|
425
|
+
? { ...facts, problem: `the package at ${directory} could not be discovered as an extension` }
|
|
426
|
+
: facts,
|
|
427
|
+
problem: facts.problem ?? `the package at ${directory} could not be discovered as an extension`,
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
return packages;
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Select the current installed record for one identity.
|
|
434
|
+
*
|
|
435
|
+
* The EXT-30 active-version pointer wins when it names an installed version; a
|
|
436
|
+
* store without pointers (the pre-update world) uses the last recorded version,
|
|
437
|
+
* which is the order `recordInstall` appends in.
|
|
438
|
+
* @param records - every installed record.
|
|
439
|
+
* @param activeVersions - the EXT-30 pointer map.
|
|
440
|
+
* @param extensionId - the identity to select.
|
|
441
|
+
* @returns the selected record, or undefined when nothing is installed for the id.
|
|
442
|
+
*/
|
|
443
|
+
function selectInstalled(records, activeVersions, extensionId) {
|
|
444
|
+
const versions = records.filter(record => record.extensionId === extensionId);
|
|
445
|
+
const newest = versions[versions.length - 1];
|
|
446
|
+
if (newest === undefined)
|
|
447
|
+
return undefined;
|
|
448
|
+
const pointedVersion = activeVersions[extensionId];
|
|
449
|
+
return pointedVersion === undefined
|
|
450
|
+
? newest
|
|
451
|
+
: versions.find(candidate => candidate.version === pointedVersion) ?? newest;
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* The extension-management service: describe, install, enable, disable.
|
|
455
|
+
*
|
|
456
|
+
* Construct one per host and share it between the wire transport and any local
|
|
457
|
+
* surface; it owns the in-process override list and must not be duplicated, or two
|
|
458
|
+
* instances would report different disabled sets.
|
|
459
|
+
*/
|
|
460
|
+
export class ExtensionsService {
|
|
461
|
+
#options;
|
|
462
|
+
/**
|
|
463
|
+
* This process's disabled choices, layered over `config.disabledExtensions`.
|
|
464
|
+
*
|
|
465
|
+
* An override lives for the life of the process: a Settings save that rewrites
|
|
466
|
+
* the config list cannot silently undo a toggle the operator just made here, and
|
|
467
|
+
* the override is dropped on restart, when the persisted config is read again.
|
|
468
|
+
*/
|
|
469
|
+
#overrides = new Map();
|
|
470
|
+
constructor(options = {}) {
|
|
471
|
+
this.#options = options;
|
|
472
|
+
}
|
|
473
|
+
/** The configured store as an absolute path, or `''` when unconfigured. */
|
|
474
|
+
#storeDir() {
|
|
475
|
+
const configured = this.#options.storeDir ?? '';
|
|
476
|
+
return configured.trim().length === 0 ? '' : resolve(configured);
|
|
477
|
+
}
|
|
478
|
+
/** The configured source directory as an absolute path, or `''` when unconfigured. */
|
|
479
|
+
#sourceDir() {
|
|
480
|
+
const configured = this.#options.sourceDir ?? '';
|
|
481
|
+
return configured.trim().length === 0 ? '' : resolve(configured);
|
|
482
|
+
}
|
|
483
|
+
/** The lifecycle profile this service reads and writes. */
|
|
484
|
+
#profile() {
|
|
485
|
+
return this.#options.profile ?? 'default';
|
|
486
|
+
}
|
|
487
|
+
/** The mounted lifecycle, or undefined when the host mounted none. */
|
|
488
|
+
#lifecycle() {
|
|
489
|
+
return this.#options.lifecycle?.();
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* Require a configured store.
|
|
493
|
+
* @throws ExtensionsServiceError `extension-store-unconfigured`.
|
|
494
|
+
* @returns the absolute store directory.
|
|
495
|
+
*/
|
|
496
|
+
#requireStoreDir() {
|
|
497
|
+
const storeDir = this.#storeDir();
|
|
498
|
+
if (storeDir === '') {
|
|
499
|
+
throw new ExtensionsServiceError('extension-store-unconfigured', 'no extension store is configured (config.extensionStore); install and activation state cannot be read or changed');
|
|
500
|
+
}
|
|
501
|
+
return storeDir;
|
|
502
|
+
}
|
|
503
|
+
/** The disabled set this process reports: configured ids, adjusted by local toggles. */
|
|
504
|
+
#effectiveDisabled() {
|
|
505
|
+
const ids = new Set(this.#options.disabledExtensions?.() ?? []);
|
|
506
|
+
for (const [id, disabled] of this.#overrides) {
|
|
507
|
+
if (disabled)
|
|
508
|
+
ids.add(id);
|
|
509
|
+
else
|
|
510
|
+
ids.delete(id);
|
|
511
|
+
}
|
|
512
|
+
return ids;
|
|
513
|
+
}
|
|
514
|
+
/** Record one toggle in this process. */
|
|
515
|
+
#rememberDisabled(extensionId, disabled) {
|
|
516
|
+
this.#overrides.set(extensionId, disabled);
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Hand the effective disabled list to the host's writer, when one exists.
|
|
520
|
+
* @throws ExtensionsServiceError `extension-override-persist-failed` when the
|
|
521
|
+
* writer rejects; the caller reports the partial outcome.
|
|
522
|
+
*/
|
|
523
|
+
async #persistDisabled() {
|
|
524
|
+
const persist = this.#options.persistDisabledExtensions;
|
|
525
|
+
if (persist === undefined)
|
|
526
|
+
return;
|
|
527
|
+
const ids = [...this.#effectiveDisabled()].sort();
|
|
528
|
+
try {
|
|
529
|
+
await persist(ids);
|
|
530
|
+
}
|
|
531
|
+
catch (error) {
|
|
532
|
+
throw new ExtensionsServiceError('extension-override-persist-failed', `the disabled list could not be persisted, so the change applies to this process only: ${messageOf(error)}`, { cause: error });
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
/** The content digest, through the test seam when one was supplied. */
|
|
536
|
+
#digest(directory) {
|
|
537
|
+
const digest = this.#options.digestContent ?? digestInstalledContent;
|
|
538
|
+
return digest(directory);
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* Select the installed version to operate on, refusing to guess over a corrupt store.
|
|
542
|
+
*
|
|
543
|
+
* A corrupt lock or profile document yields no records from the SDK's fail-closed
|
|
544
|
+
* read, and treating that as "not installed" would tell the operator the package
|
|
545
|
+
* is absent when the truth is that the store cannot be read.
|
|
546
|
+
* @param storeDir - the configured store.
|
|
547
|
+
* @param extensionId - the identity to select.
|
|
548
|
+
* @returns the current installed record.
|
|
549
|
+
* @throws ExtensionsServiceError `extension-store-unreadable` or `extension-not-installed`.
|
|
550
|
+
*/
|
|
551
|
+
#requireInstalled(storeDir, extensionId) {
|
|
552
|
+
const store = new ExtensionStore(storeDir);
|
|
553
|
+
const lock = store.readLock();
|
|
554
|
+
if (lock.corrupt) {
|
|
555
|
+
throw new ExtensionsServiceError('extension-store-unreadable', `the extension store lock at ${store.lockPath} is unreadable, so ${extensionId} cannot be managed: ${lock.diagnostic ?? 'unknown reason'}`);
|
|
556
|
+
}
|
|
557
|
+
const pointers = store.readProfilePointers(this.#profile());
|
|
558
|
+
if (pointers.corrupt) {
|
|
559
|
+
throw new ExtensionsServiceError('extension-store-unreadable', `the profile activation state for ${this.#profile()} is unreadable, so ${extensionId} cannot be managed: ${pointers.diagnostic ?? 'unknown reason'}`);
|
|
560
|
+
}
|
|
561
|
+
const selection = selectInstalled(lock.value.extensions, pointers.value.activeVersions, extensionId);
|
|
562
|
+
if (selection === undefined) {
|
|
563
|
+
throw new ExtensionsServiceError('extension-not-installed', `${extensionId} has no installed record in ${storeDir}; install it before changing its activation state`);
|
|
564
|
+
}
|
|
565
|
+
return selection;
|
|
566
|
+
}
|
|
567
|
+
/** The directory installer, through the test seam when one was supplied. */
|
|
568
|
+
#install(options) {
|
|
569
|
+
const install = this.#options.installDirectory ?? stagedInstallFromDirectory;
|
|
570
|
+
return install(options);
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* The dependency graph a first-party source install resolves against.
|
|
574
|
+
*
|
|
575
|
+
* The SDK calls it only when the package declares runtime dependencies; the
|
|
576
|
+
* default graph resolves each one from the host's own installed tree and refuses
|
|
577
|
+
* by name when it cannot. Constructing it is cheap and performs no I/O.
|
|
578
|
+
* @param sourcePackageDir - the checkout being installed.
|
|
579
|
+
* @returns the graph the SDK's directory installer consumes.
|
|
580
|
+
*/
|
|
581
|
+
#dependencyGraph(sourcePackageDir) {
|
|
582
|
+
const supplied = this.#options.dependencyGraph;
|
|
583
|
+
if (supplied !== undefined)
|
|
584
|
+
return supplied(sourcePackageDir);
|
|
585
|
+
const root = this.#options.preinstalledDependenciesRoot;
|
|
586
|
+
const base = {
|
|
587
|
+
sourcePackageDir,
|
|
588
|
+
allowPreinstalled: this.#options.allowPreinstalledDependencies === true,
|
|
589
|
+
};
|
|
590
|
+
return (root === undefined
|
|
591
|
+
? createFirstPartyDependencyGraph(base)
|
|
592
|
+
: createFirstPartyDependencyGraph({ ...base, preinstalledRoot: root })).graph;
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* Verify an installed copy's content digest.
|
|
596
|
+
* @param storeDir - the configured store.
|
|
597
|
+
* @param record - the installed record, when there is one.
|
|
598
|
+
* @returns the integrity verdict plus the diagnostic text when it is not unchanged.
|
|
599
|
+
*/
|
|
600
|
+
#integrityOf(storeDir, record) {
|
|
601
|
+
if (record === undefined || storeDir === '')
|
|
602
|
+
return { integrity: 'unknown', reason: null };
|
|
603
|
+
try {
|
|
604
|
+
const verdict = new ExtensionStore(storeDir).verifyUnchanged(record);
|
|
605
|
+
return {
|
|
606
|
+
integrity: verdict.unchanged ? 'unchanged' : 'changed',
|
|
607
|
+
reason: verdict.unchanged
|
|
608
|
+
? null
|
|
609
|
+
: `the installed content is not the content that was recorded: ${verdict.diagnostic ?? 'it changed on disk after installation'}`,
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
catch (error) {
|
|
613
|
+
return {
|
|
614
|
+
integrity: 'unknown',
|
|
615
|
+
reason: `the installed content could not be digested, so its integrity is unknown: ${messageOf(error)}`,
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* Compute EVERY prerequisite this deployment checked, satisfied or not.
|
|
621
|
+
*
|
|
622
|
+
* The list is the answer to "why can't I enable this?" BEFORE an enable is
|
|
623
|
+
* attempted: each unmet requirement names the exact fact and (when one exists)
|
|
624
|
+
* the action that clears it. Nothing here executes extension code; the only I/O
|
|
625
|
+
* is the store read the caller already performed, discovery of the package
|
|
626
|
+
* directory, a host-tree dependency walk, and the preparation PLAN (not an
|
|
627
|
+
* acquisition) when an executable approval might be pending.
|
|
628
|
+
* @param input - the row's already-read facts.
|
|
629
|
+
* @returns the requirements in display order (store, source, dependencies,
|
|
630
|
+
* platform, integrity, harness-model, executable, assets, activation).
|
|
631
|
+
*/
|
|
632
|
+
#computeRequirements(input) {
|
|
633
|
+
const requirements = [];
|
|
634
|
+
// STORE: configured and readable. Writability is a surface property the page
|
|
635
|
+
// already receives as `writable`; a read-only mount still answers reads.
|
|
636
|
+
if (input.storeDir === '') {
|
|
637
|
+
requirements.push(requirement('store', false, 'the extension store is not configured (config.extensionStore)', 'config'));
|
|
638
|
+
}
|
|
639
|
+
else if (input.lockCorrupt) {
|
|
640
|
+
requirements.push(requirement('store', false, `the extension store lock is unreadable, so no installed extension can be trusted: ${input.lockDiagnostic ?? 'unknown reason'}`, 'config'));
|
|
641
|
+
}
|
|
642
|
+
else if (input.profileStates.corrupt) {
|
|
643
|
+
requirements.push(requirement('store', false, `the profile activation state is unreadable, so the host restores nothing: ${input.profileStates.diagnostic ?? 'unknown reason'}`, 'config'));
|
|
644
|
+
}
|
|
645
|
+
else {
|
|
646
|
+
requirements.push(requirement('store', true, `the extension store at ${input.storeDir} is configured and readable`));
|
|
647
|
+
}
|
|
648
|
+
// SOURCE: where an install would get the bytes. The registry route is preferred
|
|
649
|
+
// by `auto`; the directory route (developer mode) is named when it applies.
|
|
650
|
+
if (input.installed) {
|
|
651
|
+
requirements.push(requirement('source', true, `the installed copy is the source of record (version ${input.record?.version ?? 'unknown'})`));
|
|
652
|
+
}
|
|
653
|
+
else {
|
|
654
|
+
const installSource = this.#options.installSource ?? 'auto';
|
|
655
|
+
const registryUrl = this.#registryBaseUrl();
|
|
656
|
+
const directoryRoute = input.sourceDir !== ''
|
|
657
|
+
&& input.sourcePackage !== undefined
|
|
658
|
+
&& input.sourcePackage.problem === null
|
|
659
|
+
&& this.#options.allowSourceDirectories === true;
|
|
660
|
+
const registryRoute = installSource !== 'directory' && registryUrl !== '';
|
|
661
|
+
if (registryRoute) {
|
|
662
|
+
requirements.push(requirement('source', true, `the npm registry ${registryUrl} can supply ${input.id}@latest, resolved and sha512-verified at install time`
|
|
663
|
+
+ (installSource === 'auto' && directoryRoute
|
|
664
|
+
? '; a directory fallback is available if the registry is unreachable'
|
|
665
|
+
: ''), 'install'));
|
|
666
|
+
}
|
|
667
|
+
else if (installSource === 'registry') {
|
|
668
|
+
requirements.push(requirement('source', false, 'no npm registry is configured (config.extensionRegistry), and registry-only installs are selected (config.extensionInstallSource)', 'config'));
|
|
669
|
+
}
|
|
670
|
+
else if (input.sourcePackage === undefined) {
|
|
671
|
+
requirements.push(requirement('source', false, input.sourceDir === ''
|
|
672
|
+
? 'not installed, and no first-party source directory is configured (config.extensionSourceDir)'
|
|
673
|
+
: `no first-party package with this identity exists under ${input.sourceDir}`, input.sourceDir === '' ? 'config' : 'none'));
|
|
674
|
+
}
|
|
675
|
+
else if (input.sourcePackage.problem !== null) {
|
|
676
|
+
requirements.push(requirement('source', false, input.sourcePackage.problem, 'none'));
|
|
677
|
+
}
|
|
678
|
+
else if (this.#options.allowSourceDirectories !== true) {
|
|
679
|
+
requirements.push(requirement('source', false, 'installing from a source directory is disabled by configuration (extensions.allowSourceDirectories)', 'config'));
|
|
680
|
+
}
|
|
681
|
+
else {
|
|
682
|
+
requirements.push(requirement('source', true, `the first-party package is available at ${join(input.sourceDir, packageDirFor(input.id))}`, 'install'));
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
// DEPENDENCIES: declared runtime dependencies must be resolvable/materialized.
|
|
686
|
+
requirements.push(this.#dependencyRequirement(input));
|
|
687
|
+
// PLATFORM: the manifest's declared deployment platforms must include this host.
|
|
688
|
+
if (input.facts.problem !== null) {
|
|
689
|
+
requirements.push(requirement('platform', false, input.facts.problem, 'none'));
|
|
690
|
+
}
|
|
691
|
+
else if (input.facts.platforms.length === 0) {
|
|
692
|
+
requirements.push(requirement('platform', true, 'the extension declares no platform-specific model deployment'));
|
|
693
|
+
}
|
|
694
|
+
else if (hostPlatformMatches(input.facts.platforms)) {
|
|
695
|
+
requirements.push(requirement('platform', true, `the declared deployment supports this host (${process.platform}/${process.arch}: ${input.facts.platforms.map(platformLabel).join(', ')})`));
|
|
696
|
+
}
|
|
697
|
+
else {
|
|
698
|
+
requirements.push(requirement('platform', false, `the extension supports ${input.facts.platforms.map(platformLabel).join(', ')} but this host is ${process.platform}/${process.arch}`, 'none'));
|
|
699
|
+
}
|
|
700
|
+
// INTEGRITY: the installed bytes must still be the recorded bytes.
|
|
701
|
+
if (!input.installed) {
|
|
702
|
+
requirements.push(requirement('integrity', true, 'the extension is not installed, so there are no installed bytes to verify'));
|
|
703
|
+
}
|
|
704
|
+
else if (input.integrity === 'unchanged') {
|
|
705
|
+
requirements.push(requirement('integrity', true, 'the installed content matches the recorded digest'));
|
|
706
|
+
}
|
|
707
|
+
else {
|
|
708
|
+
requirements.push(requirement('integrity', false, input.integrityReason ?? `the installed content's integrity is ${input.integrity}`, 'install'));
|
|
709
|
+
}
|
|
710
|
+
// HARNESS MODEL: only declarative packages are served by the host broker.
|
|
711
|
+
if (input.facts.executionKind === 'declarative') {
|
|
712
|
+
requirements.push(this.#harnessModelRequirement());
|
|
713
|
+
}
|
|
714
|
+
// Readiness drives both the executable gate and the assets requirement.
|
|
715
|
+
const blockers = preparationBlockers(input.state?.readiness ?? {}, input.facts.models);
|
|
716
|
+
// EXECUTABLE: a downloaded engine needs the separate RES-02 approval.
|
|
717
|
+
if (input.facts.models.length === 0) {
|
|
718
|
+
requirements.push(requirement('executable', true, 'the extension declares no model deployment, so no executable asset can be planned'));
|
|
719
|
+
}
|
|
720
|
+
else if (!input.installed) {
|
|
721
|
+
requirements.push(requirement('executable', true, 'not installed yet; the executable-asset approval is checked once the preparation plan can be resolved'));
|
|
722
|
+
}
|
|
723
|
+
else if (blockers.length === 0) {
|
|
724
|
+
requirements.push(requirement('executable', true, 'every declared model deployment is already prepared or callable, so no executable approval is pending'));
|
|
725
|
+
}
|
|
726
|
+
else {
|
|
727
|
+
requirements.push(this.#executableRequirement(input.id));
|
|
728
|
+
}
|
|
729
|
+
// ASSETS: each declared deployment must be in a callable readiness state.
|
|
730
|
+
if (input.facts.models.length === 0) {
|
|
731
|
+
requirements.push(requirement('assets', true, 'the extension declares no model deployment to prepare'));
|
|
732
|
+
}
|
|
733
|
+
else if (!input.installed) {
|
|
734
|
+
requirements.push(requirement('assets', false, `not installed; ${input.facts.models.join(', ')} need${input.facts.models.length === 1 ? 's' : ''} model assets prepared after installation`, 'install'));
|
|
735
|
+
}
|
|
736
|
+
else if (blockers.length > 0) {
|
|
737
|
+
requirements.push(requirement('assets', false, `activation blocked: ${blockers.join('; ')}`, 'prepare'));
|
|
738
|
+
}
|
|
739
|
+
else {
|
|
740
|
+
requirements.push(requirement('assets', true, `every declared model deployment is prepared or callable (${input.facts.models.join(', ')})`));
|
|
741
|
+
}
|
|
742
|
+
// ACTIVATION: a stored fault that a retry cannot clear.
|
|
743
|
+
if (!input.installed) {
|
|
744
|
+
requirements.push(requirement('activation', true, 'not installed; no activation state exists'));
|
|
745
|
+
}
|
|
746
|
+
else if (input.state?.observedActivation === 'quarantined') {
|
|
747
|
+
requirements.push(requirement('activation', false, 'the host quarantined it: some contributions could not be withdrawn, so it is not fully stopped', 'none'));
|
|
748
|
+
}
|
|
749
|
+
else if (input.state?.observedActivation === 'activating' || input.state?.observedActivation === 'deactivating') {
|
|
750
|
+
requirements.push(requirement('activation', false, `a ${input.state.observedActivation} operation was interrupted; the recorded state is not settled`, 'none'));
|
|
751
|
+
}
|
|
752
|
+
else if (input.state?.observedActivation === 'failed') {
|
|
753
|
+
const explained = requirements.some(candidate => !candidate.satisfied);
|
|
754
|
+
requirements.push(requirement('activation', true, explained
|
|
755
|
+
? 'the last activation attempt failed; the unmet requirement above is the actionable blocker'
|
|
756
|
+
: 'the last activation attempt failed, but no remaining prerequisite blocks a retry, so enable retries activation'));
|
|
757
|
+
}
|
|
758
|
+
else if (input.state === undefined) {
|
|
759
|
+
requirements.push(requirement('activation', true, 'installed but never activated in this profile; no stored activation fault'));
|
|
760
|
+
}
|
|
761
|
+
else {
|
|
762
|
+
requirements.push(requirement('activation', true, `the stored activation state is ${input.state.observedActivation}`));
|
|
763
|
+
}
|
|
764
|
+
return requirements;
|
|
765
|
+
}
|
|
766
|
+
/** The dependencies requirement: materialized in the installed copy, or resolvable in the host tree. */
|
|
767
|
+
#dependencyRequirement(input) {
|
|
768
|
+
if (input.facts.dependencies.length === 0) {
|
|
769
|
+
return requirement('dependencies', true, 'the package declares no runtime dependencies');
|
|
770
|
+
}
|
|
771
|
+
const count = input.facts.dependencies.length;
|
|
772
|
+
const missing = [];
|
|
773
|
+
if (input.installed && input.record !== undefined) {
|
|
774
|
+
for (const dependency of input.facts.dependencies) {
|
|
775
|
+
const installed = join(input.record.installPath, 'node_modules', ...dependency.name.split('/'), 'package.json');
|
|
776
|
+
if (!existsSync(installed))
|
|
777
|
+
missing.push(`${dependency.name} (declared ${dependency.range})`);
|
|
778
|
+
}
|
|
779
|
+
return missing.length === 0
|
|
780
|
+
? requirement('dependencies', true, `all ${count} declared runtime dependenc${count === 1 ? 'y is' : 'ies are'} materialized inside the extension's own installation`)
|
|
781
|
+
: requirement('dependencies', false, `the installed copy is missing ${missing.length} declared runtime dependenc${missing.length === 1 ? 'y' : 'ies'}: ${missing.join(', ')}`, 'install');
|
|
782
|
+
}
|
|
783
|
+
if (input.facts.packageDir === null) {
|
|
784
|
+
return requirement('dependencies', false, 'the declared runtime dependencies cannot be inspected because the package directory is unknown', 'none');
|
|
785
|
+
}
|
|
786
|
+
for (const dependency of input.facts.dependencies) {
|
|
787
|
+
try {
|
|
788
|
+
resolveFirstPartyDependency({ sourcePackageDir: input.facts.packageDir }, dependency.name, dependency.range);
|
|
789
|
+
}
|
|
790
|
+
catch {
|
|
791
|
+
missing.push(`${dependency.name} (declared ${dependency.range})`);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
return missing.length === 0
|
|
795
|
+
? requirement('dependencies', true, `all ${count} declared runtime dependenc${count === 1 ? 'y resolves' : 'ies resolve'} in the host's installed tree`)
|
|
796
|
+
: requirement('dependencies', false, `the host's tree is missing ${missing.length} declared runtime dependenc${missing.length === 1 ? 'y' : 'ies'}: ${missing.join(', ')}; install the dependency for the host first`, 'none');
|
|
797
|
+
}
|
|
798
|
+
/** The harness-model requirement for a declarative package. */
|
|
799
|
+
#harnessModelRequirement() {
|
|
800
|
+
const status = this.#options.harnessProviderStatus?.();
|
|
801
|
+
if (status === undefined) {
|
|
802
|
+
return requirement('harness-model', false, 'the host could not report whether a harness model provider is available (provider status unknown); '
|
|
803
|
+
+ 'a declarative extension needs a composed provider, for example config.harnessModel', 'config');
|
|
804
|
+
}
|
|
805
|
+
if (status.available) {
|
|
806
|
+
const identity = [status.provider, status.model].filter((part) => part !== undefined && part.length > 0);
|
|
807
|
+
return requirement('harness-model', true, identity.length === 0
|
|
808
|
+
? 'a harness model provider is available for declarative extensions'
|
|
809
|
+
: `a harness model provider is available (${identity.join(' / ')})`);
|
|
810
|
+
}
|
|
811
|
+
return requirement('harness-model', false, status.reason
|
|
812
|
+
?? 'no harness model provider is configured, so declarative extensions cannot be served; set config.harnessModel to a runnable model', status.action ?? 'config');
|
|
813
|
+
}
|
|
814
|
+
/** The executable requirement for an installed, not-yet-callable model deployment. */
|
|
815
|
+
#executableRequirement(extensionId) {
|
|
816
|
+
const surface = this.#options.prepareSurface?.();
|
|
817
|
+
if (surface === undefined) {
|
|
818
|
+
/**
|
|
819
|
+
* No plan exists to approve, so this requirement is NOT what blocks the row:
|
|
820
|
+
* the assets requirement names the missing preparation. Reporting it unmet
|
|
821
|
+
* here would replace the actionable "run prepare" text with a host gap the
|
|
822
|
+
* row cannot fix.
|
|
823
|
+
*/
|
|
824
|
+
return requirement('executable', true, 'the prepared-assets surface is not mounted, so there is no preparation plan whose executable assets could be approved; '
|
|
825
|
+
+ 'the assets requirement reports the missing preparation');
|
|
826
|
+
}
|
|
827
|
+
let plan;
|
|
828
|
+
try {
|
|
829
|
+
plan = surface.plan(extensionId);
|
|
830
|
+
}
|
|
831
|
+
catch (error) {
|
|
832
|
+
return requirement('executable', true, `the preparation plan could not be inspected, so executable approval is not the blocker: ${messageOf(error)}`);
|
|
833
|
+
}
|
|
834
|
+
if (!plan.requiresExecutableApproval) {
|
|
835
|
+
return requirement('executable', true, 'the preparation plan declares no executable assets');
|
|
836
|
+
}
|
|
837
|
+
return this.#options.allowExecutablePreparation?.() === true
|
|
838
|
+
? requirement('executable', true, 'the preparation plan includes an executable asset and the operator approved executable preparation (config.allowExecutablePreparation)')
|
|
839
|
+
: requirement('executable', false, 'activation blocked: the preparation plan includes an executable asset, and executable preparation is not approved for the client path '
|
|
840
|
+
+ '(config.allowExecutablePreparation is off)', 'config');
|
|
841
|
+
}
|
|
842
|
+
/**
|
|
843
|
+
* Read the current state of the extension surface.
|
|
844
|
+
*
|
|
845
|
+
* Every row is derived from the store, the profile state and the source
|
|
846
|
+
* directory; nothing is cached between calls, so a page that re-reads after an
|
|
847
|
+
* action sees the committed result.
|
|
848
|
+
* @param signal - cancellation.
|
|
849
|
+
* @returns the store/source configuration and one row per known extension.
|
|
850
|
+
*/
|
|
851
|
+
async describe(signal) {
|
|
852
|
+
signal?.throwIfAborted();
|
|
853
|
+
const storeDir = this.#storeDir();
|
|
854
|
+
const sourceDir = this.#sourceDir();
|
|
855
|
+
const disabled = this.#effectiveDisabled();
|
|
856
|
+
let lockCorrupt = false;
|
|
857
|
+
let lockDiagnostic = null;
|
|
858
|
+
let installed = [];
|
|
859
|
+
let profileStates = { byId: new Map(), corrupt: false, diagnostic: null };
|
|
860
|
+
let activeVersions = {};
|
|
861
|
+
if (storeDir !== '') {
|
|
862
|
+
const store = new ExtensionStore(storeDir);
|
|
863
|
+
const lock = store.readLock();
|
|
864
|
+
lockCorrupt = lock.corrupt;
|
|
865
|
+
lockDiagnostic = lock.diagnostic ?? null;
|
|
866
|
+
installed = lock.value.extensions;
|
|
867
|
+
profileStates = readProfileStates(store, this.#profile());
|
|
868
|
+
activeVersions = store.readProfilePointers(this.#profile()).value.activeVersions;
|
|
869
|
+
}
|
|
870
|
+
const sourcePackages = new Map();
|
|
871
|
+
if (sourceDir !== '') {
|
|
872
|
+
for (const candidate of listSourcePackages(sourceDir))
|
|
873
|
+
sourcePackages.set(candidate.id, candidate);
|
|
874
|
+
}
|
|
875
|
+
const ids = new Set();
|
|
876
|
+
for (const record of installed)
|
|
877
|
+
ids.add(record.extensionId);
|
|
878
|
+
for (const id of profileStates.byId.keys())
|
|
879
|
+
ids.add(id);
|
|
880
|
+
for (const id of sourcePackages.keys())
|
|
881
|
+
ids.add(id);
|
|
882
|
+
const extensions = [...ids].sort().map(id => this.#describeOne({
|
|
883
|
+
id,
|
|
884
|
+
installed,
|
|
885
|
+
activeVersions,
|
|
886
|
+
profileStates,
|
|
887
|
+
sourcePackage: sourcePackages.get(id),
|
|
888
|
+
disabled: disabled.has(id),
|
|
889
|
+
storeDir,
|
|
890
|
+
sourceDir,
|
|
891
|
+
lockCorrupt,
|
|
892
|
+
lockDiagnostic,
|
|
893
|
+
}));
|
|
894
|
+
const writable = storeDir !== ''
|
|
895
|
+
&& !lockCorrupt
|
|
896
|
+
&& this.#options.readOnly !== true
|
|
897
|
+
&& directoryWritable(storeDir);
|
|
898
|
+
return {
|
|
899
|
+
store: storeDir === '' ? null : storeDir,
|
|
900
|
+
writable,
|
|
901
|
+
sourceDir: sourceDir === '' ? null : sourceDir,
|
|
902
|
+
extensions,
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
/** Build one row, keeping the reason precedence in one place. */
|
|
906
|
+
#describeOne(view) {
|
|
907
|
+
const selection = selectInstalled(view.installed, view.activeVersions, view.id);
|
|
908
|
+
const record = selection;
|
|
909
|
+
const state = view.profileStates.byId.get(view.id);
|
|
910
|
+
const installed = record !== undefined;
|
|
911
|
+
const integrity = this.#integrityOf(view.storeDir, record);
|
|
912
|
+
const facts = record !== undefined
|
|
913
|
+
? readExtensionFacts(record.installPath)
|
|
914
|
+
: view.sourcePackage?.facts ?? EMPTY_FACTS;
|
|
915
|
+
const active = state !== undefined
|
|
916
|
+
&& state.observedActivation === 'active'
|
|
917
|
+
&& (state.ownerPid === undefined || state.ownerPid === process.pid);
|
|
918
|
+
const stateTools = state?.tools.map(tool => tool.toolName) ?? [];
|
|
919
|
+
const tools = facts.tools.length > 0
|
|
920
|
+
? facts.tools
|
|
921
|
+
: stateTools.length > 0
|
|
922
|
+
? stateTools
|
|
923
|
+
: view.sourcePackage?.tools ?? [];
|
|
924
|
+
const requirements = this.#computeRequirements({
|
|
925
|
+
id: view.id,
|
|
926
|
+
installed,
|
|
927
|
+
record,
|
|
928
|
+
state,
|
|
929
|
+
integrity: integrity.integrity,
|
|
930
|
+
integrityReason: integrity.reason,
|
|
931
|
+
storeDir: view.storeDir,
|
|
932
|
+
sourceDir: view.sourceDir,
|
|
933
|
+
lockCorrupt: view.lockCorrupt,
|
|
934
|
+
lockDiagnostic: view.lockDiagnostic,
|
|
935
|
+
profileStates: view.profileStates,
|
|
936
|
+
sourcePackage: view.sourcePackage,
|
|
937
|
+
facts,
|
|
938
|
+
});
|
|
939
|
+
const blocked = requirements.some(candidate => blocksRow(installed, candidate));
|
|
940
|
+
const version = record?.version ?? view.sourcePackage?.version ?? null;
|
|
941
|
+
const reason = this.#reasonFor({
|
|
942
|
+
...view,
|
|
943
|
+
state,
|
|
944
|
+
integrityReason: integrity.reason,
|
|
945
|
+
active,
|
|
946
|
+
installed,
|
|
947
|
+
requirements,
|
|
948
|
+
});
|
|
949
|
+
return {
|
|
950
|
+
id: view.id,
|
|
951
|
+
package: view.id,
|
|
952
|
+
version,
|
|
953
|
+
installed,
|
|
954
|
+
active,
|
|
955
|
+
disabled: view.disabled,
|
|
956
|
+
integrity: integrity.integrity,
|
|
957
|
+
tools: [...tools],
|
|
958
|
+
reason,
|
|
959
|
+
requirements,
|
|
960
|
+
blocked,
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
/** The first condition worth telling the operator about, or null when none is known. */
|
|
964
|
+
#reasonFor(view) {
|
|
965
|
+
if (view.lockCorrupt) {
|
|
966
|
+
return `the extension store lock is unreadable, so no installed extension can be trusted: ${view.lockDiagnostic ?? 'unknown reason'}`;
|
|
967
|
+
}
|
|
968
|
+
if (view.integrityReason !== null)
|
|
969
|
+
return view.integrityReason;
|
|
970
|
+
if (view.profileStates.corrupt) {
|
|
971
|
+
return `the profile activation state is unreadable, so the host restores nothing: ${view.profileStates.diagnostic ?? 'unknown reason'}`;
|
|
972
|
+
}
|
|
973
|
+
if (view.storeDir === '') {
|
|
974
|
+
return 'the extension store is not configured (config.extensionStore)';
|
|
975
|
+
}
|
|
976
|
+
/**
|
|
977
|
+
* The highest-priority UNMET requirement that actually blocks this row is the
|
|
978
|
+
* single summary. The wording is the requirement's own, so the page, the
|
|
979
|
+
* preflight error and the requirement list never disagree.
|
|
980
|
+
*/
|
|
981
|
+
const unmet = firstBlockingRequirement(view.requirements, view.installed);
|
|
982
|
+
if (view.state?.observedActivation === 'failed') {
|
|
983
|
+
return unmet?.reason
|
|
984
|
+
?? 'activation failed earlier in this host; the store records no remaining preparation blocker, so retry enable to see whether it succeeds';
|
|
985
|
+
}
|
|
986
|
+
if (view.state?.observedActivation === 'quarantined') {
|
|
987
|
+
return unmet?.reason
|
|
988
|
+
?? 'the host quarantined it: some contributions could not be withdrawn, so it is not fully stopped';
|
|
989
|
+
}
|
|
990
|
+
if (view.state?.observedActivation === 'activating' || view.state?.observedActivation === 'deactivating') {
|
|
991
|
+
return unmet?.reason
|
|
992
|
+
?? `a ${view.state.observedActivation} operation was interrupted; the recorded state is not settled`;
|
|
993
|
+
}
|
|
994
|
+
if (view.disabled) {
|
|
995
|
+
return 'disabled by the operator (config.disabledExtensions); the host does not restore it';
|
|
996
|
+
}
|
|
997
|
+
if (!view.installed) {
|
|
998
|
+
if (view.sourcePackage?.problem !== null && view.sourcePackage?.problem !== undefined)
|
|
999
|
+
return view.sourcePackage.problem;
|
|
1000
|
+
// The route-aware source requirement already explains where an install would
|
|
1001
|
+
// get the bytes (registry, directory, or why neither is available).
|
|
1002
|
+
const sourceRequirement = view.requirements.find(candidate => candidate.kind === 'source');
|
|
1003
|
+
if (sourceRequirement !== undefined)
|
|
1004
|
+
return sourceRequirement.reason;
|
|
1005
|
+
return 'not installed';
|
|
1006
|
+
}
|
|
1007
|
+
if (unmet !== undefined)
|
|
1008
|
+
return unmet.reason;
|
|
1009
|
+
if (!view.active) {
|
|
1010
|
+
if (view.state === undefined)
|
|
1011
|
+
return 'installed but never activated in this profile';
|
|
1012
|
+
return `installed; this host observes it as ${view.state.observedActivation} (desired ${view.state.desiredActivation})`;
|
|
1013
|
+
}
|
|
1014
|
+
return null;
|
|
1015
|
+
}
|
|
1016
|
+
/**
|
|
1017
|
+
* Install one extension, choosing the route from the operator configuration.
|
|
1018
|
+
*
|
|
1019
|
+
* - An EXPLICIT `source` path is always a developer-mode directory install (the
|
|
1020
|
+
* existing source-directory gate and refusals apply).
|
|
1021
|
+
* - Otherwise `installSource` decides. `registry` resolves `id@latest` from the
|
|
1022
|
+
* npm registry. `directory` uses the configured source directory. `auto` (the
|
|
1023
|
+
* default) prefers the registry and falls back to the directory ONLY for a
|
|
1024
|
+
* transport-level registry failure (unreachable/timeout/5xx) when directory
|
|
1025
|
+
* installs are allowed — a package-level refusal (404, missing integrity, bad
|
|
1026
|
+
* tarball, digest mismatch) is always named and never retried locally.
|
|
1027
|
+
*
|
|
1028
|
+
* INSTALLATION IS NOT ACTIVATION: the result is always `active: false`, and it
|
|
1029
|
+
* names the route that actually supplied the bytes.
|
|
1030
|
+
* @param extensionId - the package identity to install.
|
|
1031
|
+
* @param source - an explicit source directory (developer override), or absent.
|
|
1032
|
+
* @param signal - cancellation.
|
|
1033
|
+
* @returns the installed identity, version and route.
|
|
1034
|
+
* @throws ExtensionsServiceError for every refusal and failure.
|
|
1035
|
+
*/
|
|
1036
|
+
async install(extensionId, source, signal) {
|
|
1037
|
+
signal?.throwIfAborted();
|
|
1038
|
+
assertUsableId(extensionId);
|
|
1039
|
+
const storeDir = this.#requireStoreDir();
|
|
1040
|
+
if (source !== undefined && source.trim().length > 0) {
|
|
1041
|
+
return this.#installFromDirectory(extensionId, resolve(source), storeDir, signal);
|
|
1042
|
+
}
|
|
1043
|
+
const mode = this.#options.installSource ?? 'auto';
|
|
1044
|
+
if (mode === 'directory') {
|
|
1045
|
+
return this.#installFromDirectory(extensionId, undefined, storeDir, signal);
|
|
1046
|
+
}
|
|
1047
|
+
try {
|
|
1048
|
+
return await this.#installFromRegistry(extensionId, storeDir, signal);
|
|
1049
|
+
}
|
|
1050
|
+
catch (error) {
|
|
1051
|
+
const transportFallback = mode === 'auto'
|
|
1052
|
+
&& error instanceof ExtensionsServiceError
|
|
1053
|
+
&& (error.code === 'extension-registry-unavailable' || error.code === 'extension-registry-unconfigured')
|
|
1054
|
+
&& this.#options.allowSourceDirectories === true
|
|
1055
|
+
&& this.#directorySourceAvailable(extensionId);
|
|
1056
|
+
if (!transportFallback)
|
|
1057
|
+
throw error;
|
|
1058
|
+
return this.#installFromDirectory(extensionId, undefined, storeDir, signal);
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
/** The configured registry base URL, trimmed ('' when the operator set it empty). */
|
|
1062
|
+
#registryBaseUrl() {
|
|
1063
|
+
return (this.#options.registryUrl ?? DEFAULT_REGISTRY_URL).trim();
|
|
1064
|
+
}
|
|
1065
|
+
/** Whether the configured source directory holds a package directory for this id. */
|
|
1066
|
+
#directorySourceAvailable(extensionId) {
|
|
1067
|
+
const sourceDir = this.#sourceDir();
|
|
1068
|
+
if (sourceDir === '')
|
|
1069
|
+
return false;
|
|
1070
|
+
try {
|
|
1071
|
+
return statSync(join(sourceDir, packageDirFor(extensionId))).isDirectory();
|
|
1072
|
+
}
|
|
1073
|
+
catch {
|
|
1074
|
+
return false;
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
/**
|
|
1078
|
+
* Map a registry failure onto this service's named codes.
|
|
1079
|
+
* @param error - an error from the registry session or the SDK install.
|
|
1080
|
+
* @returns the service refusal, preserving the message and cause.
|
|
1081
|
+
*/
|
|
1082
|
+
#mapRegistryFailure(error) {
|
|
1083
|
+
if (!(error instanceof RegistryError)) {
|
|
1084
|
+
return new ExtensionsServiceError('extension-install-failed', messageOf(error), { cause: error });
|
|
1085
|
+
}
|
|
1086
|
+
const codes = {
|
|
1087
|
+
'registry-not-configured': 'extension-registry-unconfigured',
|
|
1088
|
+
'registry-not-found': 'extension-registry-not-found',
|
|
1089
|
+
'registry-no-matching-version': 'extension-registry-not-found',
|
|
1090
|
+
'registry-integrity-missing': 'extension-registry-integrity-missing',
|
|
1091
|
+
'registry-invalid-metadata': 'extension-registry-invalid-metadata',
|
|
1092
|
+
'registry-tarball-fetch-failed': 'extension-registry-fetch-failed',
|
|
1093
|
+
'registry-tarball-too-large': 'extension-registry-fetch-failed',
|
|
1094
|
+
'registry-unavailable': 'extension-registry-unavailable',
|
|
1095
|
+
};
|
|
1096
|
+
return new ExtensionsServiceError(codes[error.code], error.message, { cause: error });
|
|
1097
|
+
}
|
|
1098
|
+
/**
|
|
1099
|
+
* Install the extension package from the npm registry (task-104).
|
|
1100
|
+
*
|
|
1101
|
+
* The registry metadata is resolved to one exact version with a sha512 integrity;
|
|
1102
|
+
* the tarball is fetched bounded, verified by `stagedInstall` BEFORE extraction,
|
|
1103
|
+
* and installed through the SDK's tar-gzip ArchiveSource. The extension's runtime
|
|
1104
|
+
* dependencies are resolved from the SAME registry through the SDK dependency
|
|
1105
|
+
* graph (never the host-tree resolver), and EXT-10's install-script refusal is the
|
|
1106
|
+
* SDK's own.
|
|
1107
|
+
* @param extensionId - the package identity to install.
|
|
1108
|
+
* @param storeDir - the configured store.
|
|
1109
|
+
* @param signal - cancellation.
|
|
1110
|
+
* @returns the installed identity/version and `source: 'registry'`.
|
|
1111
|
+
* @throws ExtensionsServiceError for every refusal.
|
|
1112
|
+
*/
|
|
1113
|
+
async #installFromRegistry(extensionId, storeDir, signal) {
|
|
1114
|
+
const registryUrl = this.#registryBaseUrl();
|
|
1115
|
+
if (registryUrl === '') {
|
|
1116
|
+
throw new ExtensionsServiceError('extension-registry-unconfigured', 'no npm registry is configured (config.extensionRegistry), so registry installs are unavailable');
|
|
1117
|
+
}
|
|
1118
|
+
const session = createRegistrySession({
|
|
1119
|
+
registryUrl,
|
|
1120
|
+
archiveFactory: createTarGzipArchiveSource,
|
|
1121
|
+
maxTarballBytes: this.#options.registryTarballMaxBytes ?? MAX_DEPENDENCY_ARCHIVE_BYTES,
|
|
1122
|
+
});
|
|
1123
|
+
let resolved;
|
|
1124
|
+
try {
|
|
1125
|
+
// Initial slice: the Settings page does not carry a version yet, so the
|
|
1126
|
+
// configured route installs the registry's `latest` dist-tag. Exact versions
|
|
1127
|
+
// and semver ranges are supported by the resolver itself (dependency ranges
|
|
1128
|
+
// use them); a wire version field is a later slice.
|
|
1129
|
+
resolved = await session.resolve(extensionId, 'latest', signal);
|
|
1130
|
+
}
|
|
1131
|
+
catch (error) {
|
|
1132
|
+
throw this.#mapRegistryFailure(error);
|
|
1133
|
+
}
|
|
1134
|
+
let installed;
|
|
1135
|
+
try {
|
|
1136
|
+
installed = await stagedInstall({
|
|
1137
|
+
source: { kind: 'registry', requested: `${extensionId}@${resolved.version}` },
|
|
1138
|
+
storeDir,
|
|
1139
|
+
expectedSha512: resolved.integrity,
|
|
1140
|
+
archive: session.archive,
|
|
1141
|
+
// Registry metadata recorded AS DATA, never as trust: the bytes are
|
|
1142
|
+
// verified against the same sha512 before anything is extracted.
|
|
1143
|
+
provenance: { registryIntegrity: resolved.integrity },
|
|
1144
|
+
dependencies: createRegistryDependencyGraph(session),
|
|
1145
|
+
});
|
|
1146
|
+
}
|
|
1147
|
+
catch (error) {
|
|
1148
|
+
if (error instanceof RegistryError)
|
|
1149
|
+
throw this.#mapRegistryFailure(error);
|
|
1150
|
+
if (error instanceof ManagementError) {
|
|
1151
|
+
if (error.code === 'INTEGRITY_MISMATCH') {
|
|
1152
|
+
throw new ExtensionsServiceError('extension-registry-integrity-mismatch', `${extensionId}@${resolved.version}: ${error.message}`, { cause: error });
|
|
1153
|
+
}
|
|
1154
|
+
if (error.code === 'DEPENDENCY_UNSATISFIED') {
|
|
1155
|
+
throw new ExtensionsServiceError('extension-dependency-unsatisfied', `[DEPENDENCY_UNSATISFIED] ${error.message}`, { cause: error });
|
|
1156
|
+
}
|
|
1157
|
+
throw new ExtensionsServiceError('extension-install-failed', `[${error.code}] ${error.message}`, { cause: error });
|
|
1158
|
+
}
|
|
1159
|
+
throw new ExtensionsServiceError('extension-install-failed', messageOf(error), { cause: error });
|
|
1160
|
+
}
|
|
1161
|
+
if (installed.extensionId !== extensionId) {
|
|
1162
|
+
throw new ExtensionsServiceError('extension-source-mismatch', `the registry tarball requested as ${extensionId}@${resolved.version} contains ${JSON.stringify(installed.extensionId)}`);
|
|
1163
|
+
}
|
|
1164
|
+
if (installed.archiveDigest.trim().length === 0) {
|
|
1165
|
+
throw new ExtensionsServiceError('extension-digest-unavailable', `${installed.extensionId}@${installed.version} was installed without a content digest, so it cannot be verified before activation`);
|
|
1166
|
+
}
|
|
1167
|
+
return {
|
|
1168
|
+
id: installed.extensionId,
|
|
1169
|
+
version: installed.version,
|
|
1170
|
+
active: false,
|
|
1171
|
+
source: 'registry',
|
|
1172
|
+
resolvedVersion: resolved.version,
|
|
1173
|
+
};
|
|
1174
|
+
}
|
|
1175
|
+
/**
|
|
1176
|
+
* Install one extension from a local source directory (EXT-06 developer mode).
|
|
1177
|
+
*
|
|
1178
|
+
* Refusals are named and happen BEFORE the copy: an unconfigured store, an
|
|
1179
|
+
* unconfigured/missing source, a source whose package identity differs from the
|
|
1180
|
+
* requested id, a disallowed source-directory install, or content that cannot be
|
|
1181
|
+
* digested. The SDK then copies, validates, verifies and records the install; a
|
|
1182
|
+
* failure there is re-thrown as `extension-install-failed` with the SDK's own
|
|
1183
|
+
* named code in the message.
|
|
1184
|
+
* @param extensionId - the package identity to install.
|
|
1185
|
+
* @param explicitSource - an explicit source directory, or undefined for the configured one.
|
|
1186
|
+
* @param storeDir - the configured store.
|
|
1187
|
+
* @param signal - cancellation.
|
|
1188
|
+
* @returns the installed identity/version and `source: 'directory'`.
|
|
1189
|
+
* @throws ExtensionsServiceError for every refusal and failure.
|
|
1190
|
+
*/
|
|
1191
|
+
async #installFromDirectory(extensionId, explicitSource, storeDir, signal) {
|
|
1192
|
+
signal?.throwIfAborted();
|
|
1193
|
+
const sourceDir = explicitSource
|
|
1194
|
+
?? (() => {
|
|
1195
|
+
const configured = this.#sourceDir();
|
|
1196
|
+
if (configured === '') {
|
|
1197
|
+
throw new ExtensionsServiceError('extension-source-unconfigured', `no source was given for ${extensionId} and no first-party source directory is configured (config.extensionSourceDir)`);
|
|
1198
|
+
}
|
|
1199
|
+
return join(configured, packageDirFor(extensionId));
|
|
1200
|
+
})();
|
|
1201
|
+
let sourceIsDirectory = false;
|
|
1202
|
+
try {
|
|
1203
|
+
sourceIsDirectory = statSync(sourceDir).isDirectory();
|
|
1204
|
+
}
|
|
1205
|
+
catch {
|
|
1206
|
+
sourceIsDirectory = false;
|
|
1207
|
+
}
|
|
1208
|
+
if (!sourceIsDirectory) {
|
|
1209
|
+
throw new ExtensionsServiceError('extension-source-missing', `the source directory ${sourceDir} does not exist or is not a directory`);
|
|
1210
|
+
}
|
|
1211
|
+
let discovered;
|
|
1212
|
+
try {
|
|
1213
|
+
discovered = discoverExtension(sourceDir);
|
|
1214
|
+
}
|
|
1215
|
+
catch (error) {
|
|
1216
|
+
throw new ExtensionsServiceError('extension-source-invalid', `the source directory ${sourceDir} is not a usable extension package: ${messageOf(error)}`, { cause: error });
|
|
1217
|
+
}
|
|
1218
|
+
if (discovered.packageName !== extensionId) {
|
|
1219
|
+
throw new ExtensionsServiceError('extension-source-mismatch', `the source directory ${sourceDir} contains ${JSON.stringify(discovered.packageName)}, not ${JSON.stringify(extensionId)}`);
|
|
1220
|
+
}
|
|
1221
|
+
if (this.#options.allowSourceDirectories !== true) {
|
|
1222
|
+
throw new ExtensionsServiceError('extension-source-directories-not-allowed', 'source-directory installs are developer mode and are not allowed by the operator configuration (extensions.allowSourceDirectories)');
|
|
1223
|
+
}
|
|
1224
|
+
// Refuse BEFORE copying when the source cannot be digested: an installation the
|
|
1225
|
+
// store cannot verify later would be bytes nobody can attest to (EXT-12/EXT-33).
|
|
1226
|
+
try {
|
|
1227
|
+
this.#digest(sourceDir);
|
|
1228
|
+
}
|
|
1229
|
+
catch (error) {
|
|
1230
|
+
throw new ExtensionsServiceError('extension-digest-unavailable', `the content digest of ${sourceDir} could not be computed, so the install was refused before copying: ${messageOf(error)}`, { cause: error });
|
|
1231
|
+
}
|
|
1232
|
+
let installed;
|
|
1233
|
+
try {
|
|
1234
|
+
installed = await this.#install({
|
|
1235
|
+
sourceDir,
|
|
1236
|
+
storeDir,
|
|
1237
|
+
allowSourceDirectories: true,
|
|
1238
|
+
/**
|
|
1239
|
+
* The host-tree dependency graph is supplied only on this developer-mode
|
|
1240
|
+
* path. Registry installs resolve dependencies from the registry instead
|
|
1241
|
+
* (never from the host's tree).
|
|
1242
|
+
*/
|
|
1243
|
+
dependencies: this.#dependencyGraph(sourceDir),
|
|
1244
|
+
});
|
|
1245
|
+
}
|
|
1246
|
+
catch (error) {
|
|
1247
|
+
if (error instanceof ManagementError && error.code === 'DEPENDENCY_UNSATISFIED') {
|
|
1248
|
+
throw new ExtensionsServiceError('extension-dependency-unsatisfied', `[DEPENDENCY_UNSATISFIED] ${error.message}`, { cause: error });
|
|
1249
|
+
}
|
|
1250
|
+
const named = error instanceof ManagementError ? `[${error.code}] ` : '';
|
|
1251
|
+
throw new ExtensionsServiceError('extension-install-failed', `${named}${messageOf(error)}`, { cause: error });
|
|
1252
|
+
}
|
|
1253
|
+
if (installed.archiveDigest.trim().length === 0) {
|
|
1254
|
+
throw new ExtensionsServiceError('extension-digest-unavailable', `${installed.extensionId}@${installed.version} was installed without a content digest, so it cannot be verified before activation`);
|
|
1255
|
+
}
|
|
1256
|
+
return {
|
|
1257
|
+
id: installed.extensionId,
|
|
1258
|
+
version: installed.version,
|
|
1259
|
+
active: false,
|
|
1260
|
+
source: 'directory',
|
|
1261
|
+
resolvedVersion: installed.version,
|
|
1262
|
+
};
|
|
1263
|
+
}
|
|
1264
|
+
/**
|
|
1265
|
+
* Activate an installed extension and clear its disabled override.
|
|
1266
|
+
*
|
|
1267
|
+
* Activation runs through the mounted lifecycle, which verifies the installed
|
|
1268
|
+
* bytes, publishes the contributions and persists `desiredActivation: 'active'`.
|
|
1269
|
+
* The disabled list is updated only after activation succeeds, so a refused
|
|
1270
|
+
* activation does not leave the operator's override claiming a state change that
|
|
1271
|
+
* did not happen.
|
|
1272
|
+
* @param extensionId - the installed extension.
|
|
1273
|
+
* @param signal - cancellation.
|
|
1274
|
+
* @returns the id with the observed active/disabled state.
|
|
1275
|
+
* @throws ExtensionsServiceError for every refusal.
|
|
1276
|
+
*/
|
|
1277
|
+
async enable(extensionId, signal) {
|
|
1278
|
+
signal?.throwIfAborted();
|
|
1279
|
+
assertUsableId(extensionId);
|
|
1280
|
+
const storeDir = this.#requireStoreDir();
|
|
1281
|
+
const record = this.#requireInstalled(storeDir, extensionId);
|
|
1282
|
+
const lifecycle = this.#lifecycle();
|
|
1283
|
+
if (lifecycle === undefined) {
|
|
1284
|
+
throw new ExtensionsServiceError('extension-activation-unavailable', `the extension lifecycle is not mounted, so ${extensionId} cannot be activated by this host`);
|
|
1285
|
+
}
|
|
1286
|
+
const current = lifecycle.stateOf(extensionId);
|
|
1287
|
+
/**
|
|
1288
|
+
* THE PREFLIGHT. Every prerequisite the deployment can check is evaluated
|
|
1289
|
+
* BEFORE the lifecycle is touched: a deployment gap must never surface as the
|
|
1290
|
+
* SDK's generic EXTENSION_FAULT after an activation attempt. The refusal's code
|
|
1291
|
+
* maps the unmet requirement and its message is the requirement's own reason,
|
|
1292
|
+
* so the page, `describe()` and the error cannot disagree.
|
|
1293
|
+
*/
|
|
1294
|
+
const preflightIntegrity = this.#integrityOf(storeDir, record);
|
|
1295
|
+
const preflightFacts = readExtensionFacts(record.installPath);
|
|
1296
|
+
const requirements = this.#computeRequirements({
|
|
1297
|
+
id: extensionId,
|
|
1298
|
+
installed: true,
|
|
1299
|
+
record,
|
|
1300
|
+
state: current,
|
|
1301
|
+
integrity: preflightIntegrity.integrity,
|
|
1302
|
+
integrityReason: preflightIntegrity.reason,
|
|
1303
|
+
storeDir,
|
|
1304
|
+
sourceDir: this.#sourceDir(),
|
|
1305
|
+
lockCorrupt: false,
|
|
1306
|
+
lockDiagnostic: null,
|
|
1307
|
+
profileStates: readProfileStates(new ExtensionStore(storeDir), this.#profile()),
|
|
1308
|
+
sourcePackage: undefined,
|
|
1309
|
+
facts: preflightFacts,
|
|
1310
|
+
});
|
|
1311
|
+
const unmet = firstBlockingRequirement(requirements, true);
|
|
1312
|
+
if (unmet !== undefined) {
|
|
1313
|
+
if (unmet.kind === 'assets') {
|
|
1314
|
+
// The established preparation refusal keeps its own code and wording.
|
|
1315
|
+
throw new ExtensionsServiceError('extension-preparation-required', unmet.reason);
|
|
1316
|
+
}
|
|
1317
|
+
if (unmet.kind === 'integrity') {
|
|
1318
|
+
// The activation path named the SDK's integrity code; the preflight keeps
|
|
1319
|
+
// that classification while refusing before the lifecycle call.
|
|
1320
|
+
throw new ExtensionsServiceError('extension-activation-failed', `[INTEGRITY_MISMATCH] ${unmet.reason}`);
|
|
1321
|
+
}
|
|
1322
|
+
throw new ExtensionsServiceError('extension-requirement-unmet', unmet.reason);
|
|
1323
|
+
}
|
|
1324
|
+
const ownedHere = current?.observedActivation === 'active'
|
|
1325
|
+
&& (current.ownerPid === undefined || current.ownerPid === process.pid);
|
|
1326
|
+
if (!ownedHere) {
|
|
1327
|
+
/**
|
|
1328
|
+
* The registrar refuses a contribution from an extension outside its
|
|
1329
|
+
* activated set, so the id must be admitted BEFORE the lifecycle publishes.
|
|
1330
|
+
* A failed activation revokes the admission again (the host implementation
|
|
1331
|
+
* reconciles against the lifecycle state).
|
|
1332
|
+
*/
|
|
1333
|
+
this.#options.onActivationChange?.(extensionId, true);
|
|
1334
|
+
try {
|
|
1335
|
+
await lifecycle.activate(extensionId, record.version);
|
|
1336
|
+
}
|
|
1337
|
+
catch (error) {
|
|
1338
|
+
this.#options.onActivationChange?.(extensionId, false);
|
|
1339
|
+
const named = error instanceof ManagementError ? `[${error.code}] ` : '';
|
|
1340
|
+
throw new ExtensionsServiceError('extension-activation-failed', `${named}${messageOf(error)}`, { cause: error });
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
this.#rememberDisabled(extensionId, false);
|
|
1344
|
+
await this.#persistDisabled();
|
|
1345
|
+
const observed = lifecycle.stateOf(extensionId);
|
|
1346
|
+
return {
|
|
1347
|
+
id: extensionId,
|
|
1348
|
+
active: observed?.observedActivation === 'active'
|
|
1349
|
+
&& (observed.ownerPid === undefined || observed.ownerPid === process.pid),
|
|
1350
|
+
disabled: this.#effectiveDisabled().has(extensionId),
|
|
1351
|
+
};
|
|
1352
|
+
}
|
|
1353
|
+
/**
|
|
1354
|
+
* Record the failed activation attempt a fresh extension needs before its
|
|
1355
|
+
* readiness can be written.
|
|
1356
|
+
*
|
|
1357
|
+
* The SDK's `recordReadiness` refuses an extension the profile has never seen,
|
|
1358
|
+
* and a freshly installed extension has no profile state until an activation is
|
|
1359
|
+
* attempted. The probe therefore performs the SAME attempt the operator's failed
|
|
1360
|
+
* Enable performs: every declared deployment defaults to `needs-preparation`, so
|
|
1361
|
+
* the SDK refuses at the readiness assessment, records the state, and publishes
|
|
1362
|
+
* nothing. Any other refusal is a real problem and is reported.
|
|
1363
|
+
* @param lifecycle - the mounted lifecycle.
|
|
1364
|
+
* @param extensionId - the installed extension.
|
|
1365
|
+
* @param version - its installed version.
|
|
1366
|
+
* @throws ExtensionsServiceError when the probe fails for a reason other than the
|
|
1367
|
+
* expected preparation refusal.
|
|
1368
|
+
*/
|
|
1369
|
+
async #recordPreparationBlocker(lifecycle, extensionId, version) {
|
|
1370
|
+
try {
|
|
1371
|
+
await lifecycle.activate(extensionId, version);
|
|
1372
|
+
}
|
|
1373
|
+
catch (error) {
|
|
1374
|
+
if (error instanceof ManagementError && error.code === 'EXTENSION_FAULT')
|
|
1375
|
+
return;
|
|
1376
|
+
throw new ExtensionsServiceError('extension-preparation-failed', `the extension's activation record could not be written before preparation: ${messageOf(error)}`, { cause: error });
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
/**
|
|
1380
|
+
* Acquire and verify an installed extension's model assets, then record the
|
|
1381
|
+
* resulting readiness.
|
|
1382
|
+
*
|
|
1383
|
+
* This is the management-channel `extensions-prepare-plan` + `extensions-prepare`
|
|
1384
|
+
* pair behind one call, over the SAME host surface: the plan is resolved from the
|
|
1385
|
+
* installed manifest's asset lock, the acquisition runs through the injectable
|
|
1386
|
+
* source (the real ranged HTTP source unless the host injects one), and a
|
|
1387
|
+
* successful acquisition records `prepared-cold` through the lifecycle — which is
|
|
1388
|
+
* what lets a following `enable` activate the extension.
|
|
1389
|
+
*
|
|
1390
|
+
* The RES-02 executable gate is deliberately CLOSED here: the Remote call carries
|
|
1391
|
+
* no executable-approval argument, so a plan containing an executable asset is
|
|
1392
|
+
* returned as `prepared: false` with the registry's own reason rather than
|
|
1393
|
+
* acquired. The pinned EmbeddingGemma assets are not executable.
|
|
1394
|
+
* @param extensionId - the installed extension to prepare.
|
|
1395
|
+
* @param signal - cancellation.
|
|
1396
|
+
* @returns the disclosure that was executed and the resulting readiness.
|
|
1397
|
+
* @throws ExtensionsServiceError for every precondition and plan failure.
|
|
1398
|
+
*/
|
|
1399
|
+
async prepare(extensionId, signal) {
|
|
1400
|
+
signal?.throwIfAborted();
|
|
1401
|
+
assertUsableId(extensionId);
|
|
1402
|
+
const storeDir = this.#requireStoreDir();
|
|
1403
|
+
const record = this.#requireInstalled(storeDir, extensionId);
|
|
1404
|
+
const surface = this.#options.prepareSurface?.();
|
|
1405
|
+
if (surface === undefined) {
|
|
1406
|
+
throw new ExtensionsServiceError('extension-preparation-unavailable', `the prepared-assets surface is not mounted, so ${extensionId}'s model assets cannot be prepared by this host`);
|
|
1407
|
+
}
|
|
1408
|
+
const declaredModels = readExtensionFacts(record.installPath).models;
|
|
1409
|
+
if (declaredModels.length === 0) {
|
|
1410
|
+
throw new ExtensionsServiceError('extension-preparation-failed', `${extensionId} declares no model deployment to prepare`);
|
|
1411
|
+
}
|
|
1412
|
+
let plan;
|
|
1413
|
+
try {
|
|
1414
|
+
plan = surface.plan(extensionId);
|
|
1415
|
+
}
|
|
1416
|
+
catch (error) {
|
|
1417
|
+
throw new ExtensionsServiceError('extension-preparation-failed', `the preparation plan for ${extensionId} could not be resolved: ${messageOf(error)}`, { cause: error });
|
|
1418
|
+
}
|
|
1419
|
+
const lifecycle = this.#lifecycle();
|
|
1420
|
+
if (lifecycle !== undefined && lifecycle.stateOf(extensionId) === undefined) {
|
|
1421
|
+
await this.#recordPreparationBlocker(lifecycle, extensionId, record.version);
|
|
1422
|
+
}
|
|
1423
|
+
/**
|
|
1424
|
+
* RES-02: the Remote path has no per-call executable argument, so the approval
|
|
1425
|
+
* is the operator's STANDING configuration value. With it off the registry
|
|
1426
|
+
* returns its exact refusal and NOTHING is acquired; with it on the same
|
|
1427
|
+
* digest-pinned plan is approved as a whole.
|
|
1428
|
+
*/
|
|
1429
|
+
const allowExecutable = this.#options.allowExecutablePreparation?.() === true;
|
|
1430
|
+
let outcome;
|
|
1431
|
+
try {
|
|
1432
|
+
outcome = await surface.prepare(extensionId, {
|
|
1433
|
+
source: (this.#options.preparationSource ?? createHttpSource)(),
|
|
1434
|
+
allowExecutable,
|
|
1435
|
+
signal: signal ?? new AbortController().signal,
|
|
1436
|
+
});
|
|
1437
|
+
}
|
|
1438
|
+
catch (error) {
|
|
1439
|
+
throw new ExtensionsServiceError('extension-preparation-failed', `the preparation of ${extensionId} could not be recorded: ${messageOf(error)}`, { cause: error });
|
|
1440
|
+
}
|
|
1441
|
+
const state = lifecycle?.stateOf(extensionId);
|
|
1442
|
+
const readiness = readinessView(state?.readiness ?? {}, declaredModels);
|
|
1443
|
+
if (!outcome.ok) {
|
|
1444
|
+
return {
|
|
1445
|
+
id: extensionId,
|
|
1446
|
+
prepared: false,
|
|
1447
|
+
reason: outcome.reason,
|
|
1448
|
+
plan: preparationPlanView(plan),
|
|
1449
|
+
readiness,
|
|
1450
|
+
groups: [],
|
|
1451
|
+
activationReady: false,
|
|
1452
|
+
};
|
|
1453
|
+
}
|
|
1454
|
+
const integrity = new ExtensionStore(storeDir).verifyUnchanged(record).unchanged;
|
|
1455
|
+
const blockers = preparationBlockers(state?.readiness ?? {}, declaredModels);
|
|
1456
|
+
return {
|
|
1457
|
+
id: extensionId,
|
|
1458
|
+
prepared: true,
|
|
1459
|
+
reason: null,
|
|
1460
|
+
plan: preparationPlanView(plan),
|
|
1461
|
+
readiness,
|
|
1462
|
+
groups: outcome.groups.map(group => group.groupId),
|
|
1463
|
+
activationReady: integrity && blockers.length === 0,
|
|
1464
|
+
};
|
|
1465
|
+
}
|
|
1466
|
+
/**
|
|
1467
|
+
* Deactivate an installed extension and record it as disabled.
|
|
1468
|
+
*
|
|
1469
|
+
* The SDK's deactivation persists `desiredActivation: 'inactive'` BEFORE it
|
|
1470
|
+
* withdraws anything, so the durable answer survives a crash mid-withdrawal. The
|
|
1471
|
+
* disabled override is recorded only after the lifecycle call settles; a
|
|
1472
|
+
* deactivation that throws leaves the operator's list unchanged.
|
|
1473
|
+
* @param extensionId - the installed extension.
|
|
1474
|
+
* @param signal - cancellation.
|
|
1475
|
+
* @returns the id with the observed active/disabled state.
|
|
1476
|
+
* @throws ExtensionsServiceError for every refusal.
|
|
1477
|
+
*/
|
|
1478
|
+
async disable(extensionId, signal) {
|
|
1479
|
+
signal?.throwIfAborted();
|
|
1480
|
+
assertUsableId(extensionId);
|
|
1481
|
+
const storeDir = this.#requireStoreDir();
|
|
1482
|
+
// Not installed (or the store is unreadable) is still a refusal for disable:
|
|
1483
|
+
// there is no activation record this host could be switching off.
|
|
1484
|
+
this.#requireInstalled(storeDir, extensionId);
|
|
1485
|
+
const lifecycle = this.#lifecycle();
|
|
1486
|
+
if (lifecycle === undefined) {
|
|
1487
|
+
throw new ExtensionsServiceError('extension-activation-unavailable', `the extension lifecycle is not mounted, so ${extensionId} cannot be deactivated by this host`);
|
|
1488
|
+
}
|
|
1489
|
+
/**
|
|
1490
|
+
* An installed extension that was never activated has no profile state, and
|
|
1491
|
+
* `deactivate` refuses such an id. That is not an error for the operator: the
|
|
1492
|
+
* extension is already inactive, and recording the disabled choice is enough.
|
|
1493
|
+
*/
|
|
1494
|
+
if (lifecycle.stateOf(extensionId) !== undefined) {
|
|
1495
|
+
try {
|
|
1496
|
+
await lifecycle.deactivate(extensionId);
|
|
1497
|
+
}
|
|
1498
|
+
catch (error) {
|
|
1499
|
+
if (!(error instanceof ManagementError && error.code === 'EXTENSION_INACTIVE')) {
|
|
1500
|
+
const named = error instanceof ManagementError ? `[${error.code}] ` : '';
|
|
1501
|
+
throw new ExtensionsServiceError('extension-deactivation-failed', `${named}${messageOf(error)}`, { cause: error });
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
this.#rememberDisabled(extensionId, true);
|
|
1506
|
+
await this.#persistDisabled();
|
|
1507
|
+
// The registrar's activated set follows the lifecycle: a deactivated extension
|
|
1508
|
+
// must not stay admitted to publish.
|
|
1509
|
+
this.#options.onActivationChange?.(extensionId, false);
|
|
1510
|
+
const observed = lifecycle.stateOf(extensionId);
|
|
1511
|
+
return {
|
|
1512
|
+
id: extensionId,
|
|
1513
|
+
active: observed?.observedActivation === 'active'
|
|
1514
|
+
&& (observed.ownerPid === undefined || observed.ownerPid === process.pid),
|
|
1515
|
+
disabled: this.#effectiveDisabled().has(extensionId),
|
|
1516
|
+
};
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
/**
|
|
1520
|
+
* Construct the extension-management service.
|
|
1521
|
+
* @param options - store/source configuration and the host's seams.
|
|
1522
|
+
* @returns a service satisfying {@link ModelToolsRemote}.
|
|
1523
|
+
*/
|
|
1524
|
+
export function createExtensionsService(options = {}) {
|
|
1525
|
+
return new ExtensionsService(options);
|
|
1526
|
+
}
|
|
1527
|
+
//# sourceMappingURL=extensions-service.js.map
|