@hoardodile/host 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/LICENSE +18 -0
  2. package/README.md +73 -0
  3. package/dist/chunks/worker-entry.mjs +422 -0
  4. package/dist/container-fKxefO7p.d.ts +58 -0
  5. package/dist/contract/index.d.ts +49 -0
  6. package/dist/contract/index.js +187 -0
  7. package/dist/contract/index.js.map +1 -0
  8. package/dist/hoard/index.d.ts +754 -0
  9. package/dist/hoard/index.js +1939 -0
  10. package/dist/hoard/index.js.map +1 -0
  11. package/dist/image-Dx2Be3Bj.d.ts +168 -0
  12. package/dist/index.d.ts +788 -0
  13. package/dist/index.js +3311 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/media/index.d.ts +96 -0
  16. package/dist/media/index.js +152 -0
  17. package/dist/media/index.js.map +1 -0
  18. package/dist/probe/index.d.ts +113 -0
  19. package/dist/probe/index.js +454 -0
  20. package/dist/probe/index.js.map +1 -0
  21. package/dist/probe-cache-BkRI7HEK.d.ts +25 -0
  22. package/dist/render/index.d.ts +125 -0
  23. package/dist/render/index.js +430 -0
  24. package/dist/render/index.js.map +1 -0
  25. package/package.json +103 -0
  26. package/src/__testutils__/zip-fixtures.ts +139 -0
  27. package/src/activation.ts +116 -0
  28. package/src/api-types.ts +60 -0
  29. package/src/api.test.ts +564 -0
  30. package/src/api.ts +501 -0
  31. package/src/archive/7z.test.ts +222 -0
  32. package/src/archive/7z.ts +261 -0
  33. package/src/archive/archive-ops.test.ts +654 -0
  34. package/src/archive/extract-archive.test.ts +237 -0
  35. package/src/archive/extract-archive.ts +534 -0
  36. package/src/archive/extract.ts +693 -0
  37. package/src/archive/format.ts +49 -0
  38. package/src/archive/index.ts +116 -0
  39. package/src/archive/listing.ts +121 -0
  40. package/src/archive/materialize.ts +88 -0
  41. package/src/archive/name-decode.test.ts +34 -0
  42. package/src/archive/name-decode.ts +287 -0
  43. package/src/archive/nested-cd-cache.ts +55 -0
  44. package/src/archive/nested-entry.test.ts +194 -0
  45. package/src/archive/nested-entry.ts +254 -0
  46. package/src/archive/pack.ts +40 -0
  47. package/src/archive/zip-entries.test.ts +56 -0
  48. package/src/archive/zip-entries.ts +336 -0
  49. package/src/capability-guard.ts +73 -0
  50. package/src/container.ts +62 -0
  51. package/src/contract/container-fixture.ts +74 -0
  52. package/src/contract/index.ts +10 -0
  53. package/src/contract/suite.test.ts +101 -0
  54. package/src/contract/suite.ts +205 -0
  55. package/src/define-plugin.test.ts +53 -0
  56. package/src/define-plugin.ts +12 -0
  57. package/src/dev-runner.test.ts +98 -0
  58. package/src/dev-runner.ts +73 -0
  59. package/src/directory-api.test.ts +229 -0
  60. package/src/directory-api.ts +42 -0
  61. package/src/directory-container.ts +141 -0
  62. package/src/discovery.ts +295 -0
  63. package/src/errors.ts +74 -0
  64. package/src/fixtures.ts +8 -0
  65. package/src/hash.test.ts +301 -0
  66. package/src/hash.ts +167 -0
  67. package/src/hoard/dir-size.ts +58 -0
  68. package/src/hoard/index.ts +84 -0
  69. package/src/hoard/migrate-script.test.ts +539 -0
  70. package/src/hoard/order-manifest.test.ts +115 -0
  71. package/src/hoard/order-manifest.ts +131 -0
  72. package/src/hoard/paths.test.ts +226 -0
  73. package/src/hoard/paths.ts +569 -0
  74. package/src/hoard/plugin-vault.test.ts +160 -0
  75. package/src/hoard/plugin-vault.ts +233 -0
  76. package/src/hoard/sanitize.test.ts +149 -0
  77. package/src/hoard/sanitize.ts +230 -0
  78. package/src/hoard/staging-dir.ts +133 -0
  79. package/src/hoard/version.test.ts +285 -0
  80. package/src/hoard/version.ts +216 -0
  81. package/src/hoard/versioned-folder-ops.ts +152 -0
  82. package/src/hoard/write-versioned.test.ts +67 -0
  83. package/src/hoard/write-versioned.ts +39 -0
  84. package/src/hooks.test.ts +528 -0
  85. package/src/hooks.ts +428 -0
  86. package/src/index.ts +135 -0
  87. package/src/loader.test.ts +442 -0
  88. package/src/loader.ts +213 -0
  89. package/src/media/index.ts +16 -0
  90. package/src/media/render-cache.ts +27 -0
  91. package/src/media/seekable.ts +35 -0
  92. package/src/media/thumb-input.test.ts +109 -0
  93. package/src/media/thumb-input.ts +194 -0
  94. package/src/nested-view.test.ts +223 -0
  95. package/src/nested-view.ts +239 -0
  96. package/src/probe/audio.test.ts +135 -0
  97. package/src/probe/audio.ts +140 -0
  98. package/src/probe/av.ts +84 -0
  99. package/src/probe/ffprobe.ts +197 -0
  100. package/src/probe/image.test.ts +251 -0
  101. package/src/probe/image.ts +336 -0
  102. package/src/probe/index.ts +54 -0
  103. package/src/probe/sniff.test.ts +72 -0
  104. package/src/probe/sniff.ts +67 -0
  105. package/src/probe/video.test.ts +41 -0
  106. package/src/probe/video.ts +116 -0
  107. package/src/probe-cache.test.ts +89 -0
  108. package/src/probe-cache.ts +65 -0
  109. package/src/render/ffmpeg.ts +78 -0
  110. package/src/render/index.ts +34 -0
  111. package/src/render/pipeline.test.ts +187 -0
  112. package/src/render/pipeline.ts +479 -0
  113. package/src/sandbox/call-timers.ts +68 -0
  114. package/src/sandbox/fixtures/api-error-plugin.mjs +11 -0
  115. package/src/sandbox/fixtures/asset-plugin.mjs +13 -0
  116. package/src/sandbox/fixtures/chatty-plugin.mjs +14 -0
  117. package/src/sandbox/fixtures/container-plugin.mjs +8 -0
  118. package/src/sandbox/fixtures/context-plugin.mjs +19 -0
  119. package/src/sandbox/fixtures/crash-plugin.mjs +6 -0
  120. package/src/sandbox/fixtures/echo-plugin.mjs +17 -0
  121. package/src/sandbox/fixtures/exit-plugin.mjs +6 -0
  122. package/src/sandbox/fixtures/fetch-plugin.mjs +8 -0
  123. package/src/sandbox/fixtures/flood-api-plugin.mjs +8 -0
  124. package/src/sandbox/fixtures/flood-log-plugin.mjs +8 -0
  125. package/src/sandbox/fixtures/hostile-dynamic-plugin.mjs +9 -0
  126. package/src/sandbox/fixtures/hostile-plugin.mjs +13 -0
  127. package/src/sandbox/fixtures/huge-result-plugin.mjs +8 -0
  128. package/src/sandbox/fixtures/logging-plugin.mjs +9 -0
  129. package/src/sandbox/fixtures/range-plugin.mjs +9 -0
  130. package/src/sandbox/fixtures/slow-api-plugin.mjs +11 -0
  131. package/src/sandbox/fixtures/spin-plugin.mjs +9 -0
  132. package/src/sandbox/fixtures/stuck-plugin.mjs +12 -0
  133. package/src/sandbox/fixtures/thrower-plugin.mjs +7 -0
  134. package/src/sandbox/host.test.ts +619 -0
  135. package/src/sandbox/host.ts +933 -0
  136. package/src/sandbox/host.unit.test.ts +330 -0
  137. package/src/sandbox/protocol.test.ts +85 -0
  138. package/src/sandbox/protocol.ts +147 -0
  139. package/src/sandbox/sandboxed-plugin.ts +30 -0
  140. package/src/sandbox/worker-entry.mjs +422 -0
  141. package/src/seed.test.ts +102 -0
  142. package/src/seed.ts +160 -0
  143. package/src/settings-store.ts +26 -0
  144. package/src/types.ts +21 -0
@@ -0,0 +1,422 @@
1
+ /**
2
+ * Plugin sandbox entry — the main script of the per-plugin child process.
3
+ * Plain ESM JS on purpose: sandbox files do NOT get vite-node/vitest
4
+ * transforms, so they must stay dependency-free (no workspace TS imports).
5
+ * Keep HOOK_NAMES / API_METHOD_NAMES in sync with protocol.ts.
6
+ *
7
+ * The child runs under the Node permission model (no fs write, no child
8
+ * processes, no native addons; fs reads limited to the plugin directory
9
+ * and the sandbox entry itself) and this entry adds three layers before
10
+ * the plugin bundle is ever imported:
11
+ *
12
+ * 1. Startup self-check — proves the permission model is actually active
13
+ * (the host probes the flag name, but a restricted process must also
14
+ * verify the restriction). Fail-closed: exit(1) when the model is off.
15
+ * 2. Module policy gate — `registerHooks` (synchronous, main-thread, no
16
+ * worker grant needed) installs a resolve hook so every later import
17
+ * (`node:fs`, `node:http`, bare packages, ...) is denied; only
18
+ * `node:url`, files under the plugin dir and the entry itself may
19
+ * load. A follow-up self-check proves the gate is actually armed.
20
+ * 3. Global scrub — `fetch`/`WebSocket`/`EventSource` throw and
21
+ * `process.env` is emptied, so ambient network/env capability never
22
+ * exists even without an import.
23
+ *
24
+ * The plugin's own data access goes through the RPC below (`process.send`
25
+ * of `api` requests); the host executes every call in its own process.
26
+ */
27
+ import { writeFileSync } from "node:fs"
28
+ import { registerHooks } from "node:module"
29
+ import { tmpdir } from "node:os"
30
+ import { join } from "node:path"
31
+ import { pathToFileURL } from "node:url"
32
+
33
+ if (process.send === undefined) {
34
+ throw new Error(
35
+ "worker-entry must run as a forked child process with an IPC channel",
36
+ )
37
+ }
38
+
39
+ const HOOK_NAMES = [
40
+ "detect",
41
+ "sourceMeta",
42
+ "searchMeta",
43
+ "coverLocal",
44
+ "listFiles",
45
+ "imageHashes",
46
+ ]
47
+
48
+ const API_METHOD_NAMES = [
49
+ "logInfo",
50
+ "logWarn",
51
+ "logError",
52
+ "listFileNames",
53
+ "readFile",
54
+ "statFile",
55
+ "statFiles",
56
+ "sniff",
57
+ "probe",
58
+ "hashBytes",
59
+ "computeImageHashes",
60
+ "listContainer",
61
+ "extractArchive",
62
+ "download",
63
+ "statAsset",
64
+ "readAsset",
65
+ "deleteAsset",
66
+ ]
67
+
68
+ const LOG_METHOD_NAMES = new Set(["logInfo", "logWarn", "logError"])
69
+
70
+ const pluginDir = process.argv[2]
71
+ if (typeof pluginDir !== "string" || pluginDir.length === 0) {
72
+ process.stderr.write("[plugin-sandbox] missing plugin directory argument\n")
73
+ process.exit(1)
74
+ }
75
+
76
+ // Optional third argument: the host-managed plugin vault directory
77
+ // (`<plugin-dir>/vault/` for disk plugins; for dev plugins it lives under
78
+ // the versioned storage instead of the dev directory). Passed so the
79
+ // module policy gate allows loading downloaded runtimes from the vault
80
+ // even when it sits outside the plugin directory.
81
+ const assetVaultDir = process.argv[3]
82
+
83
+ /** Absolute cap for one hook result crossing the IPC boundary. */
84
+ const MAX_RESULT_BYTES =
85
+ Number(process.env.HOARDODILE_PLUGIN_MAX_RESULT_BYTES) || 256 * 1024 * 1024
86
+
87
+ /** Cap on `log*` messages per hook invocation (log spam resets the watchdog). */
88
+ const MAX_LOGS_PER_HOOK =
89
+ Number(process.env.HOARDODILE_PLUGIN_MAX_LOGS_PER_HOOK) || 1000
90
+
91
+ /** Cap on ResourceAPI calls per hook invocation. */
92
+ const MAX_API_CALLS_PER_HOOK =
93
+ Number(process.env.HOARDODILE_PLUGIN_MAX_API_CALLS_PER_HOOK) || 100000
94
+
95
+ // Per-invocation budgets: reset at the start of every hook so a burst in
96
+ // one hook never counts against the next (they fail the hook, like the
97
+ // result cap — a runaway log/RPC loop must not pin the host CPU).
98
+ let logCount = 0
99
+ let apiCallCount = 0
100
+
101
+ // -- layer 1: startup self-check (fail-closed) --
102
+
103
+ function permissionModelActive() {
104
+ const probePath = join(
105
+ tmpdir(),
106
+ `hoardodile-sandbox-probe-${process.pid}-${Date.now()}`,
107
+ )
108
+ const api = process.permission
109
+ if (api !== undefined && typeof api.has === "function") {
110
+ try {
111
+ // No fs-write grant exists at all, so a write must be denied.
112
+ return api.has("fs.write", probePath) === false
113
+ } catch {
114
+ // Unsupported probe shape — fall through to the write probe.
115
+ }
116
+ }
117
+ try {
118
+ writeFileSync(probePath, "probe")
119
+ return false
120
+ } catch {
121
+ return true
122
+ }
123
+ }
124
+
125
+ if (!permissionModelActive()) {
126
+ process.stderr.write(
127
+ "[plugin-sandbox] startup self-check failed: the Node permission model is not active in this process — refusing to run untrusted plugin code. Check the Node version and that the sandbox flags reached the child.\n",
128
+ )
129
+ process.exit(1)
130
+ }
131
+
132
+ // -- layer 2: module policy gate (before any plugin code can import) --
133
+
134
+ if (typeof registerHooks !== "function") {
135
+ process.stderr.write(
136
+ "[plugin-sandbox] startup self-check failed: this Node build has no module.registerHooks — refusing to run plugin code unsandboxed.\n",
137
+ )
138
+ process.exit(1)
139
+ }
140
+
141
+ /** Case-insensitive comparison on Windows (drive letters, case folds). */
142
+ function normalizePath(value) {
143
+ return process.platform === "win32" ? value.toLowerCase() : value
144
+ }
145
+
146
+ /** On-disk path → its canonical `file://` URL, in Node's own encoding. */
147
+ function toFileUrl(path) {
148
+ // pathToFileURL is what the ESM loader uses for these paths, so the
149
+ // gate prefixes MUST use its exact encoding — encodeURI leaves `~`
150
+ // unescaped, which mismatched the loader's `%7E` (Temp dirs on
151
+ // Windows runners are the short name, e.g. RUNNER~1) and made every
152
+ // plugin under such a path look like it was outside the plugin dir.
153
+ return pathToFileURL(path).href
154
+ }
155
+
156
+ const pluginDirPrefix = normalizePath(toFileUrl(pluginDir) + "/")
157
+ const assetVaultPrefix =
158
+ typeof assetVaultDir === "string" && assetVaultDir.length > 0
159
+ ? normalizePath(toFileUrl(assetVaultDir) + "/")
160
+ : undefined
161
+ const entryUrl = normalizePath(import.meta.url)
162
+
163
+ /**
164
+ * The only modules a sandbox may load: `node:url` (bootstrap), files under
165
+ * the plugin directory (the bundle is a single self-contained ESM file),
166
+ * the host-managed plugin vault (downloaded runtimes — see the asset API
167
+ * contract; read-only by the permission model, and the vault contents are
168
+ * data the plugin itself requested under user consent), and the entry
169
+ * itself. Everything else — every other `node:` builtin, bare package
170
+ * names, `data:`/`blob:` URLs, absolute paths outside the plugin dir —
171
+ * is denied. The Node permission model stays the second, OS-level layer
172
+ * underneath.
173
+ */
174
+ function isAllowedModule(url) {
175
+ if (url === "node:url") return true
176
+ if (!url.startsWith("file:")) return false
177
+ const normalized = normalizePath(url)
178
+ if (normalized === entryUrl) return true
179
+ if (normalized.startsWith(pluginDirPrefix)) return true
180
+ if (assetVaultPrefix !== undefined) {
181
+ return normalized.startsWith(assetVaultPrefix)
182
+ }
183
+ return false
184
+ }
185
+
186
+ registerHooks({
187
+ resolve(specifier, _context, nextResolve) {
188
+ // Resolve first, then validate the FINAL destination — a relative
189
+ // specifier like `../outside.js` only reveals its target after the
190
+ // parent URL is folded in. Hooks must stay synchronous
191
+ // (registerHooks does not support async hooks).
192
+ const result = nextResolve(specifier, _context)
193
+ if (isAllowedModule(result.url)) return result
194
+ // Show the allowed prefixes so a canonicalization mismatch (URL
195
+ // encoding, symlinked roots, casing) is diagnosable in one line.
196
+ const allowed =
197
+ assetVaultPrefix !== undefined
198
+ ? `${pluginDirPrefix}, ${assetVaultPrefix}`
199
+ : pluginDirPrefix
200
+ throw new Error(
201
+ `[plugin-sandbox] module denied by policy: ${specifier} → ${result.url} (allowed: ${allowed})`,
202
+ )
203
+ },
204
+ })
205
+
206
+ // -- layer 2a: prove the policy gate is armed --
207
+ // A builtin the entry never imported (node:http): its import is not a
208
+ // permission-model operation, so a success means the plugin would run
209
+ // with full module access — only the gate above can deny it. (`node:fs`
210
+ // is already loaded by a top-level import, which would bypass the hook.)
211
+ try {
212
+ await import("node:http")
213
+ process.stderr.write(
214
+ "[plugin-sandbox] startup self-check failed: module policy gate is not active in this process\n",
215
+ )
216
+ process.exit(1)
217
+ } catch {
218
+ // Denied as expected — the gate is live.
219
+ }
220
+
221
+ // -- layer 3: global scrub --
222
+
223
+ function disabled(name) {
224
+ return () => {
225
+ throw new Error(
226
+ `[plugin-sandbox] ${name} is disabled inside the plugin sandbox — use the ResourceAPI instead`,
227
+ )
228
+ }
229
+ }
230
+
231
+ for (const name of ["fetch", "WebSocket", "EventSource"]) {
232
+ try {
233
+ Object.defineProperty(globalThis, name, {
234
+ value: disabled(name),
235
+ writable: true,
236
+ configurable: true,
237
+ })
238
+ } catch {
239
+ // A non-configurable global stays; the module path to it is still denied.
240
+ }
241
+ }
242
+
243
+ process.env = {}
244
+
245
+ /** @type {Record<string, unknown> | undefined} */
246
+ let plugin
247
+
248
+ /**
249
+ * Payload of the last successful `detect` invocation (the result
250
+ * without the `ok` marker), exposed to later hooks as
251
+ * `api.context.detect`. Reset on load and on a failed detection.
252
+ * @type {Record<string, unknown> | undefined}
253
+ */
254
+ let detectPayload
255
+
256
+ let nextApiCallId = 1
257
+ /** @type {Map<number, { resolve: (value: unknown) => void, reject: (err: Error) => void }>} */
258
+ const pendingApi = new Map()
259
+
260
+ function serializeError(err) {
261
+ if (err instanceof Error) {
262
+ return { name: err.name, message: err.message, stack: err.stack }
263
+ }
264
+ return { name: "Error", message: String(err) }
265
+ }
266
+
267
+ function deserializeError(err) {
268
+ const e = new Error(err.message)
269
+ e.name = err.name
270
+ if (err.stack !== undefined) e.stack = err.stack
271
+ return e
272
+ }
273
+
274
+ function send(message) {
275
+ try {
276
+ process.send(message)
277
+ } catch {
278
+ // The channel closed (host gone or killed us) — nothing to deliver.
279
+ }
280
+ }
281
+
282
+ /** Approximate serialized size of a value crossing to the host. */
283
+ function approxByteSize(value) {
284
+ if (value instanceof Uint8Array) return value.byteLength
285
+ try {
286
+ return JSON.stringify(value).length * 2
287
+ } catch {
288
+ return Infinity
289
+ }
290
+ }
291
+
292
+ function buildResourceApiProxy(callId) {
293
+ const api = { context: { detect: detectPayload } }
294
+ for (const name of API_METHOD_NAMES) {
295
+ if (LOG_METHOD_NAMES.has(name)) {
296
+ // Fire-and-forget: the contract types these as sync void, so the
297
+ // proxy must not hand the plugin a promise to await. A thrown
298
+ // budget error propagates into the plugin's hook — the hook
299
+ // fails loudly instead of spamming forever.
300
+ api[name] = (message, data) => {
301
+ logCount += 1
302
+ if (logCount > MAX_LOGS_PER_HOOK) {
303
+ throw new Error(
304
+ `[plugin-sandbox] log budget exceeded (${MAX_LOGS_PER_HOOK} per hook)`,
305
+ )
306
+ }
307
+ send({
308
+ type: "log",
309
+ callId,
310
+ method: name,
311
+ args: [message, data],
312
+ })
313
+ }
314
+ continue
315
+ }
316
+ api[name] = (...args) => {
317
+ apiCallCount += 1
318
+ if (apiCallCount > MAX_API_CALLS_PER_HOOK) {
319
+ return Promise.reject(
320
+ new Error(
321
+ `[plugin-sandbox] API call budget exceeded (${MAX_API_CALLS_PER_HOOK} per hook) — batch with statFiles or reduce per-file fan-out`,
322
+ ),
323
+ )
324
+ }
325
+ const apiCallId = nextApiCallId++
326
+ return new Promise((resolve, reject) => {
327
+ pendingApi.set(apiCallId, { resolve, reject })
328
+ send({ type: "api", callId, apiCallId, method: name, args })
329
+ })
330
+ }
331
+ }
332
+ return api
333
+ }
334
+
335
+ async function handleLoad(mainPath) {
336
+ try {
337
+ const mod = await import(pathToFileURL(mainPath).href)
338
+ const def =
339
+ mod !== null && typeof mod === "object" ? mod.default : undefined
340
+ if (
341
+ def === null ||
342
+ typeof def !== "object" ||
343
+ typeof def.detect !== "function"
344
+ ) {
345
+ throw new Error(
346
+ "plugin main.js must default-export a definition with detect()",
347
+ )
348
+ }
349
+ const hooks = HOOK_NAMES.filter((h) => typeof def[h] === "function")
350
+ plugin = def
351
+ detectPayload = undefined
352
+ send({ type: "loaded", ok: true, hooks })
353
+ } catch (err) {
354
+ send({ type: "loaded", ok: false, error: serializeError(err) })
355
+ }
356
+ }
357
+
358
+ async function handleInvoke(callId, hook) {
359
+ try {
360
+ if (plugin === undefined) throw new Error("plugin not loaded")
361
+ const fn = plugin[hook]
362
+ if (typeof fn !== "function") throw new Error(`plugin has no hook ${hook}`)
363
+ logCount = 0
364
+ apiCallCount = 0
365
+ const value = await fn(buildResourceApiProxy(callId))
366
+ // Keep the payload of a successful detect for the next hooks —
367
+ // the one-pass classification every other hook can build on. A
368
+ // failed or payload-less detection leaves the context absent.
369
+ if (hook === "detect" && isRecord(value) && value.ok === true) {
370
+ const { ok: _ok, ...payload } = value
371
+ detectPayload = Object.keys(payload).length > 0 ? payload : undefined
372
+ } else if (hook === "detect") {
373
+ detectPayload = undefined
374
+ }
375
+ if (approxByteSize(value) > MAX_RESULT_BYTES) {
376
+ throw new Error(
377
+ `[plugin-sandbox] hook result exceeds ${MAX_RESULT_BYTES} bytes — return a smaller payload or read large files by byte range`,
378
+ )
379
+ }
380
+ send({ type: "result", callId, ok: true, value })
381
+ } catch (err) {
382
+ send({
383
+ type: "result",
384
+ callId,
385
+ ok: false,
386
+ error: serializeError(err),
387
+ })
388
+ }
389
+ }
390
+
391
+ function isRecord(value) {
392
+ return value !== null && typeof value === "object" && !Array.isArray(value)
393
+ }
394
+
395
+ process.on("message", (msg) => {
396
+ if (msg === null || typeof msg !== "object") return
397
+ switch (msg.type) {
398
+ case "load":
399
+ void handleLoad(msg.mainPath)
400
+ return
401
+ case "invoke":
402
+ void handleInvoke(msg.callId, msg.hook)
403
+ return
404
+ case "apiResult": {
405
+ const pending = pendingApi.get(msg.apiCallId)
406
+ if (pending === undefined) return
407
+ pendingApi.delete(msg.apiCallId)
408
+ if (msg.ok) {
409
+ pending.resolve(msg.value)
410
+ } else {
411
+ pending.reject(deserializeError(msg.error))
412
+ }
413
+ return
414
+ }
415
+ }
416
+ })
417
+
418
+ // The host is the process lifetime: when it goes away (crash, shutdown,
419
+ // tree-kill), the IPC channel closes and this child must not linger.
420
+ process.on("disconnect", () => {
421
+ process.exit(0)
422
+ })
@@ -0,0 +1,102 @@
1
+ import {
2
+ mkdirSync,
3
+ mkdtempSync,
4
+ readFileSync,
5
+ rmSync,
6
+ statSync,
7
+ writeFileSync,
8
+ } from "node:fs"
9
+ import { tmpdir } from "node:os"
10
+ import { join } from "node:path"
11
+ import { afterEach, beforeEach, describe, expect, test } from "vitest"
12
+ import { seedPlugins } from "./seed.ts"
13
+
14
+ const PLUGIN_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
15
+
16
+ function writePlugin(dir: string, name: string, extra?: string): void {
17
+ mkdirSync(dir, { recursive: true })
18
+ writeFileSync(
19
+ join(dir, "manifest.json"),
20
+ JSON.stringify({
21
+ id: PLUGIN_ID,
22
+ name,
23
+ description: "",
24
+ version: "1.0.0",
25
+ permissions: {},
26
+ }),
27
+ )
28
+ writeFileSync(join(dir, "main.js"), extra ?? "export default {}\n")
29
+ }
30
+
31
+ describe("seedPlugins", () => {
32
+ let root: string
33
+ let pluginsDir: string
34
+ let seedDir: string
35
+
36
+ beforeEach(() => {
37
+ root = mkdtempSync(join(tmpdir(), "plugin-seed-"))
38
+ pluginsDir = join(root, "plugins")
39
+ seedDir = join(root, "seed")
40
+ mkdirSync(pluginsDir, { recursive: true })
41
+ })
42
+
43
+ afterEach(() => {
44
+ rmSync(root, { recursive: true, force: true })
45
+ })
46
+
47
+ test("skips an identical installed copy without changing mtime", () => {
48
+ writePlugin(join(seedDir, "plugin"), "same")
49
+ seedPlugins(pluginsDir, [join(seedDir, "plugin")])
50
+ const dest = join(pluginsDir, PLUGIN_ID, "main.js")
51
+ const before = statSync(dest).mtimeMs
52
+ seedPlugins(pluginsDir, [join(seedDir, "plugin")])
53
+ expect(statSync(dest).mtimeMs).toBe(before)
54
+ })
55
+
56
+ test("replaces the destination when content changes", () => {
57
+ writePlugin(join(seedDir, "plugin"), "old", "old\n")
58
+ seedPlugins(pluginsDir, [join(seedDir, "plugin")])
59
+ writePlugin(join(seedDir, "plugin"), "new", "new\n")
60
+ seedPlugins(pluginsDir, [join(seedDir, "plugin")])
61
+ expect(
62
+ JSON.parse(
63
+ readFileSync(join(pluginsDir, PLUGIN_ID, "manifest.json"), "utf-8"),
64
+ ).name,
65
+ ).toBe("new")
66
+ expect(readFileSync(join(pluginsDir, PLUGIN_ID, "main.js"), "utf-8")).toBe(
67
+ "new\n",
68
+ )
69
+ })
70
+
71
+ test("the host-managed vault survives a reseeding replacement", () => {
72
+ writePlugin(join(seedDir, "plugin"), "old")
73
+ seedPlugins(pluginsDir, [join(seedDir, "plugin")])
74
+ // The plugin downloaded an asset into its vault.
75
+ const vaultDir = join(pluginsDir, PLUGIN_ID, "vault")
76
+ mkdirSync(vaultDir, { recursive: true })
77
+ writeFileSync(join(vaultDir, "runtime.mjs"), "export const x = 1\n")
78
+ // Session 2: the source changed (an app update) → the tree is
79
+ // replaced, the vault must stay (vault files are host data).
80
+ writePlugin(join(seedDir, "plugin"), "new")
81
+ seedPlugins(pluginsDir, [join(seedDir, "plugin")])
82
+ expect(
83
+ readFileSync(
84
+ join(pluginsDir, PLUGIN_ID, "vault", "runtime.mjs"),
85
+ "utf-8",
86
+ ),
87
+ ).toBe("export const x = 1\n")
88
+ })
89
+
90
+ test("an unchanged tree with a vault is left untouched (fingerprint ignores vault)", () => {
91
+ writePlugin(join(seedDir, "plugin"), "same")
92
+ seedPlugins(pluginsDir, [join(seedDir, "plugin")])
93
+ const vaultDir = join(pluginsDir, PLUGIN_ID, "vault")
94
+ mkdirSync(vaultDir, { recursive: true })
95
+ writeFileSync(join(vaultDir, "runtime.mjs"), "export const x = 1\n")
96
+ const before = statSync(join(pluginsDir, PLUGIN_ID, "main.js")).mtimeMs
97
+ seedPlugins(pluginsDir, [join(seedDir, "plugin")])
98
+ expect(statSync(join(pluginsDir, PLUGIN_ID, "main.js")).mtimeMs).toBe(
99
+ before,
100
+ )
101
+ })
102
+ })
package/src/seed.ts ADDED
@@ -0,0 +1,160 @@
1
+ import { createHash } from "node:crypto"
2
+ import {
3
+ cpSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ readdirSync,
7
+ readFileSync,
8
+ renameSync,
9
+ rmSync,
10
+ statSync,
11
+ } from "node:fs"
12
+ import { join } from "node:path"
13
+ import type { PluginManifest } from "@hoardodile/sdk-types"
14
+
15
+ /**
16
+ * The host-reserved plugin subdirectory a seed never touches: downloaded
17
+ * vault assets live there (see `VersionPaths.pluginVaultDir`) and the
18
+ * tree comparison below must ignore them, or the presence of a vault
19
+ * would mark every seeded plugin as changed on every boot.
20
+ */
21
+ const VAULT_DIR_NAME = "vault"
22
+
23
+ /**
24
+ * Copy the configured plugin directories into `pluginsDir` so discovery
25
+ * can load them as installed disk plugins. Each source is itself a plugin
26
+ * directory (`manifest.json` at its root). A seed replaces
27
+ * `pluginsDir/<manifest.id>` only when the file set, sizes, or content
28
+ * hashes differ — identical trees are left untouched so a boot does not
29
+ * dirty a synced `versions/` tree.
30
+ */
31
+ export function seedPlugins(
32
+ pluginsDir: string,
33
+ seedPluginDirs: readonly string[] | undefined,
34
+ ): void {
35
+ if (seedPluginDirs === undefined || seedPluginDirs.length === 0) {
36
+ return
37
+ }
38
+
39
+ for (const dir of seedPluginDirs) {
40
+ try {
41
+ copyPluginDir(dir, pluginsDir)
42
+ } catch (err) {
43
+ // A locked directory (e.g. a plugin watch rebuilding dist on
44
+ // Windows) must not abort the whole load — the plugin simply
45
+ // stays as-is or surfaces as missing.
46
+ console.warn(
47
+ `[plugin-loader] failed to seed plugin dir ${dir}: ${err instanceof Error ? err.message : String(err)}`,
48
+ )
49
+ }
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Copy a single plugin directory into `pluginsDir/<manifest.id>`.
55
+ * Returns false (and copies nothing) when the directory is not a valid
56
+ * plugin. An existing destination that already matches the source tree
57
+ * is left untouched.
58
+ *
59
+ * The host-managed `vault/` subdirectory is never part of the seed: it
60
+ * is skipped in both the tree comparison and the copy, and stashed
61
+ * aside during a replacement — reseeding (the update channel of bundled
62
+ * plugins) preserves the plugin's downloaded assets.
63
+ */
64
+ function copyPluginDir(srcDir: string, pluginsDir: string): boolean {
65
+ const id = readManifestId(srcDir)
66
+ if (id === undefined) return false
67
+ const dstDir = join(pluginsDir, id)
68
+ if (existsSync(dstDir) && treesMatch(srcDir, dstDir)) {
69
+ return true
70
+ }
71
+ if (existsSync(dstDir)) {
72
+ const vaultDir = join(dstDir, VAULT_DIR_NAME)
73
+ const stashDir = join(pluginsDir, `.vault-${id}-${Date.now()}`)
74
+ const hasVault = existsSync(vaultDir)
75
+ if (hasVault) {
76
+ // Same-volume rename: the vault leaves the tree before the
77
+ // replacement lands, then comes back — a crash in between can
78
+ // at worst strand the stash as a dot-directory (skipped by
79
+ // discovery, version copies and the sync tooling).
80
+ renameSync(vaultDir, stashDir)
81
+ }
82
+ rmSync(dstDir, { recursive: true, force: true })
83
+ mkdirSync(dstDir, { recursive: true })
84
+ for (const f of readdirSync(srcDir)) {
85
+ if (f === VAULT_DIR_NAME) continue
86
+ cpSync(join(srcDir, f), join(dstDir, f), { recursive: true })
87
+ }
88
+ if (existsSync(stashDir)) {
89
+ renameSync(stashDir, join(dstDir, VAULT_DIR_NAME))
90
+ }
91
+ return true
92
+ }
93
+ mkdirSync(dstDir, { recursive: true })
94
+ for (const f of readdirSync(srcDir)) {
95
+ if (f === VAULT_DIR_NAME) continue
96
+ cpSync(join(srcDir, f), join(dstDir, f), { recursive: true })
97
+ }
98
+ return true
99
+ }
100
+
101
+ function readManifestId(dir: string): string | undefined {
102
+ const manifestPath = join(dir, "manifest.json")
103
+ if (!existsSync(manifestPath)) return undefined
104
+ let manifest: PluginManifest
105
+ try {
106
+ manifest = JSON.parse(readFileSync(manifestPath, "utf-8"))
107
+ } catch {
108
+ return undefined
109
+ }
110
+ if (typeof manifest.id !== "string" || manifest.id.length === 0) {
111
+ return undefined
112
+ }
113
+ return manifest.id
114
+ }
115
+
116
+ /**
117
+ * True when both directories contain the same relative file set, each
118
+ * with matching byte size and sha256. Directories that do not exist are
119
+ * unequal unless both are missing. Comparison ignores mtime so a copy
120
+ * does not look like a change on the next boot.
121
+ */
122
+ function treesMatch(a: string, b: string): boolean {
123
+ const left = fingerprintTree(a)
124
+ const right = fingerprintTree(b)
125
+ if (left.size !== right.size) return false
126
+ for (const [path, fingerprint] of left) {
127
+ if (right.get(path) !== fingerprint) return false
128
+ }
129
+ return true
130
+ }
131
+
132
+ function fingerprintTree(root: string): Map<string, string> {
133
+ const out = new Map<string, string>()
134
+ if (!existsSync(root)) return out
135
+ walkFiles(root, "", out)
136
+ return out
137
+ }
138
+
139
+ function walkFiles(
140
+ absDir: string,
141
+ relDir: string,
142
+ out: Map<string, string>,
143
+ ): void {
144
+ const entries = readdirSync(absDir, { withFileTypes: true })
145
+ for (const entry of entries) {
146
+ // Root-level host-reserved vault directory: never fingerprinted,
147
+ // never copied — see {@link VAULT_DIR_NAME}.
148
+ if (relDir.length === 0 && entry.name === VAULT_DIR_NAME) continue
149
+ const rel = relDir.length === 0 ? entry.name : `${relDir}/${entry.name}`
150
+ const abs = join(absDir, entry.name)
151
+ if (entry.isDirectory()) {
152
+ walkFiles(abs, rel, out)
153
+ continue
154
+ }
155
+ if (!entry.isFile()) continue
156
+ const st = statSync(abs)
157
+ const hash = createHash("sha256").update(readFileSync(abs)).digest("hex")
158
+ out.set(rel.replaceAll("\\", "/"), `${st.size}:${hash}`)
159
+ }
160
+ }