@try-works/dsh-model-as-tool 0.1.3 → 0.4.0

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