@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
package/LICENSE
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Wooloo <ayan0312000@gmail.com>
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
|
6
|
+
associated documentation files (the "Software"), to deal in the Software without restriction, including
|
|
7
|
+
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
8
|
+
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
|
|
9
|
+
following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial
|
|
12
|
+
portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
|
|
15
|
+
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
|
|
16
|
+
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
|
17
|
+
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
|
|
18
|
+
USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# @hoardodile/host
|
|
2
|
+
|
|
3
|
+
The plugin runtime host: capability sandbox (one restricted child process
|
|
4
|
+
per plugin, spawned via the package's own `worker-entry` subpath), hook
|
|
5
|
+
strategy, resource containers, probe cache, capability guard, and settings
|
|
6
|
+
store. Consumed by the app server and by the developer CLI
|
|
7
|
+
(`@hoardodile/cli`) — `hooks.ts` is the only way to invoke plugin hooks.
|
|
8
|
+
|
|
9
|
+
**Sandbox boundary.** Each plugin's `main.js` runs in a forked child
|
|
10
|
+
process under Node's permission model (fs read limited to the plugin's
|
|
11
|
+
own directory, no fs write, no child processes, no worker threads, no
|
|
12
|
+
native addons) plus a module policy hook (registered in the entry via
|
|
13
|
+
`module.registerHooks`, synchronous and main-thread — no additional grants
|
|
14
|
+
needed) that denies every module outside the plugin directory and every
|
|
15
|
+
`node:` builtin except `node:url`, a scrubbed global surface
|
|
16
|
+
(`fetch`/`WebSocket`/`EventSource` throw, `process.env` is empty), and a
|
|
17
|
+
startup self-check that refuses to run when the permission model or the
|
|
18
|
+
module policy is not active. The only privileged interface left is the
|
|
19
|
+
`ResourceAPI` RPC: paths are resource-relative and every call executes
|
|
20
|
+
host-side. The sandbox is a capability boundary against plugin code (fault
|
|
21
|
+
isolation: watchdog, hard timeout, memory cap, respawn budget), not an
|
|
22
|
+
OS-level sandbox — plugins still share the server's OS user.
|
|
23
|
+
|
|
24
|
+
> **Not a plugin SDK package.** This is the app-side runtime: the
|
|
25
|
+
> authoring surface (`definePlugin`, `ResourceAPI`, fixtures) lives in
|
|
26
|
+
> `@hoardodile/sdk-types` and is re-exported by `@hoardodile/sdk-server`;
|
|
27
|
+
> everything else here (loader, discovery, sandbox, containers) exists
|
|
28
|
+
> for the app server and the CLI. Plugin code imports `@hoardodile/sdk-server`
|
|
29
|
+
> — except `runPluginHook`, which plugins consume from this package as a
|
|
30
|
+
> devDependency for Layer-2 tests (never shipped in a plugin bundle).
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pnpm add @hoardodile/host
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The content sniffer and the image/video/audio probes (`sniffBytes`,
|
|
39
|
+
`probeImageSource`, `probeAvMedia`, ...) load `sharp` and the
|
|
40
|
+
`ffmpeg-static` / `@derhuerst/ffprobe-static` binaries lazily at runtime;
|
|
41
|
+
`sharp` is an optional peer, the binaries are optional dependencies and
|
|
42
|
+
install automatically (skip with `--no-optional`; the loaders fall back
|
|
43
|
+
to PATH).
|
|
44
|
+
|
|
45
|
+
## Subpaths
|
|
46
|
+
|
|
47
|
+
| Entry | Contents |
|
|
48
|
+
| ----- | -------- |
|
|
49
|
+
| `@hoardodile/host` | Full runtime surface: loader, activation, discovery, sandbox, hook strategy, ResourceAPI builders, containers, probes, `runPluginHook` |
|
|
50
|
+
| `@hoardodile/host/probe` | Reusable probe implementations (image/video/audio metadata, animation detection) |
|
|
51
|
+
| `@hoardodile/host/contract` | The plugin contract vitest suite — validates a `PluginDefinition` against the real host. `vitest` is an optional peer |
|
|
52
|
+
| `@hoardodile/host/worker-entry` | Sandbox child-process entry (`worker-entry.mjs`, module policy inline); resolves from any bundle |
|
|
53
|
+
|
|
54
|
+
## CLI
|
|
55
|
+
|
|
56
|
+
The developer CLI lives in `@hoardodile/cli`:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
hoardodile plugin run <hook> <data-dir> --plugin-dir dist
|
|
60
|
+
hoardodile plugin bench <hook> <data-dir> --plugin-dir dist --compare baseline.json
|
|
61
|
+
hoardodile plugin dev # watch-build + workbench at http://127.0.0.1:5199
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`run`/`bench` execute hooks through this package's sandbox — what
|
|
65
|
+
you test is what runs in production. `bench` writes JSON reports (with
|
|
66
|
+
machine fingerprint, peak RSS and warmup count); `--compare` exits 1 on
|
|
67
|
+
regression and warns on cross-machine baselines.
|
|
68
|
+
|
|
69
|
+
## Licensing
|
|
70
|
+
|
|
71
|
+
MIT. This package is the terminal runtime (consumed by the GPL-3.0 app
|
|
72
|
+
server and by plugin dev tooling); it is not part of the SDK closure and
|
|
73
|
+
plugin code never links it.
|
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Readable } from 'node:stream';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Read-only view over a resource's source data, regardless of storage
|
|
5
|
+
* shape — a zip archive, a raw directory, or an in-memory fixture. The
|
|
6
|
+
* {@link ResourceAPI} builder consumes this interface, so every backend
|
|
7
|
+
* (fixture / directory / zip / the server's artifact view) behaves
|
|
8
|
+
* identically behind a plugin hook.
|
|
9
|
+
*
|
|
10
|
+
* `relPath` values are entry names as they appear in the container (may
|
|
11
|
+
* contain `/`). Containers are immutable for the lifetime of a build:
|
|
12
|
+
* the server's per-version archives never change, and the directory
|
|
13
|
+
* container documents reads as snapshot-only.
|
|
14
|
+
*/
|
|
15
|
+
type ResourceContainer = {
|
|
16
|
+
/** List every entry name in the container. Always flat. */
|
|
17
|
+
readonly listEntries: () => Promise<readonly string[]>;
|
|
18
|
+
/** Read `relPath` in full. Throws when the entry does not exist. */
|
|
19
|
+
readonly readEntry: (relPath: string) => Promise<Buffer>;
|
|
20
|
+
/**
|
|
21
|
+
* Read the byte range `[start, end)` of `relPath` (`end` exclusive).
|
|
22
|
+
* The range is clamped to the entry size; an out-of-range start
|
|
23
|
+
* resolves to an empty buffer.
|
|
24
|
+
*/
|
|
25
|
+
readonly readEntrySlice: (relPath: string, start: number, end: number) => Promise<Buffer>;
|
|
26
|
+
/**
|
|
27
|
+
* Stream entry bytes without buffering the whole entry. `path` is
|
|
28
|
+
* the absolute on-disk file backing a literal entry, when there is
|
|
29
|
+
* one — consumers serve ranges through `createReadStream(path,
|
|
30
|
+
* {start, end})` windows instead of draining + discarding the
|
|
31
|
+
* stream's prefix.
|
|
32
|
+
*/
|
|
33
|
+
readonly openEntryStream: (relPath: string) => Promise<{
|
|
34
|
+
readonly stream: Readable;
|
|
35
|
+
readonly size: number;
|
|
36
|
+
/** Modification time of the underlying file, when known. */
|
|
37
|
+
readonly mtimeMs?: number;
|
|
38
|
+
/** Absolute path of the backing file, when literal and seekable. */
|
|
39
|
+
readonly path?: string;
|
|
40
|
+
}>;
|
|
41
|
+
/**
|
|
42
|
+
* Resolve `relPath` to its byte length. Returns `undefined` when the
|
|
43
|
+
* entry does not exist (the caller decides how to report that).
|
|
44
|
+
*/
|
|
45
|
+
readonly resolveByteRange: (relPath: string) => Promise<{
|
|
46
|
+
readonly size: number;
|
|
47
|
+
} | undefined>;
|
|
48
|
+
/**
|
|
49
|
+
* Optional capability: resolve `relPath` to an absolute on-disk path
|
|
50
|
+
* when its bytes can be read as a seekable file (no extraction, no
|
|
51
|
+
* decompression). Consumers like the thumbnail pipeline hand such
|
|
52
|
+
* paths straight to ffmpeg/libvips instead of piping streams.
|
|
53
|
+
* Returns `undefined` when the container cannot provide one.
|
|
54
|
+
*/
|
|
55
|
+
readonly resolveSeekablePath?: (relPath: string) => Promise<string | undefined>;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export type { ResourceContainer as R };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { R as ResourceContainer } from '../container-fKxefO7p.js';
|
|
2
|
+
import { ResourceAPI } from '@hoardodile/sdk-types';
|
|
3
|
+
import 'node:stream';
|
|
4
|
+
|
|
5
|
+
/** Declarative in-memory content for a {@link ResourceContainer}. */
|
|
6
|
+
type ContainerFixtureConfig = {
|
|
7
|
+
/** Entry name → content. Names may contain `/` for nesting. */
|
|
8
|
+
readonly files?: Readonly<Record<string, string | Uint8Array>>;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* An in-memory {@link ResourceContainer} driven by a declarative config.
|
|
12
|
+
* The test double in the container contract suite: every assertion runs
|
|
13
|
+
* against it, the directory container and the zip container, and the
|
|
14
|
+
* three must agree.
|
|
15
|
+
*/
|
|
16
|
+
declare function createContainerFixture(config?: ContainerFixtureConfig): ResourceContainer;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Shared contract suite for the container-backed {@link ResourceAPI}
|
|
20
|
+
* implementations. Every backend (in-memory fixture, directory, zip
|
|
21
|
+
* archive, and the server's own artifact view) must behave identically:
|
|
22
|
+
* same entries, same bytes, same clamping, same probe semantics.
|
|
23
|
+
*
|
|
24
|
+
* Consumers build each backend from a declarative file map, then run the
|
|
25
|
+
* whole suite with `containerContractSuite([...])` inside a test file.
|
|
26
|
+
*/
|
|
27
|
+
/** A declarative container case: entry name → content. */
|
|
28
|
+
type ContainerContractCase = {
|
|
29
|
+
readonly name: string;
|
|
30
|
+
readonly files: Readonly<Record<string, string | Uint8Array>>;
|
|
31
|
+
};
|
|
32
|
+
declare const CONTAINER_CONTRACT_CASES: readonly ContainerContractCase[];
|
|
33
|
+
type ContractBackend = {
|
|
34
|
+
readonly name: string;
|
|
35
|
+
/**
|
|
36
|
+
* Build a {@link ResourceAPI} from a declarative file map. Called
|
|
37
|
+
* once per case; must produce an independent instance each time.
|
|
38
|
+
*/
|
|
39
|
+
readonly build: (files: Readonly<Record<string, string | Uint8Array>>) => Promise<ResourceAPI>;
|
|
40
|
+
/** Optional teardown for resources created by `build` calls. */
|
|
41
|
+
readonly cleanup?: () => Promise<void> | void;
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Run the full container contract suite against the given backends.
|
|
45
|
+
* Must be called from within a vitest test file.
|
|
46
|
+
*/
|
|
47
|
+
declare function containerContractSuite(backends: readonly ContractBackend[]): void;
|
|
48
|
+
|
|
49
|
+
export { CONTAINER_CONTRACT_CASES, type ContainerContractCase, type ContainerFixtureConfig, type ContractBackend, containerContractSuite, createContainerFixture };
|