@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,933 @@
1
+ import { type ChildProcess, fork, spawn } from "node:child_process"
2
+ import { realpathSync } from "node:fs"
3
+ import { tmpdir } from "node:os"
4
+ import { dirname, resolve, sep } from "node:path"
5
+ import { fileURLToPath } from "node:url"
6
+ import type {
7
+ PluginAssetDeleteResult,
8
+ PluginDownloadRequest,
9
+ PluginDownloadResult,
10
+ PluginPermissions,
11
+ } from "@hoardodile/sdk-types"
12
+ import { CAPABILITY_BY_METHOD } from "@hoardodile/sdk-types/plugin-capabilities"
13
+ import type { PluginDefinition, ResourceAPI } from "../types.ts"
14
+ import { createCallTimers, type PendingCall } from "./call-timers.ts"
15
+ import {
16
+ API_METHOD_NAMES,
17
+ type ApiMethodName,
18
+ deserializeError,
19
+ type HookName,
20
+ type InvokeRequest,
21
+ LOG_METHOD_NAMES,
22
+ type SerializedError,
23
+ serializeError,
24
+ type WorkerMessage,
25
+ } from "./protocol.ts"
26
+ import { createSandboxedPlugin } from "./sandboxed-plugin.ts"
27
+
28
+ /**
29
+ * The plugin asset handler: the host-side implementation of the
30
+ * `download`/`statAsset`/`readAsset`/`deleteAsset` ResourceAPI methods,
31
+ * bound to the plugin id (the sandbox knows which plugin issued the
32
+ * call). Wired by the app server; hosts without a consent channel (CLI,
33
+ * workbench) omit it and the methods answer `UNAVAILABLE`.
34
+ */
35
+ export type PluginAssetHandler = {
36
+ readonly download: (
37
+ pluginId: string,
38
+ request: PluginDownloadRequest,
39
+ ) => Promise<PluginDownloadResult>
40
+ readonly statAsset: (
41
+ pluginId: string,
42
+ path: string,
43
+ ) => Promise<{ readonly sizeBytes: number } | undefined>
44
+ readonly readAsset: (pluginId: string, path: string) => Promise<Uint8Array>
45
+ readonly deleteAsset: (
46
+ pluginId: string,
47
+ path: string,
48
+ ) => Promise<PluginAssetDeleteResult>
49
+ }
50
+
51
+ /**
52
+ * Kill a plugin sandbox process when an invocation neither returns nor
53
+ * shows resource-API activity for this long. Hooks that keep calling the
54
+ * API reset the watchdog continuously and never trip it; time spent inside
55
+ * a host-side API call does not count as inactivity.
56
+ */
57
+ export const PLUGIN_WATCHDOG_TIMEOUT_MS = 60_000
58
+
59
+ /** Absolute cap for a single plugin hook invocation, regardless of activity. */
60
+ export const PLUGIN_HOOK_HARD_TIMEOUT_MS = 30 * 60_000
61
+
62
+ /** V8 old-generation memory cap per plugin sandbox process, in MiB. */
63
+ export const PLUGIN_WORKER_MAX_OLD_SPACE_MB = 512
64
+
65
+ /**
66
+ * Absolute cap for one hook result crossing the IPC boundary back to the
67
+ * host. Exceeding it turns the hook into an error instead of letting a
68
+ * hostile bundle clone a giant payload into the host process.
69
+ */
70
+ export const PLUGIN_MAX_RESULT_BYTES = 256 * 1024 * 1024
71
+
72
+ /** Cap on `log*` messages per hook invocation (see {@link PluginSandboxConfig}). */
73
+ export const PLUGIN_MAX_LOGS_PER_HOOK = 1_000
74
+
75
+ /** Cap on ResourceAPI calls per hook invocation (see {@link PluginSandboxConfig}). */
76
+ export const PLUGIN_MAX_API_CALLS_PER_HOOK = 100_000
77
+
78
+ /**
79
+ * Sandbox-gated ResourceAPI methods, read from the single capability
80
+ * table (`@hoardodile/sdk-types/plugin-capabilities`) — a permission
81
+ * declared there is enforced here automatically, and a sandbox method
82
+ * listed there but not declared by the manifest is denied with the
83
+ * capability's own name.
84
+ *
85
+ * The asset surface (the `download` capability) is additionally
86
+ * intercepted before touching the ResourceAPI instance: its
87
+ * implementation comes from the wired {@link PluginAssetHandler}, which
88
+ * knows the owning plugin id.
89
+ */
90
+ const ASSET_METHODS = new Set<ApiMethodName>([
91
+ "download",
92
+ "statAsset",
93
+ "readAsset",
94
+ "deleteAsset",
95
+ ])
96
+
97
+ /**
98
+ * Max sandbox spawns per plugin within {@link PLUGIN_WORKER_RESPAWN_WINDOW_MS}
99
+ * before the plugin is degraded. It recovers automatically once the crash
100
+ * window slides clean, or immediately on disable/rescan.
101
+ */
102
+ export const PLUGIN_WORKER_MAX_RESPAWNS = 3
103
+
104
+ /** Sliding window for {@link PLUGIN_WORKER_MAX_RESPAWNS}. */
105
+ export const PLUGIN_WORKER_RESPAWN_WINDOW_MS = 60_000
106
+
107
+ export type PluginSandboxConfig = {
108
+ /**
109
+ * Kill the sandbox process when an invocation neither returns nor shows
110
+ * API activity for this long. Long-running hooks that keep calling the
111
+ * resource API reset the watchdog continuously and never trip it;
112
+ * time spent inside a host-side API call does not count as inactivity.
113
+ */
114
+ readonly watchdogMs: number
115
+ /** Absolute per-invocation cap, regardless of activity. */
116
+ readonly hardTimeoutMs: number
117
+ /** V8 old-generation cap per sandbox process; exceeding it aborts it. */
118
+ readonly maxOldSpaceMb: number
119
+ /**
120
+ * Max sandbox spawns per plugin within {@link respawnWindowMs} before the
121
+ * plugin is degraded (all invocations reject). The plugin recovers
122
+ * automatically once the crash window slides clean; `unloadPlugin`
123
+ * (disable or rescan) resets the budget immediately.
124
+ */
125
+ readonly maxRespawns: number
126
+ readonly respawnWindowMs: number
127
+ /** Absolute cap for one hook result returning to the host. */
128
+ readonly maxResultBytes: number
129
+ /**
130
+ * Cap on `log*` messages per hook invocation; exceeding it fails the
131
+ * hook (log messages reset the watchdog, so a log flood would
132
+ * otherwise stay alive until the hard timeout).
133
+ */
134
+ readonly maxLogsPerHook: number
135
+ /**
136
+ * Cap on ResourceAPI calls per hook invocation; exceeding it fails the
137
+ * hook. Sized generously so large per-file scans (hashBytes over tens
138
+ * of thousands of files) keep working — the cap bounds a runaway RPC
139
+ * fan-out that would otherwise pin the host's CPU.
140
+ */
141
+ readonly maxApiCallsPerHook: number
142
+ /**
143
+ * Permission-model flag to pass to the sandbox child, or `undefined` to
144
+ * probe the running Node for the first accepted name. Tests override
145
+ * this to exercise the fail-closed path.
146
+ */
147
+ readonly permissionFlag?: string
148
+ /**
149
+ * Host-managed plugin vault directory (`<plugin-dir>/vault/`, or the
150
+ * versioned-storage vault for dev plugins). When set, the child gets
151
+ * an extra fs-read grant and the module policy gate allows loading
152
+ * files from it — downloaded runtimes (JS/WASM) importable from the
153
+ * vault are the point of the asset API. A function form lets one
154
+ * shared sandbox resolve the path per plugin (disk vs dev plugins
155
+ * store their vault differently).
156
+ */
157
+ readonly assetVaultDir?: string | ((pluginId: string) => string | undefined)
158
+ /**
159
+ * Plugin asset handler wired by the app server. Absent → the asset
160
+ * methods answer `UNAVAILABLE` (CLI, workbench, tests without a
161
+ * consent channel).
162
+ */
163
+ readonly pluginAssets?: PluginAssetHandler
164
+ }
165
+
166
+ export const DEFAULT_SANDBOX_CONFIG: PluginSandboxConfig = {
167
+ watchdogMs: PLUGIN_WATCHDOG_TIMEOUT_MS,
168
+ hardTimeoutMs: PLUGIN_HOOK_HARD_TIMEOUT_MS,
169
+ maxOldSpaceMb: PLUGIN_WORKER_MAX_OLD_SPACE_MB,
170
+ maxRespawns: PLUGIN_WORKER_MAX_RESPAWNS,
171
+ respawnWindowMs: PLUGIN_WORKER_RESPAWN_WINDOW_MS,
172
+ maxResultBytes: PLUGIN_MAX_RESULT_BYTES,
173
+ maxLogsPerHook: PLUGIN_MAX_LOGS_PER_HOOK,
174
+ maxApiCallsPerHook: PLUGIN_MAX_API_CALLS_PER_HOOK,
175
+ }
176
+
177
+ export type PluginSandbox = {
178
+ /**
179
+ * Register and load a plugin bundle. With `eager` the sandbox process
180
+ * stays alive; without it the hook list is probed and the process
181
+ * immediately idles (it respawns lazily on first invocation — disabled
182
+ * plugins still serve their bound resources without holding a process).
183
+ *
184
+ * Reloading an already-registered id keeps the previous process alive
185
+ * until the new bundle loads successfully — a failed reload returns
186
+ * the previous definition instead of stranding a disposed registry.
187
+ *
188
+ * Returns `undefined` when the bundle cannot be loaded and there is no
189
+ * previous definition to fall back on (already logged) — callers fall
190
+ * back to a failing plugin.
191
+ */
192
+ readonly loadPlugin: (opts: {
193
+ readonly id: string
194
+ readonly mainPath: string
195
+ readonly eager: boolean
196
+ /**
197
+ * Manifest permissions the sandbox enforces on every API call
198
+ * (e.g. the `container` gate). Absent keys count as denied here,
199
+ * so a caller that omits this opts into the strictest view.
200
+ */
201
+ readonly permissions?: PluginPermissions
202
+ }) => Promise<PluginDefinition | undefined>
203
+ /**
204
+ * Terminate the plugin's sandbox process (if any) and reset its respawn
205
+ * budget. The hook list stays known; the next invocation lazily respawns.
206
+ */
207
+ readonly unloadPlugin: (id: string) => void
208
+ /**
209
+ * Terminate and forget every plugin whose id is not in `keepIds`.
210
+ * Registered ids keep their processes — used by the loader after a
211
+ * successful reload to free plugins that left the registry.
212
+ */
213
+ readonly disposeExcept: (keepIds: ReadonlySet<string>) => Promise<void>
214
+ /** Terminate every process and forget all plugins. Pending invocations reject. */
215
+ readonly disposeAll: () => Promise<void>
216
+ }
217
+
218
+ type LoadWaiter = {
219
+ readonly resolve: () => void
220
+ readonly reject: (err: Error) => void
221
+ }
222
+
223
+ type PluginState = {
224
+ readonly id: string
225
+ readonly mainPath: string
226
+ readonly permissions: PluginPermissions | undefined
227
+ child: ChildProcess | undefined
228
+ hooks: readonly HookName[] | undefined
229
+ loading: Promise<void> | undefined
230
+ loadWaiter: LoadWaiter | undefined
231
+ readonly pending: Map<number, PendingCall>
232
+ respawnTimes: number[]
233
+ degraded: boolean
234
+ disposed: boolean
235
+ }
236
+
237
+ /**
238
+ * Flag names the Node permission model accepted over its life: it was
239
+ * introduced as `--experimental-permission` and stabilized under
240
+ * `--permission`. Probe the running binary once and reuse the verdict.
241
+ */
242
+ const PERMISSION_FLAG_CANDIDATES = ["--permission", "--experimental-permission"]
243
+
244
+ let permissionFlagPromise: Promise<string | undefined> | undefined
245
+
246
+ async function resolvePermissionFlag(): Promise<string | undefined> {
247
+ permissionFlagPromise ??= probePermissionFlag()
248
+ return permissionFlagPromise
249
+ }
250
+
251
+ /**
252
+ * Spawn a probe child for each candidate flag name and keep the first one
253
+ * the runtime accepts. A flag the runtime does not know makes `node` exit
254
+ * non-zero before running the script, so an `exit 0` verdict is the proof.
255
+ */
256
+ async function probePermissionFlag(): Promise<string | undefined> {
257
+ for (const flag of PERMISSION_FLAG_CANDIDATES) {
258
+ if (await acceptsFlag(flag)) return flag
259
+ }
260
+ return undefined
261
+ }
262
+
263
+ function acceptsFlag(flag: string): Promise<boolean> {
264
+ return new Promise((resolveProbe) => {
265
+ const probe = spawn(
266
+ process.execPath,
267
+ [flag, `--allow-fs-read=${tmpdir()}`, "-e", "process.exit(0)"],
268
+ { stdio: "ignore", timeout: 10_000 },
269
+ )
270
+ probe.on("exit", (code) => resolveProbe(code === 0))
271
+ probe.on("error", () => resolveProbe(false))
272
+ })
273
+ }
274
+
275
+ export function createPluginSandbox(
276
+ config: PluginSandboxConfig = DEFAULT_SANDBOX_CONFIG,
277
+ ): PluginSandbox {
278
+ const states = new Map<string, PluginState>()
279
+ let nextCallId = 1
280
+ const timers = createCallTimers({
281
+ watchdogMs: config.watchdogMs,
282
+ hardTimeoutMs: config.hardTimeoutMs,
283
+ })
284
+
285
+ async function loadPlugin(opts: {
286
+ id: string
287
+ mainPath: string
288
+ eager: boolean
289
+ permissions?: PluginPermissions
290
+ }): Promise<PluginDefinition | undefined> {
291
+ const previous = states.get(opts.id)
292
+ const state: PluginState = {
293
+ id: opts.id,
294
+ mainPath: opts.mainPath,
295
+ permissions: opts.permissions,
296
+ child: undefined,
297
+ hooks: undefined,
298
+ loading: undefined,
299
+ loadWaiter: undefined,
300
+ pending: new Map(),
301
+ respawnTimes: [],
302
+ degraded: false,
303
+ disposed: false,
304
+ }
305
+ states.set(opts.id, state)
306
+ try {
307
+ await ensureLoaded(state)
308
+ } catch (err) {
309
+ console.error(`[plugin-sandbox] ${opts.id}: failed to load main.js`, err)
310
+ state.disposed = true
311
+ // A concurrent loadPlugin for the same id may have replaced this
312
+ // state — only act when the map still owns it.
313
+ if (states.get(opts.id) !== state) return undefined
314
+ if (previous !== undefined) {
315
+ // The previous process never stopped being healthy — restore
316
+ // it so a failed reload can't strand a disposed registry.
317
+ previous.disposed = false
318
+ states.set(opts.id, previous)
319
+ return createSandboxedPlugin(
320
+ previous.hooks ?? ["detect"],
321
+ (hook, api) => invoke(previous, hook, api),
322
+ )
323
+ }
324
+ states.delete(opts.id)
325
+ return undefined
326
+ }
327
+ // The new bundle loaded: retire the previous process only now —
328
+ // in-flight invocations against it settle normally, and a failed
329
+ // reload above never killed it in the first place.
330
+ if (previous !== undefined) {
331
+ previous.disposed = true
332
+ await teardownChild(previous)
333
+ }
334
+ if (!opts.eager) {
335
+ void teardownChild(state)
336
+ }
337
+ return createSandboxedPlugin(state.hooks ?? ["detect"], (hook, api) =>
338
+ invoke(state, hook, api),
339
+ )
340
+ }
341
+
342
+ function unloadPlugin(id: string): void {
343
+ const state = states.get(id)
344
+ if (state === undefined) return
345
+ teardownChild(state)
346
+ state.respawnTimes = []
347
+ state.degraded = false
348
+ }
349
+
350
+ async function disposeAll(): Promise<void> {
351
+ const tasks: Promise<void>[] = []
352
+ for (const state of states.values()) {
353
+ state.disposed = true
354
+ tasks.push(teardownChild(state))
355
+ }
356
+ states.clear()
357
+ await Promise.all(tasks)
358
+ }
359
+
360
+ async function disposeExcept(keepIds: ReadonlySet<string>): Promise<void> {
361
+ const tasks: Promise<void>[] = []
362
+ for (const [id, state] of states) {
363
+ if (keepIds.has(id)) continue
364
+ state.disposed = true
365
+ states.delete(id)
366
+ tasks.push(teardownChild(state))
367
+ }
368
+ await Promise.all(tasks)
369
+ }
370
+
371
+ // -- child lifecycle --
372
+
373
+ /**
374
+ * Keep the sandbox child referenced only while it has work pending
375
+ * (loading or an in-flight invocation); idle children must not hold
376
+ * the host process open, but an unref'd child in a host with nothing
377
+ * else running (one-shot CLI, dist smoke) would otherwise let the
378
+ * process exit before a slow sandbox boot answered.
379
+ */
380
+ function syncRef(state: PluginState): void {
381
+ const child = state.child
382
+ if (child === undefined) return
383
+ if (
384
+ state.loading !== undefined ||
385
+ state.loadWaiter !== undefined ||
386
+ state.pending.size > 0
387
+ ) {
388
+ child.ref()
389
+ } else {
390
+ child.unref()
391
+ }
392
+ }
393
+
394
+ function ensureLoaded(state: PluginState): Promise<void> {
395
+ if (state.child !== undefined) return Promise.resolve()
396
+ state.loading ??= spawnAndLoad(state).finally(() => {
397
+ state.loading = undefined
398
+ })
399
+ return state.loading
400
+ }
401
+
402
+ async function spawnAndLoad(state: PluginState): Promise<void> {
403
+ const now = Date.now()
404
+ state.respawnTimes = state.respawnTimes.filter(
405
+ (t) => now - t < config.respawnWindowMs,
406
+ )
407
+ if (state.respawnTimes.length >= config.maxRespawns) {
408
+ state.degraded = true
409
+ throw new Error(
410
+ `plugin ${state.id} unavailable: sandbox respawned ${config.maxRespawns} times within ${config.respawnWindowMs}ms`,
411
+ )
412
+ }
413
+ state.respawnTimes.push(now)
414
+
415
+ const permissionFlag =
416
+ config.permissionFlag ?? (await resolvePermissionFlag())
417
+ if (permissionFlag === undefined) {
418
+ throw new Error(
419
+ `plugin ${state.id} unavailable: this Node build has no permission-model flag (tried ${PERMISSION_FLAG_CANDIDATES.map((f) => `"${f}"`).join(", ")}); refusing to run plugin code unsandboxed`,
420
+ )
421
+ }
422
+
423
+ // Resolve the sandbox entry through the package's own exports map
424
+ // (`@hoardodile/host/worker-entry`) when that package is installed.
425
+ // The sandbox module also gets inlined into the server bundle, where
426
+ // there is no `@hoardodile/host` on disk — then we fall back to
427
+ // `dist/chunks/worker-entry.mjs` next to the emitting chunk.
428
+ // Kept out of a `new URL()` literal so Vite does not detect a browser
429
+ // worker here: it would bundle worker-entry.mjs with node: builtins
430
+ // shimmed out and rewrite the URL to an unusable /assets/ path. The
431
+ // entry ships untransformed and must run as plain ESM in a child
432
+ // process.
433
+ const entryPath = fileURLToPath(resolveWorkerEntryUrl())
434
+ const pluginDir = dirname(state.mainPath)
435
+ // The ESM loader passes every module path through realpathSync under
436
+ // `--permission`, so the grants must use the canonical on-disk form —
437
+ // a plugin dir reached through a symlink (macOS `/tmp`→`/private/tmp`,
438
+ // WSL paths, junctions) would otherwise resolve outside its own grant
439
+ // and fail to load with ERR_ACCESS_DENIED.
440
+ const realEntryPath = realPathOrResolve(entryPath)
441
+ const realPluginDir = realPathOrResolve(pluginDir)
442
+ const realMainPath = realPathOrResolve(state.mainPath)
443
+
444
+ // The child's grants are minimal: one fs-read allowlist (its own
445
+ // directory, plus the entry file it must load, plus the host-managed
446
+ // asset vault when one is wired), a memory cap, and nothing else —
447
+ // no fs write, no child processes, no worker threads, no native
448
+ // addons. The module policy hook (registered inside the entry via
449
+ // `registerHooks`) closes the remaining surface: nothing outside the
450
+ // plugin directory (and the vault) and no `node:` builtins except
451
+ // `node:url` can be imported.
452
+ const fsReadGrants = [`${realPluginDir}${sep}`, realEntryPath]
453
+ const assetVaultDir =
454
+ typeof config.assetVaultDir === "function"
455
+ ? config.assetVaultDir(state.id)
456
+ : config.assetVaultDir
457
+ const realVaultDir =
458
+ assetVaultDir === undefined ? undefined : realPathOrResolve(assetVaultDir)
459
+ if (realVaultDir !== undefined) {
460
+ fsReadGrants.push(`${realVaultDir}${sep}`)
461
+ }
462
+ const child = fork(
463
+ realEntryPath,
464
+ [realPluginDir, realEntryPath, realVaultDir].filter(
465
+ (v): v is string => v !== undefined,
466
+ ),
467
+ {
468
+ execArgv: [
469
+ permissionFlag,
470
+ ...fsReadGrants.map((p) => `--allow-fs-read=${p}`),
471
+ `--max-old-space-size=${config.maxOldSpaceMb}`,
472
+ ],
473
+ serialization: "advanced",
474
+ // Child stderr is inherited (self-check failures must surface);
475
+ // plugin console output is dropped — logs flow over the RPC.
476
+ stdio: ["ignore", "ignore", "inherit", "ipc"],
477
+ env: {
478
+ ...process.env,
479
+ HOARDODILE_PLUGIN_MAX_RESULT_BYTES: String(config.maxResultBytes),
480
+ HOARDODILE_PLUGIN_MAX_LOGS_PER_HOOK: String(config.maxLogsPerHook),
481
+ HOARDODILE_PLUGIN_MAX_API_CALLS_PER_HOOK: String(
482
+ config.maxApiCallsPerHook,
483
+ ),
484
+ },
485
+ },
486
+ )
487
+ // The sandbox must never hold the process open on its own — but
488
+ // while a load/invocation is pending the child is referenced (see
489
+ // syncRef) so a short-lived host cannot exit mid-boot.
490
+ child.unref()
491
+ state.child = child
492
+ syncRef(state)
493
+
494
+ child.on("message", (msg: WorkerMessage) =>
495
+ handleMessage(state, child, msg),
496
+ )
497
+ child.on("error", (err: unknown) => failChild(state, child, asError(err)))
498
+ child.on("exit", (code) => {
499
+ if (state.child === child) {
500
+ failChild(
501
+ state,
502
+ child,
503
+ new Error(`plugin ${state.id} worker exited (code ${code})`),
504
+ )
505
+ }
506
+ })
507
+
508
+ const loaded = new Promise<void>((resolveLoaded, reject) => {
509
+ state.loadWaiter = { resolve: resolveLoaded, reject }
510
+ })
511
+ try {
512
+ child.send({ type: "load", mainPath: realMainPath } satisfies {
513
+ type: "load"
514
+ mainPath: string
515
+ })
516
+ } catch (err) {
517
+ // The channel never opened (spawn failed hard) — the waiter
518
+ // would hang forever; tear the child down and fail the load.
519
+ await teardownChild(state, asError(err))
520
+ throw err
521
+ }
522
+ try {
523
+ await loaded
524
+ } catch (err) {
525
+ // A plugin whose main.js throws at import reports `loaded: ok:false`
526
+ // and keeps idling — terminate the child so it never outlives
527
+ // its owning state (failChild already covered error/exit).
528
+ await teardownChild(state)
529
+ throw err
530
+ } finally {
531
+ state.loadWaiter = undefined
532
+ }
533
+ // Loaded and (for eager plugins) alive: an idle sandbox must not
534
+ // hold the host open, but it stays referenced while invocations
535
+ // are in flight.
536
+ syncRef(state)
537
+ }
538
+
539
+ /**
540
+ * Terminate the child without touching the respawn budget. Rejects
541
+ * the load waiter and every pending call with `err` (or the standard
542
+ * "worker stopped" error) and clears the child slot; a no-op when
543
+ * there is no child.
544
+ */
545
+ function teardownChild(state: PluginState, err?: Error): Promise<void> {
546
+ const child = state.child
547
+ state.child = undefined
548
+ state.loading = undefined
549
+ const stopErr = err ?? new Error(`plugin ${state.id} worker stopped`)
550
+ state.loadWaiter?.reject(stopErr)
551
+ state.loadWaiter = undefined
552
+ rejectAllPending(state, stopErr)
553
+ if (child === undefined) return Promise.resolve()
554
+ // SIGTERM on POSIX, TerminateProcess on Windows. Pending calls were
555
+ // already rejected; stale messages can no longer resolve anything
556
+ // because the slot is cleared.
557
+ child.kill()
558
+ return Promise.resolve()
559
+ }
560
+
561
+ function failChild(
562
+ state: PluginState,
563
+ child: ChildProcess,
564
+ err: Error,
565
+ ): void {
566
+ if (state.child !== child) return // stale event from a replaced child
567
+ void teardownChild(state, err)
568
+ }
569
+
570
+ function rejectAllPending(state: PluginState, err: Error): void {
571
+ for (const call of state.pending.values()) {
572
+ timers.clearCallTimers(call)
573
+ call.reject(err)
574
+ }
575
+ state.pending.clear()
576
+ }
577
+
578
+ // -- invocation --
579
+
580
+ async function invoke(
581
+ state: PluginState,
582
+ hook: HookName,
583
+ api: ResourceAPI,
584
+ ): Promise<unknown> {
585
+ if (state.disposed) {
586
+ throw new Error(`plugin ${state.id} sandbox disposed`)
587
+ }
588
+ if (state.degraded) {
589
+ // Auto-recover once every crash in the budget has aged out of the
590
+ // respawn window — spawnAndLoad would accept a spawn again anyway.
591
+ const now = Date.now()
592
+ state.respawnTimes = state.respawnTimes.filter(
593
+ (t) => now - t < config.respawnWindowMs,
594
+ )
595
+ if (state.respawnTimes.length === 0) {
596
+ state.degraded = false
597
+ } else {
598
+ throw new Error(
599
+ `plugin ${state.id} unavailable: sandbox crashed repeatedly`,
600
+ )
601
+ }
602
+ }
603
+ await ensureLoaded(state)
604
+ const child = state.child
605
+ if (child === undefined) {
606
+ throw new Error(`plugin ${state.id} sandbox unavailable`)
607
+ }
608
+
609
+ const callId = nextCallId++
610
+ return new Promise((resolveCall, reject) => {
611
+ const call: PendingCall = {
612
+ api,
613
+ resolve: resolveCall,
614
+ reject,
615
+ apiInFlight: 0,
616
+ }
617
+ state.pending.set(callId, call)
618
+ syncRef(state)
619
+ timers.armWatchdog(
620
+ call,
621
+ failCall(
622
+ state,
623
+ child,
624
+ callId,
625
+ `hung: no activity for ${config.watchdogMs}ms`,
626
+ ),
627
+ )
628
+ timers.armHardTimer(
629
+ call,
630
+ failCall(
631
+ state,
632
+ child,
633
+ callId,
634
+ `exceeded hard timeout ${config.hardTimeoutMs}ms`,
635
+ ),
636
+ )
637
+ try {
638
+ child.send({
639
+ type: "invoke",
640
+ callId,
641
+ hook,
642
+ } satisfies InvokeRequest)
643
+ } catch (err) {
644
+ state.pending.delete(callId)
645
+ syncRef(state)
646
+ timers.clearCallTimers(call)
647
+ reject(err instanceof Error ? err : new Error(String(err)))
648
+ }
649
+ })
650
+ }
651
+
652
+ function failCall(
653
+ state: PluginState,
654
+ child: ChildProcess,
655
+ callId: number,
656
+ reason: string,
657
+ ): () => void {
658
+ return () =>
659
+ failChild(
660
+ state,
661
+ child,
662
+ new Error(`plugin ${state.id} ${reason} (call ${callId})`),
663
+ )
664
+ }
665
+
666
+ // -- message handling --
667
+
668
+ function handleMessage(
669
+ state: PluginState,
670
+ child: ChildProcess,
671
+ msg: WorkerMessage,
672
+ ): void {
673
+ // Stale child from a replaced spawn: its messages must never
674
+ // resolve the current child's load waiter or pending calls.
675
+ if (state.child !== child) return
676
+ if (msg === null || typeof msg !== "object") return
677
+ switch (msg.type) {
678
+ case "loaded": {
679
+ const waiter = state.loadWaiter
680
+ state.loadWaiter = undefined
681
+ if (msg.ok) {
682
+ state.hooks = msg.hooks ?? ["detect"]
683
+ waiter?.resolve()
684
+ } else {
685
+ waiter?.reject(
686
+ deserializeError(
687
+ msg.error ?? { name: "Error", message: "plugin load failed" },
688
+ ),
689
+ )
690
+ }
691
+ return
692
+ }
693
+ case "result": {
694
+ const call = state.pending.get(msg.callId)
695
+ if (call === undefined) return
696
+ state.pending.delete(msg.callId)
697
+ syncRef(state)
698
+ timers.clearCallTimers(call)
699
+ if (msg.ok) {
700
+ call.resolve(msg.value)
701
+ } else {
702
+ call.reject(
703
+ deserializeError(
704
+ msg.error ?? { name: "Error", message: "hook failed" },
705
+ ),
706
+ )
707
+ }
708
+ return
709
+ }
710
+ case "api": {
711
+ const call = state.pending.get(msg.callId)
712
+ if (call === undefined) return
713
+ // Pause the watchdog while the host executes the API call —
714
+ // a slow readFile/probeVideo is host work, not a hung plugin.
715
+ call.apiInFlight += 1
716
+ timers.pauseWatchdog(call)
717
+ void dispatchApi(
718
+ child,
719
+ state,
720
+ msg.callId,
721
+ msg.apiCallId,
722
+ call,
723
+ msg.method,
724
+ msg.args,
725
+ )
726
+ return
727
+ }
728
+ case "log": {
729
+ const call = state.pending.get(msg.callId)
730
+ if (call === undefined) return
731
+ if (call.apiInFlight === 0) {
732
+ timers.armWatchdog(
733
+ call,
734
+ failCall(
735
+ state,
736
+ child,
737
+ msg.callId,
738
+ `hung: no activity for ${config.watchdogMs}ms`,
739
+ ),
740
+ )
741
+ }
742
+ dispatchLog(state.id, msg.method, msg.args)
743
+ return
744
+ }
745
+ }
746
+ }
747
+
748
+ async function dispatchApi(
749
+ child: ChildProcess,
750
+ state: PluginState,
751
+ callId: number,
752
+ apiCallId: number,
753
+ call: PendingCall,
754
+ method: ApiMethodName,
755
+ args: readonly unknown[],
756
+ ): Promise<void> {
757
+ const respond = (
758
+ ok: boolean,
759
+ value?: unknown,
760
+ error?: SerializedError,
761
+ ): void => {
762
+ // The child may have died while the API call was in flight.
763
+ if (state.child !== child) return
764
+ try {
765
+ child.send({ type: "apiResult", apiCallId, ok, value, error })
766
+ } catch {
767
+ // Channel closed — nothing to deliver.
768
+ }
769
+ }
770
+ try {
771
+ if (!isApiMethod(method) || LOG_METHOD_NAMES.has(method)) {
772
+ respond(false, undefined, {
773
+ name: "Error",
774
+ message: `unknown API method: ${String(method)}`,
775
+ })
776
+ return
777
+ }
778
+ // Manifest permission gate, read from the single capability
779
+ // table: every sandbox-gated method maps to its capability
780
+ // and is denied unless the manifest declared it. The asset
781
+ // surface then routes to the wired handler with the owning
782
+ // plugin id — the ResourceAPI instance itself has no plugin
783
+ // identity (a shared API serves every plugin's hooks).
784
+ const capability = CAPABILITY_BY_METHOD.get(method)
785
+ if (
786
+ capability !== undefined &&
787
+ state.permissions?.[capability] !== true
788
+ ) {
789
+ respond(false, undefined, {
790
+ name: "POLICY",
791
+ code: "POLICY",
792
+ message: `${capability} permission denied for plugin ${state.id} — declare "${capability}": true in the manifest to use ${method}()`,
793
+ })
794
+ return
795
+ }
796
+ if (ASSET_METHODS.has(method)) {
797
+ const handler = config.pluginAssets
798
+ if (handler === undefined) {
799
+ respond(false, undefined, {
800
+ name: "UNAVAILABLE",
801
+ code: "UNAVAILABLE",
802
+ message: `${method}() is unavailable on this host — plugin asset downloads need the app server runtime`,
803
+ })
804
+ return
805
+ }
806
+ // RPC boundary: the child-side proxy forwards args verbatim,
807
+ // so the contract order is guaranteed (request object for
808
+ // `download`, string path for the rest).
809
+ if (method === "download") {
810
+ respond(
811
+ true,
812
+ await handler.download(state.id, args[0] as PluginDownloadRequest),
813
+ )
814
+ } else if (method === "statAsset") {
815
+ respond(true, await handler.statAsset(state.id, args[0] as string))
816
+ } else if (method === "readAsset") {
817
+ respond(true, await handler.readAsset(state.id, args[0] as string))
818
+ } else {
819
+ respond(true, await handler.deleteAsset(state.id, args[0] as string))
820
+ }
821
+ return
822
+ }
823
+ // RPC boundary: the child-side proxy is generated from the same
824
+ // method list, so args always arrive in contract order.
825
+ const fn = call.api[method] as (...a: readonly unknown[]) => unknown
826
+ respond(true, await fn(...args))
827
+ } catch (err) {
828
+ respond(false, undefined, serializeError(err))
829
+ } finally {
830
+ call.apiInFlight -= 1
831
+ // Resume the watchdog once the host-side work for this call
832
+ // has drained and the call is still alive.
833
+ if (call.apiInFlight === 0 && state.pending.get(callId) === call) {
834
+ timers.armWatchdog(
835
+ call,
836
+ failCall(
837
+ state,
838
+ child,
839
+ callId,
840
+ `hung: no activity for ${config.watchdogMs}ms`,
841
+ ),
842
+ )
843
+ }
844
+ }
845
+ }
846
+
847
+ /**
848
+ * Plugin log sink. The child-side proxy forwards log calls here, where
849
+ * the owning plugin id is known — ResourceAPI.log* stay no-ops because a
850
+ * shared API instance (e.g. one detect pass fanning out to every plugin)
851
+ * cannot attribute a log line to the plugin that emitted it.
852
+ */
853
+ function dispatchLog(
854
+ pluginId: string,
855
+ method: "logInfo" | "logWarn" | "logError",
856
+ args: readonly unknown[],
857
+ ): void {
858
+ const message = typeof args[0] === "string" ? args[0] : String(args[0])
859
+ const data = isPlainRecord(args[1]) ? args[1] : undefined
860
+ const line = `[plugin:${pluginId}] ${message}`
861
+ const extra = data === undefined ? [] : [data]
862
+ try {
863
+ if (method === "logInfo") console.log(line, ...extra)
864
+ else if (method === "logWarn") console.warn(line, ...extra)
865
+ else console.error(line, ...extra)
866
+ } catch {
867
+ // Logging must never break the host.
868
+ }
869
+ }
870
+
871
+ return { loadPlugin, unloadPlugin, disposeExcept, disposeAll }
872
+ }
873
+
874
+ function isApiMethod(name: unknown): name is ApiMethodName {
875
+ return (
876
+ typeof name === "string" &&
877
+ (API_METHOD_NAMES as readonly string[]).includes(name)
878
+ )
879
+ }
880
+
881
+ function asError(value: unknown): Error {
882
+ return value instanceof Error ? value : new Error(String(value))
883
+ }
884
+
885
+ /**
886
+ * The canonical on-disk form of a path, falling back to the resolved form
887
+ * when the file does not exist yet (a just-built bundle, a not-yet-created
888
+ * vault). Node's permission model checks the path the ESM loader resolved
889
+ * through `realpathSync`, so a plugin dir reached via a symlink must be
890
+ * granted and passed in its canonical form — otherwise the module resolves
891
+ * outside its own grant and the sandbox refuses to load it.
892
+ */
893
+ function realPathOrResolve(path: string): string {
894
+ try {
895
+ return realpathSync(path)
896
+ } catch {
897
+ return resolve(path)
898
+ }
899
+ }
900
+
901
+ /**
902
+ * vite-node's transform rewrites `import.meta` without a working `.resolve`
903
+ * (unlike vitest's), so the worker entry URL falls back to the module's own
904
+ * location. The packaged server bundle has a working `import.meta.resolve`
905
+ * that still cannot see `@hoardodile/host` — that path also falls through
906
+ * here. The sandbox runs from one of two places: the package dist root
907
+ * (`dist/chunks/worker-entry.mjs`, see scripts/postbuild.mjs), or — in dev,
908
+ * where the `development` export condition resolves `@hoardodile/host` to
909
+ * source — `src/sandbox/worker-entry.mjs` next to this module.
910
+ */
911
+ function resolveWorkerEntryUrl(): string {
912
+ try {
913
+ if (typeof import.meta.resolve === "function") {
914
+ return import.meta.resolve("@hoardodile/host/worker-entry")
915
+ }
916
+ } catch {
917
+ // No package exports (packaged server dist, or a broken install).
918
+ }
919
+ return workerEntryUrlFromModule(import.meta.url)
920
+ }
921
+
922
+ function workerEntryUrlFromModule(moduleUrl: string): string {
923
+ const distIdx = moduleUrl.lastIndexOf("/dist/")
924
+ if (distIdx >= 0) {
925
+ const distRoot = moduleUrl.slice(0, distIdx + "/dist/".length)
926
+ return new URL("chunks/worker-entry.mjs", distRoot).href
927
+ }
928
+ return new URL("worker-entry.mjs", moduleUrl).href
929
+ }
930
+
931
+ function isPlainRecord(value: unknown): value is Record<string, unknown> {
932
+ return typeof value === "object" && value !== null && !Array.isArray(value)
933
+ }