@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.
- package/LICENSE +18 -0
- package/README.md +73 -0
- package/dist/chunks/worker-entry.mjs +422 -0
- package/dist/container-fKxefO7p.d.ts +58 -0
- package/dist/contract/index.d.ts +49 -0
- package/dist/contract/index.js +187 -0
- package/dist/contract/index.js.map +1 -0
- package/dist/hoard/index.d.ts +754 -0
- package/dist/hoard/index.js +1939 -0
- package/dist/hoard/index.js.map +1 -0
- package/dist/image-Dx2Be3Bj.d.ts +168 -0
- package/dist/index.d.ts +788 -0
- package/dist/index.js +3311 -0
- package/dist/index.js.map +1 -0
- package/dist/media/index.d.ts +96 -0
- package/dist/media/index.js +152 -0
- package/dist/media/index.js.map +1 -0
- package/dist/probe/index.d.ts +113 -0
- package/dist/probe/index.js +454 -0
- package/dist/probe/index.js.map +1 -0
- package/dist/probe-cache-BkRI7HEK.d.ts +25 -0
- package/dist/render/index.d.ts +125 -0
- package/dist/render/index.js +430 -0
- package/dist/render/index.js.map +1 -0
- package/package.json +103 -0
- package/src/__testutils__/zip-fixtures.ts +139 -0
- package/src/activation.ts +116 -0
- package/src/api-types.ts +60 -0
- package/src/api.test.ts +564 -0
- package/src/api.ts +501 -0
- package/src/archive/7z.test.ts +222 -0
- package/src/archive/7z.ts +261 -0
- package/src/archive/archive-ops.test.ts +654 -0
- package/src/archive/extract-archive.test.ts +237 -0
- package/src/archive/extract-archive.ts +534 -0
- package/src/archive/extract.ts +693 -0
- package/src/archive/format.ts +49 -0
- package/src/archive/index.ts +116 -0
- package/src/archive/listing.ts +121 -0
- package/src/archive/materialize.ts +88 -0
- package/src/archive/name-decode.test.ts +34 -0
- package/src/archive/name-decode.ts +287 -0
- package/src/archive/nested-cd-cache.ts +55 -0
- package/src/archive/nested-entry.test.ts +194 -0
- package/src/archive/nested-entry.ts +254 -0
- package/src/archive/pack.ts +40 -0
- package/src/archive/zip-entries.test.ts +56 -0
- package/src/archive/zip-entries.ts +336 -0
- package/src/capability-guard.ts +73 -0
- package/src/container.ts +62 -0
- package/src/contract/container-fixture.ts +74 -0
- package/src/contract/index.ts +10 -0
- package/src/contract/suite.test.ts +101 -0
- package/src/contract/suite.ts +205 -0
- package/src/define-plugin.test.ts +53 -0
- package/src/define-plugin.ts +12 -0
- package/src/dev-runner.test.ts +98 -0
- package/src/dev-runner.ts +73 -0
- package/src/directory-api.test.ts +229 -0
- package/src/directory-api.ts +42 -0
- package/src/directory-container.ts +141 -0
- package/src/discovery.ts +295 -0
- package/src/errors.ts +74 -0
- package/src/fixtures.ts +8 -0
- package/src/hash.test.ts +301 -0
- package/src/hash.ts +167 -0
- package/src/hoard/dir-size.ts +58 -0
- package/src/hoard/index.ts +84 -0
- package/src/hoard/migrate-script.test.ts +539 -0
- package/src/hoard/order-manifest.test.ts +115 -0
- package/src/hoard/order-manifest.ts +131 -0
- package/src/hoard/paths.test.ts +226 -0
- package/src/hoard/paths.ts +569 -0
- package/src/hoard/plugin-vault.test.ts +160 -0
- package/src/hoard/plugin-vault.ts +233 -0
- package/src/hoard/sanitize.test.ts +149 -0
- package/src/hoard/sanitize.ts +230 -0
- package/src/hoard/staging-dir.ts +133 -0
- package/src/hoard/version.test.ts +285 -0
- package/src/hoard/version.ts +216 -0
- package/src/hoard/versioned-folder-ops.ts +152 -0
- package/src/hoard/write-versioned.test.ts +67 -0
- package/src/hoard/write-versioned.ts +39 -0
- package/src/hooks.test.ts +528 -0
- package/src/hooks.ts +428 -0
- package/src/index.ts +135 -0
- package/src/loader.test.ts +442 -0
- package/src/loader.ts +213 -0
- package/src/media/index.ts +16 -0
- package/src/media/render-cache.ts +27 -0
- package/src/media/seekable.ts +35 -0
- package/src/media/thumb-input.test.ts +109 -0
- package/src/media/thumb-input.ts +194 -0
- package/src/nested-view.test.ts +223 -0
- package/src/nested-view.ts +239 -0
- package/src/probe/audio.test.ts +135 -0
- package/src/probe/audio.ts +140 -0
- package/src/probe/av.ts +84 -0
- package/src/probe/ffprobe.ts +197 -0
- package/src/probe/image.test.ts +251 -0
- package/src/probe/image.ts +336 -0
- package/src/probe/index.ts +54 -0
- package/src/probe/sniff.test.ts +72 -0
- package/src/probe/sniff.ts +67 -0
- package/src/probe/video.test.ts +41 -0
- package/src/probe/video.ts +116 -0
- package/src/probe-cache.test.ts +89 -0
- package/src/probe-cache.ts +65 -0
- package/src/render/ffmpeg.ts +78 -0
- package/src/render/index.ts +34 -0
- package/src/render/pipeline.test.ts +187 -0
- package/src/render/pipeline.ts +479 -0
- package/src/sandbox/call-timers.ts +68 -0
- package/src/sandbox/fixtures/api-error-plugin.mjs +11 -0
- package/src/sandbox/fixtures/asset-plugin.mjs +13 -0
- package/src/sandbox/fixtures/chatty-plugin.mjs +14 -0
- package/src/sandbox/fixtures/container-plugin.mjs +8 -0
- package/src/sandbox/fixtures/context-plugin.mjs +19 -0
- package/src/sandbox/fixtures/crash-plugin.mjs +6 -0
- package/src/sandbox/fixtures/echo-plugin.mjs +17 -0
- package/src/sandbox/fixtures/exit-plugin.mjs +6 -0
- package/src/sandbox/fixtures/fetch-plugin.mjs +8 -0
- package/src/sandbox/fixtures/flood-api-plugin.mjs +8 -0
- package/src/sandbox/fixtures/flood-log-plugin.mjs +8 -0
- package/src/sandbox/fixtures/hostile-dynamic-plugin.mjs +9 -0
- package/src/sandbox/fixtures/hostile-plugin.mjs +13 -0
- package/src/sandbox/fixtures/huge-result-plugin.mjs +8 -0
- package/src/sandbox/fixtures/logging-plugin.mjs +9 -0
- package/src/sandbox/fixtures/range-plugin.mjs +9 -0
- package/src/sandbox/fixtures/slow-api-plugin.mjs +11 -0
- package/src/sandbox/fixtures/spin-plugin.mjs +9 -0
- package/src/sandbox/fixtures/stuck-plugin.mjs +12 -0
- package/src/sandbox/fixtures/thrower-plugin.mjs +7 -0
- package/src/sandbox/host.test.ts +619 -0
- package/src/sandbox/host.ts +933 -0
- package/src/sandbox/host.unit.test.ts +330 -0
- package/src/sandbox/protocol.test.ts +85 -0
- package/src/sandbox/protocol.ts +147 -0
- package/src/sandbox/sandboxed-plugin.ts +30 -0
- package/src/sandbox/worker-entry.mjs +422 -0
- package/src/seed.test.ts +102 -0
- package/src/seed.ts +160 -0
- package/src/settings-store.ts +26 -0
- package/src/types.ts +21 -0
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import { tmpdir } from "node:os"
|
|
2
|
+
import { join, resolve, sep } from "node:path"
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"
|
|
4
|
+
import type { ResourceAPI } from "../types.ts"
|
|
5
|
+
import {
|
|
6
|
+
createPluginSandbox,
|
|
7
|
+
DEFAULT_SANDBOX_CONFIG,
|
|
8
|
+
type PluginSandbox,
|
|
9
|
+
type PluginSandboxConfig,
|
|
10
|
+
} from "./host.ts"
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Deterministic lifecycle tests: a scripted fake ChildProcess lets us hit
|
|
14
|
+
* race windows (stale messages, concurrent loads) that real forked
|
|
15
|
+
* processes only reproduce flakily. Only this file sees the mock — the
|
|
16
|
+
* real `spawn` probe answers the first permission-flag candidate so the
|
|
17
|
+
* sandbox never actually starts a process here.
|
|
18
|
+
*/
|
|
19
|
+
const mocks = vi.hoisted(() => {
|
|
20
|
+
class FakeChild {
|
|
21
|
+
static instances: FakeChild[] = []
|
|
22
|
+
private readonly listeners = new Map<
|
|
23
|
+
string,
|
|
24
|
+
((...args: unknown[]) => void)[]
|
|
25
|
+
>()
|
|
26
|
+
readonly sent: unknown[] = []
|
|
27
|
+
readonly args: unknown[]
|
|
28
|
+
killed = false
|
|
29
|
+
|
|
30
|
+
constructor(...args: unknown[]) {
|
|
31
|
+
this.args = args
|
|
32
|
+
FakeChild.instances.push(this)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
on(event: string, fn: (...args: unknown[]) => void): this {
|
|
36
|
+
const list = this.listeners.get(event) ?? []
|
|
37
|
+
list.push(fn)
|
|
38
|
+
this.listeners.set(event, list)
|
|
39
|
+
return this
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
emit(event: string, ...args: unknown[]): void {
|
|
43
|
+
for (const fn of this.listeners.get(event) ?? []) fn(...args)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
send(msg: unknown): void {
|
|
47
|
+
this.sent.push(msg)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
kill(): void {
|
|
51
|
+
this.killed = true
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
ref(): this {
|
|
55
|
+
return this
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
unref(): this {
|
|
59
|
+
return this
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return { FakeChild }
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
vi.mock("node:child_process", () => ({
|
|
66
|
+
fork: (...args: unknown[]) => new mocks.FakeChild(...args),
|
|
67
|
+
spawn: (...args: unknown[]) => {
|
|
68
|
+
const child = new mocks.FakeChild(...args)
|
|
69
|
+
// The permission-flag probe resolves as soon as the listeners are
|
|
70
|
+
// attached — report the first candidate as accepted.
|
|
71
|
+
queueMicrotask(() => child.emit("exit", 0))
|
|
72
|
+
return child
|
|
73
|
+
},
|
|
74
|
+
}))
|
|
75
|
+
|
|
76
|
+
function unitConfig(overrides: Partial<PluginSandboxConfig> = {}) {
|
|
77
|
+
return {
|
|
78
|
+
...DEFAULT_SANDBOX_CONFIG,
|
|
79
|
+
...overrides,
|
|
80
|
+
} satisfies PluginSandboxConfig
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function lastChild(): InstanceType<typeof mocks.FakeChild> {
|
|
84
|
+
const child = mocks.FakeChild.instances.at(-1)
|
|
85
|
+
if (child === undefined) throw new Error("no sandbox child spawned")
|
|
86
|
+
return child
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function createStubApi(overrides: Partial<ResourceAPI> = {}): ResourceAPI {
|
|
90
|
+
return {
|
|
91
|
+
logInfo() {},
|
|
92
|
+
logWarn() {},
|
|
93
|
+
logError() {},
|
|
94
|
+
context: { detect: undefined },
|
|
95
|
+
listFileNames: async () => [],
|
|
96
|
+
readFile: async () => new Uint8Array(),
|
|
97
|
+
statFile: async () => ({ sizeBytes: 0 }),
|
|
98
|
+
statFiles: async (paths) => paths.map(() => ({ sizeBytes: 0 })),
|
|
99
|
+
sniff: async () => undefined,
|
|
100
|
+
probe: async () => ({ kind: "unknown", reason: "unavailable" }),
|
|
101
|
+
hashBytes: async () => "ab",
|
|
102
|
+
computeImageHashes: async () => undefined,
|
|
103
|
+
listContainer: async () => ({ entries: [] }),
|
|
104
|
+
extractArchive: async () => ({ entries: [] }),
|
|
105
|
+
download: async () => {
|
|
106
|
+
throw new Error("stub: download not configured")
|
|
107
|
+
},
|
|
108
|
+
statAsset: async () => undefined,
|
|
109
|
+
readAsset: async () => new Uint8Array(),
|
|
110
|
+
deleteAsset: async () => ({ existed: false }),
|
|
111
|
+
...overrides,
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Flush microtasks and pending macrotasks. */
|
|
116
|
+
function flush(): Promise<void> {
|
|
117
|
+
return new Promise((resolve) => setTimeout(resolve, 0))
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
describe("plugin sandbox lifecycle (fake child process)", () => {
|
|
121
|
+
let sandbox: PluginSandbox | undefined
|
|
122
|
+
|
|
123
|
+
beforeEach(() => {
|
|
124
|
+
mocks.FakeChild.instances.length = 0
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
afterEach(async () => {
|
|
128
|
+
await sandbox?.disposeAll()
|
|
129
|
+
sandbox = undefined
|
|
130
|
+
vi.restoreAllMocks()
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test("a plugin that fails to load has its sandbox child terminated", async () => {
|
|
134
|
+
vi.spyOn(console, "error").mockImplementation(() => {})
|
|
135
|
+
sandbox = createPluginSandbox(unitConfig())
|
|
136
|
+
const load = sandbox.loadPlugin({
|
|
137
|
+
id: "bad",
|
|
138
|
+
mainPath: "/plugins/bad/main.js",
|
|
139
|
+
eager: true,
|
|
140
|
+
})
|
|
141
|
+
// The fork happens after the (mocked) permission-flag probe settles.
|
|
142
|
+
await flush()
|
|
143
|
+
const child = lastChild()
|
|
144
|
+
child.emit("message", {
|
|
145
|
+
type: "loaded",
|
|
146
|
+
ok: false,
|
|
147
|
+
error: { name: "Error", message: "import exploded" },
|
|
148
|
+
})
|
|
149
|
+
await expect(load).resolves.toBeUndefined()
|
|
150
|
+
expect(child.killed).toBe(true)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
test("messages from a stale child are ignored", async () => {
|
|
154
|
+
vi.spyOn(console, "error").mockImplementation(() => {})
|
|
155
|
+
sandbox = createPluginSandbox(unitConfig())
|
|
156
|
+
// Unload mid-load: the first child's waiter rejects, load returns
|
|
157
|
+
// undefined, and the child is terminated.
|
|
158
|
+
const first = sandbox.loadPlugin({
|
|
159
|
+
id: "p",
|
|
160
|
+
mainPath: "/p/main.js",
|
|
161
|
+
eager: true,
|
|
162
|
+
})
|
|
163
|
+
await flush()
|
|
164
|
+
const stale = lastChild()
|
|
165
|
+
sandbox.unloadPlugin("p")
|
|
166
|
+
await expect(first).resolves.toBeUndefined()
|
|
167
|
+
expect(stale.killed).toBe(true)
|
|
168
|
+
|
|
169
|
+
// Respawn for the same id.
|
|
170
|
+
const second = sandbox.loadPlugin({
|
|
171
|
+
id: "p",
|
|
172
|
+
mainPath: "/p/main.js",
|
|
173
|
+
eager: true,
|
|
174
|
+
})
|
|
175
|
+
await flush()
|
|
176
|
+
const current = lastChild()
|
|
177
|
+
expect(current).not.toBe(stale)
|
|
178
|
+
|
|
179
|
+
// The stale child's late "loaded" must not resolve the new spawn's
|
|
180
|
+
// load waiter — the second load stays pending until ITS child loads.
|
|
181
|
+
let secondSettled = false
|
|
182
|
+
void second.then(() => {
|
|
183
|
+
secondSettled = true
|
|
184
|
+
})
|
|
185
|
+
stale.emit("message", { type: "loaded", ok: true, hooks: ["detect"] })
|
|
186
|
+
await flush()
|
|
187
|
+
expect(secondSettled).toBe(false)
|
|
188
|
+
|
|
189
|
+
current.emit("message", { type: "loaded", ok: true, hooks: ["detect"] })
|
|
190
|
+
const plugin = await second
|
|
191
|
+
if (plugin === undefined) throw new Error("plugin load failed")
|
|
192
|
+
|
|
193
|
+
// A stale "result" must not resolve a pending call on the new child.
|
|
194
|
+
const detect = plugin.detect(createStubApi())
|
|
195
|
+
// Let invoke() register the pending call before delivering results.
|
|
196
|
+
await flush()
|
|
197
|
+
stale.emit("message", {
|
|
198
|
+
type: "result",
|
|
199
|
+
callId: 1,
|
|
200
|
+
ok: true,
|
|
201
|
+
value: { ok: false, reasons: ["stale"] },
|
|
202
|
+
})
|
|
203
|
+
current.emit("message", {
|
|
204
|
+
type: "result",
|
|
205
|
+
callId: 1,
|
|
206
|
+
ok: true,
|
|
207
|
+
value: { ok: true },
|
|
208
|
+
})
|
|
209
|
+
await expect(detect).resolves.toEqual({ ok: true })
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
test("concurrent loadPlugin calls for the same id keep the newer state alive", async () => {
|
|
213
|
+
vi.spyOn(console, "error").mockImplementation(() => {})
|
|
214
|
+
sandbox = createPluginSandbox(unitConfig())
|
|
215
|
+
const first = sandbox.loadPlugin({
|
|
216
|
+
id: "p",
|
|
217
|
+
mainPath: "/p/old.js",
|
|
218
|
+
eager: true,
|
|
219
|
+
})
|
|
220
|
+
await flush()
|
|
221
|
+
const c1 = lastChild()
|
|
222
|
+
|
|
223
|
+
// Second load for the same id while the first is still in flight:
|
|
224
|
+
// the old child is NOT torn down until the new bundle loads — a
|
|
225
|
+
// failed reload must never strand the previous child.
|
|
226
|
+
const second = sandbox.loadPlugin({
|
|
227
|
+
id: "p",
|
|
228
|
+
mainPath: "/p/new.js",
|
|
229
|
+
eager: true,
|
|
230
|
+
})
|
|
231
|
+
await flush()
|
|
232
|
+
expect(c1.killed).toBe(false)
|
|
233
|
+
const c2 = lastChild()
|
|
234
|
+
expect(c2).not.toBe(c1)
|
|
235
|
+
|
|
236
|
+
// Both bundles load; the newer state owns the id and retires the
|
|
237
|
+
// previous child only then.
|
|
238
|
+
c1.emit("message", { type: "loaded", ok: true, hooks: ["detect"] })
|
|
239
|
+
await first
|
|
240
|
+
c2.emit("message", { type: "loaded", ok: true, hooks: ["detect"] })
|
|
241
|
+
const plugin = await second
|
|
242
|
+
if (plugin === undefined) throw new Error("plugin load failed")
|
|
243
|
+
expect(c1.killed).toBe(true)
|
|
244
|
+
|
|
245
|
+
const detect = plugin.detect(createStubApi())
|
|
246
|
+
await flush()
|
|
247
|
+
c2.emit("message", {
|
|
248
|
+
type: "result",
|
|
249
|
+
callId: 1,
|
|
250
|
+
ok: true,
|
|
251
|
+
value: { ok: true },
|
|
252
|
+
})
|
|
253
|
+
await expect(detect).resolves.toEqual({ ok: true })
|
|
254
|
+
|
|
255
|
+
// The newer state is still tracked — dispose terminates its child.
|
|
256
|
+
await sandbox.disposeAll()
|
|
257
|
+
expect(c2.killed).toBe(true)
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
test("a failed reload keeps the previous child alive and serving", async () => {
|
|
261
|
+
vi.spyOn(console, "error").mockImplementation(() => {})
|
|
262
|
+
sandbox = createPluginSandbox(unitConfig())
|
|
263
|
+
const first = sandbox.loadPlugin({
|
|
264
|
+
id: "p",
|
|
265
|
+
mainPath: "/p/old.js",
|
|
266
|
+
eager: true,
|
|
267
|
+
})
|
|
268
|
+
await flush()
|
|
269
|
+
const c1 = lastChild()
|
|
270
|
+
c1.emit("message", { type: "loaded", ok: true, hooks: ["detect"] })
|
|
271
|
+
await expect(first).resolves.toBeDefined()
|
|
272
|
+
|
|
273
|
+
// Reload with a bundle that fails to import: the load rejects, the
|
|
274
|
+
// previous child must survive and keep answering hooks.
|
|
275
|
+
const reload = sandbox.loadPlugin({
|
|
276
|
+
id: "p",
|
|
277
|
+
mainPath: "/p/broken.js",
|
|
278
|
+
eager: true,
|
|
279
|
+
})
|
|
280
|
+
await flush()
|
|
281
|
+
const c2 = lastChild()
|
|
282
|
+
expect(c2).not.toBe(c1)
|
|
283
|
+
expect(c1.killed).toBe(false)
|
|
284
|
+
|
|
285
|
+
c2.emit("message", {
|
|
286
|
+
type: "loaded",
|
|
287
|
+
ok: false,
|
|
288
|
+
error: { name: "Error", message: "import exploded" },
|
|
289
|
+
})
|
|
290
|
+
const restored = await reload
|
|
291
|
+
if (restored === undefined) throw new Error("reload should fall back")
|
|
292
|
+
expect(c1.killed).toBe(false)
|
|
293
|
+
|
|
294
|
+
// The restored definition still routes through the old child.
|
|
295
|
+
const detect = restored.detect(createStubApi())
|
|
296
|
+
await flush()
|
|
297
|
+
c1.emit("message", {
|
|
298
|
+
type: "result",
|
|
299
|
+
callId: 1,
|
|
300
|
+
ok: true,
|
|
301
|
+
value: { ok: true },
|
|
302
|
+
})
|
|
303
|
+
await expect(detect).resolves.toEqual({ ok: true })
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
test("spawn passes the asset vault dir as an extra read grant and argv", async () => {
|
|
307
|
+
const vaultDir = join(tmpdir(), "vaults", "p")
|
|
308
|
+
sandbox = createPluginSandbox(unitConfig({ assetVaultDir: vaultDir }))
|
|
309
|
+
const plugin = sandbox.loadPlugin({
|
|
310
|
+
id: "vault-grant",
|
|
311
|
+
mainPath: "/p/main.js",
|
|
312
|
+
eager: true,
|
|
313
|
+
})
|
|
314
|
+
await flush()
|
|
315
|
+
const child = lastChild()
|
|
316
|
+
const [, spawnArgs, spawnOpts] = child.args as [
|
|
317
|
+
string,
|
|
318
|
+
string[],
|
|
319
|
+
{ execArgv: string[] },
|
|
320
|
+
]
|
|
321
|
+
expect(spawnArgs.at(2)).toBe(vaultDir)
|
|
322
|
+
// host.ts grants `${resolve(assetVaultDir)}${sep}` — the asserts
|
|
323
|
+
// mirror it so POSIX (sep "/") and Windows (sep "\\") both hold.
|
|
324
|
+
expect(
|
|
325
|
+
spawnOpts.execArgv.includes(`--allow-fs-read=${resolve(vaultDir)}${sep}`),
|
|
326
|
+
).toBe(true)
|
|
327
|
+
child.emit("message", { type: "loaded", ok: true, hooks: ["detect"] })
|
|
328
|
+
await expect(plugin).resolves.toBeDefined()
|
|
329
|
+
})
|
|
330
|
+
})
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs"
|
|
2
|
+
import { fileURLToPath } from "node:url"
|
|
3
|
+
import { describe, expect, test } from "vitest"
|
|
4
|
+
import {
|
|
5
|
+
API_METHOD_NAMES,
|
|
6
|
+
deserializeError,
|
|
7
|
+
HOOK_NAMES,
|
|
8
|
+
LOG_METHOD_NAMES,
|
|
9
|
+
serializeError,
|
|
10
|
+
} from "./protocol.ts"
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* worker-entry.mjs is plain JS without access to workspace TS sources, so it
|
|
14
|
+
* keeps its own copies of the hook/API name lists. A drift between the two
|
|
15
|
+
* copies only surfaces at runtime as "unknown API method" RPC failures —
|
|
16
|
+
* this test is the compile-time sync guarantee.
|
|
17
|
+
*/
|
|
18
|
+
const workerEntrySource = readFileSync(
|
|
19
|
+
fileURLToPath(new URL("./worker-entry.mjs", import.meta.url)),
|
|
20
|
+
"utf-8",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
/** Extract the double-quoted items of the first `[...]` list after `name`. */
|
|
24
|
+
function extractStringList(source: string, name: string): string[] {
|
|
25
|
+
// Anchor on the declaration — the file header mentions the same names.
|
|
26
|
+
const start = source.indexOf(`const ${name}`)
|
|
27
|
+
if (start === -1) throw new Error(`${name} not found in worker-entry.mjs`)
|
|
28
|
+
const open = source.indexOf("[", start)
|
|
29
|
+
const close = source.indexOf("]", open)
|
|
30
|
+
if (open === -1 || close === -1) {
|
|
31
|
+
throw new Error(`${name} list not found in worker-entry.mjs`)
|
|
32
|
+
}
|
|
33
|
+
const out: string[] = []
|
|
34
|
+
for (const m of source.slice(open, close).matchAll(/"([^"]+)"/g)) {
|
|
35
|
+
if (m[1] !== undefined) out.push(m[1])
|
|
36
|
+
}
|
|
37
|
+
return out
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe("protocol ↔ worker-entry name lists", () => {
|
|
41
|
+
test("HOOK_NAMES match in contract order", () => {
|
|
42
|
+
expect(extractStringList(workerEntrySource, "HOOK_NAMES")).toEqual([
|
|
43
|
+
...HOOK_NAMES,
|
|
44
|
+
])
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test("API_METHOD_NAMES match in contract order", () => {
|
|
48
|
+
expect(extractStringList(workerEntrySource, "API_METHOD_NAMES")).toEqual([
|
|
49
|
+
...API_METHOD_NAMES,
|
|
50
|
+
])
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test("LOG_METHOD_NAMES match", () => {
|
|
54
|
+
expect(
|
|
55
|
+
extractStringList(workerEntrySource, "LOG_METHOD_NAMES").sort(),
|
|
56
|
+
).toEqual([...LOG_METHOD_NAMES].sort())
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe("error code over the sandbox IPC", () => {
|
|
61
|
+
test("the asset vocabulary round-trips via `code` (name restored verbatim)", () => {
|
|
62
|
+
const denied = new Error("declined")
|
|
63
|
+
denied.name = "DENIED"
|
|
64
|
+
const serialized = serializeError(denied)
|
|
65
|
+
expect(serialized.code).toBe("DENIED")
|
|
66
|
+
const restored = deserializeError(serialized)
|
|
67
|
+
expect(restored.name).toBe("DENIED")
|
|
68
|
+
expect(restored.message).toBe("declined")
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test("plain errors carry no code and keep their own name", () => {
|
|
72
|
+
const serialized = serializeError(new TypeError("boom"))
|
|
73
|
+
expect(serialized.code).toBeUndefined()
|
|
74
|
+
expect(deserializeError(serialized).name).toBe("TypeError")
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test("an explicit wire code wins over the legacy name field", () => {
|
|
78
|
+
const restored = deserializeError({
|
|
79
|
+
name: "SomeOldName",
|
|
80
|
+
code: "POLICY",
|
|
81
|
+
message: "nope",
|
|
82
|
+
})
|
|
83
|
+
expect(restored.name).toBe("POLICY")
|
|
84
|
+
})
|
|
85
|
+
})
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire protocol between the plugin sandbox host (server process) and the
|
|
3
|
+
* sandbox child entry (`worker-entry.mjs`), which runs as a forked child
|
|
4
|
+
* process with structured-clone IPC. The entry file is plain JS without
|
|
5
|
+
* access to workspace TS sources, so it keeps its own copy of the
|
|
6
|
+
* method/hook name lists — keep the two in sync (enforced by
|
|
7
|
+
* `protocol.test.ts`).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Plugin hook names the host can invoke, in contract order — defined in
|
|
12
|
+
* `@hoardodile/sdk-types`, re-exported so host internals keep one import
|
|
13
|
+
* point.
|
|
14
|
+
*/
|
|
15
|
+
import { HOOK_NAMES, type HookName } from "@hoardodile/sdk-types"
|
|
16
|
+
import { PLUGIN_ASSET_ERROR_NAMES } from "@hoardodile/sdk-types/plugin-asset-limits"
|
|
17
|
+
|
|
18
|
+
export { HOOK_NAMES, type HookName }
|
|
19
|
+
|
|
20
|
+
/** ResourceAPI method names bridged over RPC. */
|
|
21
|
+
export const API_METHOD_NAMES = [
|
|
22
|
+
"logInfo",
|
|
23
|
+
"logWarn",
|
|
24
|
+
"logError",
|
|
25
|
+
"listFileNames",
|
|
26
|
+
"readFile",
|
|
27
|
+
"statFile",
|
|
28
|
+
"statFiles",
|
|
29
|
+
"sniff",
|
|
30
|
+
"probe",
|
|
31
|
+
"hashBytes",
|
|
32
|
+
"computeImageHashes",
|
|
33
|
+
"listContainer",
|
|
34
|
+
"extractArchive",
|
|
35
|
+
"download",
|
|
36
|
+
"statAsset",
|
|
37
|
+
"readAsset",
|
|
38
|
+
"deleteAsset",
|
|
39
|
+
] as const
|
|
40
|
+
|
|
41
|
+
export type ApiMethodName = (typeof API_METHOD_NAMES)[number]
|
|
42
|
+
|
|
43
|
+
/** Fire-and-forget log methods — no response round-trip. */
|
|
44
|
+
export const LOG_METHOD_NAMES: ReadonlySet<ApiMethodName> = new Set([
|
|
45
|
+
"logInfo",
|
|
46
|
+
"logWarn",
|
|
47
|
+
"logError",
|
|
48
|
+
])
|
|
49
|
+
|
|
50
|
+
export type SerializedError = {
|
|
51
|
+
readonly name: string
|
|
52
|
+
readonly message: string
|
|
53
|
+
readonly stack?: string
|
|
54
|
+
/**
|
|
55
|
+
* Machine-readable plugin error code (`DENIED`/`UNAVAILABLE`/`POLICY`)
|
|
56
|
+
* carried verbatim on the wire — the single field the host side reads
|
|
57
|
+
* to restore the plugin-facing `err.name`.
|
|
58
|
+
*/
|
|
59
|
+
readonly code?: string
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// -- host → worker --
|
|
63
|
+
|
|
64
|
+
export type LoadRequest = {
|
|
65
|
+
readonly type: "load"
|
|
66
|
+
readonly mainPath: string
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export type InvokeRequest = {
|
|
70
|
+
readonly type: "invoke"
|
|
71
|
+
readonly callId: number
|
|
72
|
+
readonly hook: HookName
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export type ApiResponse = {
|
|
76
|
+
readonly type: "apiResult"
|
|
77
|
+
readonly apiCallId: number
|
|
78
|
+
readonly ok: boolean
|
|
79
|
+
readonly value?: unknown
|
|
80
|
+
readonly error?: SerializedError
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// -- worker → host --
|
|
84
|
+
|
|
85
|
+
export type LoadResponse = {
|
|
86
|
+
readonly type: "loaded"
|
|
87
|
+
readonly ok: boolean
|
|
88
|
+
readonly hooks?: readonly HookName[]
|
|
89
|
+
readonly error?: SerializedError
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export type InvokeResponse = {
|
|
93
|
+
readonly type: "result"
|
|
94
|
+
readonly callId: number
|
|
95
|
+
readonly ok: boolean
|
|
96
|
+
readonly value?: unknown
|
|
97
|
+
readonly error?: SerializedError
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export type ApiRequest = {
|
|
101
|
+
readonly type: "api"
|
|
102
|
+
readonly callId: number
|
|
103
|
+
readonly apiCallId: number
|
|
104
|
+
readonly method: ApiMethodName
|
|
105
|
+
readonly args: readonly unknown[]
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export type LogRequest = {
|
|
109
|
+
readonly type: "log"
|
|
110
|
+
readonly callId: number
|
|
111
|
+
readonly method: "logInfo" | "logWarn" | "logError"
|
|
112
|
+
readonly args: readonly unknown[]
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export type WorkerMessage =
|
|
116
|
+
| LoadResponse
|
|
117
|
+
| InvokeResponse
|
|
118
|
+
| ApiRequest
|
|
119
|
+
| LogRequest
|
|
120
|
+
|
|
121
|
+
export function serializeError(err: unknown): SerializedError {
|
|
122
|
+
if (err instanceof Error) {
|
|
123
|
+
return {
|
|
124
|
+
name: err.name,
|
|
125
|
+
message: err.message,
|
|
126
|
+
stack: err.stack,
|
|
127
|
+
// The vocabulary errors carry their code in `name`; the wire
|
|
128
|
+
// keeps an explicit `code` field so the receiving side never
|
|
129
|
+
// re-parses or re-derives it.
|
|
130
|
+
code: isAssetErrorName(err.name) ? err.name : undefined,
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return { name: "Error", message: String(err) }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function deserializeError(err: SerializedError): Error {
|
|
137
|
+
const e = new Error(err.message)
|
|
138
|
+
// `code` is authoritative when present (the vocabulary survived);
|
|
139
|
+
// `name` falls back for plain host errors.
|
|
140
|
+
e.name = err.code ?? err.name
|
|
141
|
+
if (err.stack !== undefined) e.stack = err.stack
|
|
142
|
+
return e
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function isAssetErrorName(name: string): boolean {
|
|
146
|
+
return (PLUGIN_ASSET_ERROR_NAMES as readonly string[]).includes(name)
|
|
147
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { definePlugin } from "../define-plugin.ts"
|
|
2
|
+
import type { PluginDefinition, ResourceAPI } from "../types.ts"
|
|
3
|
+
import type { HookName } from "./protocol.ts"
|
|
4
|
+
|
|
5
|
+
export type InvokeFn = (hook: HookName, api: ResourceAPI) => Promise<unknown>
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Build a contract-compliant {@link PluginDefinition} that forwards hooks
|
|
9
|
+
* into a sandboxed worker. Only hooks the plugin actually implements are
|
|
10
|
+
* exposed — presence is semantic (orchestrators branch on e.g.
|
|
11
|
+
* `plugin.listFiles === undefined`).
|
|
12
|
+
*/
|
|
13
|
+
export function createSandboxedPlugin(
|
|
14
|
+
hooks: readonly HookName[],
|
|
15
|
+
invoke: InvokeFn,
|
|
16
|
+
): PluginDefinition {
|
|
17
|
+
// RPC boundary: hook signatures share the shape `(api) => Promise<unknown>`;
|
|
18
|
+
// the concrete return type is enforced by the plugin contract at runtime.
|
|
19
|
+
const definition: Record<string, (api: ResourceAPI) => Promise<unknown>> = {}
|
|
20
|
+
for (const hook of hooks) {
|
|
21
|
+
definition[hook] = async (api: ResourceAPI) => invoke(hook, api)
|
|
22
|
+
}
|
|
23
|
+
if (definition.detect === undefined) {
|
|
24
|
+
definition.detect = async () => ({
|
|
25
|
+
ok: false,
|
|
26
|
+
reasons: ["plugin does not implement detect()"],
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
return definePlugin(definition as unknown as PluginDefinition)
|
|
30
|
+
}
|