@polderlabs/bizar 4.9.0 → 5.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/bizar-dash/dist/assets/icons-CFqu2M-c.js +656 -0
- package/bizar-dash/dist/assets/icons-CFqu2M-c.js.map +1 -0
- package/bizar-dash/dist/assets/{index-DU61awG3.js → index-DmpSFPJY.js} +1 -1
- package/bizar-dash/dist/assets/{index-DU61awG3.js.map → index-DmpSFPJY.js.map} +1 -1
- package/bizar-dash/dist/assets/main-Dl8yY5_H.js +16 -0
- package/bizar-dash/dist/assets/main-Dl8yY5_H.js.map +1 -0
- package/bizar-dash/dist/assets/{main-DfmIfOUS.css → main-ZAfGKENE.css} +1 -1
- package/bizar-dash/dist/assets/markdown-DIquRulQ.js +29 -0
- package/bizar-dash/dist/assets/markdown-DIquRulQ.js.map +1 -0
- package/bizar-dash/dist/assets/mobile-C2gysFOZ.js +2 -0
- package/bizar-dash/dist/assets/mobile-C2gysFOZ.js.map +1 -0
- package/bizar-dash/dist/assets/mobile-DHXXbn1A.js +1 -0
- package/bizar-dash/dist/assets/{mobile-CL5uUQEC.js.map → mobile-DHXXbn1A.js.map} +1 -1
- package/bizar-dash/dist/assets/react-vendor-DZRUXSPQ.js +40 -0
- package/bizar-dash/dist/assets/react-vendor-DZRUXSPQ.js.map +1 -0
- package/bizar-dash/dist/index.html +6 -3
- package/bizar-dash/dist/mobile.html +5 -2
- package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
- package/bizar-dash/skills/eval/SKILL.md +237 -0
- package/bizar-dash/src/server/api.mjs +28 -0
- package/bizar-dash/src/server/auth.mjs +155 -1
- package/bizar-dash/src/server/eval-store.mjs +226 -0
- package/bizar-dash/src/server/eval.mjs +347 -0
- package/bizar-dash/src/server/ocr.mjs +55 -0
- package/bizar-dash/src/server/plugins/registry.mjs +363 -0
- package/bizar-dash/src/server/plugins/sandbox.mjs +655 -0
- package/bizar-dash/src/server/plugins/store.mjs +659 -0
- package/bizar-dash/src/server/routes/clipboard.mjs +173 -0
- package/bizar-dash/src/server/routes/eval.mjs +147 -0
- package/bizar-dash/src/server/routes/ocr.mjs +182 -0
- package/bizar-dash/src/server/routes/plugins.mjs +220 -0
- package/bizar-dash/src/server/routes/users.mjs +84 -0
- package/bizar-dash/src/server/routes/voice.mjs +131 -0
- package/bizar-dash/src/server/routes/workspaces.mjs +204 -0
- package/bizar-dash/src/server/voice-store.mjs +202 -0
- package/bizar-dash/src/server/voice-transcribe.mjs +72 -0
- package/bizar-dash/src/server/workspaces.mjs +626 -0
- package/bizar-dash/src/web/components/InviteDialog.tsx +205 -0
- package/bizar-dash/src/web/components/ScreenshotCapture.tsx +138 -0
- package/bizar-dash/src/web/components/ScreenshotOCR.tsx +42 -0
- package/bizar-dash/src/web/components/VoiceNotesPanel.tsx +182 -0
- package/bizar-dash/src/web/components/VoiceRecorder.tsx +104 -0
- package/bizar-dash/src/web/components/WorkspaceSelector.tsx +158 -0
- package/bizar-dash/src/web/styles/memory.css +82 -0
- package/bizar-dash/src/web/views/Memory.tsx +17 -2
- package/bizar-dash/src/web/views/Settings.tsx +3 -0
- package/bizar-dash/src/web/views/Workspace.tsx +294 -0
- package/bizar-dash/src/web/views/memory/FromScreenshotPanel.tsx +23 -0
- package/bizar-dash/src/web/views/memory/VaultFromClipboardPanel.tsx +101 -0
- package/bizar-dash/src/web/views/settings/WorkspacesSection.tsx +189 -0
- package/bizar-dash/tests/bundle-analysis.test.mjs +5 -2
- package/bizar-dash/tests/clipboard.test.mjs +147 -0
- package/bizar-dash/tests/components/screenshot-ocr.test.tsx +75 -0
- package/bizar-dash/tests/components/workspace-selector.test.tsx +73 -0
- package/bizar-dash/tests/deploy-templates.test.mjs +100 -0
- package/bizar-dash/tests/eval/fixtures.test.mjs +141 -0
- package/bizar-dash/tests/eval/report.test.mjs +284 -0
- package/bizar-dash/tests/eval/runner.test.mjs +471 -0
- package/bizar-dash/tests/ocr.test.mjs +87 -0
- package/bizar-dash/tests/plugins-registry.test.mjs +387 -0
- package/bizar-dash/tests/plugins-sandbox.test.mjs +374 -0
- package/bizar-dash/tests/plugins-store.test.mjs +455 -0
- package/bizar-dash/tests/users.test.mjs +108 -0
- package/bizar-dash/tests/voice-recorder.test.tsx +95 -0
- package/bizar-dash/tests/voice-store.test.mjs +148 -0
- package/bizar-dash/tests/voice-transcribe.test.mjs +87 -0
- package/bizar-dash/tests/workspaces.test.mjs +527 -0
- package/cli/bin.mjs +72 -2
- package/cli/commands/clip.mjs +146 -0
- package/cli/commands/deploy/cloudflare.mjs +250 -0
- package/cli/commands/deploy/docker.mjs +221 -0
- package/cli/commands/deploy/fly.mjs +161 -0
- package/cli/commands/deploy/vercel.mjs +225 -0
- package/cli/commands/deploy.mjs +240 -0
- package/cli/commands/eval.mjs +378 -0
- package/cli/commands/marketplace.mjs +64 -0
- package/cli/commands/ocr.mjs +165 -0
- package/cli/commands/plugin.mjs +358 -0
- package/cli/commands/voice.mjs +211 -0
- package/cli/commands/workspace.mjs +247 -0
- package/package.json +12 -8
- package/templates/deploy/cloudflare/README.md +32 -0
- package/templates/deploy/cloudflare/functions-index.template.js +15 -0
- package/templates/deploy/cloudflare/wrangler.toml.template +9 -0
- package/templates/deploy/docker/.env.template +16 -0
- package/templates/deploy/docker/README.md +58 -0
- package/templates/deploy/docker/docker-compose.template.yml +23 -0
- package/templates/deploy/fly/README.md +35 -0
- package/templates/deploy/fly/fly.toml.template +28 -0
- package/templates/deploy/vercel/README.md +29 -0
- package/templates/deploy/vercel/api-index.template.js +18 -0
- package/templates/deploy/vercel/vercel.json.template +16 -0
- package/templates/eval-fixtures/README.md +58 -0
- package/templates/eval-fixtures/code-search-basic.json +28 -0
- package/templates/eval-fixtures/latency-bounds.json +16 -0
- package/templates/eval-fixtures/regression-suite.json +79 -0
- package/templates/eval-fixtures/response-format.json +30 -0
- package/templates/eval-fixtures/tool-call-correctness.json +24 -0
- package/templates/plugin-template/README.md +121 -0
- package/templates/plugin-template/index.js +66 -0
- package/templates/plugin-template/plugin.json +42 -0
- package/templates/plugin-template/tests/plugin.test.js +83 -0
- package/bizar-dash/dist/assets/main-DaC1Lc6q.js +0 -366
- package/bizar-dash/dist/assets/main-DaC1Lc6q.js.map +0 -1
- package/bizar-dash/dist/assets/mobile-CL5uUQEC.js +0 -1
- package/bizar-dash/dist/assets/mobile-D5WTWvuh.js +0 -338
- package/bizar-dash/dist/assets/mobile-D5WTWvuh.js.map +0 -1
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/server/plugins/sandbox.mjs
|
|
3
|
+
*
|
|
4
|
+
* v5.0.0 — Plugin execution sandbox.
|
|
5
|
+
*
|
|
6
|
+
* Plugins run in a restricted `node:vm` context with the dangerous
|
|
7
|
+
* globals stripped (`process`, `require`, `child_process`, raw `fs`).
|
|
8
|
+
* The plugin can ONLY reach the outside world through a curated
|
|
9
|
+
* `api` object whose methods are gated by the plugin's declared
|
|
10
|
+
* permissions in `plugin.json`.
|
|
11
|
+
*
|
|
12
|
+
* Why vm and not child_process?
|
|
13
|
+
* - Speed: a vm fork starts in microseconds; a child fork is 100+ ms.
|
|
14
|
+
* - Memory: a vm context shares the parent's heap (we cap via the
|
|
15
|
+
* process-level `--max-old-space-size` setting); child processes
|
|
16
|
+
* duplicate everything.
|
|
17
|
+
* - Clean error propagation: a thrown plugin error becomes a
|
|
18
|
+
* structured { ok: false, error } without IPC round-trips.
|
|
19
|
+
*
|
|
20
|
+
* Timeout model:
|
|
21
|
+
* - Synchronous part of the plugin's `main` is bounded by
|
|
22
|
+
* `vm.Script` timeout.
|
|
23
|
+
* - Async methods are bounded by an outer `Promise.race` against a
|
|
24
|
+
* setTimeout. Default 30 s.
|
|
25
|
+
*
|
|
26
|
+
* Memory limit (note): Node's vm does NOT expose per-context heap caps.
|
|
27
|
+
* We surface a `memoryLimitMb` advisory value but cannot enforce it at
|
|
28
|
+
* the vm layer. For real isolation, deploy with `--max-old-space-size`
|
|
29
|
+
* on the dashboard process.
|
|
30
|
+
*/
|
|
31
|
+
import vm from 'node:vm';
|
|
32
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
33
|
+
import { resolve as pathResolve } from 'node:path';
|
|
34
|
+
import { randomUUID } from 'node:crypto';
|
|
35
|
+
import * as logger from '../logger.mjs';
|
|
36
|
+
|
|
37
|
+
/** Default timeout for both script compile + async method execution. */
|
|
38
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
39
|
+
|
|
40
|
+
/** Advisory memory limit (not enforced by vm; documented in plugin docs). */
|
|
41
|
+
const DEFAULT_MEMORY_LIMIT_MB = 128;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Globals we expose to the plugin's vm context. Everything NOT in this
|
|
45
|
+
* list is undefined inside the sandbox — `process`, `require`, `global`,
|
|
46
|
+
* `globalThis.module`, raw `fs`, `child_process` are all stripped.
|
|
47
|
+
*
|
|
48
|
+
* Notes:
|
|
49
|
+
* - `URL` and `JSON` are safe and useful; allow them.
|
|
50
|
+
* - `Math`, `Date`, `Object`, `Array`, etc. are intrinsic prototypes
|
|
51
|
+
* that vm inherits automatically — no need to list them.
|
|
52
|
+
* - `console` is replaced with a logger-routed shim so plugin output
|
|
53
|
+
* flows through the structured logger with the plugin's id tagged.
|
|
54
|
+
* - `setTimeout` / `setInterval` / `clearTimeout` / `clearInterval`
|
|
55
|
+
* are passed through; plugins may want to debounce, etc.
|
|
56
|
+
* - `Promise` and async/await work out of the box.
|
|
57
|
+
* - `Buffer` is intentionally NOT exposed — it can be used to
|
|
58
|
+
* construct arbitrary binary blobs and exfiltrate via fetch. v2 may
|
|
59
|
+
* add an opt-in `buffer` permission.
|
|
60
|
+
*/
|
|
61
|
+
const ALLOWED_GLOBALS = new Set([
|
|
62
|
+
'URL', 'URLSearchParams',
|
|
63
|
+
'JSON',
|
|
64
|
+
'Map', 'Set', 'WeakMap', 'WeakSet',
|
|
65
|
+
'Promise',
|
|
66
|
+
'Symbol',
|
|
67
|
+
'TextEncoder', 'TextDecoder',
|
|
68
|
+
'setTimeout', 'setInterval', 'clearTimeout', 'clearInterval',
|
|
69
|
+
'queueMicrotask',
|
|
70
|
+
'console',
|
|
71
|
+
'api',
|
|
72
|
+
'plugin', // metadata exposed for diagnostics
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Permission categories we honour at the sandbox boundary.
|
|
77
|
+
* Anything else in the manifest's permissions list is ignored
|
|
78
|
+
* (and logged as a warning so the plugin author sees it).
|
|
79
|
+
*/
|
|
80
|
+
const KNOWN_PERMS = new Set(['net', 'fs:read']);
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Build the `api` object exposed to the plugin. Each method either
|
|
84
|
+
* works (if the corresponding permission is granted) or throws a
|
|
85
|
+
* structured "permission denied" error.
|
|
86
|
+
*
|
|
87
|
+
* @param {object} ctx
|
|
88
|
+
* @param {Set<string>} ctx.permissions granted permission set (canonical form)
|
|
89
|
+
* @param {Record<string, unknown>} ctx.config plugin config (already validated)
|
|
90
|
+
* @param {string} ctx.pluginId
|
|
91
|
+
* @param {(cfg: Record<string, unknown>) => void} [ctx.onConfigChange]
|
|
92
|
+
* @param {{ fetchImpl?: typeof globalThis.fetch, pluginRoot?: string }} [ctx.deps]
|
|
93
|
+
* @returns {Record<string, any>}
|
|
94
|
+
*/
|
|
95
|
+
export function buildApi({ permissions, config, pluginId, onConfigChange, deps = {} }) {
|
|
96
|
+
const fetchFn = deps.fetchImpl || globalThis.fetch;
|
|
97
|
+
const pluginRoot = deps.pluginRoot;
|
|
98
|
+
const childLog = logger.child({ module: 'plugin', pluginId });
|
|
99
|
+
|
|
100
|
+
function requirePerm(name) {
|
|
101
|
+
if (!permissions.has(name)) {
|
|
102
|
+
const err = new Error(`permission denied: plugin "${pluginId}" needs "${name}"`);
|
|
103
|
+
err.code = 'permission_denied';
|
|
104
|
+
err.permission = name;
|
|
105
|
+
throw err;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const http = {
|
|
110
|
+
/**
|
|
111
|
+
* `api.http.get(url)` — only available with the `net` permission.
|
|
112
|
+
* Returns parsed JSON if Content-Type is JSON, otherwise text.
|
|
113
|
+
* Throws on non-2xx with the body included for debugging.
|
|
114
|
+
*/
|
|
115
|
+
async get(url, opts = {}) {
|
|
116
|
+
requirePerm('net');
|
|
117
|
+
if (typeof url !== 'string' || !/^https?:\/\//.test(url)) {
|
|
118
|
+
throw new Error('api.http.get: only http(s) URLs are allowed');
|
|
119
|
+
}
|
|
120
|
+
const res = await fetchFn(url, { method: 'GET', ...opts });
|
|
121
|
+
if (!res.ok) {
|
|
122
|
+
const text = await res.text().catch(() => '');
|
|
123
|
+
const err = new Error(`HTTP ${res.status}: ${text.slice(0, 200)}`);
|
|
124
|
+
err.status = res.status;
|
|
125
|
+
throw err;
|
|
126
|
+
}
|
|
127
|
+
const ct = res.headers.get('content-type') || '';
|
|
128
|
+
return ct.includes('application/json') ? res.json() : res.text();
|
|
129
|
+
},
|
|
130
|
+
async post(url, body, opts = {}) {
|
|
131
|
+
requirePerm('net');
|
|
132
|
+
if (typeof url !== 'string' || !/^https?:\/\//.test(url)) {
|
|
133
|
+
throw new Error('api.http.post: only http(s) URLs are allowed');
|
|
134
|
+
}
|
|
135
|
+
const res = await fetchFn(url, {
|
|
136
|
+
method: 'POST',
|
|
137
|
+
headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) },
|
|
138
|
+
body: typeof body === 'string' ? body : JSON.stringify(body),
|
|
139
|
+
...opts,
|
|
140
|
+
});
|
|
141
|
+
if (!res.ok) {
|
|
142
|
+
const text = await res.text().catch(() => '');
|
|
143
|
+
const err = new Error(`HTTP ${res.status}: ${text.slice(0, 200)}`);
|
|
144
|
+
err.status = res.status;
|
|
145
|
+
throw err;
|
|
146
|
+
}
|
|
147
|
+
const ct = res.headers.get('content-type') || '';
|
|
148
|
+
return ct.includes('application/json') ? res.json() : res.text();
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const fs = {
|
|
153
|
+
/**
|
|
154
|
+
* `api.fs.read(path)` — only available with the `fs:read` permission.
|
|
155
|
+
* Path is sandboxed to the plugin's install dir + a small
|
|
156
|
+
* whitelist of safe locations (plugin root, $HOME/.cache/bizar).
|
|
157
|
+
* Anything else throws `permission_denied` with a useful message.
|
|
158
|
+
*/
|
|
159
|
+
read(filePath, opts = {}) {
|
|
160
|
+
requirePerm('fs:read');
|
|
161
|
+
if (typeof filePath !== 'string' || !filePath) {
|
|
162
|
+
throw new Error('api.fs.read: path must be a non-empty string');
|
|
163
|
+
}
|
|
164
|
+
const safe = sandboxReadPath(filePath, pluginRoot);
|
|
165
|
+
const enc = opts.encoding === undefined ? 'utf8' : opts.encoding;
|
|
166
|
+
return readFileSync(safe, { encoding: enc });
|
|
167
|
+
},
|
|
168
|
+
stat(filePath) {
|
|
169
|
+
requirePerm('fs:read');
|
|
170
|
+
const safe = sandboxReadPath(filePath, pluginRoot);
|
|
171
|
+
return statSync(safe);
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const cfg = {
|
|
176
|
+
/** `api.config.get(key)` — returns the value at `key` (dot path OK). */
|
|
177
|
+
get(key) {
|
|
178
|
+
if (typeof key !== 'string' || !key) return undefined;
|
|
179
|
+
const parts = key.split('.');
|
|
180
|
+
let cur = config;
|
|
181
|
+
for (const p of parts) {
|
|
182
|
+
if (cur == null || typeof cur !== 'object') return undefined;
|
|
183
|
+
cur = cur[p];
|
|
184
|
+
}
|
|
185
|
+
return cur;
|
|
186
|
+
},
|
|
187
|
+
/** `api.config.set(key, value)` — mutates in-memory config; persisted by the store. */
|
|
188
|
+
set(key, value) {
|
|
189
|
+
if (typeof key !== 'string' || !key) {
|
|
190
|
+
throw new Error('api.config.set: key must be a non-empty string');
|
|
191
|
+
}
|
|
192
|
+
const parts = key.split('.');
|
|
193
|
+
let cur = config;
|
|
194
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
195
|
+
const p = parts[i];
|
|
196
|
+
if (cur[p] == null || typeof cur[p] !== 'object') cur[p] = {};
|
|
197
|
+
cur = cur[p];
|
|
198
|
+
}
|
|
199
|
+
cur[parts[parts.length - 1]] = value;
|
|
200
|
+
if (typeof onConfigChange === 'function') {
|
|
201
|
+
try {
|
|
202
|
+
onConfigChange(config);
|
|
203
|
+
} catch (err) {
|
|
204
|
+
childLog.warn('plugin onConfigChange handler threw', { err: err.message });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
/** `api.config.all()` — returns a shallow copy of the full config. */
|
|
209
|
+
all() {
|
|
210
|
+
return JSON.parse(JSON.stringify(config));
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const api = {
|
|
215
|
+
http,
|
|
216
|
+
fs,
|
|
217
|
+
config: cfg,
|
|
218
|
+
/**
|
|
219
|
+
* `api.log(level, msg, ctx?)` — plugin-author friendly logger
|
|
220
|
+
* routing. Levels: debug | info | warn | error.
|
|
221
|
+
*/
|
|
222
|
+
log(level, msg, ctx) {
|
|
223
|
+
const allowed = ['debug', 'info', 'warn', 'error'];
|
|
224
|
+
if (!allowed.includes(level)) {
|
|
225
|
+
throw new Error(`api.log: level must be one of ${allowed.join(', ')}`);
|
|
226
|
+
}
|
|
227
|
+
if (typeof msg !== 'string') {
|
|
228
|
+
throw new Error('api.log: msg must be a string');
|
|
229
|
+
}
|
|
230
|
+
const safeCtx = ctx && typeof ctx === 'object' ? ctx : undefined;
|
|
231
|
+
childLog[level](msg, safeCtx);
|
|
232
|
+
},
|
|
233
|
+
/** Plugin metadata (read-only). */
|
|
234
|
+
info: {
|
|
235
|
+
id: pluginId,
|
|
236
|
+
permissions: [...permissions],
|
|
237
|
+
},
|
|
238
|
+
/** Stable per-invocation id; useful for tracing plugin calls in logs. */
|
|
239
|
+
invocationId: randomUUID(),
|
|
240
|
+
};
|
|
241
|
+
return api;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Sandbox `fs:read` to the plugin's install dir. Anything else throws.
|
|
246
|
+
* Symlinks are followed but the resulting path must still resolve
|
|
247
|
+
* inside the plugin root — we use realpath via `pathResolve` for the
|
|
248
|
+
* directory containment check, and read the literal path the plugin
|
|
249
|
+
* passed (Node's readFileSync follows symlinks itself, but we want
|
|
250
|
+
* any symlink that escapes the root to fail).
|
|
251
|
+
*
|
|
252
|
+
* @param {string} requested
|
|
253
|
+
* @param {string|undefined} pluginRoot
|
|
254
|
+
*/
|
|
255
|
+
function sandboxReadPath(requested, pluginRoot) {
|
|
256
|
+
if (!pluginRoot) {
|
|
257
|
+
const err = new Error('api.fs.read: pluginRoot not configured');
|
|
258
|
+
err.code = 'permission_denied';
|
|
259
|
+
throw err;
|
|
260
|
+
}
|
|
261
|
+
// Resolve relative paths against the plugin root; absolute paths
|
|
262
|
+
// are checked against the root's containment.
|
|
263
|
+
const abs = pathResolve(pluginRoot, requested);
|
|
264
|
+
if (abs !== pluginRoot && !abs.startsWith(pluginRoot + '/')) {
|
|
265
|
+
const err = new Error(
|
|
266
|
+
`api.fs.read: path "${requested}" is outside the plugin root`,
|
|
267
|
+
);
|
|
268
|
+
err.code = 'permission_denied';
|
|
269
|
+
throw err;
|
|
270
|
+
}
|
|
271
|
+
return abs;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Parse the plugin's declared permissions into a normalized set.
|
|
276
|
+
* Unknown permissions are returned in `invalid` so the loader can
|
|
277
|
+
* warn without failing the install.
|
|
278
|
+
*
|
|
279
|
+
* @param {unknown} perms raw permissions array from plugin.json
|
|
280
|
+
* @returns {{ allowed: Set<string>, invalid: string[] }}
|
|
281
|
+
*/
|
|
282
|
+
export function parsePluginPermissions(perms) {
|
|
283
|
+
const allowed = new Set();
|
|
284
|
+
const invalid = [];
|
|
285
|
+
if (!Array.isArray(perms)) {
|
|
286
|
+
return { allowed, invalid: [] };
|
|
287
|
+
}
|
|
288
|
+
for (const p of perms) {
|
|
289
|
+
if (typeof p !== 'string' || !p) {
|
|
290
|
+
invalid.push(String(p));
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
const trimmed = p.trim();
|
|
294
|
+
if (KNOWN_PERMS.has(trimmed)) {
|
|
295
|
+
allowed.add(trimmed);
|
|
296
|
+
} else {
|
|
297
|
+
invalid.push(trimmed);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return { allowed, invalid };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Create a fresh sandbox context object for use with `vm.runInContext`.
|
|
305
|
+
* Returns the `sandbox` you should pass to `vm.createContext(sandbox)`,
|
|
306
|
+
* plus the `console` shim so we can install it once.
|
|
307
|
+
*
|
|
308
|
+
* @param {object} api the `api` object from buildApi()
|
|
309
|
+
*/
|
|
310
|
+
function buildSandboxGlobals(api) {
|
|
311
|
+
const sandbox = {
|
|
312
|
+
api,
|
|
313
|
+
plugin: api.info,
|
|
314
|
+
// structured console — logs flow through the dashboard logger
|
|
315
|
+
// with `module: 'plugin'` tagging so user output never leaks into
|
|
316
|
+
// unrelated log channels.
|
|
317
|
+
console: {
|
|
318
|
+
debug: (...args) => safePluginLog('debug', args),
|
|
319
|
+
info: (...args) => safePluginLog('info', args),
|
|
320
|
+
warn: (...args) => safePluginLog('warn', args),
|
|
321
|
+
error: (...args) => safePluginLog('error', args),
|
|
322
|
+
log: (...args) => safePluginLog('info', args),
|
|
323
|
+
},
|
|
324
|
+
setTimeout, setInterval, clearTimeout, clearInterval,
|
|
325
|
+
queueMicrotask,
|
|
326
|
+
Promise,
|
|
327
|
+
Map, Set, WeakMap, WeakSet,
|
|
328
|
+
URL, URLSearchParams,
|
|
329
|
+
JSON, Math, Date, Object, Array,
|
|
330
|
+
Symbol,
|
|
331
|
+
TextEncoder, TextDecoder,
|
|
332
|
+
// No: process, require, Buffer, module, exports, global, globalThis,
|
|
333
|
+
// fetch (plugins must use api.http), child_process, fs, net, dns.
|
|
334
|
+
};
|
|
335
|
+
return sandbox;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function safePluginLog(level, args) {
|
|
339
|
+
try {
|
|
340
|
+
const msg = args
|
|
341
|
+
.map((a) => (typeof a === 'string' ? a : safeStringify(a)))
|
|
342
|
+
.join(' ');
|
|
343
|
+
logger[level](msg, { module: 'plugin' });
|
|
344
|
+
} catch {
|
|
345
|
+
/* never let a plugin's log call crash the dashboard */
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function safeStringify(value) {
|
|
350
|
+
try {
|
|
351
|
+
return JSON.stringify(value);
|
|
352
|
+
} catch {
|
|
353
|
+
return String(value);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Compile and execute a plugin's main script inside a fresh sandbox
|
|
359
|
+
* context. Returns the plugin's exports object (the value assigned
|
|
360
|
+
* to `module.exports` inside the wrapper).
|
|
361
|
+
*
|
|
362
|
+
* The plugin author writes CommonJS-style code:
|
|
363
|
+
*
|
|
364
|
+
* // index.js
|
|
365
|
+
* module.exports = {
|
|
366
|
+
* async echo(input) {
|
|
367
|
+
* return `echo: ${input}`;
|
|
368
|
+
* }
|
|
369
|
+
* };
|
|
370
|
+
*
|
|
371
|
+
* We wrap that in a tiny shim that provides `module`, then evaluate.
|
|
372
|
+
*
|
|
373
|
+
* @param {object} opts
|
|
374
|
+
* @param {string} opts.mainFile absolute path to the plugin's main JS file
|
|
375
|
+
* @param {Record<string, unknown>} opts.config
|
|
376
|
+
* @param {string[]} opts.permissions raw permission strings from plugin.json
|
|
377
|
+
* @param {string} opts.pluginId
|
|
378
|
+
* @param {string} opts.pluginRoot absolute path to the plugin install dir
|
|
379
|
+
* @param {(cfg: Record<string, unknown>) => void} [opts.onConfigChange]
|
|
380
|
+
* @param {number} [opts.timeoutMs]
|
|
381
|
+
* @param {number} [opts.memoryLimitMb]
|
|
382
|
+
* @param {typeof globalThis.fetch} [opts.fetchImpl]
|
|
383
|
+
* @returns {Promise<Record<string, Function>>}
|
|
384
|
+
*/
|
|
385
|
+
export async function loadPlugin(opts) {
|
|
386
|
+
const {
|
|
387
|
+
mainFile,
|
|
388
|
+
config,
|
|
389
|
+
permissions: rawPerms,
|
|
390
|
+
pluginId,
|
|
391
|
+
pluginRoot,
|
|
392
|
+
onConfigChange,
|
|
393
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
394
|
+
memoryLimitMb = DEFAULT_MEMORY_LIMIT_MB,
|
|
395
|
+
fetchImpl,
|
|
396
|
+
} = opts;
|
|
397
|
+
|
|
398
|
+
if (!mainFile || typeof mainFile !== 'string') {
|
|
399
|
+
throw new Error('loadPlugin: mainFile is required');
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const { allowed, invalid } = parsePluginPermissions(rawPerms);
|
|
403
|
+
if (invalid.length) {
|
|
404
|
+
logger.warn('plugin declares unknown permissions', {
|
|
405
|
+
module: 'plugin',
|
|
406
|
+
pluginId,
|
|
407
|
+
invalid,
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const api = buildApi({
|
|
412
|
+
permissions: allowed,
|
|
413
|
+
config: config || {},
|
|
414
|
+
pluginId,
|
|
415
|
+
onConfigChange,
|
|
416
|
+
deps: { fetchImpl, pluginRoot },
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
// The plugin's `module` / `exports` are exposed as vm globals. The
|
|
420
|
+
// user writes `module.exports = { ... }` and we capture the result
|
|
421
|
+
// by reading `module.exports` after the script runs. We deliberately
|
|
422
|
+
// expose `module` (a single object) rather than two separate
|
|
423
|
+
// parameters so `exports = ...` reassignments inside the plugin are
|
|
424
|
+
// NOT picked up — that matches Node.js CommonJS semantics.
|
|
425
|
+
const fakeModule = { exports: {} };
|
|
426
|
+
const sandbox = buildSandboxGlobals(api);
|
|
427
|
+
sandbox.module = fakeModule;
|
|
428
|
+
sandbox.exports = fakeModule.exports;
|
|
429
|
+
const context = vm.createContext(sandbox);
|
|
430
|
+
|
|
431
|
+
const source = readFileSync(mainFile, 'utf8');
|
|
432
|
+
// IIFE wrapper so the user code's top-level `return` statements
|
|
433
|
+
// work inside an arrow body, and so we capture the final
|
|
434
|
+
// `module.exports` value as the script's return value. The IIFE
|
|
435
|
+
// receives `__m` (the fake module) so it can read whatever the
|
|
436
|
+
// user assigned to `module.exports` — they may have set it via
|
|
437
|
+
// the bare `module` global too, in which case `__m.exports`
|
|
438
|
+
// matches by reference.
|
|
439
|
+
const wrapper =
|
|
440
|
+
`((function(__m){\n${source}\n;return __m.exports;\n}))(module)`;
|
|
441
|
+
let script;
|
|
442
|
+
try {
|
|
443
|
+
script = new vm.Script(wrapper, {
|
|
444
|
+
filename: mainFile,
|
|
445
|
+
lineOffset: 0,
|
|
446
|
+
displayErrors: true,
|
|
447
|
+
});
|
|
448
|
+
} catch (err) {
|
|
449
|
+
const wrap = new Error(`plugin "${pluginId}" failed to compile: ${err.message}`);
|
|
450
|
+
wrap.code = 'compile_error';
|
|
451
|
+
wrap.cause = err;
|
|
452
|
+
throw wrap;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
let exportsObj;
|
|
456
|
+
try {
|
|
457
|
+
exportsObj = script.runInContext(context, {
|
|
458
|
+
timeout: timeoutMs,
|
|
459
|
+
displayErrors: true,
|
|
460
|
+
});
|
|
461
|
+
} catch (err) {
|
|
462
|
+
const wrap = new Error(`plugin "${pluginId}" failed during init: ${err.message}`);
|
|
463
|
+
wrap.code = 'init_error';
|
|
464
|
+
wrap.cause = err;
|
|
465
|
+
throw wrap;
|
|
466
|
+
}
|
|
467
|
+
// `exportsObj` is what the wrapper returns: `__m.exports`. The user
|
|
468
|
+
// assigned their plugin's methods to it via `module.exports = {...}`.
|
|
469
|
+
// If they only assigned to `exports.foo = ...` (a common slip),
|
|
470
|
+
// those properties are visible via the `fakeModule.exports` reference
|
|
471
|
+
// — we re-read it here so both styles work.
|
|
472
|
+
const moduleExports = fakeModule.exports;
|
|
473
|
+
const resolved = (exportsObj && typeof exportsObj === 'object')
|
|
474
|
+
? exportsObj
|
|
475
|
+
: moduleExports;
|
|
476
|
+
if (!resolved || typeof resolved !== 'object') {
|
|
477
|
+
throw new Error(
|
|
478
|
+
`plugin "${pluginId}" did not export an object — ` +
|
|
479
|
+
`did you forget \`module.exports = { ... }\`?`,
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
// Sanity check: an empty exports object almost certainly means the
|
|
483
|
+
// author forgot `module.exports = ...`. We warn (don't throw) so
|
|
484
|
+
// plugins with no exported methods don't fail loudly.
|
|
485
|
+
if (Object.keys(resolved).length === 0) {
|
|
486
|
+
logger.warn('plugin exports are empty', {
|
|
487
|
+
module: 'plugin',
|
|
488
|
+
pluginId,
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
return {
|
|
492
|
+
exports: resolved,
|
|
493
|
+
permissions: allowed,
|
|
494
|
+
invalidPermissions: invalid,
|
|
495
|
+
memoryLimitMb,
|
|
496
|
+
api,
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Invoke a method on a loaded plugin inside a sandbox timeout race.
|
|
502
|
+
* Returns `{ ok: true, result }` on success, `{ ok: false, error }`
|
|
503
|
+
* on any throw (including permission errors, timeouts, plugin bugs).
|
|
504
|
+
*
|
|
505
|
+
* @param {object} loaded return value of loadPlugin()
|
|
506
|
+
* @param {string} method method name on the plugin's exports
|
|
507
|
+
* @param {unknown[]} args
|
|
508
|
+
* @param {number} [timeoutMs]
|
|
509
|
+
* @returns {Promise<{ ok: true, result: unknown } | { ok: false, error: string, code?: string }>}
|
|
510
|
+
*/
|
|
511
|
+
export async function safeInvoke(loaded, method, args = [], timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
512
|
+
if (!loaded || typeof loaded !== 'object') {
|
|
513
|
+
return { ok: false, error: 'plugin not loaded', code: 'not_loaded' };
|
|
514
|
+
}
|
|
515
|
+
const fn = loaded.exports && loaded.exports[method];
|
|
516
|
+
if (typeof fn !== 'function') {
|
|
517
|
+
return {
|
|
518
|
+
ok: false,
|
|
519
|
+
error: `plugin does not export method "${method}"`,
|
|
520
|
+
code: 'no_such_method',
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
let timer;
|
|
524
|
+
try {
|
|
525
|
+
const result = await Promise.race([
|
|
526
|
+
Promise.resolve().then(() => fn.apply(loaded.exports, args)),
|
|
527
|
+
new Promise((_, reject) => {
|
|
528
|
+
timer = setTimeout(() => {
|
|
529
|
+
const err = new Error(`plugin method "${method}" timed out after ${timeoutMs}ms`);
|
|
530
|
+
err.code = 'timeout';
|
|
531
|
+
reject(err);
|
|
532
|
+
}, timeoutMs);
|
|
533
|
+
}),
|
|
534
|
+
]);
|
|
535
|
+
return { ok: true, result };
|
|
536
|
+
} catch (err) {
|
|
537
|
+
return {
|
|
538
|
+
ok: false,
|
|
539
|
+
error: err && err.message ? err.message : String(err),
|
|
540
|
+
code: err && err.code ? err.code : 'plugin_error',
|
|
541
|
+
// Surface the structured fields that callers (HTTP routes,
|
|
542
|
+
// CLI) want to know about — permission name on a denied call,
|
|
543
|
+
// status on an HTTP error thrown by api.http.*, etc.
|
|
544
|
+
...(err && err.permission ? { permission: err.permission } : {}),
|
|
545
|
+
...(err && err.status ? { status: err.status } : {}),
|
|
546
|
+
};
|
|
547
|
+
} finally {
|
|
548
|
+
if (timer) clearTimeout(timer);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Top-level convenience: load a plugin from disk and invoke a method
|
|
554
|
+
* in one call. This is what the route handler and the CLI use.
|
|
555
|
+
*
|
|
556
|
+
* @param {object} opts
|
|
557
|
+
* @param {string} opts.pluginRoot absolute path to the plugin install dir
|
|
558
|
+
* @param {Record<string, unknown>} opts.config
|
|
559
|
+
* @param {string[]} opts.permissions
|
|
560
|
+
* @param {string} opts.method
|
|
561
|
+
* @param {unknown[]} [opts.args]
|
|
562
|
+
* @param {string} [opts.pluginId]
|
|
563
|
+
* @param {number} [opts.timeoutMs]
|
|
564
|
+
* @param {typeof globalThis.fetch} [opts.fetchImpl]
|
|
565
|
+
* @returns {Promise<{ ok: true, result: unknown } | { ok: false, error: string, code?: string }>}
|
|
566
|
+
*/
|
|
567
|
+
export async function loadAndInvoke(opts) {
|
|
568
|
+
const manifestPath = pathResolve(opts.pluginRoot, 'plugin.json');
|
|
569
|
+
const manifest = readManifest(manifestPath);
|
|
570
|
+
const mainRel = opts.main || manifest.main;
|
|
571
|
+
if (!mainRel || typeof mainRel !== 'string') {
|
|
572
|
+
return {
|
|
573
|
+
ok: false,
|
|
574
|
+
error: 'plugin.json must declare "main"',
|
|
575
|
+
code: 'bad_manifest',
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
const mainFile = pathResolve(opts.pluginRoot, mainRel);
|
|
579
|
+
let loaded;
|
|
580
|
+
try {
|
|
581
|
+
loaded = await loadPlugin({
|
|
582
|
+
mainFile,
|
|
583
|
+
config: opts.config,
|
|
584
|
+
permissions: opts.permissions,
|
|
585
|
+
pluginId: opts.pluginId || manifestPath,
|
|
586
|
+
pluginRoot: opts.pluginRoot,
|
|
587
|
+
timeoutMs: opts.timeoutMs,
|
|
588
|
+
fetchImpl: opts.fetchImpl,
|
|
589
|
+
});
|
|
590
|
+
} catch (err) {
|
|
591
|
+
return {
|
|
592
|
+
ok: false,
|
|
593
|
+
error: err.message,
|
|
594
|
+
code: err.code || 'load_error',
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
return safeInvoke(loaded, opts.method, opts.args || [], opts.timeoutMs);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Read and validate a plugin.json manifest. Throws a structured error
|
|
602
|
+
* on any required-field violation. Returns the parsed object on success.
|
|
603
|
+
*
|
|
604
|
+
* @param {string} manifestPath
|
|
605
|
+
*/
|
|
606
|
+
export function readManifest(manifestPath) {
|
|
607
|
+
let raw;
|
|
608
|
+
try {
|
|
609
|
+
raw = readFileSync(manifestPath, 'utf8');
|
|
610
|
+
} catch (err) {
|
|
611
|
+
throw new Error(`plugin.json not found at ${manifestPath}: ${err.message}`);
|
|
612
|
+
}
|
|
613
|
+
let parsed;
|
|
614
|
+
try {
|
|
615
|
+
parsed = JSON.parse(raw);
|
|
616
|
+
} catch (err) {
|
|
617
|
+
const wrap = new Error(`plugin.json at ${manifestPath} is not valid JSON: ${err.message}`);
|
|
618
|
+
wrap.code = 'bad_manifest';
|
|
619
|
+
throw wrap;
|
|
620
|
+
}
|
|
621
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
622
|
+
const err = new Error(`plugin.json at ${manifestPath} must be an object`);
|
|
623
|
+
err.code = 'bad_manifest';
|
|
624
|
+
throw err;
|
|
625
|
+
}
|
|
626
|
+
if (typeof parsed.id !== 'string' || !parsed.id) {
|
|
627
|
+
const err = new Error('plugin.json must declare a non-empty "id"');
|
|
628
|
+
err.code = 'bad_manifest';
|
|
629
|
+
throw err;
|
|
630
|
+
}
|
|
631
|
+
if (typeof parsed.version !== 'string' || !parsed.version) {
|
|
632
|
+
const err = new Error('plugin.json must declare a non-empty "version"');
|
|
633
|
+
err.code = 'bad_manifest';
|
|
634
|
+
throw err;
|
|
635
|
+
}
|
|
636
|
+
if (typeof parsed.main !== 'string' || !parsed.main) {
|
|
637
|
+
const err = new Error('plugin.json must declare a non-empty "main"');
|
|
638
|
+
err.code = 'bad_manifest';
|
|
639
|
+
throw err;
|
|
640
|
+
}
|
|
641
|
+
if (!Array.isArray(parsed.exports)) {
|
|
642
|
+
parsed.exports = [];
|
|
643
|
+
}
|
|
644
|
+
if (!Array.isArray(parsed.permissions)) {
|
|
645
|
+
parsed.permissions = [];
|
|
646
|
+
}
|
|
647
|
+
return parsed;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
export const __testing = {
|
|
651
|
+
DEFAULT_TIMEOUT_MS,
|
|
652
|
+
DEFAULT_MEMORY_LIMIT_MB,
|
|
653
|
+
ALLOWED_GLOBALS,
|
|
654
|
+
KNOWN_PERMS,
|
|
655
|
+
};
|