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