@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.
Files changed (107) hide show
  1. package/bizar-dash/dist/assets/icons-CFqu2M-c.js +656 -0
  2. package/bizar-dash/dist/assets/icons-CFqu2M-c.js.map +1 -0
  3. package/bizar-dash/dist/assets/{index-DU61awG3.js → index-DmpSFPJY.js} +1 -1
  4. package/bizar-dash/dist/assets/{index-DU61awG3.js.map → index-DmpSFPJY.js.map} +1 -1
  5. package/bizar-dash/dist/assets/main-Dl8yY5_H.js +16 -0
  6. package/bizar-dash/dist/assets/main-Dl8yY5_H.js.map +1 -0
  7. package/bizar-dash/dist/assets/{main-DfmIfOUS.css → main-ZAfGKENE.css} +1 -1
  8. package/bizar-dash/dist/assets/markdown-DIquRulQ.js +29 -0
  9. package/bizar-dash/dist/assets/markdown-DIquRulQ.js.map +1 -0
  10. package/bizar-dash/dist/assets/mobile-C2gysFOZ.js +2 -0
  11. package/bizar-dash/dist/assets/mobile-C2gysFOZ.js.map +1 -0
  12. package/bizar-dash/dist/assets/mobile-DHXXbn1A.js +1 -0
  13. package/bizar-dash/dist/assets/{mobile-CL5uUQEC.js.map → mobile-DHXXbn1A.js.map} +1 -1
  14. package/bizar-dash/dist/assets/react-vendor-DZRUXSPQ.js +40 -0
  15. package/bizar-dash/dist/assets/react-vendor-DZRUXSPQ.js.map +1 -0
  16. package/bizar-dash/dist/index.html +6 -3
  17. package/bizar-dash/dist/mobile.html +5 -2
  18. package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  19. package/bizar-dash/skills/eval/SKILL.md +237 -0
  20. package/bizar-dash/src/server/api.mjs +28 -0
  21. package/bizar-dash/src/server/auth.mjs +155 -1
  22. package/bizar-dash/src/server/eval-store.mjs +226 -0
  23. package/bizar-dash/src/server/eval.mjs +347 -0
  24. package/bizar-dash/src/server/ocr.mjs +55 -0
  25. package/bizar-dash/src/server/plugins/registry.mjs +363 -0
  26. package/bizar-dash/src/server/plugins/sandbox.mjs +655 -0
  27. package/bizar-dash/src/server/plugins/store.mjs +659 -0
  28. package/bizar-dash/src/server/routes/clipboard.mjs +173 -0
  29. package/bizar-dash/src/server/routes/eval.mjs +147 -0
  30. package/bizar-dash/src/server/routes/ocr.mjs +182 -0
  31. package/bizar-dash/src/server/routes/plugins.mjs +220 -0
  32. package/bizar-dash/src/server/routes/users.mjs +84 -0
  33. package/bizar-dash/src/server/routes/voice.mjs +131 -0
  34. package/bizar-dash/src/server/routes/workspaces.mjs +204 -0
  35. package/bizar-dash/src/server/voice-store.mjs +202 -0
  36. package/bizar-dash/src/server/voice-transcribe.mjs +72 -0
  37. package/bizar-dash/src/server/workspaces.mjs +626 -0
  38. package/bizar-dash/src/web/components/InviteDialog.tsx +205 -0
  39. package/bizar-dash/src/web/components/ScreenshotCapture.tsx +138 -0
  40. package/bizar-dash/src/web/components/ScreenshotOCR.tsx +42 -0
  41. package/bizar-dash/src/web/components/VoiceNotesPanel.tsx +182 -0
  42. package/bizar-dash/src/web/components/VoiceRecorder.tsx +104 -0
  43. package/bizar-dash/src/web/components/WorkspaceSelector.tsx +158 -0
  44. package/bizar-dash/src/web/styles/memory.css +82 -0
  45. package/bizar-dash/src/web/views/Memory.tsx +17 -2
  46. package/bizar-dash/src/web/views/Settings.tsx +3 -0
  47. package/bizar-dash/src/web/views/Workspace.tsx +294 -0
  48. package/bizar-dash/src/web/views/memory/FromScreenshotPanel.tsx +23 -0
  49. package/bizar-dash/src/web/views/memory/VaultFromClipboardPanel.tsx +101 -0
  50. package/bizar-dash/src/web/views/settings/WorkspacesSection.tsx +189 -0
  51. package/bizar-dash/tests/bundle-analysis.test.mjs +5 -2
  52. package/bizar-dash/tests/clipboard.test.mjs +147 -0
  53. package/bizar-dash/tests/components/screenshot-ocr.test.tsx +75 -0
  54. package/bizar-dash/tests/components/workspace-selector.test.tsx +73 -0
  55. package/bizar-dash/tests/deploy-templates.test.mjs +100 -0
  56. package/bizar-dash/tests/eval/fixtures.test.mjs +141 -0
  57. package/bizar-dash/tests/eval/report.test.mjs +284 -0
  58. package/bizar-dash/tests/eval/runner.test.mjs +471 -0
  59. package/bizar-dash/tests/ocr.test.mjs +87 -0
  60. package/bizar-dash/tests/plugins-registry.test.mjs +387 -0
  61. package/bizar-dash/tests/plugins-sandbox.test.mjs +374 -0
  62. package/bizar-dash/tests/plugins-store.test.mjs +455 -0
  63. package/bizar-dash/tests/users.test.mjs +108 -0
  64. package/bizar-dash/tests/voice-recorder.test.tsx +95 -0
  65. package/bizar-dash/tests/voice-store.test.mjs +148 -0
  66. package/bizar-dash/tests/voice-transcribe.test.mjs +87 -0
  67. package/bizar-dash/tests/workspaces.test.mjs +527 -0
  68. package/cli/bin.mjs +72 -2
  69. package/cli/commands/clip.mjs +146 -0
  70. package/cli/commands/deploy/cloudflare.mjs +250 -0
  71. package/cli/commands/deploy/docker.mjs +221 -0
  72. package/cli/commands/deploy/fly.mjs +161 -0
  73. package/cli/commands/deploy/vercel.mjs +225 -0
  74. package/cli/commands/deploy.mjs +240 -0
  75. package/cli/commands/eval.mjs +378 -0
  76. package/cli/commands/marketplace.mjs +64 -0
  77. package/cli/commands/ocr.mjs +165 -0
  78. package/cli/commands/plugin.mjs +358 -0
  79. package/cli/commands/voice.mjs +211 -0
  80. package/cli/commands/workspace.mjs +247 -0
  81. package/package.json +12 -8
  82. package/templates/deploy/cloudflare/README.md +32 -0
  83. package/templates/deploy/cloudflare/functions-index.template.js +15 -0
  84. package/templates/deploy/cloudflare/wrangler.toml.template +9 -0
  85. package/templates/deploy/docker/.env.template +16 -0
  86. package/templates/deploy/docker/README.md +58 -0
  87. package/templates/deploy/docker/docker-compose.template.yml +23 -0
  88. package/templates/deploy/fly/README.md +35 -0
  89. package/templates/deploy/fly/fly.toml.template +28 -0
  90. package/templates/deploy/vercel/README.md +29 -0
  91. package/templates/deploy/vercel/api-index.template.js +18 -0
  92. package/templates/deploy/vercel/vercel.json.template +16 -0
  93. package/templates/eval-fixtures/README.md +58 -0
  94. package/templates/eval-fixtures/code-search-basic.json +28 -0
  95. package/templates/eval-fixtures/latency-bounds.json +16 -0
  96. package/templates/eval-fixtures/regression-suite.json +79 -0
  97. package/templates/eval-fixtures/response-format.json +30 -0
  98. package/templates/eval-fixtures/tool-call-correctness.json +24 -0
  99. package/templates/plugin-template/README.md +121 -0
  100. package/templates/plugin-template/index.js +66 -0
  101. package/templates/plugin-template/plugin.json +42 -0
  102. package/templates/plugin-template/tests/plugin.test.js +83 -0
  103. package/bizar-dash/dist/assets/main-DaC1Lc6q.js +0 -366
  104. package/bizar-dash/dist/assets/main-DaC1Lc6q.js.map +0 -1
  105. package/bizar-dash/dist/assets/mobile-CL5uUQEC.js +0 -1
  106. package/bizar-dash/dist/assets/mobile-D5WTWvuh.js +0 -338
  107. 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
+ };