@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,619 @@
1
+ import { copyFileSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"
2
+ import { tmpdir } from "node:os"
3
+ import { join } from "node:path"
4
+ import { fileURLToPath } from "node:url"
5
+ import type { PluginPermissions } from "@hoardodile/sdk-types"
6
+ import { afterEach, describe, expect, test, vi } from "vitest"
7
+ import type { ResourceAPI } from "../types.ts"
8
+ import {
9
+ createPluginSandbox,
10
+ DEFAULT_SANDBOX_CONFIG,
11
+ PLUGIN_MAX_API_CALLS_PER_HOOK,
12
+ PLUGIN_MAX_LOGS_PER_HOOK,
13
+ PLUGIN_MAX_RESULT_BYTES,
14
+ type PluginSandbox,
15
+ type PluginSandboxConfig,
16
+ } from "./host.ts"
17
+
18
+ function fixture(name: string): string {
19
+ return fileURLToPath(new URL(`./fixtures/${name}`, import.meta.url))
20
+ }
21
+
22
+ function noPermissions(): PluginPermissions {
23
+ return {
24
+ sourceMeta: false,
25
+ searchMeta: false,
26
+ danmaku: false,
27
+ message: false,
28
+ imageHashes: false,
29
+ container: false,
30
+ download: false,
31
+ }
32
+ }
33
+
34
+ function containerPermissions(): PluginPermissions {
35
+ return { ...noPermissions(), container: true }
36
+ }
37
+
38
+ function downloadPermissions(): PluginPermissions {
39
+ return { ...noPermissions(), download: true }
40
+ }
41
+
42
+ function fastConfig(overrides: Partial<PluginSandboxConfig> = {}) {
43
+ return {
44
+ ...DEFAULT_SANDBOX_CONFIG,
45
+ watchdogMs: 300,
46
+ hardTimeoutMs: 5_000,
47
+ maxRespawns: 10,
48
+ ...overrides,
49
+ } satisfies PluginSandboxConfig
50
+ }
51
+
52
+ function createStubApi(overrides: Partial<ResourceAPI> = {}): ResourceAPI {
53
+ return {
54
+ logInfo() {},
55
+ logWarn() {},
56
+ logError() {},
57
+ context: { detect: undefined },
58
+ listFileNames: async () => ["a.jpg", "b.jpg"],
59
+ readFile: async (_path, range) => {
60
+ const bytes = new Uint8Array([1, 2, 3, 4, 250])
61
+ if (range === undefined) return bytes
62
+ return bytes.slice(range.start ?? 0, range.end)
63
+ },
64
+ statFile: async () => ({ sizeBytes: 42 }),
65
+ statFiles: async (paths) => paths.map(() => ({ sizeBytes: 42 })),
66
+ sniff: async () => ({
67
+ mime: "image/png",
68
+ ext: ".png",
69
+ kind: "image",
70
+ source: "magic",
71
+ }),
72
+ probe: async () => ({
73
+ kind: "image",
74
+ mime: "image/png",
75
+ width: 10,
76
+ height: 20,
77
+ animated: false,
78
+ }),
79
+ hashBytes: async () => "ab",
80
+ computeImageHashes: async () => undefined,
81
+ listContainer: async () => ({ entries: [] }),
82
+ extractArchive: async () => ({ entries: [] }),
83
+ download: async () => {
84
+ throw new Error("stub: download not configured")
85
+ },
86
+ statAsset: async () => undefined,
87
+ readAsset: async () => new Uint8Array(),
88
+ deleteAsset: async () => ({ existed: false }),
89
+ ...overrides,
90
+ }
91
+ }
92
+
93
+ describe("plugin sandbox", () => {
94
+ let sandbox: PluginSandbox | undefined
95
+
96
+ afterEach(async () => {
97
+ await sandbox?.disposeAll()
98
+ sandbox = undefined
99
+ vi.restoreAllMocks()
100
+ })
101
+
102
+ test("round-trip: hooks run in the worker, API calls bridge back, binary transfers intact", async () => {
103
+ sandbox = createPluginSandbox()
104
+ const plugin = await sandbox.loadPlugin({
105
+ id: "echo",
106
+ mainPath: fixture("echo-plugin.mjs"),
107
+ eager: true,
108
+ })
109
+ expect(plugin).toBeDefined()
110
+ if (plugin === undefined) return
111
+
112
+ await expect(plugin.detect(createStubApi())).resolves.toEqual({ ok: true })
113
+
114
+ // Uint8Array crosses host→worker via transferable with content intact.
115
+ expect(plugin.sourceMeta).toBeDefined()
116
+ await expect(plugin.sourceMeta?.(createStubApi())).resolves.toEqual({
117
+ bytes: [1, 2, 3, 4, 250],
118
+ })
119
+
120
+ // Hook presence mirrors the plugin's actual exports.
121
+ expect(plugin.listFiles).toBeDefined()
122
+ expect(plugin.searchMeta).toBeUndefined()
123
+ expect(plugin.coverLocal).toBeUndefined()
124
+ })
125
+
126
+ test("detect payload flows to later hooks via api.context", async () => {
127
+ sandbox = createPluginSandbox()
128
+ const plugin = await sandbox.loadPlugin({
129
+ id: "context",
130
+ mainPath: fixture("context-plugin.mjs"),
131
+ eager: true,
132
+ })
133
+ if (plugin === undefined) throw new Error("plugin load failed")
134
+
135
+ // No prior detect in this session: the context is absent.
136
+ await expect(plugin.sourceMeta?.(createStubApi())).resolves.toEqual({
137
+ fromContext: false,
138
+ })
139
+
140
+ // A successful detect with a payload seeds the session context —
141
+ // the payload also survives the RPC round-trip.
142
+ await expect(plugin.detect(createStubApi())).resolves.toEqual({
143
+ ok: true,
144
+ files: ["a.jpg", "b.jpg"],
145
+ archive: false,
146
+ })
147
+ await expect(plugin.sourceMeta?.(createStubApi())).resolves.toEqual({
148
+ fromContext: true,
149
+ files: ["a.jpg", "b.jpg"],
150
+ archive: false,
151
+ })
152
+ })
153
+
154
+ test("loads a plugin whose path has escaped, spaced and mixed-case segments", async () => {
155
+ // The sandbox spans symlinked roots (macOS /tmp), 8.3 short names
156
+ // (Windows runner temp dirs, e.g. RUNNER~1), spaces and casing —
157
+ // the gate's URL encoding and the fs-read grants must agree with
158
+ // the ESM loader's canonical form for all of them.
159
+ const root = mkdtempSync(join(tmpdir(), "hoardodile Sandbox Space~A-"))
160
+ const pluginDir = join(root, "Plugin")
161
+ mkdirSync(pluginDir, { recursive: true })
162
+ copyFileSync(fixture("echo-plugin.mjs"), join(pluginDir, "main.js"))
163
+ try {
164
+ sandbox = createPluginSandbox()
165
+ const plugin = await sandbox.loadPlugin({
166
+ id: "escaped",
167
+ mainPath: join(pluginDir, "main.js"),
168
+ eager: true,
169
+ })
170
+ if (plugin === undefined) throw new Error("plugin load failed")
171
+ await expect(plugin.detect(createStubApi())).resolves.toEqual({
172
+ ok: true,
173
+ })
174
+ } finally {
175
+ rmSync(root, { recursive: true, force: true })
176
+ }
177
+ })
178
+
179
+ test("50 concurrent invocations each keep their own ResourceAPI binding", async () => {
180
+ sandbox = createPluginSandbox()
181
+ const plugin = await sandbox.loadPlugin({
182
+ id: "echo",
183
+ mainPath: fixture("echo-plugin.mjs"),
184
+ eager: true,
185
+ })
186
+ if (plugin?.listFiles === undefined) throw new Error("plugin load failed")
187
+ const { listFiles } = plugin
188
+
189
+ const results = await Promise.all(
190
+ Array.from({ length: 50 }, (_, i) =>
191
+ listFiles(createStubApi({ statFile: async () => ({ sizeBytes: i }) })),
192
+ ),
193
+ )
194
+ for (let i = 0; i < 50; i++) {
195
+ expect(results[i]).toEqual([String(i)])
196
+ }
197
+ })
198
+
199
+ test("non-eager load probes hooks, idles the worker, and respawns on first call", async () => {
200
+ sandbox = createPluginSandbox()
201
+ const plugin = await sandbox.loadPlugin({
202
+ id: "echo-lazy",
203
+ mainPath: fixture("echo-plugin.mjs"),
204
+ eager: false,
205
+ })
206
+ // Hook list is known even though the worker was already idled.
207
+ expect(plugin?.listFiles).toBeDefined()
208
+ expect(plugin?.coverLocal).toBeUndefined()
209
+
210
+ await expect(plugin?.detect(createStubApi())).resolves.toEqual({
211
+ ok: true,
212
+ })
213
+ })
214
+
215
+ test("unloadPlugin terminates the worker; the next call respawns it", async () => {
216
+ sandbox = createPluginSandbox()
217
+ const plugin = await sandbox.loadPlugin({
218
+ id: "echo",
219
+ mainPath: fixture("echo-plugin.mjs"),
220
+ eager: true,
221
+ })
222
+ if (plugin === undefined) throw new Error("plugin load failed")
223
+ await expect(plugin.detect(createStubApi())).resolves.toEqual({ ok: true })
224
+
225
+ sandbox.unloadPlugin("echo")
226
+
227
+ await expect(plugin.detect(createStubApi())).resolves.toEqual({ ok: true })
228
+ })
229
+
230
+ test("hook exceptions propagate with the plugin's message", async () => {
231
+ sandbox = createPluginSandbox()
232
+ const plugin = await sandbox.loadPlugin({
233
+ id: "thrower",
234
+ mainPath: fixture("thrower-plugin.mjs"),
235
+ eager: true,
236
+ })
237
+ await expect(plugin?.detect(createStubApi())).rejects.toThrow(
238
+ "hook exploded",
239
+ )
240
+ })
241
+
242
+ test("host-side API errors reach the plugin as rejections", async () => {
243
+ sandbox = createPluginSandbox()
244
+ const plugin = await sandbox.loadPlugin({
245
+ id: "api-error",
246
+ mainPath: fixture("api-error-plugin.mjs"),
247
+ eager: true,
248
+ })
249
+ const api = createStubApi({
250
+ readFile: async () => {
251
+ throw new Error("no such file")
252
+ },
253
+ })
254
+ await expect(plugin?.detect(api)).resolves.toEqual({
255
+ ok: false,
256
+ reasons: ["api said: no such file"],
257
+ })
258
+ })
259
+
260
+ test("plugin logs reach the server console scoped by plugin id", async () => {
261
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => {})
262
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
263
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
264
+ sandbox = createPluginSandbox()
265
+ const plugin = await sandbox.loadPlugin({
266
+ id: "logger",
267
+ mainPath: fixture("logging-plugin.mjs"),
268
+ eager: true,
269
+ })
270
+ await expect(plugin?.detect(createStubApi())).resolves.toEqual({
271
+ ok: true,
272
+ })
273
+ expect(logSpy).toHaveBeenCalledWith("[plugin:logger] hello", { i: 1 })
274
+ expect(warnSpy).toHaveBeenCalledWith("[plugin:logger] careful")
275
+ expect(errorSpy).toHaveBeenCalledWith("[plugin:logger] bad news")
276
+ })
277
+
278
+ test("byte-range arguments cross the RPC boundary", async () => {
279
+ sandbox = createPluginSandbox()
280
+ const plugin = await sandbox.loadPlugin({
281
+ id: "range",
282
+ mainPath: fixture("range-plugin.mjs"),
283
+ eager: true,
284
+ })
285
+ await expect(plugin?.sourceMeta?.(createStubApi())).resolves.toEqual({
286
+ bytes: [2, 3, 4],
287
+ })
288
+ })
289
+
290
+ test("watchdog kills a spinning plugin without stalling the host", async () => {
291
+ sandbox = createPluginSandbox(fastConfig())
292
+ const plugin = await sandbox.loadPlugin({
293
+ id: "spin",
294
+ mainPath: fixture("spin-plugin.mjs"),
295
+ eager: true,
296
+ })
297
+ await expect(plugin?.detect(createStubApi())).rejects.toThrow(
298
+ /no activity/i,
299
+ )
300
+ // The sandbox itself stays usable afterwards.
301
+ const ok = await sandbox.loadPlugin({
302
+ id: "echo",
303
+ mainPath: fixture("echo-plugin.mjs"),
304
+ eager: true,
305
+ })
306
+ await expect(ok?.detect(createStubApi())).resolves.toEqual({ ok: true })
307
+ })
308
+
309
+ test("watchdog does not fire while the hook keeps calling the API", async () => {
310
+ sandbox = createPluginSandbox(fastConfig({ watchdogMs: 250 }))
311
+ const plugin = await sandbox.loadPlugin({
312
+ id: "chatty",
313
+ mainPath: fixture("chatty-plugin.mjs"),
314
+ eager: true,
315
+ })
316
+ // Runs ~500ms with constant API activity — longer than the watchdog.
317
+ await expect(plugin?.detect(createStubApi())).resolves.toEqual({
318
+ ok: true,
319
+ })
320
+ })
321
+
322
+ test("watchdog tolerates a host-side API call slower than the activity window", async () => {
323
+ sandbox = createPluginSandbox(fastConfig({ watchdogMs: 250 }))
324
+ const plugin = await sandbox.loadPlugin({
325
+ id: "slow-api",
326
+ mainPath: fixture("slow-api-plugin.mjs"),
327
+ eager: true,
328
+ })
329
+ const api = createStubApi({
330
+ readFile: async () => {
331
+ // Host work outlasts the watchdog with zero worker-side activity.
332
+ await new Promise((resolve) => setTimeout(resolve, 500))
333
+ return new Uint8Array([1])
334
+ },
335
+ })
336
+ await expect(plugin?.detect(api)).resolves.toEqual({ ok: true })
337
+ })
338
+
339
+ test("hard timeout stops a hook that stays active but never returns", async () => {
340
+ sandbox = createPluginSandbox(
341
+ fastConfig({ watchdogMs: 250, hardTimeoutMs: 600 }),
342
+ )
343
+ const plugin = await sandbox.loadPlugin({
344
+ id: "stuck",
345
+ mainPath: fixture("stuck-plugin.mjs"),
346
+ eager: true,
347
+ })
348
+ await expect(plugin?.detect(createStubApi())).rejects.toThrow(
349
+ /hard timeout/i,
350
+ )
351
+ })
352
+
353
+ test("a plugin that throws at import time yields undefined (failing semantics)", async () => {
354
+ vi.spyOn(console, "error").mockImplementation(() => {})
355
+ sandbox = createPluginSandbox()
356
+ await expect(
357
+ sandbox.loadPlugin({
358
+ id: "crash",
359
+ mainPath: fixture("crash-plugin.mjs"),
360
+ eager: true,
361
+ }),
362
+ ).resolves.toBeUndefined()
363
+ })
364
+
365
+ test("a worker that exits rejects pending calls; subsequent calls respawn", async () => {
366
+ sandbox = createPluginSandbox(fastConfig())
367
+ const plugin = await sandbox.loadPlugin({
368
+ id: "exit",
369
+ mainPath: fixture("exit-plugin.mjs"),
370
+ eager: true,
371
+ })
372
+ if (plugin === undefined) throw new Error("plugin load failed")
373
+ await expect(plugin.detect(createStubApi())).rejects.toThrow(/exited/i)
374
+ // The worker respawns lazily and repeats the behaviour — no wedge.
375
+ await expect(plugin.detect(createStubApi())).rejects.toThrow(/exited/i)
376
+ })
377
+
378
+ test("respawn limiting degrades a repeatedly crashing plugin", async () => {
379
+ sandbox = createPluginSandbox(fastConfig({ maxRespawns: 2 }))
380
+ const plugin = await sandbox.loadPlugin({
381
+ id: "exit",
382
+ mainPath: fixture("exit-plugin.mjs"),
383
+ eager: true,
384
+ })
385
+ if (plugin === undefined) throw new Error("plugin load failed")
386
+ await expect(plugin.detect(createStubApi())).rejects.toThrow(/exited/i)
387
+ // Second spawn is still within budget and crashes again.
388
+ await expect(plugin.detect(createStubApi())).rejects.toThrow(/exited/i)
389
+ // Budget exhausted — the plugin is degraded while the window is open.
390
+ await expect(plugin.detect(createStubApi())).rejects.toThrow(/unavailable/i)
391
+ })
392
+
393
+ test("a degraded plugin recovers once the crash window slides clean", async () => {
394
+ // Pin the clock — crash timing must not depend on machine load.
395
+ const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_000_000)
396
+ sandbox = createPluginSandbox(
397
+ fastConfig({ maxRespawns: 2, respawnWindowMs: 300 }),
398
+ )
399
+ const plugin = await sandbox.loadPlugin({
400
+ id: "exit",
401
+ mainPath: fixture("exit-plugin.mjs"),
402
+ eager: true,
403
+ })
404
+ if (plugin === undefined) throw new Error("plugin load failed")
405
+ await expect(plugin.detect(createStubApi())).rejects.toThrow(/exited/i)
406
+ await expect(plugin.detect(createStubApi())).rejects.toThrow(/exited/i)
407
+ await expect(plugin.detect(createStubApi())).rejects.toThrow(/unavailable/i)
408
+ // The window slides clean — the next call gets a fresh worker again.
409
+ nowSpy.mockReturnValue(1_000_000 + 301)
410
+ await expect(plugin.detect(createStubApi())).rejects.toThrow(/exited/i)
411
+ })
412
+
413
+ // -- capability boundary --
414
+
415
+ test("static node: imports are denied by the module policy — load fails closed", async () => {
416
+ vi.spyOn(console, "error").mockImplementation(() => {})
417
+ sandbox = createPluginSandbox()
418
+ await expect(
419
+ sandbox.loadPlugin({
420
+ id: "hostile",
421
+ mainPath: fixture("hostile-plugin.mjs"),
422
+ eager: true,
423
+ }),
424
+ ).resolves.toBeUndefined()
425
+ })
426
+
427
+ test("computed dynamic node: imports are denied inside a hook", async () => {
428
+ sandbox = createPluginSandbox()
429
+ const plugin = await sandbox.loadPlugin({
430
+ id: "hostile-dynamic",
431
+ mainPath: fixture("hostile-dynamic-plugin.mjs"),
432
+ eager: true,
433
+ })
434
+ if (plugin === undefined) throw new Error("plugin load failed")
435
+ await expect(plugin.detect(createStubApi())).rejects.toThrow(
436
+ /denied by policy/,
437
+ )
438
+ })
439
+
440
+ test("the fetch global is scrubbed inside the sandbox", async () => {
441
+ sandbox = createPluginSandbox()
442
+ const plugin = await sandbox.loadPlugin({
443
+ id: "fetch",
444
+ mainPath: fixture("fetch-plugin.mjs"),
445
+ eager: true,
446
+ })
447
+ if (plugin === undefined) throw new Error("plugin load failed")
448
+ await expect(plugin.detect(createStubApi())).rejects.toThrow(
449
+ /fetch is disabled/i,
450
+ )
451
+ })
452
+
453
+ test("oversized hook results are rejected instead of cloned into the host", async () => {
454
+ sandbox = createPluginSandbox(fastConfig({ maxResultBytes: 1024 }))
455
+ const plugin = await sandbox.loadPlugin({
456
+ id: "huge",
457
+ mainPath: fixture("huge-result-plugin.mjs"),
458
+ eager: true,
459
+ })
460
+ if (plugin === undefined) throw new Error("plugin load failed")
461
+ await expect(plugin.detect(createStubApi())).rejects.toThrow(/exceeds/)
462
+ })
463
+
464
+ test("an unknown permission flag fails closed: plugins refuse to load", async () => {
465
+ vi.spyOn(console, "error").mockImplementation(() => {})
466
+ sandbox = createPluginSandbox({
467
+ ...DEFAULT_SANDBOX_CONFIG,
468
+ permissionFlag: "--definitely-not-a-permission-flag",
469
+ })
470
+ await expect(
471
+ sandbox.loadPlugin({
472
+ id: "noline",
473
+ mainPath: fixture("echo-plugin.mjs"),
474
+ eager: true,
475
+ }),
476
+ ).resolves.toBeUndefined()
477
+ })
478
+
479
+ test("a plugin dir whose path contains spaces loads and runs", async () => {
480
+ const dir = mkdtempSync(join(tmpdir(), "hoardodile sandbox space "))
481
+ copyFileSync(fixture("echo-plugin.mjs"), join(dir, "main.js"))
482
+ sandbox = createPluginSandbox()
483
+ const plugin = await sandbox.loadPlugin({
484
+ id: "spacey",
485
+ mainPath: join(dir, "main.js"),
486
+ eager: true,
487
+ })
488
+ if (plugin === undefined) throw new Error("plugin load failed")
489
+ await expect(plugin.detect(createStubApi())).resolves.toEqual({ ok: true })
490
+ })
491
+
492
+ test("the default budgets are exported and wired into the config", () => {
493
+ expect(DEFAULT_SANDBOX_CONFIG.maxResultBytes).toBe(PLUGIN_MAX_RESULT_BYTES)
494
+ expect(PLUGIN_MAX_RESULT_BYTES).toBe(256 * 1024 * 1024)
495
+ expect(DEFAULT_SANDBOX_CONFIG.maxLogsPerHook).toBe(PLUGIN_MAX_LOGS_PER_HOOK)
496
+ expect(DEFAULT_SANDBOX_CONFIG.maxApiCallsPerHook).toBe(
497
+ PLUGIN_MAX_API_CALLS_PER_HOOK,
498
+ )
499
+ })
500
+
501
+ test("container API methods are denied without the manifest permission", async () => {
502
+ sandbox = createPluginSandbox()
503
+ const plugin = await sandbox.loadPlugin({
504
+ id: "container-denied",
505
+ mainPath: fixture("container-plugin.mjs"),
506
+ eager: true,
507
+ permissions: noPermissions(),
508
+ })
509
+ if (plugin === undefined) throw new Error("plugin load failed")
510
+ await expect(plugin.detect(createStubApi())).rejects.toThrow(
511
+ /container permission denied/,
512
+ )
513
+ })
514
+
515
+ test("container API methods run when the manifest grants it", async () => {
516
+ sandbox = createPluginSandbox()
517
+ const plugin = await sandbox.loadPlugin({
518
+ id: "container-allowed",
519
+ mainPath: fixture("container-plugin.mjs"),
520
+ eager: true,
521
+ permissions: containerPermissions(),
522
+ })
523
+ if (plugin === undefined) throw new Error("plugin load failed")
524
+ await expect(plugin.detect(createStubApi())).resolves.toEqual({
525
+ ok: true,
526
+ entries: 0,
527
+ })
528
+ })
529
+
530
+ test("asset API methods are denied without the manifest download permission", async () => {
531
+ sandbox = createPluginSandbox()
532
+ const plugin = await sandbox.loadPlugin({
533
+ id: "asset-denied",
534
+ mainPath: fixture("asset-plugin.mjs"),
535
+ eager: true,
536
+ permissions: noPermissions(),
537
+ })
538
+ if (plugin === undefined) throw new Error("plugin load failed")
539
+ await expect(plugin.detect(createStubApi())).rejects.toThrow(
540
+ /download permission denied/,
541
+ )
542
+ })
543
+
544
+ test("asset API methods route to the wired handler with the owning plugin id", async () => {
545
+ const seen: unknown[] = []
546
+ sandbox = createPluginSandbox(
547
+ fastConfig({
548
+ pluginAssets: {
549
+ download: async (pluginId, request) => {
550
+ seen.push([pluginId, request])
551
+ return {
552
+ path: request.dest,
553
+ sizeBytes: 3,
554
+ sha256: "a".repeat(64),
555
+ cached: false,
556
+ }
557
+ },
558
+ statAsset: async (pluginId, path) => {
559
+ seen.push([pluginId, path])
560
+ return undefined
561
+ },
562
+ readAsset: async () => new Uint8Array([1]),
563
+ deleteAsset: async () => ({ existed: true }),
564
+ },
565
+ }),
566
+ )
567
+ const plugin = await sandbox.loadPlugin({
568
+ id: "asset-allowed",
569
+ mainPath: fixture("asset-plugin.mjs"),
570
+ eager: true,
571
+ permissions: downloadPermissions(),
572
+ })
573
+ if (plugin === undefined) throw new Error("plugin load failed")
574
+ await expect(plugin.detect(createStubApi())).resolves.toEqual({
575
+ ok: true,
576
+ stat: undefined,
577
+ downloaded: {
578
+ path: "runtime/a.mjs",
579
+ sizeBytes: 3,
580
+ sha256: "a".repeat(64),
581
+ cached: false,
582
+ },
583
+ })
584
+ expect(seen[0]).toEqual(["asset-allowed", "runtime/a.mjs"])
585
+ expect(seen[1]).toEqual([
586
+ "asset-allowed",
587
+ {
588
+ url: "https://example.com/runtime/a.mjs",
589
+ dest: "runtime/a.mjs",
590
+ },
591
+ ])
592
+ })
593
+
594
+ test("a log flood exceeds the per-hook budget and fails the hook", async () => {
595
+ sandbox = createPluginSandbox(fastConfig({ maxLogsPerHook: 10 }))
596
+ const plugin = await sandbox.loadPlugin({
597
+ id: "flood-log",
598
+ mainPath: fixture("flood-log-plugin.mjs"),
599
+ eager: true,
600
+ })
601
+ if (plugin === undefined) throw new Error("plugin load failed")
602
+ await expect(plugin.detect(createStubApi())).rejects.toThrow(
603
+ /log budget exceeded/,
604
+ )
605
+ })
606
+
607
+ test("an API-call flood exceeds the per-hook budget and fails the hook", async () => {
608
+ sandbox = createPluginSandbox(fastConfig({ maxApiCallsPerHook: 10 }))
609
+ const plugin = await sandbox.loadPlugin({
610
+ id: "flood-api",
611
+ mainPath: fixture("flood-api-plugin.mjs"),
612
+ eager: true,
613
+ })
614
+ if (plugin === undefined) throw new Error("plugin load failed")
615
+ await expect(plugin.detect(createStubApi())).rejects.toThrow(
616
+ /API call budget exceeded/,
617
+ )
618
+ })
619
+ })