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