@try-works/dsh-model-as-tool 0.1.2 → 0.3.6

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