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