@try-works/dsh-model-as-tool 0.1.2 → 0.3.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/dsh-tool-mode-credentials.mjs +20 -0
- package/bin/dsh-tool-mode.mjs +202 -0
- package/catalog/desert-ant-models.json +693 -693
- package/client/client.model-tools.js +592 -0
- package/client/index.js +1679 -0
- package/lib/adapters/index.d.ts.map +1 -1
- package/lib/adapters/index.js +4 -4
- package/lib/bindings.d.ts.map +1 -1
- package/lib/bindings.js +26 -25
- package/lib/capability.d.ts.map +1 -1
- package/lib/capability.js +17 -0
- package/lib/capture-path.d.ts +97 -0
- package/lib/capture-path.d.ts.map +1 -0
- package/lib/capture-path.js +152 -0
- package/lib/diagnostics.d.ts +166 -0
- package/lib/diagnostics.d.ts.map +1 -0
- package/lib/diagnostics.js +135 -0
- package/lib/extension-dependencies.d.ts +197 -0
- package/lib/extension-dependencies.d.ts.map +1 -0
- package/lib/extension-dependencies.js +369 -0
- package/lib/extension-dispatch.d.ts +117 -0
- package/lib/extension-dispatch.d.ts.map +1 -0
- package/lib/extension-dispatch.js +124 -0
- package/lib/extension-registrar.d.ts +100 -0
- package/lib/extension-registrar.d.ts.map +1 -0
- package/lib/extension-registrar.js +134 -0
- package/lib/extensions-api.d.ts +217 -0
- package/lib/extensions-api.d.ts.map +1 -0
- package/lib/extensions-api.js +17 -0
- package/lib/extensions-remote.d.ts +54 -0
- package/lib/extensions-remote.d.ts.map +1 -0
- package/lib/extensions-remote.js +66 -0
- package/lib/extensions-service.d.ts +334 -0
- package/lib/extensions-service.d.ts.map +1 -0
- package/lib/extensions-service.js +1351 -0
- package/lib/harness-llm.d.ts +138 -0
- package/lib/harness-llm.d.ts.map +1 -0
- package/lib/harness-llm.js +218 -0
- package/lib/harness-serving.d.ts +95 -0
- package/lib/harness-serving.d.ts.map +1 -0
- package/lib/harness-serving.js +157 -0
- package/lib/headless.d.ts +97 -0
- package/lib/headless.d.ts.map +1 -0
- package/lib/headless.js +349 -0
- package/lib/http-acquisition.d.ts +27 -0
- package/lib/http-acquisition.d.ts.map +1 -0
- package/lib/http-acquisition.js +136 -0
- package/lib/index.d.ts +250 -7
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +2625 -70
- package/lib/invocation-accounting.d.ts +169 -0
- package/lib/invocation-accounting.d.ts.map +1 -0
- package/lib/invocation-accounting.js +148 -0
- package/lib/legacy-migration.d.ts +95 -0
- package/lib/legacy-migration.d.ts.map +1 -0
- package/lib/legacy-migration.js +429 -0
- package/lib/managed-workers.d.ts +151 -0
- package/lib/managed-workers.d.ts.map +1 -0
- package/lib/managed-workers.js +249 -0
- package/lib/mcp/client-grant.d.ts +74 -0
- package/lib/mcp/client-grant.d.ts.map +1 -0
- package/lib/mcp/client-grant.js +60 -0
- package/lib/mcp/credential-cli.d.ts +38 -0
- package/lib/mcp/credential-cli.d.ts.map +1 -0
- package/lib/mcp/credential-cli.js +148 -0
- package/lib/mcp/credential-store.d.ts +143 -0
- package/lib/mcp/credential-store.d.ts.map +1 -0
- package/lib/mcp/credential-store.js +454 -0
- package/lib/mcp/host-binding.d.ts +25 -0
- package/lib/mcp/host-binding.d.ts.map +1 -1
- package/lib/mcp/host-binding.js +107 -14
- package/lib/mcp/http.d.ts +68 -18
- package/lib/mcp/http.d.ts.map +1 -1
- package/lib/mcp/http.js +390 -62
- package/lib/mcp/oauth.d.ts +149 -0
- package/lib/mcp/oauth.d.ts.map +1 -0
- package/lib/mcp/oauth.js +396 -0
- package/lib/mcp/protocol.d.ts +46 -7
- package/lib/mcp/protocol.d.ts.map +1 -1
- package/lib/mcp/protocol.js +63 -16
- package/lib/mcp/rate-limit.d.ts +68 -0
- package/lib/mcp/rate-limit.d.ts.map +1 -0
- package/lib/mcp/rate-limit.js +114 -0
- package/lib/mcp/sdk-v2.d.ts +17 -0
- package/lib/mcp/sdk-v2.d.ts.map +1 -0
- package/lib/mcp/sdk-v2.js +181 -0
- package/lib/mcp/server.d.ts +11 -1
- package/lib/mcp/server.d.ts.map +1 -1
- package/lib/mcp/server.js +80 -13
- package/lib/mcp/stdio.d.ts.map +1 -1
- package/lib/mcp/stdio.js +9 -1
- package/lib/mcp/subscriptions.d.ts +43 -1
- package/lib/mcp/subscriptions.d.ts.map +1 -1
- package/lib/mcp/subscriptions.js +90 -9
- package/lib/model-qualification.d.ts +192 -0
- package/lib/model-qualification.d.ts.map +1 -0
- package/lib/model-qualification.js +178 -0
- package/lib/planning.d.ts +246 -0
- package/lib/planning.d.ts.map +1 -0
- package/lib/planning.js +457 -0
- package/lib/prepared-assets.d.ts +178 -0
- package/lib/prepared-assets.d.ts.map +1 -0
- package/lib/prepared-assets.js +286 -0
- package/lib/provider-bridge.d.ts +37 -0
- package/lib/provider-bridge.d.ts.map +1 -1
- package/lib/provider-bridge.js +189 -12
- package/lib/ptc-authority.d.ts +140 -0
- package/lib/ptc-authority.d.ts.map +1 -0
- package/lib/ptc-authority.js +216 -0
- package/lib/ptc-runtime.d.ts +156 -0
- package/lib/ptc-runtime.d.ts.map +1 -0
- package/lib/ptc-runtime.js +191 -0
- package/lib/publication.d.ts +2 -1
- package/lib/publication.d.ts.map +1 -1
- package/lib/publication.js +13 -5
- package/lib/receipt.d.ts.map +1 -1
- package/lib/receipt.js +8 -0
- package/lib/title-proposal.d.ts +194 -0
- package/lib/title-proposal.d.ts.map +1 -0
- package/lib/title-proposal.js +244 -0
- package/lib/tool-mode-run.d.ts +235 -0
- package/lib/tool-mode-run.d.ts.map +1 -0
- package/lib/tool-mode-run.js +283 -0
- package/lib/types.d.ts +3 -1
- package/lib/types.d.ts.map +1 -1
- package/lib/update-surface.d.ts +79 -0
- package/lib/update-surface.d.ts.map +1 -0
- package/lib/update-surface.js +224 -0
- package/node_modules/@try-works/tool-mode-contracts/lib/contracts/lang-detect.d.ts +16 -0
- package/node_modules/@try-works/tool-mode-contracts/lib/contracts/lang-detect.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-contracts/lib/contracts/lang-detect.js +85 -0
- package/node_modules/@try-works/tool-mode-contracts/lib/contracts/standard.d.ts +38 -0
- package/node_modules/@try-works/tool-mode-contracts/lib/contracts/standard.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-contracts/lib/contracts/standard.js +445 -0
- package/node_modules/@try-works/tool-mode-contracts/lib/contracts/stt-transcribe.d.ts +20 -0
- package/node_modules/@try-works/tool-mode-contracts/lib/contracts/stt-transcribe.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-contracts/lib/contracts/stt-transcribe.js +112 -0
- package/node_modules/@try-works/tool-mode-contracts/lib/contracts/text-embed.d.ts +14 -0
- package/node_modules/@try-works/tool-mode-contracts/lib/contracts/text-embed.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-contracts/lib/contracts/text-embed.js +63 -0
- package/node_modules/@try-works/tool-mode-contracts/lib/contracts/text-rerank.d.ts +14 -0
- package/node_modules/@try-works/tool-mode-contracts/lib/contracts/text-rerank.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-contracts/lib/contracts/text-rerank.js +76 -0
- package/node_modules/@try-works/tool-mode-contracts/lib/dsh.d.ts +82 -0
- package/node_modules/@try-works/tool-mode-contracts/lib/dsh.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-contracts/lib/dsh.js +274 -0
- package/node_modules/@try-works/tool-mode-contracts/lib/index.d.ts +26 -0
- package/node_modules/@try-works/tool-mode-contracts/lib/index.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-contracts/lib/index.js +48 -0
- package/node_modules/@try-works/tool-mode-contracts/lib/meta.d.ts +104 -0
- package/node_modules/@try-works/tool-mode-contracts/lib/meta.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-contracts/lib/meta.js +55 -0
- package/node_modules/@try-works/tool-mode-contracts/lib/registry.d.ts +107 -0
- package/node_modules/@try-works/tool-mode-contracts/lib/registry.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-contracts/lib/registry.js +246 -0
- package/node_modules/@try-works/tool-mode-contracts/package.json +34 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/bin/dsh-tool-mode.mjs +20 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/cli.d.ts +80 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/cli.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/cli.js +533 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/declarative-recipes.d.ts +83 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/declarative-recipes.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/declarative-recipes.js +125 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/discovery.d.ts +70 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/discovery.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/discovery.js +201 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/errors.d.ts +57 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/errors.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/errors.js +83 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/index.d.ts +31 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/index.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/index.js +31 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/archive.d.ts +86 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/archive.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/archive.js +185 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/dependencies.d.ts +126 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/dependencies.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/dependencies.js +297 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/lifecycle.d.ts +216 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/lifecycle.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/lifecycle.js +800 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/migration.d.ts +210 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/migration.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/migration.js +368 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/runtime-link.d.ts +63 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/runtime-link.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/runtime-link.js +149 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/source-directory.d.ts +35 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/source-directory.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/source-directory.js +179 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/staged.d.ts +179 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/staged.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/staged.js +235 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/store.d.ts +367 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/store.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/store.js +807 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/update-flow.d.ts +185 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/update-flow.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/update-flow.js +504 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/zip-source.d.ts +17 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/zip-source.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/zip-source.js +80 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/load.d.ts +52 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/load.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/load.js +102 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/manifest.d.ts +171 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/manifest.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/manifest.js +271 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/runtime.d.ts +242 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/runtime.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/runtime.js +58 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/state.d.ts +118 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/state.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/state.js +113 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/worker/worker-child.d.ts +2 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/worker/worker-child.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/worker/worker-child.js +351 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/worker.d.ts +150 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/worker.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/worker.js +670 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/package.json +53 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/schema/extension-manifest-1.1.0.schema.json +253 -0
- package/node_modules/@try-works/tool-mode-recipes/bin/tool-mode-recipe.mjs +15 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/broker.d.ts +427 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/broker.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/broker.js +592 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/cli.d.ts +60 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/cli.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/cli.js +936 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/dsh-adapter.d.ts +237 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/dsh-adapter.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/dsh-adapter.js +200 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/fixture-provider.d.ts +85 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/fixture-provider.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/fixture-provider.js +176 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/images.d.ts +105 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/images.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/images.js +129 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/index.d.ts +25 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/index.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/index.js +25 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/recipe-format.d.ts +325 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/recipe-format.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/recipe-format.js +1310 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/templates.d.ts +51 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/templates.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/templates.js +310 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/titles.d.ts +227 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/titles.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-recipes/lib/titles.js +407 -0
- package/node_modules/@try-works/tool-mode-recipes/package.json +56 -0
- package/node_modules/@try-works/tool-mode-recipes/recipes/describe-image.json +38 -0
- package/node_modules/@try-works/tool-mode-recipes/recipes/text-title.json +45 -0
- package/node_modules/@try-works/tool-mode-recipes/schema/harness-model-recipe.schema.json +273 -0
- package/package.json +50 -17
- package/remote/spec.mjs +439 -0
- package/remote/typert.host.mjs +14 -0
- package/remote/typert.remote-client.mjs +16 -0
- package/lib/adapters/tongue.d.ts +0 -31
- package/lib/adapters/tongue.d.ts.map +0 -1
- package/lib/adapters/tongue.js +0 -93
- package/lib/adapters/voz.d.ts +0 -28
- package/lib/adapters/voz.d.ts.map +0 -1
- package/lib/adapters/voz.js +0 -102
- /package/{lib → node_modules/@try-works/tool-mode-extension-sdk/lib}/licensing.d.ts +0 -0
- /package/{lib → node_modules/@try-works/tool-mode-extension-sdk/lib}/licensing.d.ts.map +0 -0
- /package/{lib → node_modules/@try-works/tool-mode-extension-sdk/lib}/licensing.js +0 -0
package/lib/index.js
CHANGED
|
@@ -19,16 +19,40 @@ import { resolveCapability } from "./capability.js";
|
|
|
19
19
|
import { createAdapter } from "./adapters/index.js";
|
|
20
20
|
import { registerModelTool } from "./registrar.js";
|
|
21
21
|
import { registerContractTool } from "./bindings.js";
|
|
22
|
-
import {
|
|
22
|
+
import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
23
|
+
import { dirname, join, resolve } from 'node:path';
|
|
24
|
+
import { createContributionRegistrar } from "./extension-registrar.js";
|
|
25
|
+
import { createExternalGrantContext, createProgramRunner, externalRefusal, serveProgram, TOOL_MODE_RUN_CONTRACT, PTC_SERVICE } from "./tool-mode-run.js";
|
|
26
|
+
import { buildDiagnostics } from "./diagnostics.js";
|
|
27
|
+
import { CAPTURE_PATH } from "./capture-path.js";
|
|
28
|
+
import { prepareDeclarative } from "./harness-serving.js";
|
|
29
|
+
import { createFileCredentialStore, defaultCredentialStoreFile } from "./mcp/credential-store.js";
|
|
30
|
+
import { buildHarnessLlmProvider, discoverDefaultHarnessModel, resolveRunnableHarnessModel } from "./harness-llm.js";
|
|
31
|
+
import { parseRecipe } from '@try-works/tool-mode-recipes';
|
|
32
|
+
import { createManagedWorkerRegistry } from "./managed-workers.js";
|
|
33
|
+
import { createDispatch, describeDispatchFailure, resolveImplementation } from "./extension-dispatch.js";
|
|
34
|
+
import { captureToolSchemaSnapshot, describePlanningRejections, hasPlanningInputShape, offerToolsFromSnapshot, PLANNING_EXCLUDED_TOOLS, PLANNING_TOOL_INPUT_SCHEMA, PLANNING_TOOL_NAME, recheckProposals, } from "./planning.js";
|
|
35
|
+
import { discoverExtension, loadExtensionModule, resolveDeclarativeImplementations, resolveExtensionEntry } from '@try-works/tool-mode-extension-sdk';
|
|
36
|
+
import { ExtensionStore, ExtensionLifecycle } from '@try-works/tool-mode-extension-sdk';
|
|
37
|
+
import { authorizeTool, createGrant } from "./authority.js";
|
|
23
38
|
import { ProviderBridge } from "./provider-bridge.js";
|
|
39
|
+
import { createUpdateHostSurface } from "./update-surface.js";
|
|
40
|
+
import { SubscriptionRegistry, deliverCatalogChange, MemoryDeliverySink } from "./mcp/subscriptions.js";
|
|
41
|
+
import { createPreparedAssetRegistry, identityFor, lockGroups, parseAssetLock, plannedAssets, } from "./prepared-assets.js";
|
|
42
|
+
import { planPreparation } from "./deployment-coordinator.js";
|
|
43
|
+
import { createHttpSource } from "./http-acquisition.js";
|
|
24
44
|
import { buildSurface } from "./publication.js";
|
|
45
|
+
import { buildInvocationReceipt } from "./receipt.js";
|
|
25
46
|
import { createMcpHost } from "./mcp/host-binding.js";
|
|
26
47
|
import { ToolCallId } from '@deepseek-ai/dsh-llm';
|
|
27
|
-
import { STANDARD_CONTRACTS } from '@try-works/tool-mode-contracts';
|
|
48
|
+
import { STANDARD_CONTRACTS, assertAgainstSchema, compileSchema, projectSchemaToDsh } from '@try-works/tool-mode-contracts';
|
|
28
49
|
import { renderSkillBody } from "./skills.js";
|
|
29
50
|
import { loadPlaybooks } from "./playbook.js";
|
|
30
51
|
import { commandExists, DEFAULT_CLI_COMMAND } from "./transport.js";
|
|
31
52
|
import { createFileStateStore, createMemoryStateStore, defaultStateFile, resolveSelection, withChoice, } from "./state.js";
|
|
53
|
+
import { runLegacyStartupMigration } from "./legacy-migration.js";
|
|
54
|
+
import { createExtensionsService } from "./extensions-service.js";
|
|
55
|
+
import { provideModelToolsService } from "./extensions-remote.js";
|
|
32
56
|
export const name = 'model-tools';
|
|
33
57
|
export const inject = ['tools'];
|
|
34
58
|
/**
|
|
@@ -37,27 +61,70 @@ export const inject = ['tools'];
|
|
|
37
61
|
*/
|
|
38
62
|
export /** How many invocation receipts are retained in memory (EXT-35). */ const MAX_RETAINED_RECEIPTS = 200;
|
|
39
63
|
const HOST_VERSION = '0.1.1';
|
|
64
|
+
/** Largest extension tool schema read at registration, in bytes. */
|
|
65
|
+
const MAX_SCHEMA_BYTES = 512 * 1024;
|
|
66
|
+
/** The DSH value-schema keywords a recipe input may advertise (mirrors contracts/dsh.ts's DSH_SUPPORTED). */
|
|
67
|
+
const DSH_PARAMETER_KEYWORDS = new Set([
|
|
68
|
+
'type', 'enum', 'const', 'description', 'default', 'examples',
|
|
69
|
+
'items', 'properties', 'additionalProperties', 'required',
|
|
70
|
+
]);
|
|
40
71
|
/** Validate and default the plugin configuration. */
|
|
72
|
+
/**
|
|
73
|
+
* Mark one Config field as Settings-UI editable.
|
|
74
|
+
*
|
|
75
|
+
* schemastery exposes a field to the settings mirror only when it carries the
|
|
76
|
+
* volatile marker (the reference plugin `@try-works/dsh-memory` documents this:
|
|
77
|
+
* "Only .volatile() fields are exposed to the Settings UI and may be written by
|
|
78
|
+
* the config editor"). `.volatile()` also narrows the schema's inferred type, so
|
|
79
|
+
* this helper keeps the declared `z<Config>` surface intact.
|
|
80
|
+
* @param schema - the field schema.
|
|
81
|
+
* @returns the same schema with the runtime volatile marker.
|
|
82
|
+
*/
|
|
83
|
+
function settingsField(schema) {
|
|
84
|
+
return schema.volatile();
|
|
85
|
+
}
|
|
41
86
|
export const Config = z.object({
|
|
42
87
|
catalogFile: z.string(),
|
|
43
88
|
playbookFile: z.string(),
|
|
44
89
|
stateFile: z.string(),
|
|
90
|
+
credentialsFile: z.string(),
|
|
91
|
+
/**
|
|
92
|
+
* Where first-party extension package checkouts live (developer-mode installs).
|
|
93
|
+
* Empty by default: a deployment with no first-party source advertises none.
|
|
94
|
+
*/
|
|
95
|
+
extensionSourceDir: z.string().default(''),
|
|
96
|
+
allowPreinstalledDependencies: z.boolean().default(false),
|
|
97
|
+
preinstalledDependenciesRoot: z.string().default(''),
|
|
98
|
+
/**
|
|
99
|
+
* Standing RES-02 approval for executable assets prepared from the client path.
|
|
100
|
+
* FALSE by default: a downloaded engine still needs the explicit approval.
|
|
101
|
+
*/
|
|
102
|
+
allowExecutablePreparation: z.boolean().default(false),
|
|
45
103
|
persist: z.boolean().default(true),
|
|
46
104
|
providers: z.dict(z.string()).default({}),
|
|
47
105
|
legacyAliases: z.boolean().default(false),
|
|
48
|
-
|
|
106
|
+
/**
|
|
107
|
+
* Settings → Model as Tool.
|
|
108
|
+
*
|
|
109
|
+
* Only volatile fields are exposed to the Settings UI and may be written by
|
|
110
|
+
* the config editor; every other field is config-file only. Keep this surface
|
|
111
|
+
* the operator knobs the client page edits.
|
|
112
|
+
*/
|
|
113
|
+
approvedRoots: settingsField(z.array(z.string()).default([])),
|
|
49
114
|
grantId: z.string().default('local-interactive'),
|
|
50
115
|
recordReceipts: z.boolean().default(true),
|
|
116
|
+
repairAttempts: settingsField(z.number().min(0).max(8).default(1)),
|
|
51
117
|
limits: z.object({
|
|
52
118
|
maxInputBytes: z.number(),
|
|
53
119
|
maxAudioSeconds: z.number(),
|
|
54
120
|
maxResultBytes: z.number(),
|
|
55
121
|
}),
|
|
56
|
-
models: z.array(z.string()).default([]),
|
|
57
|
-
exclude: z.array(z.string()).default([]),
|
|
58
|
-
exposeUnavailable: z.boolean().default(false),
|
|
59
|
-
registerSkills: z.boolean().default(true),
|
|
60
|
-
registerControlTool: z.boolean().default(true),
|
|
122
|
+
models: settingsField(z.array(z.string()).default([])),
|
|
123
|
+
exclude: settingsField(z.array(z.string()).default([])),
|
|
124
|
+
exposeUnavailable: settingsField(z.boolean().default(false)),
|
|
125
|
+
registerSkills: settingsField(z.boolean().default(true)),
|
|
126
|
+
registerControlTool: settingsField(z.boolean().default(true)),
|
|
127
|
+
hostedCode: z.boolean().default(false),
|
|
61
128
|
cli: z.object({
|
|
62
129
|
enabled: z.boolean().default(true),
|
|
63
130
|
command: z.string().default(DEFAULT_CLI_COMMAND),
|
|
@@ -66,10 +133,130 @@ export const Config = z.object({
|
|
|
66
133
|
endpoint: z.string(),
|
|
67
134
|
token: z.string(),
|
|
68
135
|
}),
|
|
69
|
-
|
|
136
|
+
harnessModel: z.object({
|
|
137
|
+
provider: z.string(),
|
|
138
|
+
model: z.string(),
|
|
139
|
+
}),
|
|
140
|
+
managedWorkers: settingsField(z.boolean().default(false)),
|
|
141
|
+
workerEnvPassthrough: z.array(z.string()).default([]),
|
|
142
|
+
allowNpm: settingsField(z.boolean().default(true)),
|
|
143
|
+
disabledExtensions: settingsField(z.array(z.string()).default([])),
|
|
144
|
+
/**
|
|
145
|
+
* §12 CONFIG V3: the proposed nested schemaVersion-3 shape. The spec's
|
|
146
|
+
* fixed-false flags are parsed here and REJECTED when set true (unsupported
|
|
147
|
+
* values refuse rather than being accepted as ineffective flags); the
|
|
148
|
+
* remaining nested fields are carried as data for the launcher's doctor and
|
|
149
|
+
* future wiring. The flat legacy fields above remain the deprecated aliases
|
|
150
|
+
* that actually drive behavior, so existing v1/v2 configs stay green.
|
|
151
|
+
*/
|
|
152
|
+
schemaVersion: z.number(),
|
|
153
|
+
extensions: z.object({
|
|
154
|
+
protocolMajor: z.number(),
|
|
155
|
+
manifestVersion: z.string(),
|
|
156
|
+
registry: z.string(),
|
|
157
|
+
allowHttpsArchives: z.boolean(),
|
|
158
|
+
allowLocalArchives: z.boolean(),
|
|
159
|
+
allowSourceDirectories: z.boolean(),
|
|
160
|
+
automaticUpdates: z.boolean(),
|
|
161
|
+
runInstallScripts: z.boolean(),
|
|
162
|
+
}),
|
|
163
|
+
mcpV3: z.object({
|
|
164
|
+
enabled: z.boolean(),
|
|
165
|
+
protocolVersions: z.array(z.string()),
|
|
166
|
+
legacyCompatibility: z.boolean(),
|
|
167
|
+
host: z.string(),
|
|
168
|
+
port: z.number(),
|
|
169
|
+
path: z.string(),
|
|
170
|
+
allowedOrigins: z.array(z.string()),
|
|
171
|
+
exportTools: z.array(z.string()),
|
|
172
|
+
catalogTtlMs: z.number(),
|
|
173
|
+
catalogCacheScope: z.string(),
|
|
174
|
+
auth: z.object({ mode: z.string(), authorizationServerProfile: z.any() }),
|
|
175
|
+
}),
|
|
176
|
+
compositionV3: z.object({
|
|
177
|
+
enabled: z.boolean(),
|
|
178
|
+
defaultTimeoutMs: z.number(),
|
|
179
|
+
maxTimeoutMs: z.number(),
|
|
180
|
+
maxToolCalls: z.number(),
|
|
181
|
+
maxConcurrentBindings: z.number(),
|
|
182
|
+
maxHarnessModelCalls: z.number(),
|
|
183
|
+
}),
|
|
184
|
+
multimodalV3: z.object({
|
|
185
|
+
defaultDimensions: z.number(),
|
|
186
|
+
defaultOverflow: z.string(),
|
|
187
|
+
maxVideoFrames: z.number(),
|
|
188
|
+
audioSampleRateHz: z.number(),
|
|
189
|
+
selectiveEncoderLoading: z.boolean(),
|
|
190
|
+
}),
|
|
191
|
+
harnessExecutorV3: z.object({
|
|
192
|
+
enabled: z.boolean(),
|
|
193
|
+
nativeSelection: z.string(),
|
|
194
|
+
externalDefaultProfile: z.any(),
|
|
195
|
+
profiles: z.array(z.any()),
|
|
196
|
+
defaultMaxAttempts: z.number(),
|
|
197
|
+
defaultRepairAttempts: z.number().min(0).max(8),
|
|
198
|
+
defaultMaxOutputTokens: z.number(),
|
|
199
|
+
defaultTimeoutMs: z.number(),
|
|
200
|
+
inheritConversation: z.boolean(),
|
|
201
|
+
allowModelToolCalls: z.boolean(),
|
|
202
|
+
}),
|
|
203
|
+
recipesV3: z.object({
|
|
204
|
+
schemaVersion: z.string(),
|
|
205
|
+
allowLocalAuthoring: z.boolean(),
|
|
206
|
+
automaticActivation: z.boolean(),
|
|
207
|
+
allowExecutableTemplates: z.boolean(),
|
|
208
|
+
}),
|
|
209
|
+
clientsV3: z.array(z.any()),
|
|
210
|
+
captureV3: z.object({
|
|
211
|
+
bodies: z.boolean(),
|
|
212
|
+
retentionDays: z.number(),
|
|
213
|
+
maxBytes: z.number(),
|
|
214
|
+
auxiliaryLlmJournal: z.object({ enabled: z.boolean(), retentionDays: z.number() }),
|
|
215
|
+
}),
|
|
70
216
|
});
|
|
217
|
+
/**
|
|
218
|
+
* §12: the spec's fixed-false flags REJECT unsupported values instead of being
|
|
219
|
+
* accepted as ineffective flags. A config that sets any of them to true fails
|
|
220
|
+
* the configuration with the named flag, never silently ignores it.
|
|
221
|
+
* @param config - the parsed configuration.
|
|
222
|
+
* @throws Error naming the first flag set to an unsupported value.
|
|
223
|
+
*/
|
|
224
|
+
export function assertConfigV3DisabledFlags(config) {
|
|
225
|
+
const fixedFalse = [
|
|
226
|
+
['extensions.automaticUpdates', config.extensions?.automaticUpdates],
|
|
227
|
+
['extensions.runInstallScripts', config.extensions?.runInstallScripts],
|
|
228
|
+
['recipes.automaticActivation', config.recipesV3?.automaticActivation],
|
|
229
|
+
['recipes.allowExecutableTemplates', config.recipesV3?.allowExecutableTemplates],
|
|
230
|
+
['harnessExecutor.inheritConversation', config.harnessExecutorV3?.inheritConversation],
|
|
231
|
+
['harnessExecutor.allowModelToolCalls', config.harnessExecutorV3?.allowModelToolCalls],
|
|
232
|
+
];
|
|
233
|
+
for (const [name, value] of fixedFalse) {
|
|
234
|
+
if (value === true) {
|
|
235
|
+
throw new Error(`${name} is fixed false in this implementation (spec §12); setting it to true is refused rather than `
|
|
236
|
+
+ 'accepted as an ineffective flag. Remove the field or set it to false.');
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* The CODE-11 repair-attempt ceiling for one configuration (task-74).
|
|
242
|
+
*
|
|
243
|
+
* The flat `repairAttempts` key drives; the v3 nested spelling
|
|
244
|
+
* `harnessExecutorV3.defaultRepairAttempts` is its equivalent; absent both,
|
|
245
|
+
* the default is 1. An out-of-range value is a REFUSAL, never a silent clamp:
|
|
246
|
+
* a ceiling the operator did not write is a budget nobody approved.
|
|
247
|
+
* @param config - the parsed configuration.
|
|
248
|
+
* @throws Error naming the invalid value.
|
|
249
|
+
*/
|
|
250
|
+
export function repairCeilingOf(config) {
|
|
251
|
+
const ceiling = config.repairAttempts ?? config.harnessExecutorV3?.defaultRepairAttempts ?? 1;
|
|
252
|
+
if (typeof ceiling !== 'number' || !Number.isInteger(ceiling) || ceiling < 0 || ceiling > 8) {
|
|
253
|
+
throw new Error(`repairAttempts must be an integer between 0 and 8 (CODE-11); received ${JSON.stringify(ceiling)}. `
|
|
254
|
+
+ 'A repair ceiling outside this range is refused rather than silently clamped.');
|
|
255
|
+
}
|
|
256
|
+
return ceiling;
|
|
257
|
+
}
|
|
71
258
|
/** Render the model list the control tool returns, and the plugin logs. */
|
|
72
|
-
function describeModels(resolved) {
|
|
259
|
+
export function describeModels(resolved) {
|
|
73
260
|
return resolved.map(({ entry, verdict }) => ({
|
|
74
261
|
id: entry.id,
|
|
75
262
|
name: entry.name,
|
|
@@ -91,8 +278,151 @@ function describeModels(resolved) {
|
|
|
91
278
|
* @param ctx - the plugin context whose tool registry receives the definitions.
|
|
92
279
|
* @param config - deployment configuration for which models to expose.
|
|
93
280
|
*/
|
|
94
|
-
|
|
281
|
+
/**
|
|
282
|
+
* The Config fields the Settings page edits (marked volatile in the schema).
|
|
283
|
+
*
|
|
284
|
+
* schemastery parses a volatile field into a LIVE REFERENCE (`{ get() }`) rather
|
|
285
|
+
* than a plain value, so the settings editor can commit a new value into the
|
|
286
|
+
* running fiber. Every consumer here reads the plain value, so apply unwraps
|
|
287
|
+
* them once and re-unwraps on `loader/volatile-update`.
|
|
288
|
+
*/
|
|
289
|
+
const VOLATILE_CONFIG_KEYS = [
|
|
290
|
+
'approvedRoots', 'repairAttempts', 'models', 'exclude', 'exposeUnavailable',
|
|
291
|
+
'registerSkills', 'registerControlTool', 'managedWorkers', 'allowNpm', 'disabledExtensions',
|
|
292
|
+
];
|
|
293
|
+
/** Read a plain value out of a volatile reference (or return it untouched). */
|
|
294
|
+
function unwrapVolatile(value) {
|
|
295
|
+
const candidate = value;
|
|
296
|
+
return candidate !== undefined && typeof candidate.get === 'function' ? candidate.get() : value;
|
|
297
|
+
}
|
|
298
|
+
/** Replace every volatile field on the config object with its current value. */
|
|
299
|
+
function absorbVolatileConfig(config) {
|
|
300
|
+
const writable = config;
|
|
301
|
+
for (const key of VOLATILE_CONFIG_KEYS) {
|
|
302
|
+
if (key in writable)
|
|
303
|
+
writable[key] = unwrapVolatile(writable[key]);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
export async function apply(ctx, config = {}) {
|
|
307
|
+
// Volatile fields arrive as live references; the rest of apply reads values.
|
|
308
|
+
absorbVolatileConfig(config);
|
|
309
|
+
// A Settings save commits a new volatile value into the running fiber; without
|
|
310
|
+
// this the page would persist the change and the plugin keep the old value.
|
|
311
|
+
// The event is declared by the harness loader (not by the cordis types this
|
|
312
|
+
// package compiles against), so the name is cast rather than left unchecked.
|
|
313
|
+
ctx.on('loader/volatile-update', (() => {
|
|
314
|
+
try {
|
|
315
|
+
absorbVolatileConfig(config);
|
|
316
|
+
}
|
|
317
|
+
catch (error) {
|
|
318
|
+
ctx.logger?.warn?.(`could not re-read volatile configuration: ${error instanceof Error ? error.message : String(error)}`);
|
|
319
|
+
}
|
|
320
|
+
}));
|
|
321
|
+
// §12: the fixed-false flags REFUSE unsupported values before anything runs.
|
|
322
|
+
assertConfigV3DisabledFlags(config);
|
|
95
323
|
const entries = loadCatalog(config.catalogFile);
|
|
324
|
+
/**
|
|
325
|
+
* Declared at the apply top so the catalog-change notifier (below) can read
|
|
326
|
+
* the generation without a temporal-dead-zone window during the startup
|
|
327
|
+
* install loop; the extension-store block assigns it.
|
|
328
|
+
*/
|
|
329
|
+
let extensionLifecycle;
|
|
330
|
+
/**
|
|
331
|
+
* The extensions the contribution registrar currently admits.
|
|
332
|
+
*
|
|
333
|
+
* Seeded from the durable desired-active state when the store mounts, and kept
|
|
334
|
+
* in step by the extension-management service: the registrar refuses a
|
|
335
|
+
* contribution whose extension is absent, so a runtime `enable` must admit the
|
|
336
|
+
* id BEFORE the lifecycle publishes, and `disable` must revoke it.
|
|
337
|
+
*/
|
|
338
|
+
const activatedExtensions = new Set();
|
|
339
|
+
/**
|
|
340
|
+
* The host half of Settings → Model as Tool's extension management.
|
|
341
|
+
*
|
|
342
|
+
* Constructed even when no extension store is configured, so `describe()` can
|
|
343
|
+
* truthfully answer `store: null` instead of the surface being absent. The
|
|
344
|
+
* lifecycle thunk resolves once the extension-store block below mounts it; the
|
|
345
|
+
* disabled getter reads the live (volatile) config list, so a Settings save is
|
|
346
|
+
* observed without reconstructing the service.
|
|
347
|
+
*
|
|
348
|
+
* `provideModelToolsService` attaches the gateway's visible `typertRemote`
|
|
349
|
+
* binding and provides the object as the `modelTools` Cordis service, which is
|
|
350
|
+
* what the generated Host artifact dispatches through. It throws at apply time
|
|
351
|
+
* if a wire method is missing, so a half-wired surface cannot start.
|
|
352
|
+
*/
|
|
353
|
+
const extensionsService = createExtensionsService({
|
|
354
|
+
storeDir: config.extensionStore ?? '',
|
|
355
|
+
sourceDir: config.extensionSourceDir ?? '',
|
|
356
|
+
allowSourceDirectories: config.extensions?.allowSourceDirectories === true,
|
|
357
|
+
/**
|
|
358
|
+
* The operator gate for already-materialized dependencies. OFF by default:
|
|
359
|
+
* a dependency whose package.json declares install-time scripts still
|
|
360
|
+
* refuses (EXT-10). When ON, the resolver may supply content from a
|
|
361
|
+
* completed host install (e.g. onnxruntime-node after the operator's npm
|
|
362
|
+
* ran its postinstall), and the SDK records provenance rather than
|
|
363
|
+
* pretending it ran the scripts.
|
|
364
|
+
*/
|
|
365
|
+
allowPreinstalledDependencies: config.allowPreinstalledDependencies === true,
|
|
366
|
+
preinstalledDependenciesRoot: config.preinstalledDependenciesRoot ?? '',
|
|
367
|
+
profile: 'default',
|
|
368
|
+
disabledExtensions: () => config.disabledExtensions ?? [],
|
|
369
|
+
lifecycle: () => extensionLifecycle,
|
|
370
|
+
/**
|
|
371
|
+
* The prepared-assets surface is constructed inside the extension-store block
|
|
372
|
+
* below; the thunk resolves at call time, after apply has finished. The source
|
|
373
|
+
* is the SAME seam `extensions-prepare` uses, so the Settings page and the
|
|
374
|
+
* management channel acquire through one implementation (and the test suite
|
|
375
|
+
* injects a local source through `assetSourceFactory`, never the network).
|
|
376
|
+
*/
|
|
377
|
+
prepareSurface: () => prepareAssetsSurface,
|
|
378
|
+
preparationSource: () => config.assetSourceFactory?.() ?? createHttpSource(),
|
|
379
|
+
/**
|
|
380
|
+
* RES-02 for the client/Remote path: the operator's standing approval, read
|
|
381
|
+
* live so a config edit applies without reconstructing the service. DEFAULT
|
|
382
|
+
* FALSE — with it off the exact executable-preparation refusal is returned and
|
|
383
|
+
* nothing is acquired. The management channel keeps its per-call flag.
|
|
384
|
+
*/
|
|
385
|
+
allowExecutablePreparation: () => config.allowExecutablePreparation === true,
|
|
386
|
+
/**
|
|
387
|
+
* Keep the registrar's admission set in step with activation changes made
|
|
388
|
+
* through the Settings surface. A failed activation revokes the admission,
|
|
389
|
+
* unless the lifecycle still reports the extension active under another owner
|
|
390
|
+
* (another host's surface is not this registrar's to revoke).
|
|
391
|
+
*/
|
|
392
|
+
onActivationChange: (extensionId, active) => {
|
|
393
|
+
if (active) {
|
|
394
|
+
activatedExtensions.add(extensionId);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
if (extensionLifecycle?.stateOf(extensionId)?.observedActivation !== 'active') {
|
|
398
|
+
activatedExtensions.delete(extensionId);
|
|
399
|
+
}
|
|
400
|
+
},
|
|
401
|
+
/**
|
|
402
|
+
* The harness-model provider status seam (task-93): the thunk evaluates the
|
|
403
|
+
* SAME composition decided below (explicit provider > config.harnessModel >
|
|
404
|
+
* AUTO discovery), so the preflight reason the extensions service reports
|
|
405
|
+
* can never contradict what activation would actually do.
|
|
406
|
+
*/
|
|
407
|
+
harnessProviderStatus: () => harnessStatusThunk(),
|
|
408
|
+
});
|
|
409
|
+
provideModelToolsService(ctx, extensionsService);
|
|
410
|
+
/**
|
|
411
|
+
* P26-09/MCP-07 (task-65 + task-73): the host-side catalog subscription
|
|
412
|
+
* pipeline. Every lifecycle transition publishes
|
|
413
|
+
* `notifications/tools/list_changed` and runs it through the DELIVERY
|
|
414
|
+
* consumer (deliverCatalogChange): the registry's per-caller filtering
|
|
415
|
+
* decides who gets the event, and the sink carries it to each client's
|
|
416
|
+
* stream. The memory sink is the transport seam — a streaming transport
|
|
417
|
+
* injects its own writer; the consumer path, the ack-first ordering and the
|
|
418
|
+
* revocation discipline are all exercised by the tests.
|
|
419
|
+
*/
|
|
420
|
+
const subscriptionRegistry = new SubscriptionRegistry();
|
|
421
|
+
const subscriptionSink = new MemoryDeliverySink();
|
|
422
|
+
const notifyCatalogChange = () => {
|
|
423
|
+
const generation = extensionLifecycle?.generation ?? host.activationGeneration;
|
|
424
|
+
deliverCatalogChange(subscriptionRegistry, generation, subscriptionSink);
|
|
425
|
+
};
|
|
96
426
|
const playbooks = loadPlaybooks(config.playbookFile);
|
|
97
427
|
const installed = new Map();
|
|
98
428
|
// An empty allowlist means "no explicit selection" rather than "select
|
|
@@ -110,6 +440,301 @@ export function apply(ctx, config = {}) {
|
|
|
110
440
|
? createFileStateStore(config.stateFile ?? defaultStateFile())
|
|
111
441
|
: createMemoryStateStore();
|
|
112
442
|
let persisted = stateStore.read();
|
|
443
|
+
/**
|
|
444
|
+
* BASE-09/10 LEGACY MIGRATION (one-time, per profile — task-26).
|
|
445
|
+
*
|
|
446
|
+
* A legacy model-id era state document is migrated on the FIRST startup that
|
|
447
|
+
* sees it: disabled choices are preserved into the host's persisted state,
|
|
448
|
+
* legacy enables are STAGED (never granted — no implicit consent to install,
|
|
449
|
+
* activate or prepare third-party code), the original document is journaled,
|
|
450
|
+
* and the SDK profile half runs through the SDK's own migration when an
|
|
451
|
+
* extension store is mounted. A sidecar marker commits the transaction, so
|
|
452
|
+
* every later startup is a byte-stable no-op. Corrupt or foreign-version
|
|
453
|
+
* documents and markers fail the boot closed (src/legacy-migration.ts).
|
|
454
|
+
*/
|
|
455
|
+
const legacy = persist
|
|
456
|
+
? await runLegacyStartupMigration({
|
|
457
|
+
stateFile: config.stateFile ?? defaultStateFile(),
|
|
458
|
+
store: stateStore,
|
|
459
|
+
extensionStoreDir: config.extensionStore ?? '',
|
|
460
|
+
log: line => ctx.logger.info(`model-tools: ${line}`),
|
|
461
|
+
})
|
|
462
|
+
: undefined;
|
|
463
|
+
// ALWAYS adopt the hook's fresh read: another process may have committed the
|
|
464
|
+
// migration between OUR initial stateStore.read() above and the hook's lock,
|
|
465
|
+
// and the pre-hook snapshot would silently re-apply the legacy enables.
|
|
466
|
+
if (legacy !== undefined)
|
|
467
|
+
persisted = legacy.hostState;
|
|
468
|
+
/**
|
|
469
|
+
* The operator-owned credential store (SEC-01).
|
|
470
|
+
*
|
|
471
|
+
* Only a DEPLOYMENT THAT PERSISTS may generate credentials: with persistence off
|
|
472
|
+
* there is nowhere owner-only to put a secret, and issuing one that a restart
|
|
473
|
+
* would forget violates the fail-closed clause, so generation refuses.
|
|
474
|
+
*/
|
|
475
|
+
const credentialStore = persist
|
|
476
|
+
? createFileCredentialStore(config.credentialsFile ?? defaultCredentialStoreFile())
|
|
477
|
+
: undefined;
|
|
478
|
+
/**
|
|
479
|
+
* The harness provider composition (HLM-01/02/03) + the AUTO step (task-93).
|
|
480
|
+
*
|
|
481
|
+
* RESOLUTION ORDER: an EXPLICIT `harnessProvider` (a test double, or a
|
|
482
|
+
* deployment-built provider) always wins; then the opt-in `harnessModel`
|
|
483
|
+
* builds the named route; then AUTO composes from the deployment's LLM
|
|
484
|
+
* surface using the deployment's default/selected model when it is
|
|
485
|
+
* discoverable unambiguously (the `agent-default-model` settings entry, or
|
|
486
|
+
* `config.model.default` as `provider/model`). DEFAULT-SAFE: an absent LLM
|
|
487
|
+
* surface or an unresolvable model constructs NOTHING, and the exact reason
|
|
488
|
+
* is what the extensions service reports at preflight — the same composition
|
|
489
|
+
* decides both, so the preflight and the activation can never contradict.
|
|
490
|
+
*/
|
|
491
|
+
const harnessWarn = (message) => {
|
|
492
|
+
ctx.logger?.warn?.(`model-tools: ${message}`);
|
|
493
|
+
};
|
|
494
|
+
const harnessBuildEnv = () => ({
|
|
495
|
+
// ctx.get (not property access): the plugin declares only `tools` in its
|
|
496
|
+
// inject list, so reading an undeclared service as a property would throw;
|
|
497
|
+
// get() answers undefined for anything the deployment did not mount.
|
|
498
|
+
...ctx.get('llm') === undefined ? {} : { llm: ctx.get('llm') },
|
|
499
|
+
...ctx.get('attachments') === undefined
|
|
500
|
+
? {}
|
|
501
|
+
: {
|
|
502
|
+
attachments: ctx.get('attachments'),
|
|
503
|
+
},
|
|
504
|
+
});
|
|
505
|
+
let harnessProvider;
|
|
506
|
+
let harnessProviderStatus;
|
|
507
|
+
/**
|
|
508
|
+
* The harnessModel is EXPLICIT only when BOTH halves are non-empty strings.
|
|
509
|
+
* The parsed Config MATERIALIZES the nested object even when the operator
|
|
510
|
+
* set nothing ({} or {provider: undefined, model: undefined}), so a bare
|
|
511
|
+
* presence check would run the explicit branch with an undefined/undefined
|
|
512
|
+
* pair and print a nonsense reason — the live 0.3.0 regression. Absent,
|
|
513
|
+
* empty or partial always falls through to AUTO.
|
|
514
|
+
*/
|
|
515
|
+
const explicitHarnessModel = config.harnessModel !== undefined
|
|
516
|
+
&& typeof config.harnessModel.provider === 'string' && config.harnessModel.provider.length > 0
|
|
517
|
+
&& typeof config.harnessModel.model === 'string' && config.harnessModel.model.length > 0
|
|
518
|
+
? config.harnessModel
|
|
519
|
+
: undefined;
|
|
520
|
+
/**
|
|
521
|
+
* TASK-98: the composition resolves the RUNNABLE pair the way the runtime
|
|
522
|
+
* does — the configured provider may be a settings namespace, not the
|
|
523
|
+
* registered ROUTE — and 'not ready yet' (the adapters register later in
|
|
524
|
+
* the composition order) is a RETRYABLE state, never locked in as 'none'.
|
|
525
|
+
* The refresh re-runs the same resolution; the status thunk triggers it
|
|
526
|
+
* when the last answer was not-ready-yet, so the next preflight read sees
|
|
527
|
+
* the refreshed result (preflight and activation share this ONE resolver).
|
|
528
|
+
*/
|
|
529
|
+
let harnessNotReadyYet = false;
|
|
530
|
+
let harnessRefreshInFlight = false;
|
|
531
|
+
let harnessRetrying = false;
|
|
532
|
+
let harnessPendingStatus;
|
|
533
|
+
/**
|
|
534
|
+
* TASK-101: the transient presentation. While the bounded retry is ACTIVE,
|
|
535
|
+
* any failing AUTO verdict (not-ready-yet OR not-configured) is presented as
|
|
536
|
+
* the not-ready-yet string — a startup blocker, never a false configuration
|
|
537
|
+
* error — and the REAL verdict is parked; the cap publishes it.
|
|
538
|
+
*/
|
|
539
|
+
const harnessNotReadyStatus = () => ({
|
|
540
|
+
available: false,
|
|
541
|
+
reason: 'the deployment\'s LLM surface has not registered its provider routes yet; retry once the deployment '
|
|
542
|
+
+ 'finishes starting (this is not a configuration error)',
|
|
543
|
+
action: 'config',
|
|
544
|
+
});
|
|
545
|
+
const refreshHarness = async () => {
|
|
546
|
+
if (harnessRefreshInFlight)
|
|
547
|
+
return;
|
|
548
|
+
harnessRefreshInFlight = true;
|
|
549
|
+
try {
|
|
550
|
+
const llmSurface = ctx.get('llm');
|
|
551
|
+
if (config.harnessProvider !== undefined) {
|
|
552
|
+
harnessProvider = config.harnessProvider;
|
|
553
|
+
harnessProviderStatus = {
|
|
554
|
+
available: true,
|
|
555
|
+
provider: config.harnessProvider.id,
|
|
556
|
+
model: 'explicit harnessProvider',
|
|
557
|
+
};
|
|
558
|
+
harnessNotReadyYet = false;
|
|
559
|
+
harnessPendingStatus = undefined;
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
const desired = explicitHarnessModel ?? (() => {
|
|
563
|
+
const hostConfig = ctx.get('config');
|
|
564
|
+
return discoverDefaultHarnessModel({
|
|
565
|
+
settings: ctx.get('settings'),
|
|
566
|
+
...hostConfig === undefined ? {} : { config: hostConfig },
|
|
567
|
+
});
|
|
568
|
+
})();
|
|
569
|
+
if (desired === undefined) {
|
|
570
|
+
harnessProvider = undefined;
|
|
571
|
+
harnessProviderStatus = {
|
|
572
|
+
available: false,
|
|
573
|
+
reason: 'no chat model is configured for this deployment, so declarative extensions cannot be served; '
|
|
574
|
+
+ 'set config.harnessModel = {provider, model} to a runnable model',
|
|
575
|
+
action: 'config',
|
|
576
|
+
};
|
|
577
|
+
harnessNotReadyYet = false;
|
|
578
|
+
harnessPendingStatus = undefined;
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
const labeled = explicitHarnessModel === undefined ? 'the deployment\'s default model' : 'config.harnessModel';
|
|
582
|
+
if (llmSurface === undefined) {
|
|
583
|
+
harnessProvider = undefined;
|
|
584
|
+
harnessProviderStatus = {
|
|
585
|
+
available: false,
|
|
586
|
+
reason: `${labeled} ${desired.provider}/${desired.model} cannot be served: the DSH LLM surface is not mounted `
|
|
587
|
+
+ 'in this deployment; set config.harnessModel = {provider, model} to a runnable model',
|
|
588
|
+
action: 'config',
|
|
589
|
+
};
|
|
590
|
+
harnessNotReadyYet = false;
|
|
591
|
+
harnessPendingStatus = undefined;
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
const resolution = await resolveRunnableHarnessModel(llmSurface, desired, harnessWarn);
|
|
595
|
+
if (!resolution.ok) {
|
|
596
|
+
/**
|
|
597
|
+
* TASK-99: never downgrade a satisfied status on a later probe. A
|
|
598
|
+
* retry whose registry momentarily reports empty after a success keeps
|
|
599
|
+
* the composed provider and the satisfied status — the composed pair
|
|
600
|
+
* was built from a REAL resolution and stays the truth for the process.
|
|
601
|
+
*/
|
|
602
|
+
if (harnessProvider !== undefined || harnessProviderStatus?.available === true) {
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
const realStatus = {
|
|
606
|
+
available: false,
|
|
607
|
+
reason: resolution.kind === 'not-ready-yet'
|
|
608
|
+
? resolution.reason
|
|
609
|
+
: `${labeled} ${desired.provider}/${desired.model} cannot be served: ${resolution.reason}`,
|
|
610
|
+
action: 'config',
|
|
611
|
+
};
|
|
612
|
+
harnessProvider = undefined;
|
|
613
|
+
if (explicitHarnessModel !== undefined) {
|
|
614
|
+
// Explicit config is final: the real verdict, immediately, no retry.
|
|
615
|
+
harnessNotReadyYet = false;
|
|
616
|
+
harnessProviderStatus = realStatus;
|
|
617
|
+
harnessPendingStatus = undefined;
|
|
618
|
+
}
|
|
619
|
+
else if (harnessRetrying) {
|
|
620
|
+
// TASK-101: the retry is active — present the transient startup
|
|
621
|
+
// blocker and park the real verdict for the cap.
|
|
622
|
+
harnessNotReadyYet = true;
|
|
623
|
+
harnessPendingStatus = realStatus;
|
|
624
|
+
harnessProviderStatus = harnessNotReadyStatus();
|
|
625
|
+
}
|
|
626
|
+
else {
|
|
627
|
+
// The initial AUTO resolution: start the retry cycle with the
|
|
628
|
+
// transient presentation.
|
|
629
|
+
harnessNotReadyYet = true;
|
|
630
|
+
harnessPendingStatus = realStatus;
|
|
631
|
+
harnessProviderStatus = harnessNotReadyStatus();
|
|
632
|
+
}
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
const built = await buildHarnessLlmProvider(harnessBuildEnv(), { provider: resolution.route, model: resolution.model }, harnessWarn);
|
|
636
|
+
harnessProvider = built;
|
|
637
|
+
harnessNotReadyYet = false;
|
|
638
|
+
harnessPendingStatus = undefined;
|
|
639
|
+
harnessProviderStatus = built === undefined
|
|
640
|
+
? {
|
|
641
|
+
available: false,
|
|
642
|
+
reason: `${labeled} ${desired.provider}/${desired.model} resolves to the ${resolution.route} route, but the `
|
|
643
|
+
+ 'provider could not be built; set config.harnessModel = {provider, model} to a runnable model',
|
|
644
|
+
action: 'config',
|
|
645
|
+
}
|
|
646
|
+
: {
|
|
647
|
+
available: true,
|
|
648
|
+
provider: resolution.route,
|
|
649
|
+
model: resolution.model,
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
finally {
|
|
653
|
+
harnessRefreshInFlight = false;
|
|
654
|
+
}
|
|
655
|
+
};
|
|
656
|
+
await refreshHarness();
|
|
657
|
+
/**
|
|
658
|
+
* The status thunk (consumed by the extensions service): a snapshot of the
|
|
659
|
+
* SAME resolution the declarative lane uses. A not-ready-yet answer kicks a
|
|
660
|
+
* refresh (fire-and-forget), so the NEXT preflight read observes the
|
|
661
|
+
* refreshed state — the one-read lag is the honest cost of the synchronous
|
|
662
|
+
* seam over an asynchronous registry.
|
|
663
|
+
*/
|
|
664
|
+
const harnessStatusThunk = () => {
|
|
665
|
+
if (harnessNotReadyYet && !harnessRefreshInFlight)
|
|
666
|
+
void refreshHarness();
|
|
667
|
+
return harnessProviderStatus;
|
|
668
|
+
};
|
|
669
|
+
/**
|
|
670
|
+
* TASK-99/101: the bounded background retry. After a failing AUTO verdict,
|
|
671
|
+
* re-run the SAME resolution on an interval so the FIRST Settings read lands
|
|
672
|
+
* on the refreshed status instead of a blocked row the operator has to
|
|
673
|
+
* close/reopen to clear. TASK-101's safe rule: an AUTO candidate is
|
|
674
|
+
* RETRYABLE UNTIL THE CAP — the retry stops only on success, the cap, or
|
|
675
|
+
* dispose (and never starts for explicit config). While it runs, ANY failing
|
|
676
|
+
* verdict is presented as the transient not-ready-yet string; at the cap the
|
|
677
|
+
* REAL parked verdict (unknown provider / unserved model) is published and
|
|
678
|
+
* polling ends. A satisfied status is never downgraded.
|
|
679
|
+
*/
|
|
680
|
+
const HARNESS_RETRY_INTERVAL_MS = config.harnessRetryIntervalMs ?? 250;
|
|
681
|
+
const HARNESS_RETRY_CAP_MS = config.harnessRetryCapMs ?? 30_000;
|
|
682
|
+
const HARNESS_RETRY_MAX_POLLS = Math.max(1, Math.floor(HARNESS_RETRY_CAP_MS / HARNESS_RETRY_INTERVAL_MS));
|
|
683
|
+
let harnessRetryTimer;
|
|
684
|
+
let harnessRetryPolls = 0;
|
|
685
|
+
const stopHarnessRetry = () => {
|
|
686
|
+
if (harnessRetryTimer !== undefined) {
|
|
687
|
+
clearInterval(harnessRetryTimer);
|
|
688
|
+
harnessRetryTimer = undefined;
|
|
689
|
+
}
|
|
690
|
+
harnessRetrying = false;
|
|
691
|
+
};
|
|
692
|
+
const startHarnessRetry = () => {
|
|
693
|
+
// Explicit config is final: no background probe may override it.
|
|
694
|
+
if (config.harnessProvider !== undefined || explicitHarnessModel !== undefined)
|
|
695
|
+
return;
|
|
696
|
+
if (harnessRetryTimer !== undefined)
|
|
697
|
+
return;
|
|
698
|
+
harnessRetrying = true;
|
|
699
|
+
harnessRetryPolls = 0;
|
|
700
|
+
harnessRetryTimer = setInterval(() => {
|
|
701
|
+
harnessRetryPolls += 1;
|
|
702
|
+
void refreshHarness().then(() => {
|
|
703
|
+
if (harnessProvider !== undefined) {
|
|
704
|
+
// Success: the answer is final.
|
|
705
|
+
stopHarnessRetry();
|
|
706
|
+
}
|
|
707
|
+
else if (harnessRetryPolls >= HARNESS_RETRY_MAX_POLLS) {
|
|
708
|
+
// TASK-101: the cap publishes the REAL parked verdict and ends polling.
|
|
709
|
+
stopHarnessRetry();
|
|
710
|
+
harnessNotReadyYet = false;
|
|
711
|
+
if (harnessPendingStatus !== undefined) {
|
|
712
|
+
harnessProviderStatus = harnessPendingStatus;
|
|
713
|
+
harnessPendingStatus = undefined;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
// Otherwise keep retrying: an unresolved AUTO candidate is retryable
|
|
717
|
+
// until the cap, never terminal on an incomplete registry/directory.
|
|
718
|
+
});
|
|
719
|
+
}, HARNESS_RETRY_INTERVAL_MS);
|
|
720
|
+
harnessRetryTimer.unref?.();
|
|
721
|
+
};
|
|
722
|
+
if (harnessNotReadyYet)
|
|
723
|
+
startHarnessRetry();
|
|
724
|
+
// The plugin fiber's teardown stops the retry: no leaked timers.
|
|
725
|
+
ctx.effect(() => () => stopHarnessRetry());
|
|
726
|
+
/**
|
|
727
|
+
* The managed-worker half (EXT-20), DEFAULT OFF.
|
|
728
|
+
*
|
|
729
|
+
* `managedWorkers` opts the deployment in; absent, the registry exists but never
|
|
730
|
+
* receives a binding, so no child process is ever spawned. The per-extension
|
|
731
|
+
* activation generation is captured at RESTORE (the host's only activation surface)
|
|
732
|
+
* and the registry disposes the old process the moment a newer generation is bound.
|
|
733
|
+
*/
|
|
734
|
+
const managedWorkers = config.managedWorkers ?? false;
|
|
735
|
+
const workerEnvPassthrough = config.workerEnvPassthrough ?? [];
|
|
736
|
+
const workerRegistry = createManagedWorkerRegistry();
|
|
737
|
+
const activationGenerations = new Map();
|
|
113
738
|
const cliCommand = config.cli?.command ?? DEFAULT_CLI_COMMAND;
|
|
114
739
|
const cliEnabled = config.cli?.enabled ?? true;
|
|
115
740
|
// Established once: probing PATH per model would repeat identical work and
|
|
@@ -145,10 +770,24 @@ export function apply(ctx, config = {}) {
|
|
|
145
770
|
let receiptCounter = 0;
|
|
146
771
|
/** The most recent invocation receipts (EXT-35), bounded so retention cannot grow without limit. */
|
|
147
772
|
const invocationReceipts = [];
|
|
773
|
+
/**
|
|
774
|
+
* External (MCP) call accounting, bounded like the receipts (OBS-02).
|
|
775
|
+
*
|
|
776
|
+
* Kept SEPARATE from the native receipts: the shapes differ (a native receipt
|
|
777
|
+
* is provenance — contract/implementation/extension identities — while an
|
|
778
|
+
* external accounting is timings, class and sizes), and merging them would
|
|
779
|
+
* force one record to pretend it is the other.
|
|
780
|
+
*/
|
|
781
|
+
const externalAccountings = [];
|
|
148
782
|
const grant = createGrant({
|
|
149
783
|
grantId: config.grantId ?? 'local-interactive',
|
|
150
|
-
// Every registered tool is granted to the interactive operator
|
|
151
|
-
//
|
|
784
|
+
// Every registered tool is granted to the interactive operator. EXTERNAL
|
|
785
|
+
// clients do NOT share this grant: since round 66, each authenticated MCP
|
|
786
|
+
// client's call carries its OWN derived grant (SEC-04) — resolved by the
|
|
787
|
+
// transport, enforced at the surface boundary below with authorizeTool — so
|
|
788
|
+
// an external call never dispatches as the operator. model_tools is part of
|
|
789
|
+
// THIS grant only, which is why it is reachable interactively but never
|
|
790
|
+
// appears on the external surface.
|
|
152
791
|
allowedTools: [...STANDARD_CONTRACTS.map(contract => contract.toolName), 'model_tools'],
|
|
153
792
|
approvedRoots: config.approvedRoots ?? [],
|
|
154
793
|
hostedCode: false,
|
|
@@ -181,6 +820,10 @@ export function apply(ctx, config = {}) {
|
|
|
181
820
|
const bridge = new ProviderBridge({
|
|
182
821
|
grant,
|
|
183
822
|
activationGeneration: host.activationGeneration,
|
|
823
|
+
// EXT-20: the opt-in routes the bridge's catalog invocation AND inspection
|
|
824
|
+
// through the managed-worker registry; without it the in-process path is
|
|
825
|
+
// exactly as before (default OFF).
|
|
826
|
+
...managedWorkers ? { managed: workerRegistry } : {},
|
|
184
827
|
log: (message, fields) => ctx.logger?.debug?.(`[model-as-tool] ${message}`, fields),
|
|
185
828
|
});
|
|
186
829
|
const contractById = new Map(STANDARD_CONTRACTS.map(contract => [contract.contract, contract]));
|
|
@@ -224,26 +867,104 @@ export function apply(ctx, config = {}) {
|
|
|
224
867
|
continue;
|
|
225
868
|
out.push({ toolName: registered.toolName, contract });
|
|
226
869
|
}
|
|
870
|
+
// CODE-02: `tool_mode_run` is exported over MCP exactly when the exposure
|
|
871
|
+
// gate permits it — the conjunction of operator consent and confinement
|
|
872
|
+
// evidence. While the gate is closed the export is simply absent, which is
|
|
873
|
+
// the disabled-until-granted state CODE-10 names. The contract is ONE
|
|
874
|
+
// shared definition (TOOL_MODE_RUN_CONTRACT), validated by a conformance
|
|
875
|
+
// test before it ever appears on a live tools/list.
|
|
876
|
+
if (programRunner.externalExposurePermitted()) {
|
|
877
|
+
out.push({ toolName: 'tool_mode_run', contract: TOOL_MODE_RUN_CONTRACT });
|
|
878
|
+
}
|
|
227
879
|
return out;
|
|
228
880
|
},
|
|
229
|
-
async execute({ name, arguments: args, signal, callId }) {
|
|
881
|
+
async execute({ name, arguments: args, signal, callId, caller }) {
|
|
882
|
+
/**
|
|
883
|
+
* SEC-04: AN EXTERNAL CLIENT RUNS UNDER ITS OWN GRANT, never the host's.
|
|
884
|
+
*
|
|
885
|
+
* The transport resolved the authenticated caller and its derived grant; the
|
|
886
|
+
* host ENFORCES it here with the SEC-03 authority — a tool the operator is
|
|
887
|
+
* granted but the client is not stays refused for that client. A call with no
|
|
888
|
+
* caller (the native/interactive path) keeps the host's own authority, which
|
|
889
|
+
* is unchanged.
|
|
890
|
+
*/
|
|
891
|
+
if (caller !== undefined) {
|
|
892
|
+
try {
|
|
893
|
+
authorizeTool({
|
|
894
|
+
grantId: caller.grantId,
|
|
895
|
+
allowedTools: caller.allowedTools,
|
|
896
|
+
approvedRoots: [],
|
|
897
|
+
remoteDestinations: [],
|
|
898
|
+
hostedCode: false,
|
|
899
|
+
recordingPolicy: caller.recordingPolicy,
|
|
900
|
+
limits: caller.limits,
|
|
901
|
+
}, name);
|
|
902
|
+
}
|
|
903
|
+
catch (error) {
|
|
904
|
+
// The refusal is a TOOL error, not a protocol error: the exchange
|
|
905
|
+
// succeeded and the grant refused the work (P26-04, SEC-03).
|
|
906
|
+
return { isError: true, message: error instanceof Error ? error.message : String(error) };
|
|
907
|
+
}
|
|
908
|
+
}
|
|
230
909
|
// MCP-08: routed through the DSH tool runtime with a SERVER-ISSUED call
|
|
231
910
|
// identity and the CALLER's signal, never by resolving a definition and
|
|
232
911
|
// calling it directly — that path would bypass the grant, the receipt and
|
|
233
912
|
// the result policy the runtime applies.
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
913
|
+
//
|
|
914
|
+
// SEC-04 COMPOSITION: an external call runs INSIDE the caller's grant
|
|
915
|
+
// context, so any COMPOSED work — a tool_mode_run program dispatching
|
|
916
|
+
// sub-calls — authorizes against the CLIENT's grant, not the host's
|
|
917
|
+
// (H1). The native path carries no context and is unchanged.
|
|
918
|
+
const result = caller === undefined
|
|
919
|
+
? await ctx.tools.execute({
|
|
920
|
+
signal,
|
|
921
|
+
callId: ToolCallId(callId),
|
|
922
|
+
name,
|
|
923
|
+
arguments: args,
|
|
924
|
+
})
|
|
925
|
+
: await externalGrantContext.run(caller.allowedTools, () => ctx.tools.execute({
|
|
926
|
+
signal,
|
|
927
|
+
callId: ToolCallId(callId),
|
|
928
|
+
name,
|
|
929
|
+
arguments: args,
|
|
930
|
+
}));
|
|
240
931
|
return result.isError
|
|
241
932
|
? { isError: true, message: result.error.message }
|
|
242
933
|
: { isError: false, value: result.value };
|
|
243
934
|
},
|
|
244
935
|
},
|
|
245
936
|
onWithheld: tool => ctx.logger.info(`model-tools: ${tool.toolName} is not exported over MCP: ${tool.notExportableReason ?? 'no reason given'}`),
|
|
937
|
+
// OBS-02: the external half of the traffic gets its receipt WIRED rather than
|
|
938
|
+
// dropped. The hook receives timings, class and SIZES — never the payload —
|
|
939
|
+
// so this records the external call without duplicating a large result. The
|
|
940
|
+
// ring is bounded exactly like the native one (EXT-35).
|
|
941
|
+
onAccounted: accounting => {
|
|
942
|
+
externalAccountings.push(accounting);
|
|
943
|
+
if (externalAccountings.length > MAX_RETAINED_RECEIPTS)
|
|
944
|
+
externalAccountings.shift();
|
|
945
|
+
},
|
|
246
946
|
});
|
|
947
|
+
/**
|
|
948
|
+
* THE HOST'S OWN MCP SURFACE, EXPOSED so an operator can serve it (OBS-02).
|
|
949
|
+
*
|
|
950
|
+
* Before this service existed the host's mcpHost was reachable only through
|
|
951
|
+
* the model_tools inspection actions — which meant its `callTool` path (and
|
|
952
|
+
* therefore the wired external accounting) had NO production caller. The
|
|
953
|
+
* service is what `startHttpServer` composes over; a deployment that serves
|
|
954
|
+
* MCP reads it here instead of rebuilding a surface that would not carry the
|
|
955
|
+
* host's receipts.
|
|
956
|
+
*
|
|
957
|
+
* Guarded (round 65): `provide` THROWS if the name is already provided in the
|
|
958
|
+
* scope (a double mount), and a context without `provide` must degrade with a
|
|
959
|
+
* diagnostic rather than silently or loudly failing the mount.
|
|
960
|
+
*/
|
|
961
|
+
try {
|
|
962
|
+
;
|
|
963
|
+
ctx.provide?.('model-tools.mcp-host', mcpHost);
|
|
964
|
+
}
|
|
965
|
+
catch (error) {
|
|
966
|
+
ctx.logger?.warn?.(`model-tools: could not expose the MCP host service: ${error instanceof Error ? error.message : String(error)}`);
|
|
967
|
+
}
|
|
247
968
|
/**
|
|
248
969
|
* The publication decision for one catalog entry (EXT-24, EXT-32).
|
|
249
970
|
*
|
|
@@ -251,10 +972,49 @@ export function apply(ctx, config = {}) {
|
|
|
251
972
|
* discovery surface. Two call sites computing this separately is how a tool ends
|
|
252
973
|
* up registered while discovery reports it withheld, or the reverse.
|
|
253
974
|
*/
|
|
254
|
-
|
|
975
|
+
/**
|
|
976
|
+
* The selected implementation's current preparation state.
|
|
977
|
+
*
|
|
978
|
+
* Capability resolution only proves that a route can be reached. For extension
|
|
979
|
+
* providers that is not enough to publish a working tool: the extension's
|
|
980
|
+
* read-only inspection owns the distinction between missing prerequisites,
|
|
981
|
+
* prepared-cold and ready. Non-extension transports have no preparation
|
|
982
|
+
* lifecycle yet, so their existing capability verdict remains the readiness
|
|
983
|
+
* source.
|
|
984
|
+
*/
|
|
985
|
+
const callableReadiness = new Set([
|
|
986
|
+
'prepared-cold',
|
|
987
|
+
'loading',
|
|
988
|
+
'ready',
|
|
989
|
+
'busy',
|
|
990
|
+
]);
|
|
991
|
+
const readinessOf = async (item) => {
|
|
992
|
+
if (item.verdict.route !== 'extension')
|
|
993
|
+
return 'ready';
|
|
994
|
+
try {
|
|
995
|
+
const inspection = await bridge.inspect(item.entry, new AbortController().signal);
|
|
996
|
+
return inspection?.readiness ?? 'failed';
|
|
997
|
+
}
|
|
998
|
+
catch (error) {
|
|
999
|
+
ctx.logger.warn(`model-tools: ${item.entry.name} provider inspection failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1000
|
+
return 'failed';
|
|
1001
|
+
}
|
|
1002
|
+
};
|
|
1003
|
+
const implementationFor = async (item) => ({
|
|
1004
|
+
extensionId: item.entry.provider?.extension
|
|
1005
|
+
?? item.entry.npmPackage
|
|
1006
|
+
?? 'unknown',
|
|
1007
|
+
modelId: item.entry.id,
|
|
1008
|
+
readiness: await readinessOf(item),
|
|
1009
|
+
runnable: item.verdict.runnable,
|
|
1010
|
+
});
|
|
1011
|
+
const surfaceFor = async (entry) => {
|
|
255
1012
|
const binding = entry.contract === null ? undefined : bindings.get(entry.contract);
|
|
256
1013
|
if (binding === undefined)
|
|
257
1014
|
return undefined;
|
|
1015
|
+
const selected = resolved.find(item => item.entry.id === binding.implementation.id);
|
|
1016
|
+
if (selected === undefined)
|
|
1017
|
+
return undefined;
|
|
258
1018
|
return buildSurface({
|
|
259
1019
|
generation: host.activationGeneration,
|
|
260
1020
|
tools: [{
|
|
@@ -262,32 +1022,18 @@ export function apply(ctx, config = {}) {
|
|
|
262
1022
|
contractId: binding.contract.contract,
|
|
263
1023
|
selectedModelId: binding.implementation.id,
|
|
264
1024
|
implementations: [
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
?? binding.implementation.npmPackage
|
|
268
|
-
?? 'unknown',
|
|
269
|
-
modelId: binding.implementation.id,
|
|
270
|
-
// There is no preparation lifecycle yet, so a model the capability
|
|
271
|
-
// resolver proved runnable is treated as ready. When preparation exists
|
|
272
|
-
// this reads the deployment's real readiness instead, and the surface
|
|
273
|
-
// already withholds the tool when it is not ready.
|
|
274
|
-
readiness: 'ready',
|
|
275
|
-
runnable: true,
|
|
276
|
-
},
|
|
277
|
-
...binding.alternatives.map(alternativeId => {
|
|
278
|
-
// The alternative's own extension, looked up rather than assumed: an
|
|
279
|
-
// alternative served by a different package is exactly the case a
|
|
280
|
-
// second implementation exists for (EXT-22).
|
|
1025
|
+
await implementationFor(selected),
|
|
1026
|
+
...await Promise.all(binding.alternatives.map(async (alternativeId) => {
|
|
281
1027
|
const alternative = resolved.find(item => item.entry.id === alternativeId);
|
|
282
|
-
return
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
}),
|
|
1028
|
+
return alternative === undefined
|
|
1029
|
+
? {
|
|
1030
|
+
extensionId: 'unknown',
|
|
1031
|
+
modelId: alternativeId,
|
|
1032
|
+
readiness: 'failed',
|
|
1033
|
+
runnable: false,
|
|
1034
|
+
}
|
|
1035
|
+
: implementationFor(alternative);
|
|
1036
|
+
})),
|
|
291
1037
|
],
|
|
292
1038
|
}],
|
|
293
1039
|
skills: [],
|
|
@@ -302,7 +1048,7 @@ export function apply(ctx, config = {}) {
|
|
|
302
1048
|
* @param item - the resolved model to install.
|
|
303
1049
|
* @returns whether the model is installed after the call.
|
|
304
1050
|
*/
|
|
305
|
-
const install = (item) => {
|
|
1051
|
+
const install = async (item) => {
|
|
306
1052
|
const { entry, verdict } = item;
|
|
307
1053
|
if (installed.has(entry.id))
|
|
308
1054
|
return true;
|
|
@@ -331,7 +1077,7 @@ export function apply(ctx, config = {}) {
|
|
|
331
1077
|
* sites computing this separately is how a tool gets registered while
|
|
332
1078
|
* discovery reports it withheld, or the reverse.
|
|
333
1079
|
*/
|
|
334
|
-
const surface = surfaceFor(entry);
|
|
1080
|
+
const surface = await surfaceFor(entry);
|
|
335
1081
|
const decision = surface?.tools[0];
|
|
336
1082
|
if (decision === undefined) {
|
|
337
1083
|
const reason = surface?.withheld[0];
|
|
@@ -353,6 +1099,13 @@ export function apply(ctx, config = {}) {
|
|
|
353
1099
|
}
|
|
354
1100
|
}
|
|
355
1101
|
else {
|
|
1102
|
+
if (verdict.route === 'extension') {
|
|
1103
|
+
const readiness = await readinessOf(item);
|
|
1104
|
+
if (!callableReadiness.has(readiness)) {
|
|
1105
|
+
ctx.logger.info(`model-tools: ${entry.name} is active but its provider reports ${readiness}; skipping ${entry.tool.name}`);
|
|
1106
|
+
return false;
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
356
1109
|
const adapter = createAdapter(entry, { route: verdict.route, ...transportOptions }, undefined, bridge);
|
|
357
1110
|
if (adapter === undefined) {
|
|
358
1111
|
// Runnable but no adapter yet: report rather than register a dead tool.
|
|
@@ -390,6 +1143,7 @@ export function apply(ctx, config = {}) {
|
|
|
390
1143
|
}
|
|
391
1144
|
installed.set(entry.id, record);
|
|
392
1145
|
ctx.logger.info(`model-tools: registered ${toolName} for ${entry.name} (${verdict.route})`);
|
|
1146
|
+
notifyCatalogChange();
|
|
393
1147
|
return true;
|
|
394
1148
|
};
|
|
395
1149
|
/**
|
|
@@ -414,6 +1168,7 @@ export function apply(ctx, config = {}) {
|
|
|
414
1168
|
}
|
|
415
1169
|
installed.delete(modelId);
|
|
416
1170
|
ctx.logger.info(`model-tools: unregistered ${record.toolName}`);
|
|
1171
|
+
notifyCatalogChange();
|
|
417
1172
|
return true;
|
|
418
1173
|
};
|
|
419
1174
|
/**
|
|
@@ -447,8 +1202,21 @@ export function apply(ctx, config = {}) {
|
|
|
447
1202
|
}
|
|
448
1203
|
// A model the user enabled that cannot run here is reported, not installed:
|
|
449
1204
|
// the persisted choice is kept so it takes effect once a route appears.
|
|
450
|
-
install(item);
|
|
1205
|
+
await install(item);
|
|
451
1206
|
}
|
|
1207
|
+
/**
|
|
1208
|
+
* The host's EXT-30 update surface, constructed when the extension store is
|
|
1209
|
+
* configured (the management-channel actions below drive it). Declared here,
|
|
1210
|
+
* outside the control-tool registration, so the execute closure and the
|
|
1211
|
+
* extension-store block share it.
|
|
1212
|
+
*/
|
|
1213
|
+
let updateSurface;
|
|
1214
|
+
/**
|
|
1215
|
+
* The host's prepared-assets surface (RES-02/03/05, the last slice): lock
|
|
1216
|
+
* reading, the preparation plan, acquisition and the PreparedAssetMapV1 the
|
|
1217
|
+
* provider routes consume. Same outer-scope reason as updateSurface.
|
|
1218
|
+
*/
|
|
1219
|
+
let prepareAssetsSurface;
|
|
452
1220
|
if (config.registerControlTool ?? true) {
|
|
453
1221
|
ctx.tools.register(defineTool({
|
|
454
1222
|
name: 'model_tools',
|
|
@@ -461,15 +1229,69 @@ export function apply(ctx, config = {}) {
|
|
|
461
1229
|
action: {
|
|
462
1230
|
type: 'string',
|
|
463
1231
|
required: true,
|
|
464
|
-
enum: ['list', 'installed', 'describe', 'enable', 'disable', 'reset', 'receipts', 'mcp'],
|
|
1232
|
+
enum: ['list', 'installed', 'describe', 'enable', 'disable', 'reset', 'receipts', 'mcp', 'diagnostics', 'credentials', 'extensions-pending', 'extensions-approve', 'extensions-deny', 'extensions-commit', 'extensions-rollback', 'extensions-prepare-plan', 'extensions-prepare', 'extensions-license-basis'],
|
|
465
1233
|
description: 'list: every catalogued model and whether it runs here. installed: only the models whose tools are '
|
|
466
1234
|
+ 'registered. describe: full detail for one model. enable: install a model\'s tool and remember it. '
|
|
467
1235
|
+ 'disable: remove a model\'s tool and remember it. reset: forget the remembered choice for one model, '
|
|
468
|
-
+ 'or for every model when no id is given.'
|
|
1236
|
+
+ 'or for every model when no id is given. credentials: list or revoke external MCP client credentials '
|
|
1237
|
+
+ '(SEC-01). GENERATION IS REFUSED HERE: the generated secret must never reach a model, so credentials '
|
|
1238
|
+
+ 'are generated by the human-only CLI instead. extensions-pending: the staged extension updates awaiting '
|
|
1239
|
+
+ 'an operator decision (EXT-30). extensions-approve/extensions-deny: record the operator decision for one '
|
|
1240
|
+
+ 'staged update. extensions-commit: commit an APPROVED staged update (quiesce, pointer, rebind). '
|
|
1241
|
+
+ 'extensions-rollback: restore the previous version pointer. extensions-prepare-plan: disclose the '
|
|
1242
|
+
+ 'preparation plan (per-asset bytes, total, license, executable gate) for one installed extension. '
|
|
1243
|
+
+ 'extensions-prepare: download and digest-verify the planned assets into the isolated cache and flip the '
|
|
1244
|
+
+ 'deployment to prepared-cold (the acquisition IS the operator consent; a real download is opt-in).',
|
|
469
1245
|
},
|
|
470
1246
|
model: {
|
|
471
1247
|
type: 'string',
|
|
472
|
-
description: 'Model id, required for describe, enable and disable, and optional for reset (for example "tongue" or "title").'
|
|
1248
|
+
description: 'Model id, required for describe, enable and disable, and optional for reset (for example "tongue" or "title"). '
|
|
1249
|
+
+ 'For the extensions actions it names the EXTENSION id (for example "@try-works/extension-desert-ant-voz").',
|
|
1250
|
+
},
|
|
1251
|
+
version: {
|
|
1252
|
+
type: 'string',
|
|
1253
|
+
description: 'The staged extension version, required for extensions-approve, extensions-deny and extensions-commit.',
|
|
1254
|
+
},
|
|
1255
|
+
allowExecutable: {
|
|
1256
|
+
type: 'boolean',
|
|
1257
|
+
description: 'The extensions-prepare action: set true to grant the separate EXECUTABLE-preparation approval (RES-02). Without it a plan containing an executable asset refuses. The pinned EmbeddingGemma assets are NOT executable.',
|
|
1258
|
+
},
|
|
1259
|
+
licenseBasis: {
|
|
1260
|
+
type: 'object',
|
|
1261
|
+
additionalProperties: false,
|
|
1262
|
+
description: 'The extensions-license-basis action (LIC-03): the human-recorded permission basis — native use, '
|
|
1263
|
+
+ 'external export, distribution and telemetry — for one provider, recorded with the approving principal.',
|
|
1264
|
+
properties: {
|
|
1265
|
+
native: { type: 'boolean' },
|
|
1266
|
+
export: { type: 'boolean' },
|
|
1267
|
+
distribution: { type: 'boolean' },
|
|
1268
|
+
telemetry: { type: 'boolean' },
|
|
1269
|
+
},
|
|
1270
|
+
},
|
|
1271
|
+
principal: {
|
|
1272
|
+
type: 'string',
|
|
1273
|
+
description: 'The approving principal (operator identity) for the extensions-license-basis action (LIC-03).',
|
|
1274
|
+
},
|
|
1275
|
+
sub: {
|
|
1276
|
+
type: 'string',
|
|
1277
|
+
enum: ['generate', 'list', 'revoke'],
|
|
1278
|
+
description: 'The credentials action: list names existing clients, revoke removes one. generate is DECLARED for '
|
|
1279
|
+
+ 'schema completeness but always refused on this model-visible surface (SEC-01): the secret must '
|
|
1280
|
+
+ 'never reach a model, so generation happens in the human-only CLI.',
|
|
1281
|
+
},
|
|
1282
|
+
scheme: {
|
|
1283
|
+
type: 'string',
|
|
1284
|
+
enum: ['static-token', 'oauth-resource-server'],
|
|
1285
|
+
description: 'Which authentication scheme the generated credential is for.',
|
|
1286
|
+
},
|
|
1287
|
+
clientId: {
|
|
1288
|
+
type: 'string',
|
|
1289
|
+
description: 'The client identity to generate or revoke, at most 64 characters.',
|
|
1290
|
+
},
|
|
1291
|
+
scope: {
|
|
1292
|
+
type: 'array',
|
|
1293
|
+
items: { type: 'string' },
|
|
1294
|
+
description: 'OAuth scope tokens for the generated client; defaults to mcp:call.',
|
|
473
1295
|
},
|
|
474
1296
|
},
|
|
475
1297
|
output: {
|
|
@@ -588,6 +1410,57 @@ export function apply(ctx, config = {}) {
|
|
|
588
1410
|
},
|
|
589
1411
|
},
|
|
590
1412
|
},
|
|
1413
|
+
/**
|
|
1414
|
+
* Set by the receipts action: external (MCP) call accounting (OBS-02).
|
|
1415
|
+
*
|
|
1416
|
+
* Sizes, class and engine version — never a payload. Declared here so
|
|
1417
|
+
* the output projection admits the wired external half. SEC-04: the
|
|
1418
|
+
* entry also names the CLIENT's grant identity, so the operator can tell
|
|
1419
|
+
* who called — identity only, still never the payload.
|
|
1420
|
+
*/
|
|
1421
|
+
external: {
|
|
1422
|
+
type: 'array',
|
|
1423
|
+
items: {
|
|
1424
|
+
type: 'object',
|
|
1425
|
+
additionalProperties: false,
|
|
1426
|
+
properties: {
|
|
1427
|
+
receiptId: { type: 'string', required: true },
|
|
1428
|
+
failureClass: { type: 'string', required: true },
|
|
1429
|
+
reason: {
|
|
1430
|
+
required: true,
|
|
1431
|
+
oneOf: [{ type: 'string' }, { type: 'null' }],
|
|
1432
|
+
},
|
|
1433
|
+
totalMs: { type: 'number', required: true },
|
|
1434
|
+
inputBytes: { type: 'integer', required: true },
|
|
1435
|
+
outputBytes: { type: 'integer', required: true },
|
|
1436
|
+
engineVersion: { type: 'string', required: true },
|
|
1437
|
+
/** The CLIENT's grant identity (SEC-04); absent for host-initiated external calls. */
|
|
1438
|
+
grantId: { type: 'string' },
|
|
1439
|
+
clientId: { type: 'string' },
|
|
1440
|
+
},
|
|
1441
|
+
},
|
|
1442
|
+
},
|
|
1443
|
+
/**
|
|
1444
|
+
* Set by the diagnostics action: the OBS-04 surface.
|
|
1445
|
+
*
|
|
1446
|
+
* Declared here because DSH refuses an undeclared output property — which is a
|
|
1447
|
+
* useful constraint rather than an obstacle: it means a new action cannot quietly
|
|
1448
|
+
* return a shape no caller was told about. The declaration is deliberately SHALLOW
|
|
1449
|
+
* (`additionalProperties: true`) because the value schema DSL cannot express the
|
|
1450
|
+
* nested report faithfully, and a wrong declaration is worse than a permissive one.
|
|
1451
|
+
* See the note on unrepresentable keywords for the same reasoning.
|
|
1452
|
+
*/
|
|
1453
|
+
capture: {
|
|
1454
|
+
type: 'object',
|
|
1455
|
+
additionalProperties: true,
|
|
1456
|
+
},
|
|
1457
|
+
diagnostics: {
|
|
1458
|
+
type: 'object',
|
|
1459
|
+
additionalProperties: true,
|
|
1460
|
+
// NOT required: only the diagnostics action returns it, and marking it
|
|
1461
|
+
// required would demand the property from every other action's result — which
|
|
1462
|
+
// is what the type checker refused, correctly.
|
|
1463
|
+
},
|
|
591
1464
|
/** Set by enable/disable/reset: whether the requested change took effect. */
|
|
592
1465
|
changed: { type: 'boolean' },
|
|
593
1466
|
/** Set by enable/disable/reset: what the call did to the tool surface. */
|
|
@@ -596,9 +1469,134 @@ export function apply(ctx, config = {}) {
|
|
|
596
1469
|
persisted: { type: 'boolean' },
|
|
597
1470
|
/** Where choices are stored, so a caller can tell the user what to edit. */
|
|
598
1471
|
stateFile: { type: 'string' },
|
|
1472
|
+
/**
|
|
1473
|
+
* Set by the credentials action when generation was asked for on this
|
|
1474
|
+
* surface (SEC-01). It NEVER carries a secret: the model-visible surface
|
|
1475
|
+
* refuses generation outright, and this note is how the refusal guidance
|
|
1476
|
+
* reaches a caller without any secret ever entering the transcript.
|
|
1477
|
+
*/
|
|
1478
|
+
credentialNote: { type: 'string' },
|
|
1479
|
+
/** Set by credentials list: live clients WITHOUT secrets. */
|
|
1480
|
+
credentials: {
|
|
1481
|
+
type: 'array',
|
|
1482
|
+
items: {
|
|
1483
|
+
type: 'object',
|
|
1484
|
+
additionalProperties: false,
|
|
1485
|
+
properties: {
|
|
1486
|
+
clientId: { type: 'string', required: true },
|
|
1487
|
+
scheme: { type: 'string', required: true },
|
|
1488
|
+
createdAt: { type: 'string', required: true },
|
|
1489
|
+
},
|
|
1490
|
+
},
|
|
1491
|
+
},
|
|
1492
|
+
/** Set by extensions-pending: the staged updates awaiting a decision (EXT-30). */
|
|
1493
|
+
pending: {
|
|
1494
|
+
type: 'array',
|
|
1495
|
+
items: {
|
|
1496
|
+
type: 'object',
|
|
1497
|
+
additionalProperties: false,
|
|
1498
|
+
properties: {
|
|
1499
|
+
extensionId: { type: 'string', required: true },
|
|
1500
|
+
stagedVersion: { type: 'string', required: true },
|
|
1501
|
+
currentVersion: { type: 'string', required: true },
|
|
1502
|
+
stagedAt: { type: 'string', required: true },
|
|
1503
|
+
licenseDelta: { type: 'string' },
|
|
1504
|
+
permissionDeltas: { type: 'array', items: { type: 'string' } },
|
|
1505
|
+
requiresApproval: { type: 'boolean', required: true },
|
|
1506
|
+
approval: {
|
|
1507
|
+
type: 'object',
|
|
1508
|
+
additionalProperties: false,
|
|
1509
|
+
properties: {
|
|
1510
|
+
approved: { type: 'boolean', required: true },
|
|
1511
|
+
reason: { type: 'string' },
|
|
1512
|
+
},
|
|
1513
|
+
},
|
|
1514
|
+
},
|
|
1515
|
+
},
|
|
1516
|
+
},
|
|
1517
|
+
/** Set by extensions-commit/extensions-rollback: the resulting pointer. */
|
|
1518
|
+
updateResult: {
|
|
1519
|
+
type: 'object',
|
|
1520
|
+
additionalProperties: false,
|
|
1521
|
+
properties: {
|
|
1522
|
+
extensionId: { type: 'string', required: true },
|
|
1523
|
+
activeVersion: { type: 'string', required: true },
|
|
1524
|
+
previousVersion: { type: 'string' },
|
|
1525
|
+
hookFailure: { type: 'string' },
|
|
1526
|
+
},
|
|
1527
|
+
},
|
|
1528
|
+
/** Set by extensions-prepare-plan: the RES-02 disclosure. */
|
|
1529
|
+
plan: {
|
|
1530
|
+
type: 'object',
|
|
1531
|
+
additionalProperties: false,
|
|
1532
|
+
properties: {
|
|
1533
|
+
extensionId: { type: 'string', required: true },
|
|
1534
|
+
modelId: { type: 'string', required: true },
|
|
1535
|
+
modelRevision: { type: 'string', required: true },
|
|
1536
|
+
assets: {
|
|
1537
|
+
type: 'array',
|
|
1538
|
+
items: {
|
|
1539
|
+
type: 'object',
|
|
1540
|
+
additionalProperties: false,
|
|
1541
|
+
properties: {
|
|
1542
|
+
assetId: { type: 'string', required: true },
|
|
1543
|
+
bytes: { type: 'number', required: true },
|
|
1544
|
+
source: { type: 'string', required: true },
|
|
1545
|
+
executable: { type: 'boolean', required: true },
|
|
1546
|
+
},
|
|
1547
|
+
},
|
|
1548
|
+
},
|
|
1549
|
+
totalBytes: { type: 'number', required: true },
|
|
1550
|
+
storageBytes: { type: 'number', required: true },
|
|
1551
|
+
requiresExecutableApproval: { type: 'boolean', required: true },
|
|
1552
|
+
license: {
|
|
1553
|
+
type: 'object',
|
|
1554
|
+
additionalProperties: false,
|
|
1555
|
+
properties: {
|
|
1556
|
+
name: { type: 'string', required: true },
|
|
1557
|
+
url: { type: 'string', required: true },
|
|
1558
|
+
},
|
|
1559
|
+
},
|
|
1560
|
+
},
|
|
1561
|
+
},
|
|
1562
|
+
/** Set by extensions-prepare: the acquired groups. */
|
|
1563
|
+
prepared: { type: 'boolean' },
|
|
1564
|
+
/** Set by extensions-license-basis: the recorded permission basis (LIC-03). */
|
|
1565
|
+
licenseBasis: {
|
|
1566
|
+
type: 'object',
|
|
1567
|
+
additionalProperties: false,
|
|
1568
|
+
properties: {
|
|
1569
|
+
native: { type: 'boolean' },
|
|
1570
|
+
export: { type: 'boolean' },
|
|
1571
|
+
distribution: { type: 'boolean' },
|
|
1572
|
+
telemetry: { type: 'boolean' },
|
|
1573
|
+
principal: { type: 'string' },
|
|
1574
|
+
recordedAt: { type: 'string' },
|
|
1575
|
+
},
|
|
1576
|
+
},
|
|
1577
|
+
providerId: { type: 'string' },
|
|
1578
|
+
groups: {
|
|
1579
|
+
type: 'array',
|
|
1580
|
+
items: {
|
|
1581
|
+
type: 'object',
|
|
1582
|
+
additionalProperties: false,
|
|
1583
|
+
properties: {
|
|
1584
|
+
groupId: { type: 'string', required: true },
|
|
1585
|
+
path: { type: 'string', required: true },
|
|
1586
|
+
digests: { type: 'array', items: { type: 'string' } },
|
|
1587
|
+
},
|
|
1588
|
+
},
|
|
1589
|
+
},
|
|
599
1590
|
},
|
|
600
1591
|
},
|
|
601
1592
|
render: (_args, value) => {
|
|
1593
|
+
if (value.credentialNote !== undefined) {
|
|
1594
|
+
return [{ type: 'text', text: value.credentialNote }];
|
|
1595
|
+
}
|
|
1596
|
+
if (value.credentials !== undefined) {
|
|
1597
|
+
const lines = value.credentials.map(entry => `- ${entry.clientId} (${entry.scheme}, ${entry.createdAt})`);
|
|
1598
|
+
return [{ type: 'text', text: `${lines.length} live client credential(s):\n${lines.join('\n')}` }];
|
|
1599
|
+
}
|
|
602
1600
|
const headline = value.action !== undefined
|
|
603
1601
|
? `${value.action}: ${value.changed === true ? 'done' : 'no change'}`
|
|
604
1602
|
+ `${value.persisted === true ? ', remembered across restarts' : ''}. `
|
|
@@ -614,10 +1612,13 @@ export function apply(ctx, config = {}) {
|
|
|
614
1612
|
}];
|
|
615
1613
|
},
|
|
616
1614
|
},
|
|
617
|
-
execute(args, exec) {
|
|
1615
|
+
async execute(args, exec) {
|
|
618
1616
|
void exec;
|
|
619
1617
|
const action = args.action;
|
|
620
1618
|
const modelId = args.model;
|
|
1619
|
+
const sub = args.sub;
|
|
1620
|
+
const clientId = args.clientId;
|
|
1621
|
+
const stagedVersion = args.version;
|
|
621
1622
|
/** The user's remembered choice for one model. */
|
|
622
1623
|
const choiceOf = (id) => persisted.enabled.includes(id) ? 'enabled' : persisted.disabled.includes(id) ? 'disabled' : 'none';
|
|
623
1624
|
/**
|
|
@@ -628,8 +1629,8 @@ export function apply(ctx, config = {}) {
|
|
|
628
1629
|
* partial object would be refused — and a caller reading `readiness` should
|
|
629
1630
|
* get an answer for every model rather than having to handle its absence.
|
|
630
1631
|
*/
|
|
631
|
-
const provenanceOf = (entry) => {
|
|
632
|
-
const surface = surfaceFor(entry);
|
|
1632
|
+
const provenanceOf = async (entry) => {
|
|
1633
|
+
const surface = await surfaceFor(entry);
|
|
633
1634
|
const published = surface?.tools[0];
|
|
634
1635
|
const notice = surface?.readiness.find(item => item.modelId === entry.id);
|
|
635
1636
|
const withheld = surface === undefined
|
|
@@ -662,16 +1663,16 @@ export function apply(ctx, config = {}) {
|
|
|
662
1663
|
* name: under a contract two implementations share one tool, and reporting
|
|
663
1664
|
* the catalog name would tell a caller to call something that does not exist.
|
|
664
1665
|
*/
|
|
665
|
-
const snapshot = (items) => items.map(item => {
|
|
1666
|
+
const snapshot = async (items) => Promise.all(items.map(async (item) => {
|
|
666
1667
|
const record = installed.get(item.entry.id);
|
|
667
1668
|
return {
|
|
668
1669
|
...describeModels([item])[0],
|
|
669
1670
|
...record === undefined ? {} : { tool: record.toolName },
|
|
670
|
-
...provenanceOf(item.entry),
|
|
1671
|
+
...await provenanceOf(item.entry),
|
|
671
1672
|
installed: record !== undefined,
|
|
672
1673
|
choice: choiceOf(item.entry.id),
|
|
673
1674
|
};
|
|
674
|
-
});
|
|
1675
|
+
}));
|
|
675
1676
|
/**
|
|
676
1677
|
* The most recent invocation receipts (EXT-35).
|
|
677
1678
|
*
|
|
@@ -703,8 +1704,131 @@ export function apply(ctx, config = {}) {
|
|
|
703
1704
|
title: tool.title ?? null,
|
|
704
1705
|
})) };
|
|
705
1706
|
}
|
|
1707
|
+
if (action === 'diagnostics') {
|
|
1708
|
+
/**
|
|
1709
|
+
* THE OBS-04 SURFACE, WIRED RATHER THAN MERELY BUILT.
|
|
1710
|
+
*
|
|
1711
|
+
* `buildDiagnostics` is only worth having if something calls it, and this repository
|
|
1712
|
+
* has produced fully-tested components with zero callers three times already
|
|
1713
|
+
* (`ptc-authority.ts`, `ptc-runtime.ts`, and the recipes broker). Reporting through
|
|
1714
|
+
* the control tool the agent already has keeps the surface reachable without adding a
|
|
1715
|
+
* second way in.
|
|
1716
|
+
*/
|
|
1717
|
+
const registered = ctx.tools.schemas().map(schema => schema.name).sort();
|
|
1718
|
+
const exported = mcpHost.listTools().map(tool => tool.name).sort();
|
|
1719
|
+
const exportedSet = new Set(exported);
|
|
1720
|
+
return {
|
|
1721
|
+
models: [],
|
|
1722
|
+
...counts(),
|
|
1723
|
+
diagnostics: buildDiagnostics({
|
|
1724
|
+
mcpMounted: true,
|
|
1725
|
+
/**
|
|
1726
|
+
* The endpoint is OMITTED rather than set to `undefined`, because the input type
|
|
1727
|
+
* is exact-optional. The host serves its own agent and the endpoint is a
|
|
1728
|
+
* deployment decision this plugin does not own, so it is reported as ABSENT —
|
|
1729
|
+
* which the surface distinguishes from "MCP is off".
|
|
1730
|
+
*/
|
|
1731
|
+
exportedTools: exported,
|
|
1732
|
+
// The withheld half is the security-relevant half, and it is derivable: anything
|
|
1733
|
+
// registered that is NOT exported was withheld deliberately.
|
|
1734
|
+
withheldTools: registered.filter(name => !exportedSet.has(name)),
|
|
1735
|
+
platform: {
|
|
1736
|
+
os: process.platform,
|
|
1737
|
+
arch: process.arch,
|
|
1738
|
+
nodeVersion: process.version,
|
|
1739
|
+
},
|
|
1740
|
+
}),
|
|
1741
|
+
/**
|
|
1742
|
+
* THE CAPTURE PATH, on the surface an operator actually reads (OBS-03).
|
|
1743
|
+
*
|
|
1744
|
+
* Reported here rather than in a document because a reader asking "what is recorded"
|
|
1745
|
+
* must see the surfaces this plugin does NOT control — a sibling logging plugin and
|
|
1746
|
+
* the DSH LLM service — not only its own writes. A list of our own writes would be
|
|
1747
|
+
* taken as complete, which is the misreading OBS-03 exists to prevent.
|
|
1748
|
+
*/
|
|
1749
|
+
capture: {
|
|
1750
|
+
profile: (config.captureProfile ?? 'standard'),
|
|
1751
|
+
surfaces: CAPTURE_PATH.map(surface => ({
|
|
1752
|
+
surface: surface.surface,
|
|
1753
|
+
writtenByThisPlugin: surface.writtenByThisPlugin,
|
|
1754
|
+
control: surface.control,
|
|
1755
|
+
})),
|
|
1756
|
+
},
|
|
1757
|
+
};
|
|
1758
|
+
}
|
|
1759
|
+
if (action === 'credentials') {
|
|
1760
|
+
/**
|
|
1761
|
+
* SEC-01 CREDENTIAL SURFACE, model-visible and therefore SECRET-FREE.
|
|
1762
|
+
*
|
|
1763
|
+
* `model_tools` is the operator's management surface. It was never
|
|
1764
|
+
* published over MCP, but it IS model-visible: a generated secret
|
|
1765
|
+
* returned here would land in the model's transcript. Generation is
|
|
1766
|
+
* therefore REFUSED on this surface with guidance to the human-only
|
|
1767
|
+
* CLI, which prints the secret exactly once to the operator's
|
|
1768
|
+
* terminal. The generation primitive itself lives in
|
|
1769
|
+
* credential-store.ts (`generateCredential`) for the CLI and tests.
|
|
1770
|
+
*/
|
|
1771
|
+
if (credentialStore === undefined) {
|
|
1772
|
+
throw new Error('credential generation requires persistence (SEC-01): this deployment runs with persistence disabled, '
|
|
1773
|
+
+ 'and a credential a restart would forget must never be issued');
|
|
1774
|
+
}
|
|
1775
|
+
if (sub === 'generate') {
|
|
1776
|
+
return {
|
|
1777
|
+
models: [],
|
|
1778
|
+
...counts(),
|
|
1779
|
+
credentialNote: 'Credential generation is refused on this model-visible surface (SEC-01): a generated secret must '
|
|
1780
|
+
+ 'never enter a model transcript. Run the human-only CLI instead — `dsh-tool-mode-credentials '
|
|
1781
|
+
+ 'generate --scheme static-token --client-id <id>` (or --scheme oauth-resource-server) — which '
|
|
1782
|
+
+ 'prints the secret exactly once to the operator\'s terminal and persists it owner-only.',
|
|
1783
|
+
};
|
|
1784
|
+
}
|
|
1785
|
+
if (sub === 'list') {
|
|
1786
|
+
return {
|
|
1787
|
+
models: [],
|
|
1788
|
+
...counts(),
|
|
1789
|
+
// SECRETS ARE NEVER RE-ECHOED: the list carries identity only.
|
|
1790
|
+
credentials: credentialStore.list().map(entry => ({
|
|
1791
|
+
clientId: entry.clientId,
|
|
1792
|
+
scheme: entry.scheme,
|
|
1793
|
+
createdAt: entry.createdAt,
|
|
1794
|
+
})),
|
|
1795
|
+
};
|
|
1796
|
+
}
|
|
1797
|
+
if (sub === 'revoke') {
|
|
1798
|
+
if (typeof clientId !== 'string' || clientId.length === 0) {
|
|
1799
|
+
throw new Error('credentials revoke requires a clientId');
|
|
1800
|
+
}
|
|
1801
|
+
const existed = credentialStore.get(clientId) !== undefined;
|
|
1802
|
+
credentialStore.delete(clientId);
|
|
1803
|
+
return {
|
|
1804
|
+
models: [],
|
|
1805
|
+
...counts(),
|
|
1806
|
+
changed: existed,
|
|
1807
|
+
action: `credentials revoke ${clientId}`,
|
|
1808
|
+
persisted: existed,
|
|
1809
|
+
};
|
|
1810
|
+
}
|
|
1811
|
+
throw new Error('credentials requires a sub: generate, list or revoke');
|
|
1812
|
+
}
|
|
706
1813
|
if (action === 'receipts') {
|
|
707
|
-
return {
|
|
1814
|
+
return {
|
|
1815
|
+
models: [],
|
|
1816
|
+
...counts(),
|
|
1817
|
+
receipts: receipts(),
|
|
1818
|
+
external: externalAccountings.map(accounting => ({
|
|
1819
|
+
receiptId: accounting.receiptId,
|
|
1820
|
+
failureClass: accounting.failureClass,
|
|
1821
|
+
reason: accounting.reason,
|
|
1822
|
+
totalMs: accounting.timings.totalMs,
|
|
1823
|
+
inputBytes: accounting.sizes.inputBytes,
|
|
1824
|
+
outputBytes: accounting.sizes.outputBytes,
|
|
1825
|
+
engineVersion: accounting.measuredOn.engineVersion,
|
|
1826
|
+
// SEC-04: the CLIENT's own identity, so the operator can tell who
|
|
1827
|
+
// called — identity only, never the payload.
|
|
1828
|
+
...accounting.grantId === undefined ? {} : { grantId: accounting.grantId },
|
|
1829
|
+
...accounting.clientId === undefined ? {} : { clientId: accounting.clientId },
|
|
1830
|
+
})),
|
|
1831
|
+
};
|
|
708
1832
|
}
|
|
709
1833
|
if (action === 'reset') {
|
|
710
1834
|
// Forgetting a choice is what makes the config authoritative again.
|
|
@@ -725,18 +1849,18 @@ export function apply(ctx, config = {}) {
|
|
|
725
1849
|
// rather than only at the next start.
|
|
726
1850
|
for (const item of resolved) {
|
|
727
1851
|
if (configuredIds.includes(item.entry.id))
|
|
728
|
-
install(item);
|
|
1852
|
+
await install(item);
|
|
729
1853
|
else
|
|
730
1854
|
uninstall(item.entry.id);
|
|
731
1855
|
}
|
|
732
1856
|
const models = modelId === undefined ? resolved : [byId.get(modelId)];
|
|
733
|
-
return
|
|
734
|
-
models: snapshot(models),
|
|
1857
|
+
return {
|
|
1858
|
+
models: await snapshot(models),
|
|
735
1859
|
...counts(),
|
|
736
1860
|
changed: true,
|
|
737
1861
|
action,
|
|
738
1862
|
persisted: persist,
|
|
739
|
-
}
|
|
1863
|
+
};
|
|
740
1864
|
}
|
|
741
1865
|
if (action === 'describe' || action === 'enable' || action === 'disable') {
|
|
742
1866
|
if (modelId === undefined)
|
|
@@ -745,9 +1869,9 @@ export function apply(ctx, config = {}) {
|
|
|
745
1869
|
if (item === undefined)
|
|
746
1870
|
throw new Error(`unknown model "${modelId}"`);
|
|
747
1871
|
if (action === 'describe') {
|
|
748
|
-
return
|
|
1872
|
+
return { models: await snapshot([item]), ...counts() };
|
|
749
1873
|
}
|
|
750
|
-
const changed = action === 'enable' ? install(item) : uninstall(modelId);
|
|
1874
|
+
const changed = action === 'enable' ? await install(item) : uninstall(modelId);
|
|
751
1875
|
if (action === 'enable' && !changed) {
|
|
752
1876
|
// Say why rather than reporting a silent no-op: the caller asked for
|
|
753
1877
|
// a tool and did not get one.
|
|
@@ -756,23 +1880,290 @@ export function apply(ctx, config = {}) {
|
|
|
756
1880
|
// Only remember a choice that actually took effect: persisting a
|
|
757
1881
|
// failed enable would install it silently on the next start.
|
|
758
1882
|
const remembered = remember(modelId, action === 'enable');
|
|
759
|
-
return
|
|
760
|
-
models: snapshot([item]),
|
|
1883
|
+
return {
|
|
1884
|
+
models: await snapshot([item]),
|
|
761
1885
|
...counts(),
|
|
762
1886
|
changed,
|
|
763
1887
|
action,
|
|
764
1888
|
persisted: remembered,
|
|
1889
|
+
};
|
|
1890
|
+
}
|
|
1891
|
+
/**
|
|
1892
|
+
* LIC-03: the human permission-basis recording surface. The basis
|
|
1893
|
+
* (native use, external export, distribution, telemetry) is recorded
|
|
1894
|
+
* per provider with the approving principal, durably, in the isolated
|
|
1895
|
+
* state dir. It is a RECORD — the export eligibility itself stays on
|
|
1896
|
+
* the existing publication gate (the operator's exportTools
|
|
1897
|
+
* selection); the basis is what a reviewer reads before flipping it.
|
|
1898
|
+
*/
|
|
1899
|
+
if (action === 'extensions-license-basis') {
|
|
1900
|
+
if (modelId === undefined)
|
|
1901
|
+
throw new Error('extensions-license-basis requires the provider id in "model"');
|
|
1902
|
+
const basis = args.licenseBasis;
|
|
1903
|
+
const principal = typeof args.principal === 'string' && args.principal.length > 0 ? args.principal : 'unattributed';
|
|
1904
|
+
if (basis === undefined)
|
|
1905
|
+
throw new Error('extensions-license-basis requires the "licenseBasis" fields');
|
|
1906
|
+
const basisPath = join(dirname(resolve(config.stateFile ?? defaultStateFile())), 'license-bases.json');
|
|
1907
|
+
let bases = {};
|
|
1908
|
+
try {
|
|
1909
|
+
bases = JSON.parse(readFileSync(basisPath, 'utf8'));
|
|
1910
|
+
}
|
|
1911
|
+
catch {
|
|
1912
|
+
// No record yet: the first basis starts the file.
|
|
1913
|
+
}
|
|
1914
|
+
const record = {
|
|
1915
|
+
native: basis.native === true,
|
|
1916
|
+
export: basis.export === true,
|
|
1917
|
+
distribution: basis.distribution === true,
|
|
1918
|
+
telemetry: basis.telemetry === true,
|
|
1919
|
+
principal,
|
|
1920
|
+
recordedAt: new Date().toISOString(),
|
|
1921
|
+
};
|
|
1922
|
+
bases[modelId] = record;
|
|
1923
|
+
mkdirSync(dirname(basisPath), { recursive: true });
|
|
1924
|
+
writeFileSync(basisPath, `${JSON.stringify(bases, null, 2)}\n`);
|
|
1925
|
+
return {
|
|
1926
|
+
models: [],
|
|
1927
|
+
...counts(),
|
|
1928
|
+
action,
|
|
1929
|
+
changed: true,
|
|
1930
|
+
licenseBasis: record,
|
|
1931
|
+
providerId: modelId,
|
|
1932
|
+
};
|
|
1933
|
+
}
|
|
1934
|
+
if (action === 'extensions-pending' || action === 'extensions-approve' || action === 'extensions-deny'
|
|
1935
|
+
|| action === 'extensions-commit' || action === 'extensions-rollback') {
|
|
1936
|
+
/**
|
|
1937
|
+
* EXT-30 slice 3: the MANAGEMENT channel is the ONLY update path (AT-39)
|
|
1938
|
+
* — these actions live inside model_tools, never on the model-callable
|
|
1939
|
+
* MCP surface. The surface is wired when the extension store exists.
|
|
1940
|
+
*/
|
|
1941
|
+
const surface = updateSurface;
|
|
1942
|
+
if (surface === undefined) {
|
|
1943
|
+
throw new Error('extension updates require the extension store (config.extensionStoreDir); it is not configured');
|
|
1944
|
+
}
|
|
1945
|
+
if (action === 'extensions-pending') {
|
|
1946
|
+
return {
|
|
1947
|
+
models: [],
|
|
1948
|
+
...counts(),
|
|
1949
|
+
pending: surface.listPendingUpdates().map(entry => ({
|
|
1950
|
+
extensionId: entry.extensionId,
|
|
1951
|
+
stagedVersion: entry.stagedVersion,
|
|
1952
|
+
currentVersion: entry.currentVersion,
|
|
1953
|
+
stagedAt: entry.stagedAt,
|
|
1954
|
+
...entry.report.licenseDelta === undefined ? {} : { licenseDelta: entry.report.licenseDelta },
|
|
1955
|
+
permissionDeltas: entry.report.permissionDeltas,
|
|
1956
|
+
requiresApproval: entry.report.requiresApproval,
|
|
1957
|
+
...entry.approval === undefined ? {} : { approval: entry.approval },
|
|
1958
|
+
})),
|
|
1959
|
+
};
|
|
1960
|
+
}
|
|
1961
|
+
if (modelId === undefined)
|
|
1962
|
+
throw new Error(`${action} requires the extension id in "model"`);
|
|
1963
|
+
if (stagedVersion === undefined && action !== 'extensions-rollback') {
|
|
1964
|
+
throw new Error(`${action} requires a "version"`);
|
|
1965
|
+
}
|
|
1966
|
+
const version = stagedVersion; // narrowed by the guard above for every action but rollback
|
|
1967
|
+
if (action === 'extensions-approve' || action === 'extensions-deny') {
|
|
1968
|
+
surface.decide(modelId, version, action === 'extensions-approve'
|
|
1969
|
+
? { approved: true }
|
|
1970
|
+
: { approved: false, reason: 'denied through the management channel' });
|
|
1971
|
+
return {
|
|
1972
|
+
models: [],
|
|
1973
|
+
...counts(),
|
|
1974
|
+
action,
|
|
1975
|
+
changed: true,
|
|
1976
|
+
};
|
|
1977
|
+
}
|
|
1978
|
+
const result = action === 'extensions-commit'
|
|
1979
|
+
? await surface.commit(modelId, version)
|
|
1980
|
+
: await surface.rollback(modelId);
|
|
1981
|
+
// P26-09/MCP-07: a committed pointer or a rollback both REPUBLISH the
|
|
1982
|
+
// extension surface through the lifecycle — the catalog changed, and
|
|
1983
|
+
// every subscriber is notified.
|
|
1984
|
+
notifyCatalogChange();
|
|
1985
|
+
// The lossless-JSON contract: undefined fields are omitted, never present.
|
|
1986
|
+
return {
|
|
1987
|
+
models: [],
|
|
1988
|
+
...counts(),
|
|
1989
|
+
action,
|
|
1990
|
+
updateResult: {
|
|
1991
|
+
extensionId: result.extensionId,
|
|
1992
|
+
activeVersion: result.activeVersion,
|
|
1993
|
+
...result.previousVersion === undefined ? {} : { previousVersion: result.previousVersion },
|
|
1994
|
+
...'hookFailure' in result && result.hookFailure !== undefined ? { hookFailure: result.hookFailure } : {},
|
|
1995
|
+
},
|
|
1996
|
+
};
|
|
1997
|
+
}
|
|
1998
|
+
if (action === 'extensions-prepare-plan' || action === 'extensions-prepare') {
|
|
1999
|
+
/**
|
|
2000
|
+
* RES-02: the preparation plan is DISCLOSED (bytes, storage, license,
|
|
2001
|
+
* executable gate) before the acquisition; the acquisition itself IS
|
|
2002
|
+
* the operator's consent, through the management channel only. A REAL
|
|
2003
|
+
* acquisition uses the ranged HTTP source (opt-in); the suite injects
|
|
2004
|
+
* a local source and never reaches the network.
|
|
2005
|
+
*/
|
|
2006
|
+
const surface = prepareAssetsSurface;
|
|
2007
|
+
if (surface === undefined) {
|
|
2008
|
+
throw new Error('asset preparation requires the extension store (config.extensionStoreDir); it is not configured');
|
|
2009
|
+
}
|
|
2010
|
+
if (modelId === undefined)
|
|
2011
|
+
throw new Error(`${action} requires the extension id in "model"`);
|
|
2012
|
+
if (action === 'extensions-prepare-plan') {
|
|
2013
|
+
const plan = surface.plan(modelId);
|
|
2014
|
+
return {
|
|
2015
|
+
models: [],
|
|
2016
|
+
...counts(),
|
|
2017
|
+
action,
|
|
2018
|
+
plan: {
|
|
2019
|
+
extensionId: plan.identity.extensionId,
|
|
2020
|
+
modelId: plan.identity.modelId,
|
|
2021
|
+
modelRevision: plan.identity.modelRevision,
|
|
2022
|
+
assets: plan.assets.map(asset => ({
|
|
2023
|
+
assetId: asset.assetId,
|
|
2024
|
+
bytes: asset.bytes,
|
|
2025
|
+
source: asset.source,
|
|
2026
|
+
executable: asset.executable,
|
|
2027
|
+
})),
|
|
2028
|
+
totalBytes: plan.totalBytes,
|
|
2029
|
+
storageBytes: plan.storageBytes,
|
|
2030
|
+
requiresExecutableApproval: plan.requiresExecutableApproval,
|
|
2031
|
+
license: plan.license,
|
|
2032
|
+
},
|
|
2033
|
+
};
|
|
2034
|
+
}
|
|
2035
|
+
const outcome = await surface.prepare(modelId, {
|
|
2036
|
+
source: config.assetSourceFactory?.() ?? createHttpSource(),
|
|
2037
|
+
allowExecutable: args.allowExecutable === true,
|
|
2038
|
+
signal: exec?.signal ?? new AbortController().signal,
|
|
765
2039
|
});
|
|
2040
|
+
if (!outcome.ok)
|
|
2041
|
+
throw new Error(`the preparation failed: ${outcome.reason}`);
|
|
2042
|
+
return {
|
|
2043
|
+
models: [],
|
|
2044
|
+
...counts(),
|
|
2045
|
+
action,
|
|
2046
|
+
prepared: true,
|
|
2047
|
+
groups: outcome.groups.map(group => ({ groupId: group.groupId, path: group.path, digests: group.digests })),
|
|
2048
|
+
};
|
|
766
2049
|
}
|
|
767
2050
|
const models = action === 'installed'
|
|
768
|
-
? snapshot(resolved.filter(item => installed.has(item.entry.id)))
|
|
769
|
-
: snapshot(resolved);
|
|
770
|
-
return
|
|
2051
|
+
? await snapshot(resolved.filter(item => installed.has(item.entry.id)))
|
|
2052
|
+
: await snapshot(resolved);
|
|
2053
|
+
return { models, ...counts() };
|
|
771
2054
|
},
|
|
772
2055
|
presentCall(args) {
|
|
773
2056
|
return { card: 'generic', title: `Model tools: ${args.action}`, kind: 'read', rawInput: args.model ?? args.action };
|
|
774
2057
|
},
|
|
775
2058
|
}));
|
|
2059
|
+
/**
|
|
2060
|
+
* DECL-07: the read-only discovery pair under the SPEC NAMES. Ordinary
|
|
2061
|
+
* tool-only clients get the permitted tools + readiness (list) and the
|
|
2062
|
+
* selected schemas/limits/locations (describe) without relying on skills
|
|
2063
|
+
* or server instructions. The existing model_tools actions stay as the
|
|
2064
|
+
* management aliases; these two are READ-ONLY and never mutate.
|
|
2065
|
+
*/
|
|
2066
|
+
const catalogRevision = () => String(extensionLifecycle?.generation ?? host.activationGeneration);
|
|
2067
|
+
const describeOne = async (name) => {
|
|
2068
|
+
const registrySchema = ctx.tools.schemas().find(schema => schema.name === name);
|
|
2069
|
+
const modelItem = resolved.find(item => (installed.get(item.entry.id)?.toolName ?? item.entry.tool ?? '') === name);
|
|
2070
|
+
const descriptor = modelItem === undefined ? undefined : describeModels([modelItem])[0];
|
|
2071
|
+
const owner = undefined;
|
|
2072
|
+
return {
|
|
2073
|
+
name,
|
|
2074
|
+
description: registrySchema?.description ?? descriptor?.summary ?? '',
|
|
2075
|
+
parameters: registrySchema?.parameters ?? {},
|
|
2076
|
+
...descriptor === undefined ? {} : {
|
|
2077
|
+
model: {
|
|
2078
|
+
id: descriptor.id,
|
|
2079
|
+
capability: descriptor.capability,
|
|
2080
|
+
runnable: descriptor.runnable,
|
|
2081
|
+
reason: descriptor.reason,
|
|
2082
|
+
route: descriptor.route,
|
|
2083
|
+
},
|
|
2084
|
+
},
|
|
2085
|
+
...owner === undefined ? {} : { ownerExtension: owner },
|
|
2086
|
+
executionLocation: 'device',
|
|
2087
|
+
catalogRevision: catalogRevision(),
|
|
2088
|
+
};
|
|
2089
|
+
};
|
|
2090
|
+
ctx.tools.register(defineTool({
|
|
2091
|
+
name: 'tool_mode_list',
|
|
2092
|
+
description: 'List the tools this caller is permitted to use, with readiness summaries and the current catalog '
|
|
2093
|
+
+ 'revision. Read-only discovery (DECL-07): it never installs, prepares, activates or changes anything.',
|
|
2094
|
+
parameters: {},
|
|
2095
|
+
output: {
|
|
2096
|
+
schema: {
|
|
2097
|
+
type: 'object',
|
|
2098
|
+
additionalProperties: false,
|
|
2099
|
+
properties: {
|
|
2100
|
+
catalogRevision: { type: 'string' },
|
|
2101
|
+
tools: { type: 'array' },
|
|
2102
|
+
},
|
|
2103
|
+
},
|
|
2104
|
+
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
2105
|
+
},
|
|
2106
|
+
async execute() {
|
|
2107
|
+
const tools = [];
|
|
2108
|
+
for (const item of resolved) {
|
|
2109
|
+
const record = installed.get(item.entry.id);
|
|
2110
|
+
const descriptor = describeModels([item])[0];
|
|
2111
|
+
tools.push({
|
|
2112
|
+
name: record?.toolName ?? item.entry.tool ?? item.entry.id,
|
|
2113
|
+
description: descriptor.summary,
|
|
2114
|
+
readiness: item.verdict.runnable ? 'callable' : `unavailable: ${descriptor.reason}`,
|
|
2115
|
+
model: item.entry.id,
|
|
2116
|
+
});
|
|
2117
|
+
}
|
|
2118
|
+
return { catalogRevision: catalogRevision(), tools };
|
|
2119
|
+
},
|
|
2120
|
+
presentCall() {
|
|
2121
|
+
return { card: 'generic', title: 'Tool mode list', kind: 'read', rawInput: '' };
|
|
2122
|
+
},
|
|
2123
|
+
}));
|
|
2124
|
+
ctx.tools.register(defineTool({
|
|
2125
|
+
name: 'tool_mode_describe',
|
|
2126
|
+
description: 'Describe one or more tools by name: the selected input/output schemas, the resolved model, limits and '
|
|
2127
|
+
+ 'the catalog revision. Read-only discovery (DECL-07): it never installs, prepares, activates or changes anything.',
|
|
2128
|
+
parameters: {
|
|
2129
|
+
tools: {
|
|
2130
|
+
type: 'array',
|
|
2131
|
+
required: true,
|
|
2132
|
+
items: { type: 'string' },
|
|
2133
|
+
description: 'The tool names to describe.',
|
|
2134
|
+
},
|
|
2135
|
+
},
|
|
2136
|
+
output: {
|
|
2137
|
+
schema: {
|
|
2138
|
+
type: 'object',
|
|
2139
|
+
additionalProperties: false,
|
|
2140
|
+
properties: {
|
|
2141
|
+
catalogRevision: { type: 'string' },
|
|
2142
|
+
tools: { type: 'array' },
|
|
2143
|
+
},
|
|
2144
|
+
},
|
|
2145
|
+
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
2146
|
+
},
|
|
2147
|
+
async execute(args) {
|
|
2148
|
+
const names = args.tools;
|
|
2149
|
+
const entries = [];
|
|
2150
|
+
for (const name of names) {
|
|
2151
|
+
const described = await describeOne(name);
|
|
2152
|
+
// describeOne returns an EMPTY description + empty parameters + no
|
|
2153
|
+
// model for an unknown name; a real tool always has at least one.
|
|
2154
|
+
if (described.description.length === 0
|
|
2155
|
+
&& Object.keys(described.parameters).length === 0
|
|
2156
|
+
&& described.model === undefined) {
|
|
2157
|
+
throw new Error(`unknown tool ${JSON.stringify(name)}`);
|
|
2158
|
+
}
|
|
2159
|
+
entries.push(described);
|
|
2160
|
+
}
|
|
2161
|
+
return { catalogRevision: catalogRevision(), tools: entries };
|
|
2162
|
+
},
|
|
2163
|
+
presentCall(args) {
|
|
2164
|
+
return { card: 'generic', title: 'Tool mode describe', kind: 'read', rawInput: String(args.tools?.join(',') ?? '') };
|
|
2165
|
+
},
|
|
2166
|
+
}));
|
|
776
2167
|
}
|
|
777
2168
|
// The registry disposes each registration with the plugin fiber, but doing it
|
|
778
2169
|
// explicitly keeps model resources (a loaded ONNX session, a cached weight
|
|
@@ -784,7 +2175,1171 @@ export function apply(ctx, config = {}) {
|
|
|
784
2175
|
record.dispose();
|
|
785
2176
|
}
|
|
786
2177
|
installed.clear();
|
|
2178
|
+
void bridge.dispose('shutdown');
|
|
787
2179
|
}, 'model-tools teardown');
|
|
2180
|
+
/**
|
|
2181
|
+
* THE AUTHORIZED PROGRAM PIPELINE, wired so its controls have a CALLER (CODE-02/03/08).
|
|
2182
|
+
*
|
|
2183
|
+
* Adversarial review found `ptc-authority.ts` and `ptc-runtime.ts` with zero callers
|
|
2184
|
+
* outside tests: every control in them was correct, thoroughly covered, and never ran.
|
|
2185
|
+
* `tool_mode_run` did not exist, so the mount guard never executed and the exposure gate
|
|
2186
|
+
* guarded nothing — there was no caller to refuse. A control with no callers is a symbol.
|
|
2187
|
+
*
|
|
2188
|
+
* The runner reaches tools through `buildToolBindings`, the SAME pipeline an external
|
|
2189
|
+
* surface would use, so authorization cannot differ between the two doors.
|
|
2190
|
+
*
|
|
2191
|
+
* SEC-04 COMPOSITION: the grant is read PER RUN from {@link externalGrantContext}. An
|
|
2192
|
+
* external client's call runs inside that context (set by the surface's execute), so a
|
|
2193
|
+
* program the client composes authorizes its sub-calls against the CLIENT's grant —
|
|
2194
|
+
* a client granted only `tool_mode_run` cannot reach host-granted tools through a
|
|
2195
|
+
* program. The native/interactive path carries no context and keeps the host's grant.
|
|
2196
|
+
*/
|
|
2197
|
+
const externalGrantContext = createExternalGrantContext();
|
|
2198
|
+
const programRunner = createProgramRunner({
|
|
2199
|
+
context: ctx,
|
|
2200
|
+
/**
|
|
2201
|
+
* Resolved lazily and may be ABSENT: a deployment without the Node PTC backend still
|
|
2202
|
+
* runs every other tool, and failing the plugin load because an optional capability is
|
|
2203
|
+
* missing would remove working functionality. `prepare()` reports the absence.
|
|
2204
|
+
*/
|
|
2205
|
+
backend: async () => {
|
|
2206
|
+
try {
|
|
2207
|
+
// The backend is an optional deployment fact, imported only when a run needs it
|
|
2208
|
+
// (EXT-19: no heavyweight load at plugin startup). The dynamic import keeps it
|
|
2209
|
+
// out of the module graph for hosts that never run programs.
|
|
2210
|
+
const module = await import('@deepseek-ai/dsh-ptc-runtime-node');
|
|
2211
|
+
return module.NodePtcRuntime ?? module.default;
|
|
2212
|
+
}
|
|
2213
|
+
catch (error) {
|
|
2214
|
+
ctx.logger.warn(`model-tools: the Node PTC backend is unavailable: ${error instanceof Error ? error.message : String(error)}`);
|
|
2215
|
+
return undefined;
|
|
2216
|
+
}
|
|
2217
|
+
},
|
|
2218
|
+
/**
|
|
2219
|
+
* THE GRANT A PROGRAM'S SUB-CALLS AUTHORIZE AGAINST, read per run:
|
|
2220
|
+
* the external caller's own grant when the run happens inside an external call,
|
|
2221
|
+
* the host's own grant otherwise. Never a caller's claim about what it may use.
|
|
2222
|
+
*/
|
|
2223
|
+
grant: () => externalGrantContext.current() ?? grant.allowedTools,
|
|
2224
|
+
// Read at RUN time, not once: extension restore registers tools after this closure
|
|
2225
|
+
// is created, and a program must see the live registry, not a stale snapshot.
|
|
2226
|
+
visible: () => ctx.tools.schemas().map(schema => schema.name),
|
|
2227
|
+
resolve: name => async (args) => {
|
|
2228
|
+
// A binding dispatches through the SAME registry pipeline a native call uses
|
|
2229
|
+
// (ARCH-01), so a program cannot reach a tool the registry would refuse.
|
|
2230
|
+
const result = await ctx.tools.execute({
|
|
2231
|
+
signal: new AbortController().signal,
|
|
2232
|
+
callId: `ptc_${name}_${Date.now().toString(36)}`,
|
|
2233
|
+
name,
|
|
2234
|
+
arguments: args,
|
|
2235
|
+
});
|
|
2236
|
+
if (result.isError)
|
|
2237
|
+
throw new Error(result.error?.message ?? `${name} failed`);
|
|
2238
|
+
return result.value;
|
|
2239
|
+
},
|
|
2240
|
+
runtime: () => ctx.get(PTC_SERVICE),
|
|
2241
|
+
// CODE-10: the operator's separate consent for hosted code execution, read
|
|
2242
|
+
// at check time so a config change mid-session takes effect.
|
|
2243
|
+
operatorGranted: () => config.hostedCode === true,
|
|
2244
|
+
});
|
|
2245
|
+
if (config.registerControlTool ?? true) {
|
|
2246
|
+
ctx.tools.register(defineTool({
|
|
2247
|
+
name: 'tool_mode_run',
|
|
2248
|
+
description: 'Run a program that calls Tool Mode tools (CODE-02). The requested `tools` list is '
|
|
2249
|
+
+ 'a binding subset, not a grant: only names the host itself has granted are bound, '
|
|
2250
|
+
+ 'and the whole request is refused when any name is unattainable.',
|
|
2251
|
+
parameters: {
|
|
2252
|
+
description: { type: 'string', required: true, description: 'What the program does.' },
|
|
2253
|
+
code: { type: 'string', required: true, description: 'Body of an async TypeScript function.' },
|
|
2254
|
+
input: { type: 'json', description: 'Program data, bound structurally — never interpolated into source.' },
|
|
2255
|
+
tools: {
|
|
2256
|
+
type: 'array',
|
|
2257
|
+
required: true,
|
|
2258
|
+
items: { type: 'string' },
|
|
2259
|
+
description: 'The tool names the program will call.',
|
|
2260
|
+
},
|
|
2261
|
+
timeout_ms: { type: 'integer', description: 'A requested deadline; clamped to host policy.' },
|
|
2262
|
+
},
|
|
2263
|
+
output: {
|
|
2264
|
+
schema: {
|
|
2265
|
+
type: 'object',
|
|
2266
|
+
additionalProperties: false,
|
|
2267
|
+
properties: {
|
|
2268
|
+
result: { type: 'json' },
|
|
2269
|
+
logs: { type: 'array', items: { type: 'string' } },
|
|
2270
|
+
meta: {
|
|
2271
|
+
type: 'object',
|
|
2272
|
+
additionalProperties: false,
|
|
2273
|
+
properties: {
|
|
2274
|
+
receiptId: { type: 'string', required: true },
|
|
2275
|
+
elapsedMs: { type: 'integer', required: true },
|
|
2276
|
+
toolCalls: { type: 'integer', required: true },
|
|
2277
|
+
catalogRevision: { type: 'string', required: true },
|
|
2278
|
+
warnings: { type: 'array', items: { type: 'string' } },
|
|
2279
|
+
},
|
|
2280
|
+
},
|
|
2281
|
+
},
|
|
2282
|
+
},
|
|
2283
|
+
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
2284
|
+
},
|
|
2285
|
+
async execute(args) {
|
|
2286
|
+
/**
|
|
2287
|
+
* THE GATE, ENFORCED — the caller `mayExposeToClients()` never had, so the refusal
|
|
2288
|
+
* is now REACHABLE rather than a boolean nothing consults. This tool is only
|
|
2289
|
+
* served to callers when the confinement tests have passed; the in-process
|
|
2290
|
+
* pipeline below is the same one the external surface would use.
|
|
2291
|
+
*/
|
|
2292
|
+
const refusal = externalRefusal(programRunner);
|
|
2293
|
+
if (refusal !== undefined)
|
|
2294
|
+
throw new Error(refusal.message);
|
|
2295
|
+
// The catalog revision is the LIVE extension-lifecycle generation when the
|
|
2296
|
+
// lifecycle is mounted (CODE-05 needs a revision that changes), falling
|
|
2297
|
+
// back to the host activation generation otherwise.
|
|
2298
|
+
const revision = String(extensionLifecycle?.generation ?? host.activationGeneration);
|
|
2299
|
+
const receiptId = host.nextReceiptId();
|
|
2300
|
+
const report = await serveProgram(args, programRunner, {
|
|
2301
|
+
receiptId,
|
|
2302
|
+
catalogRevision: revision,
|
|
2303
|
+
});
|
|
2304
|
+
// The program run's receipt is RECORDED like a contract invocation's, so
|
|
2305
|
+
// the receipts action can answer for it (EXT-35 symmetry the review named).
|
|
2306
|
+
host.recordReceipt?.(buildInvocationReceipt({
|
|
2307
|
+
receiptId,
|
|
2308
|
+
contract: 'tool_mode_run',
|
|
2309
|
+
contractVersion: HOST_VERSION,
|
|
2310
|
+
implementationId: 'hosted-composition',
|
|
2311
|
+
extension: host.extension,
|
|
2312
|
+
extensionVersion: host.extensionVersion,
|
|
2313
|
+
activationGeneration: Number(revision),
|
|
2314
|
+
executionLocation: 'host',
|
|
2315
|
+
modelIdentity: 'none',
|
|
2316
|
+
}));
|
|
2317
|
+
return report;
|
|
2318
|
+
},
|
|
2319
|
+
}));
|
|
2320
|
+
}
|
|
2321
|
+
/**
|
|
2322
|
+
* Mount the EXTENSION LIFECYCLE, which is what lets an extension's tools reach the
|
|
2323
|
+
* registry at all.
|
|
2324
|
+
*
|
|
2325
|
+
* This was MISSING: the SDK could install, activate and publish contributions and was
|
|
2326
|
+
* fully tested doing so, but the host never mounted the lifecycle — so an extension
|
|
2327
|
+
* could report `observedActivation: 'active'` with its tool listed and a caller still
|
|
2328
|
+
* could not invoke it. The registrar is the other half of that bridge.
|
|
2329
|
+
*
|
|
2330
|
+
* Opt-in through `extensionStore`, so a deployment with no extensions behaves exactly
|
|
2331
|
+
* as before. RUN-05 governs what happens here: startup RESTORES extensions the operator
|
|
2332
|
+
* already approved and reads their declarations. It performs NO model preparation and
|
|
2333
|
+
* NO package resolution, so nothing downloads because a plugin loaded.
|
|
2334
|
+
*/
|
|
2335
|
+
const extensionStoreDir = config.extensionStore ?? '';
|
|
2336
|
+
if (extensionStoreDir.length > 0) {
|
|
2337
|
+
const store = new ExtensionStore(extensionStoreDir);
|
|
2338
|
+
/**
|
|
2339
|
+
* How dispatch reaches an ACTIVATED extension.
|
|
2340
|
+
*
|
|
2341
|
+
* The module is imported and the instance created ONCE per extension and cached,
|
|
2342
|
+
* because `create()` is documented as lightweight and repeating it per call would
|
|
2343
|
+
* re-run an extension's activation on every invocation. PROVIDERS are created per call
|
|
2344
|
+
* inside `createDispatch`, so a deactivated extension is never served from a stale
|
|
2345
|
+
* provider handle.
|
|
2346
|
+
*/
|
|
2347
|
+
const extensionInstances = new Map();
|
|
2348
|
+
/** Per-instance storage. See the note on storage below. */
|
|
2349
|
+
const memoryStorage = new Map();
|
|
2350
|
+
/**
|
|
2351
|
+
* The host-issued context an extension receives.
|
|
2352
|
+
*
|
|
2353
|
+
* It carries NO DSH context, no registry access, no global credentials and no
|
|
2354
|
+
* unfiltered environment (ARCH-06) — an extension gets identity, private storage, a
|
|
2355
|
+
* bounded logger and a teardown signal, and nothing else.
|
|
2356
|
+
*/
|
|
2357
|
+
function contextForExtension(extensionId) {
|
|
2358
|
+
const installed = store.listInstalled().value.find(record => record.extensionId === extensionId);
|
|
2359
|
+
return {
|
|
2360
|
+
extensionId,
|
|
2361
|
+
extensionVersion: installed?.version ?? '0.0.0',
|
|
2362
|
+
activationGeneration: 1,
|
|
2363
|
+
/**
|
|
2364
|
+
* Storage is an IN-MEMORY map for the life of this instance, and that is stated
|
|
2365
|
+
* rather than implied: a full private on-disk store is separate work, and a stub
|
|
2366
|
+
* that LOOKS persistent while losing everything on restart would be worse than one
|
|
2367
|
+
* that visibly does not persist.
|
|
2368
|
+
*/
|
|
2369
|
+
storage: {
|
|
2370
|
+
// The declared private directory, so an extension that writes files has a place
|
|
2371
|
+
// the HOST chose rather than one the extension invented for itself.
|
|
2372
|
+
directory: join(store.rootDir, 'extension-private', extensionId),
|
|
2373
|
+
async read(key) { return memoryStorage.get(`${extensionId}:${key}`); },
|
|
2374
|
+
async write(key, value) {
|
|
2375
|
+
memoryStorage.set(`${extensionId}:${key}`, value);
|
|
2376
|
+
},
|
|
2377
|
+
},
|
|
2378
|
+
logger: {
|
|
2379
|
+
log: (level, message) => {
|
|
2380
|
+
if (level === 'error')
|
|
2381
|
+
ctx.logger.error(`[${extensionId}] ${message}`);
|
|
2382
|
+
else if (level === 'warn')
|
|
2383
|
+
ctx.logger.warn(`[${extensionId}] ${message}`);
|
|
2384
|
+
else
|
|
2385
|
+
ctx.logger.info(`[${extensionId}] ${message}`);
|
|
2386
|
+
},
|
|
2387
|
+
},
|
|
2388
|
+
signal: new AbortController().signal,
|
|
2389
|
+
configuration: {},
|
|
2390
|
+
};
|
|
2391
|
+
}
|
|
2392
|
+
function environmentFor(extensionId) {
|
|
2393
|
+
/**
|
|
2394
|
+
* The installed package ROOT for an extension, from the store.
|
|
2395
|
+
*
|
|
2396
|
+
* Deliberately NOT named installPathFor: the SDK exports a function of that name
|
|
2397
|
+
* which returns the path SEGMENT a package occupies, not a resolved store lookup. Two
|
|
2398
|
+
* different meanings under one name is how a caller ends up with the wrong one.
|
|
2399
|
+
*/
|
|
2400
|
+
const installedRootFor = (id) => {
|
|
2401
|
+
const installed = store.listInstalled().value.find(record => record.extensionId === id);
|
|
2402
|
+
if (installed === undefined) {
|
|
2403
|
+
throw new Error(`${id} is not installed, so its recipes cannot be resolved`);
|
|
2404
|
+
}
|
|
2405
|
+
return installed.installPath;
|
|
2406
|
+
};
|
|
2407
|
+
const discoveryFor = () => {
|
|
2408
|
+
const installed = store.listInstalled().value.find(record => record.extensionId === extensionId);
|
|
2409
|
+
if (installed === undefined) {
|
|
2410
|
+
throw new Error(`${extensionId} is not installed, so its tools cannot be dispatched`);
|
|
2411
|
+
}
|
|
2412
|
+
return discoverExtension(installed.installPath);
|
|
2413
|
+
};
|
|
2414
|
+
return {
|
|
2415
|
+
getManifest: () => discoveryFor().manifest,
|
|
2416
|
+
/**
|
|
2417
|
+
* The host-verified prepared-asset map for THIS extension, built by the
|
|
2418
|
+
* extensions-prepare flow; undefined (the empty map) until then.
|
|
2419
|
+
*/
|
|
2420
|
+
assetsFor: () => prepareAssetsSurface?.assetMapFor(extensionId),
|
|
2421
|
+
async providerFor(modelId) {
|
|
2422
|
+
const discovery = discoveryFor();
|
|
2423
|
+
/**
|
|
2424
|
+
* A DECLARATIVE PACKAGE HAS NO MODULE, so it never reaches the import path.
|
|
2425
|
+
*
|
|
2426
|
+
* This branch did not exist, and its absence is why `PTCAuthority`-style
|
|
2427
|
+
* disconnection went unnoticed here for so long: a declarative manifest was
|
|
2428
|
+
* schema-valid, its recipe convention was implemented, and its broker had 132
|
|
2429
|
+
* passing tests — and NOTHING joined them, because this function assumed every
|
|
2430
|
+
* package could be imported. Resolving an entry for a no-code package fails with
|
|
2431
|
+
* "the package declares no importable ./tool-mode export", which is true and
|
|
2432
|
+
* useless: the package was never supposed to declare one.
|
|
2433
|
+
*
|
|
2434
|
+
* Recipes are resolved through the published convention instead, and a package
|
|
2435
|
+
* whose recipes do not resolve is refused BY NAME rather than by import failure.
|
|
2436
|
+
*/
|
|
2437
|
+
const kind = discovery.manifest
|
|
2438
|
+
.execution?.kind;
|
|
2439
|
+
if (kind === 'declarative') {
|
|
2440
|
+
const resolved = resolveDeclarativeImplementations(installedRootFor(extensionId), discovery.manifest.implementations ?? []);
|
|
2441
|
+
if (!resolved.ok) {
|
|
2442
|
+
throw new Error(`${extensionId} is declarative but its recipe could not be resolved: `
|
|
2443
|
+
+ `${resolved.id} — ${resolved.detail}`);
|
|
2444
|
+
}
|
|
2445
|
+
/**
|
|
2446
|
+
* The recipes RESOLVE, and execution is refused honestly rather than faked.
|
|
2447
|
+
*
|
|
2448
|
+
* THIS MESSAGE WAS CORRECTED, and the correction matters. It used to say "the link
|
|
2449
|
+
* from the host to @try-works/tool-mode-recipes is not wired" — which was true when
|
|
2450
|
+
* written and is NO LONGER: `scripts/link-dsh.mjs` listed only four of eight
|
|
2451
|
+
* workspace packages, so the broker could not be imported by anything, and
|
|
2452
|
+
* completing that map made the bare specifier resolve. Leaving the old text would
|
|
2453
|
+
* have pointed the next reader at a link that now exists.
|
|
2454
|
+
*
|
|
2455
|
+
* What is genuinely missing is narrower, and it is a COMPOSITION rather than a
|
|
2456
|
+
* connection: a declarative package's behaviour is executed by a harness broker
|
|
2457
|
+
* fed by an `LlmProvider`, and this deployment supplies none. The recipes are real,
|
|
2458
|
+
* the broker is importable, and nothing can call a model.
|
|
2459
|
+
*/
|
|
2460
|
+
/**
|
|
2461
|
+
* SERVED WHEN THE DEPLOYMENT SUPPLIES A PROVIDER, refused when it does not.
|
|
2462
|
+
*
|
|
2463
|
+
* The joining lives in `prepareDeclarative` so the host holds no model-specific code:
|
|
2464
|
+
* this branch hands over the deployment's provider and a journal sink, and receives
|
|
2465
|
+
* callable tools or an honest refusal. The journal is required by the BROKER itself,
|
|
2466
|
+
* which will not construct one that reports nothing (HLM-09).
|
|
2467
|
+
*/
|
|
2468
|
+
const served = prepareDeclarative({
|
|
2469
|
+
...harnessProvider === undefined ? {} : { provider: harnessProvider },
|
|
2470
|
+
journal: record => ctx.logger?.debug?.('harness invocation', record),
|
|
2471
|
+
}, resolved.resolved);
|
|
2472
|
+
if (!served.ok) {
|
|
2473
|
+
throw new Error(`${extensionId} is declarative and its ${resolved.resolved.length} recipe(s) resolved, `
|
|
2474
|
+
+ `but they cannot be served: ${served.detail}`);
|
|
2475
|
+
}
|
|
2476
|
+
/**
|
|
2477
|
+
* THE DISPATCH PROVIDER SHAPE, and the previous version of this branch did not have it.
|
|
2478
|
+
*
|
|
2479
|
+
* It returned `{ async createProvider() { return { async invoke() {} } } }` — an
|
|
2480
|
+
* INSTANCE, where `createDispatch` requires a `DispatchProvider`. Adversarial review
|
|
2481
|
+
* reproduced the consequence exactly: `dispatch.ok = true` and then
|
|
2482
|
+
* `TypeError: provider.load is not a function`. The module branch below has the right
|
|
2483
|
+
* shape and this one did not, so the two differed in a way no test could see because
|
|
2484
|
+
* `harnessProvider` appeared in no test at all.
|
|
2485
|
+
*
|
|
2486
|
+
* The contract is:
|
|
2487
|
+
* load({ assets, signal }) -> Promise<void>
|
|
2488
|
+
* invoke({ implementationId, input, context }) -> Promise<Record<string, unknown>>
|
|
2489
|
+
*
|
|
2490
|
+
* AND `implementationId` IS USED. The previous version called `served.served[0]`
|
|
2491
|
+
* unconditionally, so the SECOND recipe of a two-recipe package could never be served
|
|
2492
|
+
* — it would silently answer with the first one's output. A package declaring two
|
|
2493
|
+
* implementations is the normal case, not an edge case.
|
|
2494
|
+
*/
|
|
2495
|
+
return {
|
|
2496
|
+
async load() {
|
|
2497
|
+
// Recipes are already parsed, validated and authorized by `prepareDeclarative`.
|
|
2498
|
+
// There is no module to import and no asset to fetch, so loading is complete.
|
|
2499
|
+
},
|
|
2500
|
+
async invoke(request) {
|
|
2501
|
+
const recipe = served.served.find(entry => entry.implementationId === request.implementationId);
|
|
2502
|
+
// An unknown id is REFUSED rather than defaulted to the first recipe, which is the
|
|
2503
|
+
// silent-wrong-answer failure the previous version would have produced.
|
|
2504
|
+
if (recipe === undefined) {
|
|
2505
|
+
throw new Error(`${extensionId} declares no implementation ${JSON.stringify(request.implementationId)}`
|
|
2506
|
+
+ `; it serves: ${served.served.map(entry => entry.implementationId).join(', ')}`);
|
|
2507
|
+
}
|
|
2508
|
+
const text = await recipe.invoke(request.input);
|
|
2509
|
+
// The dispatch contract returns a RECORD, not a bare value.
|
|
2510
|
+
return { text };
|
|
2511
|
+
},
|
|
2512
|
+
};
|
|
2513
|
+
}
|
|
2514
|
+
/**
|
|
2515
|
+
* EXT-20, THE MANAGED HALF: when the deployment opted in, the extension's
|
|
2516
|
+
* module entry runs OUT OF PROCESS — one scrubbed-environment worker per
|
|
2517
|
+
* extension per activation generation, disposed when the generation advances.
|
|
2518
|
+
*
|
|
2519
|
+
* The GRANT and the schemas stay host-side: this branch only runs after the
|
|
2520
|
+
* caller passed authorization and the input passed the declared schema, and
|
|
2521
|
+
* the returned value is validated by the same output-schema path as the
|
|
2522
|
+
* in-process result. The worker receives exactly `{implementationId, input}`.
|
|
2523
|
+
*/
|
|
2524
|
+
if (managedWorkers) {
|
|
2525
|
+
const entry = resolveExtensionEntry(discovery);
|
|
2526
|
+
const generation = activationGenerations.get(extensionId)
|
|
2527
|
+
?? extensionLifecycle?.generation
|
|
2528
|
+
?? host.activationGeneration;
|
|
2529
|
+
return await workerRegistry.providerFor({
|
|
2530
|
+
extensionId,
|
|
2531
|
+
entryPath: entry,
|
|
2532
|
+
generation,
|
|
2533
|
+
envPassthrough: workerEnvPassthrough,
|
|
2534
|
+
});
|
|
2535
|
+
}
|
|
2536
|
+
let pending = extensionInstances.get(extensionId);
|
|
2537
|
+
if (pending === undefined) {
|
|
2538
|
+
pending = (async () => {
|
|
2539
|
+
const module = await loadExtensionModule(resolveExtensionEntry(discovery));
|
|
2540
|
+
return module.create(contextForExtension(extensionId));
|
|
2541
|
+
})();
|
|
2542
|
+
extensionInstances.set(extensionId, pending);
|
|
2543
|
+
}
|
|
2544
|
+
const instance = await pending;
|
|
2545
|
+
return instance.createProvider({ modelId, configuration: {} });
|
|
2546
|
+
},
|
|
2547
|
+
// The manifest is resolved per call: a deactivated+reactivated extension's
|
|
2548
|
+
// discovery must be fresh, not a startup snapshot.
|
|
2549
|
+
contextFor: (implementationId, signal) => ({ implementationId, signal }),
|
|
2550
|
+
};
|
|
2551
|
+
}
|
|
2552
|
+
/**
|
|
2553
|
+
* Stamp the reserved root `meta` envelope on an extension tool result (EXT-17, DECL-03).
|
|
2554
|
+
*
|
|
2555
|
+
* An extension's shipped output schema may REQUIRE `meta` — the Needle planning and
|
|
2556
|
+
* extraction contracts do, because the schema declares the field the HOST authors and
|
|
2557
|
+
* the provider must not (EXT-17 reserves it, `assertNoReservedMeta` rejects a provider
|
|
2558
|
+
* attempt). Without this stamp the host's own output validation would refuse every
|
|
2559
|
+
* successful call for such a tool. The stamp is only applied when the declared schema
|
|
2560
|
+
* requires it, so a tool whose schema has no envelope keeps returning exactly what its
|
|
2561
|
+
* provider produced.
|
|
2562
|
+
*
|
|
2563
|
+
* The receipt is recorded with the same envelope (EXT-35 symmetry with the
|
|
2564
|
+
* `tool_mode_run` path): a caller that wants provenance for a planning call can ask the
|
|
2565
|
+
* receipts action, and a receipt never appears only in the result object.
|
|
2566
|
+
* @param ownerExtension - the extension the tool belongs to.
|
|
2567
|
+
* @param toolName - the caller-facing name.
|
|
2568
|
+
* @param value - the provider's result.
|
|
2569
|
+
* @param warnings - non-fatal notes already free of caller content.
|
|
2570
|
+
* @returns the result with the host-authored `meta`.
|
|
2571
|
+
*/
|
|
2572
|
+
const stampResultMeta = (ownerExtension, toolName, value, warnings = []) => {
|
|
2573
|
+
const resolved = resolveImplementation(environmentFor(ownerExtension).getManifest(), toolName);
|
|
2574
|
+
if (!resolved.ok)
|
|
2575
|
+
throw new Error(describeDispatchFailure(resolved.failure));
|
|
2576
|
+
const installed = store.listInstalled().value.find(candidate => candidate.extensionId === ownerExtension);
|
|
2577
|
+
const receiptId = host.nextReceiptId();
|
|
2578
|
+
const activationGeneration = extensionLifecycle?.generation ?? host.activationGeneration;
|
|
2579
|
+
const meta = {
|
|
2580
|
+
contract: resolved.tool.contractId,
|
|
2581
|
+
contractVersion: resolved.tool.contractVersion,
|
|
2582
|
+
implementationId: resolved.implementation.id,
|
|
2583
|
+
extension: ownerExtension,
|
|
2584
|
+
extensionVersion: installed?.version ?? '0.0.0',
|
|
2585
|
+
activationGeneration,
|
|
2586
|
+
executionLocation: 'device',
|
|
2587
|
+
receiptId,
|
|
2588
|
+
warnings: [...warnings],
|
|
2589
|
+
};
|
|
2590
|
+
host.recordReceipt?.(buildInvocationReceipt({
|
|
2591
|
+
receiptId,
|
|
2592
|
+
contract: resolved.tool.contractId,
|
|
2593
|
+
contractVersion: resolved.tool.contractVersion,
|
|
2594
|
+
implementationId: resolved.implementation.id,
|
|
2595
|
+
extension: ownerExtension,
|
|
2596
|
+
extensionVersion: meta.extensionVersion,
|
|
2597
|
+
modelIdentity: resolved.implementation.modelId,
|
|
2598
|
+
activationGeneration,
|
|
2599
|
+
executionLocation: meta.executionLocation,
|
|
2600
|
+
warnings,
|
|
2601
|
+
}));
|
|
2602
|
+
return { ...value, meta };
|
|
2603
|
+
};
|
|
2604
|
+
/**
|
|
2605
|
+
* Read the schemas an extension's manifest declares for one contributed tool.
|
|
2606
|
+
*
|
|
2607
|
+
* Bounded and confined: the manifest validator already proved the referenced files
|
|
2608
|
+
* exist inside the package, and the byte cap keeps a hostile package from making
|
|
2609
|
+
* registration read an unbounded file. A DECLARED schema the host cannot read or
|
|
2610
|
+
* compile REFUSES the tool (P26-13): publishing it unenforced would advertise
|
|
2611
|
+
* constraints that are enforced nowhere. The throw reaches the lifecycle, whose
|
|
2612
|
+
* narrow rollback withdraws only this extension's handles and quarantines it with
|
|
2613
|
+
* the reason named, so other extensions are untouched.
|
|
2614
|
+
* @param tool - the contributed tool whose schemas are wanted.
|
|
2615
|
+
* @returns the declared input/output schemas and description.
|
|
2616
|
+
*/
|
|
2617
|
+
const readDeclaredToolSchemas = (tool) => {
|
|
2618
|
+
const record = store.listInstalled().value.find(candidate => candidate.extensionId === tool.ownerExtension);
|
|
2619
|
+
if (record === undefined)
|
|
2620
|
+
return {};
|
|
2621
|
+
let discovery;
|
|
2622
|
+
try {
|
|
2623
|
+
discovery = discoverExtension(record.installPath);
|
|
2624
|
+
}
|
|
2625
|
+
catch {
|
|
2626
|
+
return {};
|
|
2627
|
+
}
|
|
2628
|
+
const declared = discovery.manifest.tools.find(candidate => candidate.name === tool.name);
|
|
2629
|
+
if (declared === undefined)
|
|
2630
|
+
return {};
|
|
2631
|
+
const readSchema = (relative) => {
|
|
2632
|
+
// A MANIFEST-DECLARED schema the host cannot read or compile is a broken
|
|
2633
|
+
// package, and the tool is REFUSED rather than published with the declared
|
|
2634
|
+
// constraints enforced nowhere (P26-13: reject unsupported constraints, do
|
|
2635
|
+
// not silently strip them). The throw reaches the lifecycle, whose narrow
|
|
2636
|
+
// rollback withdraws this extension's published handles and quarantines it
|
|
2637
|
+
// with the reason named.
|
|
2638
|
+
try {
|
|
2639
|
+
const path = join(record.installPath, relative);
|
|
2640
|
+
const size = statSync(path).size;
|
|
2641
|
+
if (size > MAX_SCHEMA_BYTES) {
|
|
2642
|
+
throw new Error(`the declared schema is ${size} bytes, over the ${MAX_SCHEMA_BYTES} cap`);
|
|
2643
|
+
}
|
|
2644
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
2645
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
2646
|
+
throw new Error('the declared schema is not a JSON object');
|
|
2647
|
+
}
|
|
2648
|
+
return parsed;
|
|
2649
|
+
}
|
|
2650
|
+
catch (error) {
|
|
2651
|
+
throw new Error(`${tool.name} declares ${relative} which cannot be enforced: `
|
|
2652
|
+
+ `${error instanceof Error ? error.message : String(error)}. `
|
|
2653
|
+
+ 'Fix or replace the package; the tool is not published.');
|
|
2654
|
+
}
|
|
2655
|
+
};
|
|
2656
|
+
const inputSchema = readSchema(declared.inputSchema);
|
|
2657
|
+
const outputSchema = readSchema(declared.outputSchema);
|
|
2658
|
+
return {
|
|
2659
|
+
...inputSchema === undefined ? {} : { inputSchema },
|
|
2660
|
+
...outputSchema === undefined ? {} : { outputSchema },
|
|
2661
|
+
description: declared.description,
|
|
2662
|
+
};
|
|
2663
|
+
};
|
|
2664
|
+
/**
|
|
2665
|
+
* The host's own name → owning-extension map for CONTRIBUTED tools.
|
|
2666
|
+
*
|
|
2667
|
+
* The registry's `schemas()` projection exposes names only, so the planning re-check
|
|
2668
|
+
* (NDL-03) cannot ask `lifecycle.admits(extensionId, toolName)` without knowing WHICH
|
|
2669
|
+
* extension owns a proposed tool. This map is that knowledge, maintained at the same
|
|
2670
|
+
* boundary that publishes and withdraws the tool — a tool present here but not
|
|
2671
|
+
* admitted is deactivated or quarantined, which is exactly the state a fresh policy
|
|
2672
|
+
* check must refuse.
|
|
2673
|
+
*/
|
|
2674
|
+
const extensionToolOwners = new Map();
|
|
2675
|
+
// Seed the LIVE admission set the management service also maintains; the
|
|
2676
|
+
// registrar holds the same Set, not a snapshot, so a runtime enable publishes.
|
|
2677
|
+
for (const entry of store.readProfileState('default').value) {
|
|
2678
|
+
if (entry.desiredActivation === 'active')
|
|
2679
|
+
activatedExtensions.add(entry.extensionId);
|
|
2680
|
+
}
|
|
2681
|
+
const registrar = createContributionRegistrar({
|
|
2682
|
+
activated: activatedExtensions,
|
|
2683
|
+
registry: {
|
|
2684
|
+
registerTool: tool => {
|
|
2685
|
+
/**
|
|
2686
|
+
* The tool is registered through the SAME DSH registry a native tool uses, so an
|
|
2687
|
+
* extension contribution is subject to the same pipeline rather than a parallel
|
|
2688
|
+
* one (ARCH-05).
|
|
2689
|
+
*
|
|
2690
|
+
* Its `execute` walks the REAL chain: the TMX runtime API has no
|
|
2691
|
+
* tool-invocation surface, so a tool is a name whose contract binds to an
|
|
2692
|
+
* implementation, and the implementation is a model provider. The provider is
|
|
2693
|
+
* created on first CALL, keeping activation lightweight (EXT-19).
|
|
2694
|
+
*/
|
|
2695
|
+
// The manifest the contribution came from declares the tool's REAL schemas;
|
|
2696
|
+
// reading them here closes the exact-schema gap an empty `parameters: {}`
|
|
2697
|
+
// and open output would leave. Files are bounded and the manifest already
|
|
2698
|
+
// validated they exist inside the package.
|
|
2699
|
+
const declared = readDeclaredToolSchemas(tool);
|
|
2700
|
+
/**
|
|
2701
|
+
* THE PLANNING CONTRACT IS RECOGNIZED BY NAME PLUS SHAPE (NDL-02).
|
|
2702
|
+
*
|
|
2703
|
+
* `tools_plan` is the spec's normative caller-facing name for the read-only
|
|
2704
|
+
* proposal tool, so any contribution using the name is served through the
|
|
2705
|
+
* planning pipeline — the frozen host snapshot supplies the tool list, never
|
|
2706
|
+
* the caller's input. A contribution that takes the name WITHOUT the planning
|
|
2707
|
+
* input shape (a declared `tools` array) is refused outright: serving it as an
|
|
2708
|
+
* ordinary tool would put a non-planner behind the planner's name, and serving
|
|
2709
|
+
* it through the planning pipeline would fail in ways nobody could see.
|
|
2710
|
+
*/
|
|
2711
|
+
const isPlanning = tool.name === PLANNING_TOOL_NAME;
|
|
2712
|
+
if (isPlanning && !hasPlanningInputShape(declared.inputSchema)) {
|
|
2713
|
+
throw new Error('tools_plan is the reserved planning contract (NDL-02): a contribution using the name '
|
|
2714
|
+
+ 'must declare the planning input shape, an array-valued "tools" property in its '
|
|
2715
|
+
+ 'input schema. Refused rather than published, so the name cannot be taken by a '
|
|
2716
|
+
+ 'package that is not a planner.');
|
|
2717
|
+
}
|
|
2718
|
+
// Compile the schemas ONCE, at registration. A declared schema
|
|
2719
|
+
// that cannot be compiled REFUSES the tool (P26-13): publishing it with
|
|
2720
|
+
// the projection but no full-schema enforcement would advertise
|
|
2721
|
+
// constraints that are enforced nowhere, and silently stripping them is
|
|
2722
|
+
// exactly what P26-13 forbids. The throw reaches the lifecycle, which
|
|
2723
|
+
// rolls back narrowly and quarantines the extension with the reason.
|
|
2724
|
+
// The planning tool's INPUT is the host's own schema: the caller names
|
|
2725
|
+
// tools as strings and the host supplies the snapshot entries, so the
|
|
2726
|
+
// extension's declared input shape is its provider contract, not the
|
|
2727
|
+
// caller-facing advertisement.
|
|
2728
|
+
const inputValidator = isPlanning
|
|
2729
|
+
? compileSchema(PLANNING_TOOL_INPUT_SCHEMA)
|
|
2730
|
+
: declared.inputSchema === undefined
|
|
2731
|
+
? undefined
|
|
2732
|
+
: compileSchema(declared.inputSchema);
|
|
2733
|
+
const outputValidator = declared.outputSchema === undefined
|
|
2734
|
+
? undefined
|
|
2735
|
+
: compileSchema(declared.outputSchema);
|
|
2736
|
+
const inputProjection = isPlanning
|
|
2737
|
+
? projectSchemaToDsh(PLANNING_TOOL_INPUT_SCHEMA).parameters
|
|
2738
|
+
: declared.inputSchema === undefined
|
|
2739
|
+
? {}
|
|
2740
|
+
: projectSchemaToDsh(declared.inputSchema).parameters;
|
|
2741
|
+
// Closedness comes from the DECLARED schema, not a hardcoded `false`: an
|
|
2742
|
+
// extension that declares an open output schema must not have legitimate
|
|
2743
|
+
// extra fields rejected by the registry as undeclared.
|
|
2744
|
+
const declaredClosed = declared.outputSchema?.additionalProperties === false;
|
|
2745
|
+
const outputProjection = declared.outputSchema === undefined
|
|
2746
|
+
? { type: 'object', additionalProperties: true }
|
|
2747
|
+
: {
|
|
2748
|
+
type: 'object',
|
|
2749
|
+
properties: projectSchemaToDsh(declared.outputSchema).parameters,
|
|
2750
|
+
additionalProperties: declaredClosed,
|
|
2751
|
+
};
|
|
2752
|
+
// The envelope is stamped only when the DECLARED schema requires it (EXT-17):
|
|
2753
|
+
// the needle contracts declare `meta` as host-authored, and output validation
|
|
2754
|
+
// must not be run against a result that is still missing it.
|
|
2755
|
+
const requiredMeta = Array.isArray(declared.outputSchema?.required)
|
|
2756
|
+
&& (declared.outputSchema?.required).includes('meta');
|
|
2757
|
+
/**
|
|
2758
|
+
* THE PLANNING PIPELINE STATE, closed over per registration (NDL-02/03).
|
|
2759
|
+
*
|
|
2760
|
+
* The snapshot is captured LAZILY on the first planning call rather than at
|
|
2761
|
+
* publication: restoration is asynchronous across extensions, so a
|
|
2762
|
+
* publication-time capture would freeze whatever subset had published so far
|
|
2763
|
+
* and make the plannable set depend on restore order. By the first call the
|
|
2764
|
+
* registry is settled, and the snapshot is then FROZEN for the tool's life — a
|
|
2765
|
+
* later registration does not enter it (that is the "frozen at capture"
|
|
2766
|
+
* property the tests pin). Freshness is handled by the per-call re-checks, not
|
|
2767
|
+
* by re-capturing.
|
|
2768
|
+
*/
|
|
2769
|
+
let planningSnapshot;
|
|
2770
|
+
const planningLiveLeaf = () => new Set(ctx.tools.schemas()
|
|
2771
|
+
.map(schema => schema.name)
|
|
2772
|
+
.filter(name => !PLANNING_EXCLUDED_TOOLS.includes(name)));
|
|
2773
|
+
const capturePlanningSnapshot = () => {
|
|
2774
|
+
planningSnapshot ??= captureToolSchemaSnapshot(ctx.tools.schemas(), {
|
|
2775
|
+
generation: extensionLifecycle?.generation ?? host.activationGeneration,
|
|
2776
|
+
excluded: PLANNING_EXCLUDED_TOOLS,
|
|
2777
|
+
});
|
|
2778
|
+
return planningSnapshot;
|
|
2779
|
+
};
|
|
2780
|
+
/**
|
|
2781
|
+
* The fresh NDL-03 authorization check, consulted at PROPOSAL time.
|
|
2782
|
+
*
|
|
2783
|
+
* Registry membership alone is not admission: a quarantined extension's tool can
|
|
2784
|
+
* still be registered (its withdrawal is what failed), and a proposal for it
|
|
2785
|
+
* must land in `rejected` rather than pass as valid. Tools the HOST registered
|
|
2786
|
+
* itself (model catalog tools) have no owner here and are authorized by their
|
|
2787
|
+
* registry presence; contributed tools are additionally admitted through the
|
|
2788
|
+
* lifecycle, the same signal the dispatch path itself enforces per call.
|
|
2789
|
+
*/
|
|
2790
|
+
const planningAuthorized = (name) => {
|
|
2791
|
+
if (!planningLiveLeaf().has(name)) {
|
|
2792
|
+
return 'it was withdrawn or deactivated after the snapshot was captured';
|
|
2793
|
+
}
|
|
2794
|
+
const owner = undefined;
|
|
2795
|
+
const lifecycle = extensionLifecycle;
|
|
2796
|
+
if (owner === undefined || lifecycle === undefined)
|
|
2797
|
+
return true;
|
|
2798
|
+
return lifecycle.admits(owner, name)
|
|
2799
|
+
? true
|
|
2800
|
+
: 'its extension no longer admits it (deactivated or quarantined)';
|
|
2801
|
+
};
|
|
2802
|
+
const dispose = ctx.tools.register(defineTool({
|
|
2803
|
+
name: tool.name,
|
|
2804
|
+
description: declared.description ?? `Provided by ${tool.ownerExtension}.`,
|
|
2805
|
+
parameters: inputProjection,
|
|
2806
|
+
output: {
|
|
2807
|
+
schema: outputProjection,
|
|
2808
|
+
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
2809
|
+
},
|
|
2810
|
+
async execute(args, exec) {
|
|
2811
|
+
/**
|
|
2812
|
+
* ADMISSION IS ENFORCED AT THE CALL PATH (EXT-04, EXT-24). A registered
|
|
2813
|
+
* definition is not enough: a tool whose extension was deactivated, whose
|
|
2814
|
+
* handle failed to withdraw, or whose toggle disabled it must refuse here,
|
|
2815
|
+
* not dispatch. The lifecycle's admission is the same signal publication
|
|
2816
|
+
* used, so a caller can never run a tool the host has stopped admitting.
|
|
2817
|
+
*/
|
|
2818
|
+
const lifecycle = extensionLifecycle;
|
|
2819
|
+
if (lifecycle !== undefined && !lifecycle.admits(tool.ownerExtension, tool.name)) {
|
|
2820
|
+
throw new Error(`${tool.name} is not admitted: its extension is inactive, disabled, or no longer publishes it`);
|
|
2821
|
+
}
|
|
2822
|
+
if (isPlanning) {
|
|
2823
|
+
return servePlanningCall((args ?? {}), exec?.signal ?? new AbortController().signal);
|
|
2824
|
+
}
|
|
2825
|
+
/**
|
|
2826
|
+
* FULL-SCHEMA enforcement against the DECLARED manifest schemas. The
|
|
2827
|
+
* projected advertisement the registry validates is deliberately LOOSER
|
|
2828
|
+
* (union members become `json`, unsupported keywords are dropped), so
|
|
2829
|
+
* without this the dropped keywords — maxLength, minimum, pattern — are
|
|
2830
|
+
* enforced nowhere for extension tools.
|
|
2831
|
+
*/
|
|
2832
|
+
if (inputValidator !== undefined) {
|
|
2833
|
+
assertAgainstSchema(`${tool.name} input`, inputValidator, args ?? {});
|
|
2834
|
+
}
|
|
2835
|
+
const dispatch = await createDispatch(environmentFor(tool.ownerExtension), tool.name);
|
|
2836
|
+
if (!dispatch.ok) {
|
|
2837
|
+
// A declared-but-unserved contract is an ERROR naming which situation the
|
|
2838
|
+
// caller is in, never an empty success.
|
|
2839
|
+
throw new Error(describeDispatchFailure(dispatch.failure));
|
|
2840
|
+
}
|
|
2841
|
+
let value = await dispatch.execute((args ?? {}), exec?.signal ?? new AbortController().signal);
|
|
2842
|
+
if (requiredMeta) {
|
|
2843
|
+
value = stampResultMeta(tool.ownerExtension, tool.name, value);
|
|
2844
|
+
}
|
|
2845
|
+
// The RESULT is checked against the declared output schema before it
|
|
2846
|
+
// reaches a caller: the projected advertisement is looser, so this is the
|
|
2847
|
+
// check that enforces the keywords the projection dropped.
|
|
2848
|
+
if (outputValidator !== undefined) {
|
|
2849
|
+
assertAgainstSchema(`${tool.name} output`, outputValidator, value);
|
|
2850
|
+
}
|
|
2851
|
+
return value;
|
|
2852
|
+
},
|
|
2853
|
+
}));
|
|
2854
|
+
/**
|
|
2855
|
+
* Serve one `tools_plan` call through the frozen-snapshot pipeline (NDL-02/03).
|
|
2856
|
+
*
|
|
2857
|
+
* Kept INSIDE registration so it closes over the tool's validators, dispatch
|
|
2858
|
+
* environment and the snapshot state — and outside the registry definition so
|
|
2859
|
+
* the shared admission check above still runs first.
|
|
2860
|
+
* @param callArgs - the caller's arguments, already shape-checked by the registry.
|
|
2861
|
+
* @param signal - cancellation.
|
|
2862
|
+
* @returns the validated, meta-stamped plan.
|
|
2863
|
+
*/
|
|
2864
|
+
async function servePlanningCall(callArgs, signal) {
|
|
2865
|
+
// The host's own schema is the call contract; the extension's shipped input
|
|
2866
|
+
// schema is its provider contract, so the declared input validator is
|
|
2867
|
+
// deliberately not consulted here.
|
|
2868
|
+
if (inputValidator !== undefined) {
|
|
2869
|
+
assertAgainstSchema(`${tool.name} input`, inputValidator, callArgs);
|
|
2870
|
+
}
|
|
2871
|
+
const snapshot = capturePlanningSnapshot();
|
|
2872
|
+
const requested = Array.isArray(callArgs.tools)
|
|
2873
|
+
? callArgs.tools.filter((name) => typeof name === 'string')
|
|
2874
|
+
: [];
|
|
2875
|
+
/**
|
|
2876
|
+
* THE REQUESTED-NAME GATE. The caller's names are intersected with the LIVE
|
|
2877
|
+
* registry and the FROZEN snapshot; anything unattainable refuses the WHOLE
|
|
2878
|
+
* call rather than shrinking the offer set silently.
|
|
2879
|
+
*/
|
|
2880
|
+
const decision = offerToolsFromSnapshot(snapshot, requested, planningLiveLeaf());
|
|
2881
|
+
if (!decision.ok)
|
|
2882
|
+
throw new Error(describePlanningRejections(decision.rejections));
|
|
2883
|
+
const dispatch = await createDispatch(environmentFor(tool.ownerExtension), tool.name);
|
|
2884
|
+
if (!dispatch.ok)
|
|
2885
|
+
throw new Error(describeDispatchFailure(dispatch.failure));
|
|
2886
|
+
/**
|
|
2887
|
+
* The provider receives SNAPSHOT entries, never the caller's declared list and
|
|
2888
|
+
* never anything executable — names, descriptions and JSON Schema parameters,
|
|
2889
|
+
* which is exactly NDL-02's "narrow public broker".
|
|
2890
|
+
*/
|
|
2891
|
+
const providerInput = {
|
|
2892
|
+
request: callArgs.request,
|
|
2893
|
+
tools: decision.offered.map(entry => ({
|
|
2894
|
+
name: entry.name,
|
|
2895
|
+
description: entry.description,
|
|
2896
|
+
parameters: entry.parameters,
|
|
2897
|
+
})),
|
|
2898
|
+
};
|
|
2899
|
+
if (typeof callArgs.system === 'string')
|
|
2900
|
+
providerInput.system = callArgs.system;
|
|
2901
|
+
if (typeof callArgs.max_new_tokens === 'number')
|
|
2902
|
+
providerInput.max_new_tokens = callArgs.max_new_tokens;
|
|
2903
|
+
const raw = await dispatch.execute(providerInput, signal);
|
|
2904
|
+
/**
|
|
2905
|
+
* HOST-SIDE RE-VALIDATION plus the NDL-03 fresh policy check: every proposal
|
|
2906
|
+
* is re-checked against the frozen snapshot, the per-call OFFERED set, and the
|
|
2907
|
+
* live authorization state (registry membership AND lifecycle admission for
|
|
2908
|
+
* contributed tools) — a proposal that is snapshotted, schema-valid but no
|
|
2909
|
+
* longer offered or no longer admitted is moved to `rejected` with the reason,
|
|
2910
|
+
* never returned as a valid plan step.
|
|
2911
|
+
*/
|
|
2912
|
+
const { payload, warnings } = recheckProposals(snapshot, raw, new Set(decision.offered.map(entry => entry.name)), planningAuthorized);
|
|
2913
|
+
const stamped = stampResultMeta(tool.ownerExtension, tool.name, payload, warnings);
|
|
2914
|
+
if (outputValidator !== undefined) {
|
|
2915
|
+
assertAgainstSchema(`${tool.name} output`, outputValidator, stamped);
|
|
2916
|
+
}
|
|
2917
|
+
return stamped;
|
|
2918
|
+
}
|
|
2919
|
+
// The owner map is maintained at the same boundary that publishes and withdraws
|
|
2920
|
+
// the tool, so the planning admission check never consults a stale owner.
|
|
2921
|
+
extensionToolOwners.set(tool.name, tool.ownerExtension);
|
|
2922
|
+
return () => {
|
|
2923
|
+
extensionToolOwners.delete(tool.name);
|
|
2924
|
+
dispose();
|
|
2925
|
+
};
|
|
2926
|
+
},
|
|
2927
|
+
/**
|
|
2928
|
+
* REGISTER ONE DECLARATIVE IMPLEMENTATION'S CALLER-FACING TOOL (RCP-01, HLM-01).
|
|
2929
|
+
*
|
|
2930
|
+
* A declarative package's manifest declares implementations only — no `tools`
|
|
2931
|
+
* array — so the tool names and schemas can only come from the RESOLVED RECIPES,
|
|
2932
|
+
* and a recipe can only be SERVED when the deployment composes a harness
|
|
2933
|
+
* provider. The contribution arrives per implementation; this resolves the whole
|
|
2934
|
+
* package, prepares the broker, and registers the one tool this implementation
|
|
2935
|
+
* names, returning its disposer so the registrar withdraws it on deactivation
|
|
2936
|
+
* exactly like a module tool.
|
|
2937
|
+
*
|
|
2938
|
+
* The refusal without a provider is the SAME message the per-call declarative
|
|
2939
|
+
* branch raises, moved to publication: activating a declarative package on a
|
|
2940
|
+
* deployment with no harness composition fails loudly (the narrow rollback
|
|
2941
|
+
* quarantines it) instead of silently registering nothing.
|
|
2942
|
+
*/
|
|
2943
|
+
registerDeclarative: (contribution) => {
|
|
2944
|
+
const record = store.listInstalled().value.find(candidate => candidate.extensionId === contribution.extensionId);
|
|
2945
|
+
if (record === undefined)
|
|
2946
|
+
return () => { };
|
|
2947
|
+
const discovery = discoverExtension(record.installPath);
|
|
2948
|
+
const manifest = discovery.manifest;
|
|
2949
|
+
if (manifest.execution?.kind !== 'declarative') {
|
|
2950
|
+
// A MODULE package's implementation is not a caller-facing tool; the model
|
|
2951
|
+
// catalogue binds it, and there is nothing to register.
|
|
2952
|
+
return () => { };
|
|
2953
|
+
}
|
|
2954
|
+
if (harnessProvider === undefined) {
|
|
2955
|
+
throw new Error(`${contribution.extensionId} is declarative and its recipes resolved, but they cannot be served: `
|
|
2956
|
+
+ 'this deployment composes no harness LlmProvider, so a declarative package\'s recipes '
|
|
2957
|
+
+ 'cannot be executed. Set config.harnessModel (or supply harnessProvider) to serve them.');
|
|
2958
|
+
}
|
|
2959
|
+
const resolved = resolveDeclarativeImplementations(record.installPath, manifest.implementations ?? []);
|
|
2960
|
+
if (!resolved.ok) {
|
|
2961
|
+
throw new Error(`${contribution.extensionId} is declarative but its recipe could not be resolved: `
|
|
2962
|
+
+ `${resolved.id} — ${resolved.detail}`);
|
|
2963
|
+
}
|
|
2964
|
+
const served = prepareDeclarative({
|
|
2965
|
+
provider: harnessProvider,
|
|
2966
|
+
journal: journalRecord => ctx.logger?.debug?.('harness invocation', journalRecord),
|
|
2967
|
+
}, resolved.resolved);
|
|
2968
|
+
if (!served.ok) {
|
|
2969
|
+
throw new Error(`${contribution.extensionId} is declarative and its ${resolved.resolved.length} recipe(s) resolved, `
|
|
2970
|
+
+ `but they cannot be served: ${served.detail}`);
|
|
2971
|
+
}
|
|
2972
|
+
const rawEntry = resolved.resolved.find(entry => entry.id === contribution.name);
|
|
2973
|
+
if (rawEntry === undefined)
|
|
2974
|
+
return () => { };
|
|
2975
|
+
const parsed = parseRecipe(rawEntry.recipe);
|
|
2976
|
+
if (!parsed.ok) {
|
|
2977
|
+
throw new Error(`${contribution.extensionId} declares a recipe that did not validate: ${JSON.stringify(parsed).slice(0, 300)}`);
|
|
2978
|
+
}
|
|
2979
|
+
const recipe = parsed.recipe;
|
|
2980
|
+
const servedRecipe = served.served.find(entry => entry.implementationId === contribution.name);
|
|
2981
|
+
if (servedRecipe === undefined)
|
|
2982
|
+
return () => { };
|
|
2983
|
+
// The ADVERTISEMENT comes from the RECIPE's declared inputs (AT-63: the built-in's
|
|
2984
|
+
// input schema comes from the recipe, never a host guess). The recipe format is
|
|
2985
|
+
// JSON-Schema-flavoured (per-property `required` flags, minLength/maxLength), which
|
|
2986
|
+
// the DSH value-schema DSL does not fully speak — so the host applies the same
|
|
2987
|
+
// dual discipline it applies to extension tools (P26-13): the registry advertises
|
|
2988
|
+
// the DSL subset, and the FULL schema is compiled and enforced at call time.
|
|
2989
|
+
const dsl = {};
|
|
2990
|
+
const properties = {};
|
|
2991
|
+
const requiredNames = [];
|
|
2992
|
+
for (const [name, raw] of Object.entries(recipe.inputs)) {
|
|
2993
|
+
const record = raw;
|
|
2994
|
+
const advertised = {};
|
|
2995
|
+
for (const [key, value] of Object.entries(record)) {
|
|
2996
|
+
// The DSH DSL admits `required` only as true; a false flag is simply omitted.
|
|
2997
|
+
if (key === 'required' && value !== true)
|
|
2998
|
+
continue;
|
|
2999
|
+
if (key === 'type' && value === 'image') {
|
|
3000
|
+
// Typed media is not a DSH scalar type; the broker does its own media
|
|
3001
|
+
// typing (and refuses a path/URL that is not typed media), so the
|
|
3002
|
+
// advertisement is the permissive json while enforcement stays full.
|
|
3003
|
+
advertised.type = 'json';
|
|
3004
|
+
continue;
|
|
3005
|
+
}
|
|
3006
|
+
if (DSH_PARAMETER_KEYWORDS.has(key))
|
|
3007
|
+
advertised[key] = value;
|
|
3008
|
+
}
|
|
3009
|
+
dsl[name] = advertised;
|
|
3010
|
+
// The full-schema half: per-property `required` folds into the root array,
|
|
3011
|
+
// image-typed properties validate as permissive objects (the broker types
|
|
3012
|
+
// them), and everything else keeps its declared keywords.
|
|
3013
|
+
const full = { ...record };
|
|
3014
|
+
delete full.required;
|
|
3015
|
+
if (record.required === true)
|
|
3016
|
+
requiredNames.push(name);
|
|
3017
|
+
if (record.type === 'image') {
|
|
3018
|
+
full.type = 'object';
|
|
3019
|
+
full.additionalProperties = true;
|
|
3020
|
+
delete full.mediaTypes;
|
|
3021
|
+
delete full.maxBytes;
|
|
3022
|
+
}
|
|
3023
|
+
properties[name] = full;
|
|
3024
|
+
}
|
|
3025
|
+
const inputValidator = compileSchema({
|
|
3026
|
+
type: 'object',
|
|
3027
|
+
additionalProperties: false,
|
|
3028
|
+
properties,
|
|
3029
|
+
required: requiredNames,
|
|
3030
|
+
});
|
|
3031
|
+
return ctx.tools.register(defineTool({
|
|
3032
|
+
name: recipe.tool.name,
|
|
3033
|
+
description: recipe.description,
|
|
3034
|
+
parameters: dsl,
|
|
3035
|
+
output: {
|
|
3036
|
+
schema: { type: 'string' },
|
|
3037
|
+
render: (_args, value) => [{ type: 'text', text: typeof value === 'string' ? value : JSON.stringify(value) }],
|
|
3038
|
+
},
|
|
3039
|
+
async execute(args, exec) {
|
|
3040
|
+
// FULL-SCHEMA enforcement before the broker runs the recipe: the
|
|
3041
|
+
// advertised DSL subset is looser, so the dropped keywords are enforced
|
|
3042
|
+
// here, exactly as the extension-tool path does (P26-13).
|
|
3043
|
+
assertAgainstSchema(`${recipe.tool.name} input`, inputValidator, args ?? {});
|
|
3044
|
+
// Typed media crosses the tool boundary in the JSON-safe form
|
|
3045
|
+
// `{ mediaType, bytes: number[] }` (the harness requires losslessly
|
|
3046
|
+
// JSON-serializable arguments); the recipe wants the ACTUAL bytes, so the
|
|
3047
|
+
// conversion happens HERE, and only here — never a path and never a URL,
|
|
3048
|
+
// which the recipe binding refuses (AT-64).
|
|
3049
|
+
const converted = { ...(args ?? {}) };
|
|
3050
|
+
for (const [name, value] of Object.entries(converted)) {
|
|
3051
|
+
const candidate = value;
|
|
3052
|
+
if (typeof candidate?.mediaType === 'string'
|
|
3053
|
+
&& Array.isArray(candidate.bytes)
|
|
3054
|
+
&& candidate.bytes.every(entry => typeof entry === 'number' && Number.isInteger(entry) && entry >= 0 && entry <= 255)) {
|
|
3055
|
+
converted[name] = {
|
|
3056
|
+
mediaType: candidate.mediaType,
|
|
3057
|
+
bytes: Uint8Array.from(candidate.bytes),
|
|
3058
|
+
};
|
|
3059
|
+
}
|
|
3060
|
+
}
|
|
3061
|
+
// NO lifecycle.admits gate: declarative tools have no per-tool entries in the
|
|
3062
|
+
// lifecycle state (the SDK models manifest tools only). Deactivation
|
|
3063
|
+
// withdrawal — the registrar's handle for exactly this disposer — is the
|
|
3064
|
+
// enforcement, and it removes the tool from the registry before any call.
|
|
3065
|
+
return servedRecipe.invoke(converted, exec?.signal);
|
|
3066
|
+
},
|
|
3067
|
+
}));
|
|
3068
|
+
},
|
|
3069
|
+
},
|
|
3070
|
+
onRefused: (contribution, error) => {
|
|
3071
|
+
ctx.logger.warn(`model-tools: refused ${contribution.name} from ${contribution.extensionId}: ${error.message}`);
|
|
3072
|
+
},
|
|
3073
|
+
});
|
|
3074
|
+
extensionLifecycle = new ExtensionLifecycle({
|
|
3075
|
+
store,
|
|
3076
|
+
profile: 'default',
|
|
3077
|
+
registrar,
|
|
3078
|
+
// EXT-20, task-29's hook: when the lifecycle finishes withdrawing an
|
|
3079
|
+
// extension's handles (deactivate or uninstall, success or failure), the host
|
|
3080
|
+
// disposes that extension's managed worker so the child process is joined
|
|
3081
|
+
// before the deactivation returns — no worker outlives its extension.
|
|
3082
|
+
onWithdrawal: (extensionId) => workerRegistry.dispose(extensionId),
|
|
3083
|
+
});
|
|
3084
|
+
/**
|
|
3085
|
+
* EXT-30 slice 3: the host update surface over the SDK's staged-update seams.
|
|
3086
|
+
*
|
|
3087
|
+
* quiesce = deactivate through the lifecycle (withdraws the handles, stops
|
|
3088
|
+
* admissions — the onWithdrawal hook disposes the worker). rebind = activate
|
|
3089
|
+
* the named version through the lifecycle (all-or-nothing: a failed
|
|
3090
|
+
* activation withdraws its own partial publications). The surface module
|
|
3091
|
+
* wraps the rebind all-or-nothing with the internal revert and bounds the
|
|
3092
|
+
* rollback's rebindRestored host-side.
|
|
3093
|
+
*/
|
|
3094
|
+
updateSurface = createUpdateHostSurface({
|
|
3095
|
+
storeDir: extensionStoreDir,
|
|
3096
|
+
profile: 'default',
|
|
3097
|
+
hooks: {
|
|
3098
|
+
quiesce: async (extensionId) => {
|
|
3099
|
+
const lifecycle = extensionLifecycle;
|
|
3100
|
+
if (lifecycle === undefined)
|
|
3101
|
+
return;
|
|
3102
|
+
await lifecycle.deactivate(extensionId);
|
|
3103
|
+
},
|
|
3104
|
+
rebind: async (extensionId, version) => {
|
|
3105
|
+
const lifecycle = extensionLifecycle;
|
|
3106
|
+
if (lifecycle === undefined)
|
|
3107
|
+
throw new Error(`the extension lifecycle is not mounted; ${extensionId}@${version} cannot rebind`);
|
|
3108
|
+
await lifecycle.activate(extensionId, version);
|
|
3109
|
+
},
|
|
3110
|
+
},
|
|
3111
|
+
});
|
|
3112
|
+
/**
|
|
3113
|
+
* RES-02/03/05, the LAST in-repo slice: the prepared-assets surface.
|
|
3114
|
+
*
|
|
3115
|
+
* The cache root is the ISOLATED STATE DIR the host already owns (the
|
|
3116
|
+
* directory of the state file), never the repository and never HOME. The
|
|
3117
|
+
* default source is the real ranged HTTP source — a real acquisition run
|
|
3118
|
+
* stays OPT-IN (an operator explicitly runs extensions-prepare); the test
|
|
3119
|
+
* suite injects a local file source and performs no network.
|
|
3120
|
+
*/
|
|
3121
|
+
{
|
|
3122
|
+
const statePath = config.stateFile ?? defaultStateFile();
|
|
3123
|
+
const registry = createPreparedAssetRegistry({
|
|
3124
|
+
cacheRoot: join(dirname(resolve(statePath)), 'asset-cache'),
|
|
3125
|
+
});
|
|
3126
|
+
const planCache = new Map();
|
|
3127
|
+
prepareAssetsSurface = {
|
|
3128
|
+
plan(extensionId) {
|
|
3129
|
+
const cached = planCache.get(extensionId);
|
|
3130
|
+
if (cached !== undefined)
|
|
3131
|
+
return cached.plan;
|
|
3132
|
+
const record = store.listInstalled().value.find(candidate => candidate.extensionId === extensionId);
|
|
3133
|
+
if (record === undefined)
|
|
3134
|
+
throw new Error(`${extensionId} is not installed, so its assets cannot be planned`);
|
|
3135
|
+
const discovery = discoverExtension(record.installPath);
|
|
3136
|
+
const model = discovery.manifest.models[0];
|
|
3137
|
+
if (model === undefined)
|
|
3138
|
+
throw new Error(`${extensionId} declares no model deployment to prepare`);
|
|
3139
|
+
const lockPath = join(dirname(discovery.manifestPath), model.assetLock);
|
|
3140
|
+
const lock = parseAssetLock(readFileSync(lockPath, 'utf8'));
|
|
3141
|
+
const groups = lockGroups(lock, model.assetSet);
|
|
3142
|
+
const plan = planPreparation(identityFor(extensionId, model.id, lock), plannedAssets(lock, model.assetSet), lock.license);
|
|
3143
|
+
planCache.set(extensionId, { plan, groups, modelId: model.id });
|
|
3144
|
+
return plan;
|
|
3145
|
+
},
|
|
3146
|
+
groupsForPlan(extensionId) {
|
|
3147
|
+
const cached = planCache.get(extensionId);
|
|
3148
|
+
if (cached === undefined)
|
|
3149
|
+
throw new Error(`no preparation plan for ${extensionId}; run the plan disclosure first`);
|
|
3150
|
+
return cached.groups;
|
|
3151
|
+
},
|
|
3152
|
+
modelIdFor(extensionId) {
|
|
3153
|
+
const cached = planCache.get(extensionId);
|
|
3154
|
+
if (cached === undefined)
|
|
3155
|
+
throw new Error(`no preparation plan for ${extensionId}`);
|
|
3156
|
+
return cached.modelId;
|
|
3157
|
+
},
|
|
3158
|
+
async prepare(extensionId, options) {
|
|
3159
|
+
const cached = planCache.get(extensionId);
|
|
3160
|
+
if (cached === undefined)
|
|
3161
|
+
throw new Error(`no preparation plan for ${extensionId}; run the plan disclosure first`);
|
|
3162
|
+
const outcome = await registry.prepare(extensionId, cached.plan, cached.groups, options);
|
|
3163
|
+
if (outcome.ok) {
|
|
3164
|
+
// The verified assets flip the deployment from needs-preparation to
|
|
3165
|
+
// PREPARED-COLD through the same readiness recorder the restore
|
|
3166
|
+
// chain uses — the prepared-cold-callable admission then admits the
|
|
3167
|
+
// tool's first call.
|
|
3168
|
+
const lifecycle = extensionLifecycle;
|
|
3169
|
+
if (lifecycle !== undefined) {
|
|
3170
|
+
await lifecycle.recordReadiness(extensionId, { [cached.modelId]: 'prepared-cold' });
|
|
3171
|
+
}
|
|
3172
|
+
}
|
|
3173
|
+
return outcome;
|
|
3174
|
+
},
|
|
3175
|
+
assetMapFor(extensionId) {
|
|
3176
|
+
return registry.assetMapFor(extensionId);
|
|
3177
|
+
},
|
|
3178
|
+
registry,
|
|
3179
|
+
};
|
|
3180
|
+
}
|
|
3181
|
+
/**
|
|
3182
|
+
* RESTORE what the operator already approved.
|
|
3183
|
+
*
|
|
3184
|
+
* RUN-05 permits exactly this much at startup: an approved activation may be restored
|
|
3185
|
+
* and its declarations read. It performs NO model preparation and NO package
|
|
3186
|
+
* resolution, so nothing downloads because the plugin loaded.
|
|
3187
|
+
*
|
|
3188
|
+
* A failure is LOGGED rather than thrown: one broken extension must not remove every
|
|
3189
|
+
* working tool (RUN-06).
|
|
3190
|
+
*/
|
|
3191
|
+
/**
|
|
3192
|
+
* Inspect every provider-backed catalog entry belonging to an extension and record
|
|
3193
|
+
* the results into the lifecycle, so activation can publish callable tools instead
|
|
3194
|
+
* of leaving every deployment at the `needs-preparation` default.
|
|
3195
|
+
*
|
|
3196
|
+
* Inspection is read-only (EXT-18): it never loads model assets, so this does not
|
|
3197
|
+
* prepare anything at startup.
|
|
3198
|
+
* @param lifecycle - the lifecycle whose store receives the recorded readiness.
|
|
3199
|
+
* @param extensionId - the extension being restored.
|
|
3200
|
+
*/
|
|
3201
|
+
const recordInspectedReadiness = async (lifecycle, extensionId) => {
|
|
3202
|
+
/**
|
|
3203
|
+
* Every inspection is TIME-BOUNDED: the restore chain is sequential, and one
|
|
3204
|
+
* extension whose inspect() hangs would stall activation for every later
|
|
3205
|
+
* extension indefinitely. The timeout turns a hang into a recorded `failed`
|
|
3206
|
+
* deployment, which is honest and lets the rest of the chain proceed.
|
|
3207
|
+
*/
|
|
3208
|
+
const inspectionSignal = () => AbortSignal.timeout(15_000);
|
|
3209
|
+
const inspected = {};
|
|
3210
|
+
const entries = resolved.filter(item => item.entry.provider?.extension === extensionId);
|
|
3211
|
+
for (const item of entries) {
|
|
3212
|
+
// Key by the MANIFEST model id, not the catalog entry id: the durable state is
|
|
3213
|
+
// keyed by what the manifest declares, and an alias row whose entry id differs
|
|
3214
|
+
// from its provider.modelId would otherwise be silently dropped.
|
|
3215
|
+
const modelId = item.entry.provider?.modelId ?? item.entry.id;
|
|
3216
|
+
try {
|
|
3217
|
+
const inspection = await bridge.inspect(item.entry, inspectionSignal());
|
|
3218
|
+
if (inspection?.readiness !== undefined)
|
|
3219
|
+
inspected[modelId] = inspection.readiness;
|
|
3220
|
+
}
|
|
3221
|
+
catch (error) {
|
|
3222
|
+
// A FAILED inspection is a failed deployment, not "keep the old answer": a
|
|
3223
|
+
// previously-recorded prepared-cold surviving an inspection error would
|
|
3224
|
+
// publish a tool whose prerequisites just went missing, and the first call
|
|
3225
|
+
// would fail. This matches the catalog-path readinessOf, which maps a thrown
|
|
3226
|
+
// or absent inspection to 'failed'. In managed mode the inspection ran in
|
|
3227
|
+
// the WORKER process, so this catch covers the worker's fault too.
|
|
3228
|
+
ctx.logger.warn(`model-tools: readiness inspection failed for ${item.entry.name}: `
|
|
3229
|
+
+ `${error instanceof Error ? error.message : String(error)}`);
|
|
3230
|
+
inspected[modelId] = 'failed';
|
|
3231
|
+
}
|
|
3232
|
+
}
|
|
3233
|
+
/**
|
|
3234
|
+
* Extensions WITHOUT catalog rows (EmbeddingGemma, Needle) declare models the
|
|
3235
|
+
* catalog never binds. Inspect them through their own manifest: the store record
|
|
3236
|
+
* gives the install path, discovery reads the declared model ids, and the
|
|
3237
|
+
* extension-level inspection route activates the public entry like dispatch does.
|
|
3238
|
+
*/
|
|
3239
|
+
const record = installedRecords.find(candidate => candidate.extensionId === extensionId);
|
|
3240
|
+
if (record !== undefined) {
|
|
3241
|
+
let declaredIds = [];
|
|
3242
|
+
try {
|
|
3243
|
+
declaredIds = discoverExtension(record.installPath).manifest.models.map(model => model.id);
|
|
3244
|
+
}
|
|
3245
|
+
catch (error) {
|
|
3246
|
+
ctx.logger.warn(`model-tools: could not read ${extensionId} manifest for inspection: `
|
|
3247
|
+
+ `${error instanceof Error ? error.message : String(error)}`);
|
|
3248
|
+
}
|
|
3249
|
+
for (const modelId of declaredIds) {
|
|
3250
|
+
// Only a value the CATALOG path recorded wins; a catalog-path `failed` (a
|
|
3251
|
+
// stale or wrong catalog binding) must not suppress the manifest-path
|
|
3252
|
+
// inspection, which inspects the verified store copy this extension actually is.
|
|
3253
|
+
if (inspected[modelId] !== undefined)
|
|
3254
|
+
continue;
|
|
3255
|
+
try {
|
|
3256
|
+
// Inspect the STORE COPY, not whatever the package name resolves to in
|
|
3257
|
+
// node_modules: the restore loop just verified these bytes, and running the
|
|
3258
|
+
// unverified dependency copy instead would invert the integrity gate.
|
|
3259
|
+
const inspection = await bridge.inspectExtension(record.installPath, modelId, inspectionSignal());
|
|
3260
|
+
if (inspection?.readiness !== undefined)
|
|
3261
|
+
inspected[modelId] = inspection.readiness;
|
|
3262
|
+
}
|
|
3263
|
+
catch (error) {
|
|
3264
|
+
ctx.logger.warn(`model-tools: readiness inspection failed for ${extensionId} model ${modelId}: `
|
|
3265
|
+
+ `${error instanceof Error ? error.message : String(error)}`);
|
|
3266
|
+
inspected[modelId] = 'failed';
|
|
3267
|
+
}
|
|
3268
|
+
}
|
|
3269
|
+
}
|
|
3270
|
+
if (Object.keys(inspected).length > 0)
|
|
3271
|
+
await lifecycle.recordReadiness(extensionId, inspected);
|
|
3272
|
+
};
|
|
3273
|
+
const installedRecords = store.listInstalled().value;
|
|
3274
|
+
/**
|
|
3275
|
+
* Extensions the operator switched OFF in Settings.
|
|
3276
|
+
*
|
|
3277
|
+
* Checked at the restore decision because this loop is the host's only
|
|
3278
|
+
* activation surface: filtering after activation would let the extension
|
|
3279
|
+
* register its tools and then be denied, which is the two-sources-of-truth
|
|
3280
|
+
* mistake the publication path already forbids.
|
|
3281
|
+
*/
|
|
3282
|
+
const disabledExtensions = new Set(config.disabledExtensions ?? []);
|
|
3283
|
+
const toRestore = store.readProfileState('default').value
|
|
3284
|
+
.filter(entry => entry.desiredActivation === 'active' && !disabledExtensions.has(entry.extensionId))
|
|
3285
|
+
.map(entry => ({
|
|
3286
|
+
extensionId: entry.extensionId,
|
|
3287
|
+
version: installedRecords.find(candidate => candidate.extensionId === entry.extensionId)?.version,
|
|
3288
|
+
}))
|
|
3289
|
+
.filter((entry) => entry.version !== undefined);
|
|
3290
|
+
/**
|
|
3291
|
+
* `apply` is SYNCHRONOUS, so restoration runs as a settled chain rather than blocking
|
|
3292
|
+
* plugin load. That is also the right behaviour: a host must not stall on each
|
|
3293
|
+
* extension's activation, and RUN-06 says one broken extension must not prevent the
|
|
3294
|
+
* rest from working — so every failure is logged and the chain continues.
|
|
3295
|
+
*/
|
|
3296
|
+
const lifecycleForRestore = extensionLifecycle;
|
|
3297
|
+
void (async () => {
|
|
3298
|
+
for (const entry of toRestore) {
|
|
3299
|
+
try {
|
|
3300
|
+
/**
|
|
3301
|
+
* INTEGRITY BEFORE ANY EXTENSION CODE RUNS. Inspection imports the extension
|
|
3302
|
+
* module and runs its `create()`, so it must come AFTER the same byte-integrity
|
|
3303
|
+
* gate activation applies — otherwise a tampered installed package executes
|
|
3304
|
+
* first and is only refused afterwards.
|
|
3305
|
+
*/
|
|
3306
|
+
const record = installedRecords.find(candidate => candidate.extensionId === entry.extensionId && candidate.version === entry.version);
|
|
3307
|
+
if (record !== undefined) {
|
|
3308
|
+
const integrity = store.verifyUnchanged(record);
|
|
3309
|
+
if (!integrity.unchanged) {
|
|
3310
|
+
ctx.logger.warn(`model-tools: not restoring ${entry.extensionId}: `
|
|
3311
|
+
+ `${integrity.diagnostic ?? 'the installed content changed'}`);
|
|
3312
|
+
continue;
|
|
3313
|
+
}
|
|
3314
|
+
}
|
|
3315
|
+
/**
|
|
3316
|
+
* RECORD what inspection knows BEFORE activation publishes. The lifecycle
|
|
3317
|
+
* defaults every declared deployment to `needs-preparation`, so without this
|
|
3318
|
+
* a model-backed package can never publish a callable tool. Inspection is the
|
|
3319
|
+
* read-only EXT-18 surface — it may import the extension but loads no assets —
|
|
3320
|
+
* so this stays inside RUN-05's "read declarations, prepare nothing" boundary.
|
|
3321
|
+
*/
|
|
3322
|
+
await recordInspectedReadiness(lifecycleForRestore, entry.extensionId);
|
|
3323
|
+
const activated = await lifecycleForRestore.activate(entry.extensionId, entry.version);
|
|
3324
|
+
// EXT-20: the activation generation is captured here so the managed-worker
|
|
3325
|
+
// registry can bind a process to exactly this activation and dispose it when
|
|
3326
|
+
// a later one is observed.
|
|
3327
|
+
activationGenerations.set(entry.extensionId, activated.generation);
|
|
3328
|
+
}
|
|
3329
|
+
catch (error) {
|
|
3330
|
+
ctx.logger.warn(`model-tools: could not restore ${entry.extensionId}: `
|
|
3331
|
+
+ `${error instanceof Error ? error.message : String(error)}`);
|
|
3332
|
+
}
|
|
3333
|
+
}
|
|
3334
|
+
})();
|
|
3335
|
+
ctx.effect(() => () => {
|
|
3336
|
+
extensionLifecycle = undefined;
|
|
3337
|
+
// EXT-20: host teardown disposes every managed worker process — no child
|
|
3338
|
+
// outlives the host that spawned it.
|
|
3339
|
+
void workerRegistry.disposeAll();
|
|
3340
|
+
}, 'model-tools extension teardown');
|
|
3341
|
+
ctx.logger.info(`model-tools: extension store mounted at ${config.extensionStore}`);
|
|
3342
|
+
}
|
|
788
3343
|
const runnable = resolved.filter(item => item.verdict.runnable).length;
|
|
789
3344
|
ctx.logger.info(`model-tools: ${installed.size} model tool(s) registered from ${entries.length} catalogued models `
|
|
790
3345
|
+ `(${runnable} runnable in this process)`);
|