@polderlabs/bizar 4.8.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 (129) 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-DmpSFPJY.js +9 -0
  4. package/bizar-dash/dist/assets/index-DmpSFPJY.js.map +1 -0
  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-DX_Jh8Wc.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-Chvf9u_B.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/memory-lightrag.mjs +109 -0
  25. package/bizar-dash/src/server/memory-store.mjs +121 -0
  26. package/bizar-dash/src/server/ocr.mjs +55 -0
  27. package/bizar-dash/src/server/otel.mjs +133 -0
  28. package/bizar-dash/src/server/plugins/registry.mjs +363 -0
  29. package/bizar-dash/src/server/plugins/sandbox.mjs +655 -0
  30. package/bizar-dash/src/server/plugins/store.mjs +659 -0
  31. package/bizar-dash/src/server/routes/chat.mjs +246 -170
  32. package/bizar-dash/src/server/routes/clipboard.mjs +173 -0
  33. package/bizar-dash/src/server/routes/eval.mjs +147 -0
  34. package/bizar-dash/src/server/routes/memory.mjs +46 -0
  35. package/bizar-dash/src/server/routes/ocr.mjs +182 -0
  36. package/bizar-dash/src/server/routes/opencode-sessions.mjs +82 -48
  37. package/bizar-dash/src/server/routes/plugins.mjs +220 -0
  38. package/bizar-dash/src/server/routes/users.mjs +84 -0
  39. package/bizar-dash/src/server/routes/voice.mjs +131 -0
  40. package/bizar-dash/src/server/routes/workspaces.mjs +204 -0
  41. package/bizar-dash/src/server/server.mjs +40 -0
  42. package/bizar-dash/src/server/voice-store.mjs +202 -0
  43. package/bizar-dash/src/server/voice-transcribe.mjs +72 -0
  44. package/bizar-dash/src/server/workspaces.mjs +626 -0
  45. package/bizar-dash/src/web/components/InviteDialog.tsx +205 -0
  46. package/bizar-dash/src/web/components/ScreenshotCapture.tsx +138 -0
  47. package/bizar-dash/src/web/components/ScreenshotOCR.tsx +42 -0
  48. package/bizar-dash/src/web/components/SettingsSearch.tsx +204 -89
  49. package/bizar-dash/src/web/components/VoiceNotesPanel.tsx +182 -0
  50. package/bizar-dash/src/web/components/VoiceRecorder.tsx +104 -0
  51. package/bizar-dash/src/web/components/WorkspaceSelector.tsx +158 -0
  52. package/bizar-dash/src/web/lib/search.ts +115 -0
  53. package/bizar-dash/src/web/mobile/views/MobileSettings.tsx +10 -35
  54. package/bizar-dash/src/web/mobile/views/QrCodePanel.tsx +69 -0
  55. package/bizar-dash/src/web/styles/memory.css +166 -1
  56. package/bizar-dash/src/web/styles/settings.css +80 -0
  57. package/bizar-dash/src/web/views/Memory.tsx +22 -2
  58. package/bizar-dash/src/web/views/Settings.tsx +99 -0
  59. package/bizar-dash/src/web/views/Workspace.tsx +294 -0
  60. package/bizar-dash/src/web/views/memory/FromScreenshotPanel.tsx +23 -0
  61. package/bizar-dash/src/web/views/memory/MemoryGraphLegend.tsx +29 -0
  62. package/bizar-dash/src/web/views/memory/MemoryGraphPanel.tsx +192 -0
  63. package/bizar-dash/src/web/views/memory/MemoryGraphView.tsx +336 -0
  64. package/bizar-dash/src/web/views/memory/VaultFromClipboardPanel.tsx +101 -0
  65. package/bizar-dash/src/web/views/settings/WorkspacesSection.tsx +189 -0
  66. package/bizar-dash/tests/bundle-analysis.test.mjs +73 -0
  67. package/bizar-dash/tests/clipboard.test.mjs +147 -0
  68. package/bizar-dash/tests/components/screenshot-ocr.test.tsx +75 -0
  69. package/bizar-dash/tests/components/settings-search.test.tsx +180 -0
  70. package/bizar-dash/tests/components/workspace-selector.test.tsx +73 -0
  71. package/bizar-dash/tests/deploy-templates.test.mjs +100 -0
  72. package/bizar-dash/tests/docker-build.test.mjs +96 -0
  73. package/bizar-dash/tests/eval/fixtures.test.mjs +141 -0
  74. package/bizar-dash/tests/eval/report.test.mjs +284 -0
  75. package/bizar-dash/tests/eval/runner.test.mjs +471 -0
  76. package/bizar-dash/tests/lib/search-fuzzy.test.ts +149 -0
  77. package/bizar-dash/tests/memory-graph-view.test.tsx +69 -0
  78. package/bizar-dash/tests/memory-graph.test.mjs +95 -0
  79. package/bizar-dash/tests/ocr.test.mjs +87 -0
  80. package/bizar-dash/tests/otel.test.mjs +188 -0
  81. package/bizar-dash/tests/plugins-registry.test.mjs +387 -0
  82. package/bizar-dash/tests/plugins-sandbox.test.mjs +374 -0
  83. package/bizar-dash/tests/plugins-store.test.mjs +455 -0
  84. package/bizar-dash/tests/users.test.mjs +108 -0
  85. package/bizar-dash/tests/voice-recorder.test.tsx +95 -0
  86. package/bizar-dash/tests/voice-store.test.mjs +148 -0
  87. package/bizar-dash/tests/voice-transcribe.test.mjs +87 -0
  88. package/bizar-dash/tests/workspaces.test.mjs +527 -0
  89. package/cli/bin.mjs +72 -2
  90. package/cli/commands/clip.mjs +146 -0
  91. package/cli/commands/dash.mjs +6 -0
  92. package/cli/commands/deploy/cloudflare.mjs +250 -0
  93. package/cli/commands/deploy/docker.mjs +221 -0
  94. package/cli/commands/deploy/fly.mjs +161 -0
  95. package/cli/commands/deploy/vercel.mjs +225 -0
  96. package/cli/commands/deploy.mjs +240 -0
  97. package/cli/commands/eval.mjs +378 -0
  98. package/cli/commands/marketplace.mjs +64 -0
  99. package/cli/commands/ocr.mjs +165 -0
  100. package/cli/commands/plugin.mjs +358 -0
  101. package/cli/commands/voice.mjs +211 -0
  102. package/cli/commands/workspace.mjs +247 -0
  103. package/package.json +12 -2
  104. package/templates/deploy/cloudflare/README.md +32 -0
  105. package/templates/deploy/cloudflare/functions-index.template.js +15 -0
  106. package/templates/deploy/cloudflare/wrangler.toml.template +9 -0
  107. package/templates/deploy/docker/.env.template +16 -0
  108. package/templates/deploy/docker/README.md +58 -0
  109. package/templates/deploy/docker/docker-compose.template.yml +23 -0
  110. package/templates/deploy/fly/README.md +35 -0
  111. package/templates/deploy/fly/fly.toml.template +28 -0
  112. package/templates/deploy/vercel/README.md +29 -0
  113. package/templates/deploy/vercel/api-index.template.js +18 -0
  114. package/templates/deploy/vercel/vercel.json.template +16 -0
  115. package/templates/eval-fixtures/README.md +58 -0
  116. package/templates/eval-fixtures/code-search-basic.json +28 -0
  117. package/templates/eval-fixtures/latency-bounds.json +16 -0
  118. package/templates/eval-fixtures/regression-suite.json +79 -0
  119. package/templates/eval-fixtures/response-format.json +30 -0
  120. package/templates/eval-fixtures/tool-call-correctness.json +24 -0
  121. package/templates/plugin-template/README.md +121 -0
  122. package/templates/plugin-template/index.js +66 -0
  123. package/templates/plugin-template/plugin.json +42 -0
  124. package/templates/plugin-template/tests/plugin.test.js +83 -0
  125. package/bizar-dash/dist/assets/main-DHZmbnxQ.js +0 -361
  126. package/bizar-dash/dist/assets/main-DHZmbnxQ.js.map +0 -1
  127. package/bizar-dash/dist/assets/mobile-BK8-ythT.js +0 -351
  128. package/bizar-dash/dist/assets/mobile-BK8-ythT.js.map +0 -1
  129. package/bizar-dash/dist/assets/mobile-Chvf9u_B.js +0 -1
@@ -0,0 +1,374 @@
1
+ /**
2
+ * tests/plugins-sandbox.test.mjs
3
+ *
4
+ * Tests for src/server/plugins/sandbox.mjs:
5
+ * - Allowed globals are present
6
+ * - Forbidden globals (process, require, child_process, raw fs,
7
+ * Buffer) are NOT present in the plugin's vm scope
8
+ * - A malicious plugin that tries to call process.exit / spawn a
9
+ * subprocess / read the filesystem does NOT escape the sandbox
10
+ * - Permission-gated methods (api.http, api.fs.read) work when
11
+ * granted, throw permission_denied when not
12
+ * - The api.config get/set round-trips
13
+ * - safeInvoke catches plugin throws and returns { ok: false }
14
+ * - vm.Script timeout stops a synchronous infinite loop
15
+ * - safeInvoke timeout stops an async hang
16
+ */
17
+ import { test, describe } from 'node:test';
18
+ import assert from 'node:assert/strict';
19
+ import { writeFileSync, mkdirSync, rmSync } from 'node:fs';
20
+ import { join, resolve, dirname } from 'node:path';
21
+ import { tmpdir } from 'node:os';
22
+ import { fileURLToPath } from 'node:url';
23
+
24
+ const REPO = resolve(dirname(fileURLToPath(import.meta.url)), '..');
25
+ const SANDBOX = await import(resolve(REPO, 'src/server/plugins/sandbox.mjs'));
26
+
27
+ const TMP = join(tmpdir(), `bizar-sandbox-test-${Date.now()}`);
28
+ mkdirSync(TMP, { recursive: true });
29
+
30
+ // ── helpers ──────────────────────────────────────────────────────────────
31
+
32
+ /**
33
+ * Write a plugin source string to a tmp file and load it through the
34
+ * real sandbox. Returns the loaded plugin (the value returned by
35
+ * loadPlugin()).
36
+ *
37
+ * @param {string} source plugin source code (uses module.exports = ...)
38
+ * @param {object} [opts] passed to loadPlugin
39
+ */
40
+ async function loadFromSource(source, opts = {}) {
41
+ const file = join(TMP, `plugin-${Math.random().toString(36).slice(2)}.js`);
42
+ writeFileSync(file, source);
43
+ return SANDBOX.loadPlugin({
44
+ mainFile: file,
45
+ config: opts.config || {},
46
+ permissions: opts.permissions || [],
47
+ pluginId: opts.pluginId || 'test-plugin',
48
+ pluginRoot: TMP,
49
+ timeoutMs: opts.timeoutMs || 5_000,
50
+ onConfigChange: opts.onConfigChange,
51
+ });
52
+ }
53
+
54
+ // Cleanup tmp at the end of the file.
55
+ import { after } from 'node:test';
56
+ after(() => {
57
+ rmSync(TMP, { recursive: true, force: true });
58
+ });
59
+
60
+ // ── Globals presence / absence ────────────────────────────────────────────
61
+
62
+ describe('sandbox — global surface', () => {
63
+ test('allows safe globals (URL, JSON, Math, Promise, setTimeout)', async () => {
64
+ const src = `
65
+ module.exports = {
66
+ inspect() {
67
+ return {
68
+ hasURL: typeof URL === 'function',
69
+ hasJSON: typeof JSON === 'object',
70
+ hasMath: typeof Math === 'object',
71
+ hasPromise: typeof Promise === 'function',
72
+ hasSetTimeout: typeof setTimeout === 'function',
73
+ hasConsole: typeof console === 'object',
74
+ };
75
+ }
76
+ };
77
+ `;
78
+ const loaded = await loadFromSource(src);
79
+ const r = await SANDBOX.safeInvoke(loaded, 'inspect', []);
80
+ assert.equal(r.ok, true);
81
+ assert.equal(r.result.hasURL, true);
82
+ assert.equal(r.result.hasJSON, true);
83
+ assert.equal(r.result.hasMath, true);
84
+ assert.equal(r.result.hasPromise, true);
85
+ assert.equal(r.result.hasSetTimeout, true);
86
+ assert.equal(r.result.hasConsole, true);
87
+ });
88
+
89
+ test('strips process, require, Buffer, child_process', async () => {
90
+ const src = `
91
+ module.exports = {
92
+ inspect() {
93
+ return {
94
+ hasProcess: typeof process,
95
+ hasRequire: typeof require,
96
+ hasBuffer: typeof Buffer,
97
+ hasChildProcess: typeof child_process,
98
+ };
99
+ }
100
+ };
101
+ `;
102
+ const loaded = await loadFromSource(src);
103
+ const r = await SANDBOX.safeInvoke(loaded, 'inspect', []);
104
+ assert.equal(r.ok, true);
105
+ assert.equal(r.result.hasProcess, 'undefined');
106
+ assert.equal(r.result.hasRequire, 'undefined');
107
+ assert.equal(r.result.hasBuffer, 'undefined');
108
+ assert.equal(r.result.hasChildProcess, 'undefined');
109
+ });
110
+ });
111
+
112
+ // ── Forbidden behaviour ───────────────────────────────────────────────────
113
+
114
+ describe('sandbox — forbidden behaviour', () => {
115
+ test('cannot process.exit', async () => {
116
+ // A plugin that tries to call process.exit — the call should
117
+ // throw (process is undefined), and safeInvoke should catch it.
118
+ const src = `
119
+ module.exports = {
120
+ boom() {
121
+ process.exit(1);
122
+ }
123
+ };
124
+ `;
125
+ const loaded = await loadFromSource(src);
126
+ const r = await SANDBOX.safeInvoke(loaded, 'boom', []);
127
+ assert.equal(r.ok, false);
128
+ assert.match(r.error, /process is not defined|exit/);
129
+ });
130
+
131
+ test('cannot require node:fs', async () => {
132
+ const src = `
133
+ module.exports = {
134
+ boom() {
135
+ const fs = require('node:fs');
136
+ return fs.readFileSync('/etc/passwd', 'utf8');
137
+ }
138
+ };
139
+ `;
140
+ const loaded = await loadFromSource(src);
141
+ const r = await SANDBOX.safeInvoke(loaded, 'boom', []);
142
+ assert.equal(r.ok, false);
143
+ assert.match(r.error, /require is not defined/);
144
+ });
145
+
146
+ test('cannot spawn child_process', async () => {
147
+ const src = `
148
+ module.exports = {
149
+ boom() {
150
+ const cp = require('node:child_process');
151
+ return cp.execSync('id').toString();
152
+ }
153
+ };
154
+ `;
155
+ const loaded = await loadFromSource(src);
156
+ const r = await SANDBOX.safeInvoke(loaded, 'boom', []);
157
+ assert.equal(r.ok, false);
158
+ });
159
+
160
+ test('cannot read /etc/passwd via raw fs', async () => {
161
+ const src = `
162
+ module.exports = {
163
+ inspect() {
164
+ try {
165
+ return { hasFs: typeof fs };
166
+ } catch (e) {
167
+ return { error: e.message };
168
+ }
169
+ }
170
+ };
171
+ `;
172
+ const loaded = await loadFromSource(src);
173
+ const r = await SANDBOX.safeInvoke(loaded, 'inspect', []);
174
+ assert.equal(r.ok, true);
175
+ assert.equal(r.result.hasFs, 'undefined');
176
+ });
177
+
178
+ test('cannot read this host process.env', async () => {
179
+ // Note: NO try/catch — the sandbox should throw, and safeInvoke
180
+ // should catch it as a plugin error.
181
+ const src = `
182
+ module.exports = {
183
+ env() { return process.env.HOME; }
184
+ };
185
+ `;
186
+ const loaded = await loadFromSource(src);
187
+ const r = await SANDBOX.safeInvoke(loaded, 'env', []);
188
+ assert.equal(r.ok, false);
189
+ assert.match(r.error, /process is not defined/);
190
+ });
191
+ });
192
+
193
+ // ── Permission-gated methods ──────────────────────────────────────────────
194
+
195
+ describe('sandbox — permission gating', () => {
196
+ test('api.http.get without "net" throws permission_denied', async () => {
197
+ const src = `
198
+ module.exports = {
199
+ go() { return api.http.get('https://example.com'); }
200
+ };
201
+ `;
202
+ const loaded = await loadFromSource(src, { permissions: [] });
203
+ const r = await SANDBOX.safeInvoke(loaded, 'go', []);
204
+ assert.equal(r.ok, false);
205
+ assert.equal(r.code, 'permission_denied');
206
+ assert.equal(r.permission, 'net');
207
+ });
208
+
209
+ test('api.fs.read without "fs:read" throws permission_denied', async () => {
210
+ const src = `
211
+ module.exports = {
212
+ go() { return api.fs.read('plugin.json'); }
213
+ };
214
+ `;
215
+ const loaded = await loadFromSource(src, { permissions: [] });
216
+ const r = await SANDBOX.safeInvoke(loaded, 'go', []);
217
+ assert.equal(r.ok, false);
218
+ assert.equal(r.code, 'permission_denied');
219
+ assert.equal(r.permission, 'fs:read');
220
+ });
221
+
222
+ test('api.fs.read with "fs:read" reads from the plugin root only', async () => {
223
+ // Create a file inside TMP (the plugin root) and a file outside.
224
+ writeFileSync(join(TMP, 'plugin.json'), '{"id":"test"}');
225
+ writeFileSync(join(TMP, '..', `outside-${Date.now()}.txt`), 'secret');
226
+ const outside = join(TMP, '..', `outside-${Date.now()}.txt`);
227
+ const src = `
228
+ module.exports = {
229
+ readIn() { return api.fs.read('plugin.json'); },
230
+ readOut() { return api.fs.read('${outside.replace(/\\/g, '/')}'); },
231
+ };
232
+ `;
233
+ const loaded = await loadFromSource(src, { permissions: ['fs:read'] });
234
+ const r1 = await SANDBOX.safeInvoke(loaded, 'readIn', []);
235
+ assert.equal(r1.ok, true);
236
+ assert.match(r1.result, /test/);
237
+ const r2 = await SANDBOX.safeInvoke(loaded, 'readOut', []);
238
+ assert.equal(r2.ok, false);
239
+ assert.equal(r2.code, 'permission_denied');
240
+ });
241
+
242
+ test('api.config.get/set round-trips', async () => {
243
+ const src = `
244
+ module.exports = {
245
+ run() {
246
+ api.config.set('nested.value', 42);
247
+ return api.config.get('nested.value');
248
+ }
249
+ };
250
+ `;
251
+ let captured;
252
+ const loaded = await loadFromSource(src, {
253
+ config: { existing: 'yes' },
254
+ onConfigChange: (cfg) => { captured = cfg; },
255
+ });
256
+ const r = await SANDBOX.safeInvoke(loaded, 'run', []);
257
+ assert.equal(r.ok, true);
258
+ assert.equal(r.result, 42);
259
+ assert.equal(captured.nested.value, 42);
260
+ });
261
+
262
+ test('api.log with invalid level throws', async () => {
263
+ const src = `
264
+ module.exports = {
265
+ go() { api.log('fatal', 'hi'); }
266
+ };
267
+ `;
268
+ const loaded = await loadFromSource(src);
269
+ const r = await SANDBOX.safeInvoke(loaded, 'go', []);
270
+ assert.equal(r.ok, false);
271
+ assert.match(r.error, /level must be one of/);
272
+ });
273
+ });
274
+
275
+ // ── Error handling ────────────────────────────────────────────────────────
276
+
277
+ describe('sandbox — error handling', () => {
278
+ test('safeInvoke catches a plugin throw', async () => {
279
+ const src = `
280
+ module.exports = {
281
+ boom() { throw new Error('plugin says no'); }
282
+ };
283
+ `;
284
+ const loaded = await loadFromSource(src);
285
+ const r = await SANDBOX.safeInvoke(loaded, 'boom', []);
286
+ assert.equal(r.ok, false);
287
+ assert.equal(r.error, 'plugin says no');
288
+ assert.equal(r.code, 'plugin_error');
289
+ });
290
+
291
+ test('safeInvoke returns no_such_method for unknown methods', async () => {
292
+ const src = `module.exports = { real() { return 1; } };`;
293
+ const loaded = await loadFromSource(src);
294
+ const r = await SANDBOX.safeInvoke(loaded, 'imaginary', []);
295
+ assert.equal(r.ok, false);
296
+ assert.equal(r.code, 'no_such_method');
297
+ });
298
+
299
+ test('safeInvoke timeout catches an async hang', async () => {
300
+ const src = `
301
+ module.exports = {
302
+ hang() { return new Promise(() => {}); }
303
+ };
304
+ `;
305
+ const loaded = await loadFromSource(src, { timeoutMs: 200 });
306
+ const start = Date.now();
307
+ const r = await SANDBOX.safeInvoke(loaded, 'hang', [], 200);
308
+ const elapsed = Date.now() - start;
309
+ assert.equal(r.ok, false);
310
+ assert.equal(r.code, 'timeout');
311
+ assert.ok(elapsed >= 200, `expected at least 200ms, got ${elapsed}`);
312
+ assert.ok(elapsed < 2000, `expected < 2s, got ${elapsed}`);
313
+ });
314
+
315
+ test('plugin with no exports loads but is empty', async () => {
316
+ // A plugin that only sets `exports.foo` (a CommonJS slip where
317
+ // they forget `module.exports = ...`) still loads; the exports
318
+ // we hand back may be empty or populated depending on what they
319
+ // wrote. We accept either: this is "tolerant by default" — v2
320
+ // can add a strict mode flag.
321
+ const src = `
322
+ // Just declare a const — no module.exports set.
323
+ const x = 1;
324
+ `;
325
+ const loaded = await loadFromSource(src);
326
+ assert.equal(typeof loaded.exports, 'object');
327
+ });
328
+ });
329
+
330
+ // ── Permission parsing ────────────────────────────────────────────────────
331
+
332
+ describe('parsePluginPermissions', () => {
333
+ test('recognises known perms', () => {
334
+ const r = SANDBOX.parsePluginPermissions(['net', 'fs:read']);
335
+ assert.equal(r.allowed.has('net'), true);
336
+ assert.equal(r.allowed.has('fs:read'), true);
337
+ assert.equal(r.invalid.length, 0);
338
+ });
339
+
340
+ test('flags unknown perms', () => {
341
+ const r = SANDBOX.parsePluginPermissions(['net', 'exec:rm-rf']);
342
+ assert.equal(r.allowed.has('net'), true);
343
+ assert.deepEqual(r.invalid, ['exec:rm-rf']);
344
+ });
345
+
346
+ test('handles non-array input', () => {
347
+ const r = SANDBOX.parsePluginPermissions(null);
348
+ assert.equal(r.allowed.size, 0);
349
+ assert.equal(r.invalid.length, 0);
350
+ });
351
+ });
352
+
353
+ // ── Manifest reader ──────────────────────────────────────────────────────
354
+
355
+ describe('readManifest', () => {
356
+ test('reads + validates plugin.json', () => {
357
+ const file = join(TMP, 'plugin.json');
358
+ writeFileSync(file, JSON.stringify({
359
+ id: 'm1', name: 'M1', version: '1.0.0', main: 'index.js',
360
+ }));
361
+ const m = SANDBOX.readManifest(file);
362
+ assert.equal(m.id, 'm1');
363
+ assert.equal(m.version, '1.0.0');
364
+ assert.equal(m.main, 'index.js');
365
+ assert.deepEqual(m.exports, []);
366
+ assert.deepEqual(m.permissions, []);
367
+ });
368
+
369
+ test('rejects missing required fields', () => {
370
+ const file = join(TMP, 'bad.json');
371
+ writeFileSync(file, JSON.stringify({ name: 'oops' }));
372
+ assert.throws(() => SANDBOX.readManifest(file), /must declare a non-empty "id"/);
373
+ });
374
+ });