@lobu/worker 6.1.1 → 7.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 (82) hide show
  1. package/dist/embedded/just-bash-bootstrap.d.ts.map +1 -1
  2. package/dist/embedded/just-bash-bootstrap.js +26 -2
  3. package/dist/embedded/just-bash-bootstrap.js.map +1 -1
  4. package/dist/gateway/gateway-integration.js +4 -4
  5. package/dist/gateway/gateway-integration.js.map +1 -1
  6. package/dist/gateway/message-batcher.d.ts.map +1 -1
  7. package/dist/gateway/message-batcher.js +3 -5
  8. package/dist/gateway/message-batcher.js.map +1 -1
  9. package/dist/gateway/sse-client.d.ts +1 -0
  10. package/dist/gateway/sse-client.d.ts.map +1 -1
  11. package/dist/gateway/sse-client.js +8 -0
  12. package/dist/gateway/sse-client.js.map +1 -1
  13. package/dist/openclaw/worker.d.ts +0 -1
  14. package/dist/openclaw/worker.d.ts.map +1 -1
  15. package/dist/openclaw/worker.js +18 -75
  16. package/dist/openclaw/worker.js.map +1 -1
  17. package/dist/shared/tool-implementations.d.ts.map +1 -1
  18. package/dist/shared/tool-implementations.js +37 -13
  19. package/dist/shared/tool-implementations.js.map +1 -1
  20. package/package.json +14 -4
  21. package/src/__tests__/audio-provider-suggestions.test.ts +199 -0
  22. package/src/__tests__/custom-tools.test.ts +92 -0
  23. package/src/__tests__/embedded-just-bash-bootstrap.test.ts +128 -0
  24. package/src/__tests__/embedded-mcp-cli-bash.test.ts +179 -0
  25. package/src/__tests__/embedded-tools.test.ts +744 -0
  26. package/src/__tests__/exec-sandbox-extra.test.ts +0 -0
  27. package/src/__tests__/exec-sandbox.test.ts +550 -0
  28. package/src/__tests__/generated-media.test.ts +142 -0
  29. package/src/__tests__/instructions.test.ts +60 -0
  30. package/src/__tests__/mcp-cli-commands-extra.test.ts +478 -0
  31. package/src/__tests__/mcp-cli-commands.test.ts +383 -0
  32. package/src/__tests__/mcp-tool-call.test.ts +423 -0
  33. package/src/__tests__/memory-flush-harden.test.ts +367 -0
  34. package/src/__tests__/memory-flush-runtime.test.ts +138 -0
  35. package/src/__tests__/memory-flush.test.ts +64 -0
  36. package/src/__tests__/message-batcher.test.ts +247 -0
  37. package/src/__tests__/model-resolver-harden.test.ts +197 -0
  38. package/src/__tests__/model-resolver.test.ts +156 -0
  39. package/src/__tests__/processor-harden.test.ts +269 -0
  40. package/src/__tests__/processor.test.ts +225 -0
  41. package/src/__tests__/replace-base-prompt-identity.test.ts +41 -0
  42. package/src/__tests__/sandbox-leak-harden.test.ts +200 -0
  43. package/src/__tests__/sandbox-leak.test.ts +167 -0
  44. package/src/__tests__/setup.ts +102 -0
  45. package/src/__tests__/sse-client-harden.test.ts +588 -0
  46. package/src/__tests__/sse-client.test.ts +90 -0
  47. package/src/__tests__/tool-implementations.test.ts +196 -0
  48. package/src/__tests__/tool-policy-edge-cases.test.ts +263 -0
  49. package/src/__tests__/tool-policy.test.ts +269 -0
  50. package/src/__tests__/worker.test.ts +89 -0
  51. package/src/core/error-handler.ts +62 -0
  52. package/src/core/project-scanner.ts +65 -0
  53. package/src/core/types.ts +128 -0
  54. package/src/core/workspace.ts +89 -0
  55. package/src/embedded/exec-sandbox.ts +372 -0
  56. package/src/embedded/just-bash-bootstrap.ts +543 -0
  57. package/src/embedded/mcp-cli-commands.ts +402 -0
  58. package/src/gateway/gateway-integration.ts +298 -0
  59. package/src/gateway/message-batcher.ts +123 -0
  60. package/src/gateway/sse-client.ts +951 -0
  61. package/src/gateway/types.ts +68 -0
  62. package/src/index.ts +141 -0
  63. package/src/instructions/builder.ts +45 -0
  64. package/src/instructions/providers.ts +27 -0
  65. package/src/modules/lifecycle.ts +92 -0
  66. package/src/openclaw/custom-tools.ts +315 -0
  67. package/src/openclaw/instructions.ts +36 -0
  68. package/src/openclaw/model-resolver.ts +150 -0
  69. package/src/openclaw/plugin-loader.ts +427 -0
  70. package/src/openclaw/processor.ts +198 -0
  71. package/src/openclaw/sandbox-leak.ts +105 -0
  72. package/src/openclaw/session-context.ts +320 -0
  73. package/src/openclaw/tool-policy.ts +248 -0
  74. package/src/openclaw/tools.ts +277 -0
  75. package/src/openclaw/worker.ts +1847 -0
  76. package/src/server.ts +334 -0
  77. package/src/shared/audio-provider-suggestions.ts +132 -0
  78. package/src/shared/processor-utils.ts +33 -0
  79. package/src/shared/provider-auth-hints.ts +68 -0
  80. package/src/shared/tool-display-config.ts +75 -0
  81. package/src/shared/tool-implementations.ts +940 -0
  82. package/src/shared/worker-env-keys.ts +8 -0
@@ -0,0 +1,550 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { execFile as execFileCb, execFileSync } from "node:child_process";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { promisify } from "node:util";
7
+ import {
8
+ type SandboxStrategy,
9
+ probeSandboxStrategy,
10
+ resetSandboxProbeForTests,
11
+ wrapInvocation,
12
+ } from "../embedded/exec-sandbox";
13
+
14
+ const execFile = promisify(execFileCb);
15
+
16
+ function tmpWorkspace(): string {
17
+ return fs.realpathSync(
18
+ fs.mkdtempSync(path.join(os.tmpdir(), "exec-sandbox-test-"))
19
+ );
20
+ }
21
+
22
+ const cleanups: (() => void)[] = [];
23
+ afterEach(() => {
24
+ for (const c of cleanups.splice(0)) c();
25
+ resetSandboxProbeForTests();
26
+ delete process.env.LOBU_EXEC_SANDBOX;
27
+ });
28
+
29
+ describe("probeSandboxStrategy", () => {
30
+ beforeEach(() => {
31
+ resetSandboxProbeForTests();
32
+ });
33
+
34
+ test("returns kind=none when LOBU_EXEC_SANDBOX=off", () => {
35
+ process.env.LOBU_EXEC_SANDBOX = "off";
36
+ expect(probeSandboxStrategy()).toEqual({ kind: "none" });
37
+ });
38
+
39
+ test("auto-detects sandbox-exec on darwin", () => {
40
+ if (process.platform !== "darwin") return;
41
+ const s = probeSandboxStrategy();
42
+ expect(s.kind).toBe("sandbox-exec");
43
+ expect(s.path).toMatch(/sandbox-exec$/);
44
+ });
45
+
46
+ test("caches the result across calls", () => {
47
+ const a = probeSandboxStrategy();
48
+ const b = probeSandboxStrategy();
49
+ expect(b).toBe(a);
50
+ });
51
+
52
+ test("ignores unknown override and falls back to auto", () => {
53
+ process.env.LOBU_EXEC_SANDBOX = "garbage";
54
+ const s = probeSandboxStrategy();
55
+ if (process.platform === "darwin") {
56
+ expect(s.kind).toBe("sandbox-exec");
57
+ } else if (process.platform === "linux") {
58
+ expect(["bwrap", "none"]).toContain(s.kind);
59
+ }
60
+ });
61
+
62
+ test("explicit override fails closed when backend unavailable", () => {
63
+ if (process.platform !== "darwin") return;
64
+ process.env.LOBU_EXEC_SANDBOX = "bwrap";
65
+ expect(() => probeSandboxStrategy()).toThrow(/bubblewrap is unavailable/);
66
+ });
67
+
68
+ test("cache invalidates when override env changes", () => {
69
+ process.env.LOBU_EXEC_SANDBOX = "off";
70
+ const a = probeSandboxStrategy();
71
+ expect(a.kind).toBe("none");
72
+ process.env.LOBU_EXEC_SANDBOX = "auto";
73
+ const b = probeSandboxStrategy();
74
+ expect(b).not.toBe(a);
75
+ });
76
+ });
77
+
78
+ describe("workspaceDir validation", () => {
79
+ test("sandbox-exec rejects workspaceDir with double-quote", () => {
80
+ expect(() =>
81
+ wrapInvocation(
82
+ { kind: "sandbox-exec", path: "/usr/bin/sandbox-exec" },
83
+ { command: "/bin/cat", args: [] },
84
+ { workspaceDir: '/tmp/evil"; (allow file-read*)', allowNet: false }
85
+ )
86
+ ).toThrow(/unsafe characters/);
87
+ });
88
+
89
+ test("sandbox-exec rejects workspaceDir with newline", () => {
90
+ expect(() =>
91
+ wrapInvocation(
92
+ { kind: "sandbox-exec", path: "/usr/bin/sandbox-exec" },
93
+ { command: "/bin/cat", args: [] },
94
+ { workspaceDir: "/tmp/evil\n(allow file-read*)", allowNet: false }
95
+ )
96
+ ).toThrow(/unsafe characters/);
97
+ });
98
+
99
+ test("bwrap rejects workspaceDir with paren", () => {
100
+ expect(() =>
101
+ wrapInvocation(
102
+ { kind: "bwrap", path: "/usr/bin/bwrap" },
103
+ { command: "/bin/cat", args: [] },
104
+ { workspaceDir: "/tmp/(evil)", allowNet: false }
105
+ )
106
+ ).toThrow(/unsafe characters/);
107
+ });
108
+
109
+ test("accepts plausible developer paths", () => {
110
+ expect(() =>
111
+ wrapInvocation(
112
+ { kind: "sandbox-exec", path: "/usr/bin/sandbox-exec" },
113
+ { command: "/bin/cat", args: [] },
114
+ {
115
+ workspaceDir: "/Users/dev/.lobu/workspaces/thread-123",
116
+ allowNet: false,
117
+ }
118
+ )
119
+ ).not.toThrow();
120
+ });
121
+
122
+ test.each([
123
+ ["/", "root"],
124
+ ["/tmp//evil", "double-slash"],
125
+ ["/tmp/../etc", "dotdot segment"],
126
+ ["/tmp/", "trailing slash"],
127
+ ])("rejects non-canonical path: %s (%s)", (badPath) => {
128
+ expect(() =>
129
+ wrapInvocation(
130
+ { kind: "sandbox-exec", path: "/usr/bin/sandbox-exec" },
131
+ { command: "/bin/cat", args: [] },
132
+ { workspaceDir: badPath, allowNet: false }
133
+ )
134
+ ).toThrow();
135
+ });
136
+ });
137
+
138
+ describe("wrapInvocation", () => {
139
+ test("pass-through when strategy.kind === none", () => {
140
+ const ws = tmpWorkspace();
141
+ cleanups.push(() => fs.rmSync(ws, { recursive: true, force: true }));
142
+ const r = wrapInvocation(
143
+ { kind: "none" },
144
+ { command: "/bin/echo", args: ["hi"] },
145
+ { workspaceDir: ws }
146
+ );
147
+ expect(r).toEqual({ command: "/bin/echo", args: ["hi"] });
148
+ });
149
+
150
+ test("sandbox-exec wraps with -p <profile> <cmd> <args>", () => {
151
+ const ws = tmpWorkspace();
152
+ cleanups.push(() => fs.rmSync(ws, { recursive: true, force: true }));
153
+ const r = wrapInvocation(
154
+ { kind: "sandbox-exec", path: "/usr/bin/sandbox-exec" },
155
+ { command: "/bin/cat", args: ["foo"] },
156
+ { workspaceDir: ws, allowNet: false }
157
+ );
158
+ expect(r.command).toBe("/usr/bin/sandbox-exec");
159
+ expect(r.args[0]).toBe("-p");
160
+ expect(r.args[1]).toContain(`(subpath "${ws}")`);
161
+ expect(r.args[1]).toContain("(deny network*)");
162
+ expect(r.args.slice(2)).toEqual(["/bin/cat", "foo"]);
163
+ });
164
+
165
+ test("sandbox-exec emits (allow network*) when allowNet=true", () => {
166
+ const ws = tmpWorkspace();
167
+ cleanups.push(() => fs.rmSync(ws, { recursive: true, force: true }));
168
+ const r = wrapInvocation(
169
+ { kind: "sandbox-exec", path: "/usr/bin/sandbox-exec" },
170
+ { command: "/bin/cat", args: [] },
171
+ { workspaceDir: ws, allowNet: true }
172
+ );
173
+ expect(r.args[1]).toContain("(allow network*)");
174
+ });
175
+
176
+ test("bwrap wraps with bind mounts + -- <cmd> <args>", () => {
177
+ const ws = tmpWorkspace();
178
+ cleanups.push(() => fs.rmSync(ws, { recursive: true, force: true }));
179
+ const r = wrapInvocation(
180
+ { kind: "bwrap", path: "/usr/bin/bwrap" },
181
+ { command: "/bin/cat", args: ["foo"] },
182
+ { workspaceDir: ws, allowNet: false }
183
+ );
184
+ expect(r.command).toBe("/usr/bin/bwrap");
185
+ expect(r.args).toContain("--bind");
186
+ expect(r.args).toContain(ws);
187
+ expect(r.args).toContain("/workspace");
188
+ const chdir = r.args.indexOf("--chdir");
189
+ expect(r.args[chdir + 1]).toBe("/workspace");
190
+ expect(r.args).toContain("--unshare-net");
191
+ expect(r.args).toContain("--unshare-user");
192
+ expect(r.args).toContain("--new-session");
193
+ expect(r.args).toContain("--die-with-parent");
194
+ expect(r.args).toContain("--");
195
+ const sep = r.args.indexOf("--");
196
+ expect(r.args.slice(sep + 1)).toEqual(["/bin/cat", "foo"]);
197
+ });
198
+
199
+ test("bwrap honors requested namespace cwd", () => {
200
+ const ws = tmpWorkspace();
201
+ cleanups.push(() => fs.rmSync(ws, { recursive: true, force: true }));
202
+ const r = wrapInvocation(
203
+ { kind: "bwrap", path: "/usr/bin/bwrap" },
204
+ { command: "/bin/pwd", args: [] },
205
+ { workspaceDir: ws, bwrapCwd: "/workspace/subdir" }
206
+ );
207
+ const chdir = r.args.indexOf("--chdir");
208
+ expect(r.args[chdir + 1]).toBe("/workspace/subdir");
209
+ });
210
+
211
+ test("bwrap rejects namespace cwd outside /workspace", () => {
212
+ const ws = tmpWorkspace();
213
+ cleanups.push(() => fs.rmSync(ws, { recursive: true, force: true }));
214
+ expect(() =>
215
+ wrapInvocation(
216
+ { kind: "bwrap", path: "/usr/bin/bwrap" },
217
+ { command: "/bin/pwd", args: [] },
218
+ { workspaceDir: ws, bwrapCwd: "/usr" }
219
+ )
220
+ ).toThrow(/must be \/workspace or below/);
221
+ });
222
+
223
+ test("sandbox-exec profile denies var/run unix sockets", () => {
224
+ const ws = tmpWorkspace();
225
+ cleanups.push(() => fs.rmSync(ws, { recursive: true, force: true }));
226
+ const r = wrapInvocation(
227
+ { kind: "sandbox-exec", path: "/usr/bin/sandbox-exec" },
228
+ { command: "/bin/cat", args: [] },
229
+ { workspaceDir: ws }
230
+ );
231
+ expect(r.args[1]).toContain('(subpath "/var/run")');
232
+ expect(r.args[1]).toContain('(subpath "/private/var/run")');
233
+ expect(r.args[1]).toContain('(subpath "/Library/Preferences")');
234
+ });
235
+ });
236
+
237
+ // Real escape-matrix tests against the live macOS sandbox. Skipped on other
238
+ // platforms — the bwrap counterpart runs under CI on Linux.
239
+ const isDarwin = process.platform === "darwin";
240
+ const describeDarwin = isDarwin ? describe : describe.skip;
241
+
242
+ describeDarwin("sandbox-exec escape matrix", () => {
243
+ let strategy: SandboxStrategy;
244
+ let workspace: string;
245
+
246
+ beforeEach(() => {
247
+ process.env.LOBU_EXEC_SANDBOX = "sandbox-exec";
248
+ resetSandboxProbeForTests();
249
+ strategy = probeSandboxStrategy();
250
+ workspace = tmpWorkspace();
251
+ fs.writeFileSync(path.join(workspace, "hello.txt"), "hi from workspace\n");
252
+ cleanups.push(() => fs.rmSync(workspace, { recursive: true, force: true }));
253
+ });
254
+
255
+ async function runIn(cmd: string, args: string[]) {
256
+ const r = wrapInvocation(
257
+ strategy,
258
+ { command: cmd, args },
259
+ {
260
+ workspaceDir: workspace,
261
+ allowNet: false,
262
+ }
263
+ );
264
+ try {
265
+ // codeql[js/shell-command-injection-from-environment]: this test intentionally executes the sandbox wrapper via execFile (no shell) to validate isolation.
266
+ const { stdout } = await execFile(r.command, r.args, {
267
+ cwd: workspace,
268
+ timeout: 5000,
269
+ env: { PATH: "/usr/bin:/bin:/usr/sbin:/sbin", HOME: workspace },
270
+ });
271
+ return { ok: true, stdout: stdout.toString() };
272
+ } catch (e: unknown) {
273
+ const err = e as { code?: number; stdout?: string; stderr?: string };
274
+ return {
275
+ ok: false,
276
+ stdout: err.stdout?.toString() ?? "",
277
+ stderr: err.stderr?.toString() ?? "",
278
+ code: err.code,
279
+ };
280
+ }
281
+ }
282
+
283
+ test("blocks /etc/passwd", async () => {
284
+ const r = await runIn("/bin/cat", ["/etc/passwd"]);
285
+ expect(r.ok && r.stdout.includes("root:")).toBe(false);
286
+ });
287
+
288
+ test("blocks listing real /Users", async () => {
289
+ const r = await runIn("/bin/ls", ["/Users"]);
290
+ expect(r.ok).toBe(false);
291
+ });
292
+
293
+ test("blocks ../../etc/passwd traversal", async () => {
294
+ const r = await runIn("/bin/sh", ["-c", "cat ../../etc/passwd"]);
295
+ expect(r.ok && r.stdout.includes("root:")).toBe(false);
296
+ });
297
+
298
+ test("blocks symlink-to-/etc/passwd", async () => {
299
+ const r = await runIn("/bin/sh", [
300
+ "-c",
301
+ "ln -sf /etc/passwd evil && cat evil",
302
+ ]);
303
+ expect(r.ok && r.stdout.includes("root:")).toBe(false);
304
+ });
305
+
306
+ test("blocks write outside workspace", async () => {
307
+ const r = await runIn("/bin/sh", [
308
+ "-c",
309
+ "echo pwn > /tmp/spike-pwn-write && cat /tmp/spike-pwn-write",
310
+ ]);
311
+ expect(r.ok).toBe(false);
312
+ });
313
+
314
+ test("blocks home-dir secrets", async () => {
315
+ const r = await runIn("/bin/sh", [
316
+ "-c",
317
+ `cat ${os.homedir()}/.ssh/id_rsa || cat ${os.homedir()}/.ssh/id_ed25519`,
318
+ ]);
319
+ expect(r.ok).toBe(false);
320
+ });
321
+
322
+ test("allows reading workspace file", async () => {
323
+ const r = await runIn("/bin/cat", ["hello.txt"]);
324
+ expect(r.ok).toBe(true);
325
+ expect(r.stdout).toContain("hi from workspace");
326
+ });
327
+
328
+ test("allows writing workspace file", async () => {
329
+ const r = await runIn("/bin/sh", [
330
+ "-c",
331
+ "echo wrote > out.txt && cat out.txt",
332
+ ]);
333
+ expect(r.ok).toBe(true);
334
+ expect(r.stdout).toContain("wrote");
335
+ });
336
+
337
+ test("argv injection: shell metacharacters not interpreted by execFile", async () => {
338
+ const r = await runIn("/bin/cat", ["hello.txt; cat /etc/passwd"]);
339
+ expect(r.ok && r.stdout.includes("root:")).toBe(false);
340
+ });
341
+ });
342
+
343
+ // Real escape-matrix tests against bwrap. Auto-skip when not on Linux, when
344
+ // bwrap isn't on PATH, or when user namespaces are blocked at the OS level
345
+ // (Ubuntu 24.04 / GitHub Actions default until apparmor sysctl is flipped).
346
+ // Probed once at module load so each test starts with a known-good sandbox.
347
+ const isLinux = process.platform === "linux";
348
+ const linuxBwrapWorks = (() => {
349
+ if (!isLinux) return false;
350
+ const candidates = ["/usr/bin/bwrap", "/usr/local/bin/bwrap"];
351
+ const bwrapPath = candidates.find((p) => {
352
+ try {
353
+ fs.accessSync(p, fs.constants.X_OK);
354
+ return true;
355
+ } catch {
356
+ return false;
357
+ }
358
+ });
359
+ if (!bwrapPath) return false;
360
+ try {
361
+ // Mirror `bwrapDeliversIsolation` in exec-sandbox.ts. /lib64 must be
362
+ // bound because /usr/bin/true's ELF interpreter is /lib64/ld-linux-*.so.
363
+ execFileSync(
364
+ bwrapPath,
365
+ [
366
+ "--unshare-user",
367
+ "--unshare-pid",
368
+ "--unshare-net",
369
+ "--ro-bind",
370
+ "/usr",
371
+ "/usr",
372
+ "--ro-bind-try",
373
+ "/lib",
374
+ "/lib",
375
+ "--ro-bind-try",
376
+ "/lib64",
377
+ "/lib64",
378
+ "--ro-bind-try",
379
+ "/bin",
380
+ "/bin",
381
+ "--proc",
382
+ "/proc",
383
+ "--dev",
384
+ "/dev",
385
+ "--",
386
+ "/usr/bin/true",
387
+ ],
388
+ { stdio: "ignore", timeout: 3000 }
389
+ );
390
+ return true;
391
+ } catch {
392
+ return false;
393
+ }
394
+ })();
395
+ const describeBwrap = linuxBwrapWorks ? describe : describe.skip;
396
+
397
+ describeBwrap("bwrap escape matrix", () => {
398
+ let strategy: SandboxStrategy;
399
+ let workspace: string;
400
+
401
+ beforeEach(() => {
402
+ process.env.LOBU_EXEC_SANDBOX = "bwrap";
403
+ resetSandboxProbeForTests();
404
+ strategy = probeSandboxStrategy();
405
+ workspace = tmpWorkspace();
406
+ fs.writeFileSync(path.join(workspace, "hello.txt"), "hi from workspace\n");
407
+ cleanups.push(() => fs.rmSync(workspace, { recursive: true, force: true }));
408
+ });
409
+
410
+ async function runIn(
411
+ cmd: string,
412
+ args: string[],
413
+ allowNet = false,
414
+ bwrapCwd = "/workspace"
415
+ ) {
416
+ if (!strategy || strategy.kind !== "bwrap") {
417
+ return { ok: false, stdout: "", stderr: "skipped", code: -1 };
418
+ }
419
+ const r = wrapInvocation(
420
+ strategy,
421
+ { command: cmd, args },
422
+ {
423
+ workspaceDir: workspace,
424
+ allowNet,
425
+ bwrapCwd,
426
+ }
427
+ );
428
+ try {
429
+ // codeql[js/shell-command-injection-from-environment]: this test intentionally executes the sandbox wrapper via execFile (no shell) to validate isolation.
430
+ const { stdout } = await execFile(r.command, r.args, {
431
+ timeout: 5000,
432
+ env: { PATH: "/usr/bin:/bin", HOME: "/workspace/.sandbox-home" },
433
+ });
434
+ return { ok: true, stdout: stdout.toString() };
435
+ } catch (e: unknown) {
436
+ const err = e as { code?: number; stdout?: string; stderr?: string };
437
+ return {
438
+ ok: false,
439
+ stdout: err.stdout?.toString() ?? "",
440
+ stderr: err.stderr?.toString() ?? "",
441
+ code: err.code,
442
+ };
443
+ }
444
+ }
445
+
446
+ test("blocks /etc/passwd outside the bind", async () => {
447
+ // /etc is not bound, so the path doesn't exist inside the namespace at all.
448
+ const r = await runIn("/bin/cat", ["/etc/passwd"]);
449
+ expect(r.ok && r.stdout.includes("root:")).toBe(false);
450
+ });
451
+
452
+ test("blocks listing /home", async () => {
453
+ const r = await runIn("/bin/ls", ["/home"]);
454
+ // /home is not bound; the path either doesn't exist or returns ENOENT.
455
+ expect(r.ok).toBe(false);
456
+ });
457
+
458
+ test("blocks ../../etc/passwd traversal from /workspace", async () => {
459
+ const r = await runIn("/bin/sh", [
460
+ "-c",
461
+ "cd /workspace && cat ../../etc/passwd",
462
+ ]);
463
+ expect(r.ok && r.stdout.includes("root:")).toBe(false);
464
+ });
465
+
466
+ test("blocks symlink escape from workspace", async () => {
467
+ fs.symlinkSync("/etc/passwd", path.join(workspace, "evil"));
468
+ const r = await runIn("/bin/sh", ["-c", "cat /workspace/evil"]);
469
+ // The symlink target /etc/passwd doesn't exist inside the namespace.
470
+ expect(r.ok && r.stdout.includes("root:")).toBe(false);
471
+ });
472
+
473
+ test("blocks writes to ro-bound /usr", async () => {
474
+ // /usr is --ro-bind'd, so writes there are denied at the kernel level
475
+ // regardless of namespace.
476
+ const r = await runIn("/bin/sh", ["-c", "echo pwn > /usr/spike-pwn"]);
477
+ expect(r.ok).toBe(false);
478
+ });
479
+
480
+ test("namespace writes don't escape to host filesystem", async () => {
481
+ // The namespace's /etc is a writable empty dir created by bwrap (it's the
482
+ // mountpoint for --ro-bind-try /etc/resolv.conf). Writes there succeed
483
+ // *inside the namespace* but must not affect the host's /etc.
484
+ const marker = `/etc/spike-pwn-${process.pid}-${Date.now()}`;
485
+ await runIn("/bin/sh", ["-c", `echo pwn > ${marker} || true`]);
486
+ expect(fs.existsSync(marker)).toBe(false);
487
+ });
488
+
489
+ test("blocks read of host root filesystem dirs", async () => {
490
+ const r = await runIn("/bin/sh", [
491
+ "-c",
492
+ "ls -la /root 2>/dev/null || ls -la /var/log 2>/dev/null",
493
+ ]);
494
+ expect(r.ok && r.stdout.length > 0).toBe(false);
495
+ });
496
+
497
+ test("allows reading workspace file via /workspace bind", async () => {
498
+ const r = await runIn("/bin/cat", ["/workspace/hello.txt"]);
499
+ expect(r.ok).toBe(true);
500
+ expect(r.stdout).toContain("hi from workspace");
501
+ });
502
+
503
+ test("runs relative commands from requested bwrap cwd", async () => {
504
+ fs.mkdirSync(path.join(workspace, "subdir"));
505
+ fs.writeFileSync(
506
+ path.join(workspace, "subdir", "local.txt"),
507
+ "from subdir\n"
508
+ );
509
+ const r = await runIn(
510
+ "/bin/cat",
511
+ ["local.txt"],
512
+ false,
513
+ "/workspace/subdir"
514
+ );
515
+ expect(r.ok).toBe(true);
516
+ expect(r.stdout).toContain("from subdir");
517
+ });
518
+
519
+ test("allows writing inside /workspace", async () => {
520
+ const r = await runIn("/bin/sh", [
521
+ "-c",
522
+ "echo wrote > /workspace/out.txt && cat /workspace/out.txt",
523
+ ]);
524
+ expect(r.ok).toBe(true);
525
+ expect(r.stdout).toContain("wrote");
526
+ expect(fs.readFileSync(path.join(workspace, "out.txt"), "utf8")).toContain(
527
+ "wrote"
528
+ );
529
+ });
530
+
531
+ test("argv injection: shell metacharacters in execFile args don't escape", async () => {
532
+ const r = await runIn("/bin/cat", [
533
+ "/workspace/hello.txt; cat /etc/passwd",
534
+ ]);
535
+ expect(r.ok && r.stdout.includes("root:")).toBe(false);
536
+ });
537
+
538
+ test("--unshare-net blocks outbound network", async () => {
539
+ // /usr/bin/getent uses libnss; cleanest is just to try a local socket.
540
+ const r = await runIn("/bin/sh", [
541
+ "-c",
542
+ "exec 3<>/dev/tcp/8.8.8.8/53 2>&1; echo $?",
543
+ ]);
544
+ // bwrap's tmpfs at /dev only includes minimal nodes, and --unshare-net
545
+ // means even if /dev/tcp works there's no route. Either is fine.
546
+ if (r.ok) {
547
+ expect(r.stdout.trim()).not.toBe("0");
548
+ }
549
+ });
550
+ });
@@ -0,0 +1,142 @@
1
+ import { afterEach, describe, expect, mock, test } from "bun:test";
2
+ import { generateAudio, generateImage } from "../shared/tool-implementations";
3
+
4
+ const originalFetch = globalThis.fetch;
5
+
6
+ function extractText(result: {
7
+ content: Array<{ type: "text"; text: string }>;
8
+ }): string {
9
+ return result.content[0]?.text || "";
10
+ }
11
+
12
+ function bodyToString(body: BodyInit | null | undefined): string {
13
+ if (!body) return "";
14
+ if (typeof body === "string") return body;
15
+ if (Buffer.isBuffer(body)) return body.toString("utf8");
16
+ if (body instanceof Uint8Array) return Buffer.from(body).toString("utf8");
17
+ return "";
18
+ }
19
+
20
+ describe("generated media upload flow", () => {
21
+ afterEach(() => {
22
+ globalThis.fetch = originalFetch;
23
+ mock.restore();
24
+ });
25
+
26
+ test("GenerateImage uploads the generated image to the gateway", async () => {
27
+ const fetchMock = mock(
28
+ async (input: RequestInfo | URL, init?: RequestInit) => {
29
+ const url = String(input);
30
+
31
+ if (url.endsWith("/internal/images/capabilities")) {
32
+ return Response.json({ available: true });
33
+ }
34
+
35
+ if (url.endsWith("/internal/images/generate")) {
36
+ return new Response(Buffer.from("png-bytes"), {
37
+ status: 200,
38
+ headers: {
39
+ "Content-Type": "image/png",
40
+ "X-Image-Provider": "openai",
41
+ },
42
+ });
43
+ }
44
+
45
+ if (url.endsWith("/internal/files/upload")) {
46
+ const headers = new Headers(init?.headers);
47
+ const body = bodyToString(init?.body);
48
+
49
+ expect(init?.method).toBe("POST");
50
+ expect(headers.get("Authorization")).toBe("Bearer worker-token");
51
+ expect(headers.get("X-Channel-Id")).toBe("channel-1");
52
+ expect(headers.get("X-Conversation-Id")).toBe("conversation-1");
53
+ expect(headers.get("X-Voice-Message")).toBeNull();
54
+ expect(headers.get("Content-Type")).toContain("multipart/form-data");
55
+ expect(body).toContain("generated_image.png");
56
+ expect(body).toContain("Generated content");
57
+
58
+ return Response.json({ success: true, fileId: "file-1" });
59
+ }
60
+
61
+ throw new Error(`Unexpected fetch: ${url}`);
62
+ }
63
+ );
64
+
65
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
66
+
67
+ const result = await generateImage(
68
+ {
69
+ gatewayUrl: "http://gateway",
70
+ workerToken: "worker-token",
71
+ channelId: "channel-1",
72
+ conversationId: "conversation-1",
73
+ platform: "telegram",
74
+ },
75
+ { prompt: "A watercolor fox" }
76
+ );
77
+
78
+ expect(extractText(result as any)).toContain("Image sent successfully");
79
+ expect(fetchMock).toHaveBeenCalledTimes(3);
80
+ });
81
+
82
+ test("GenerateAudio uploads synthesized speech as a voice message", async () => {
83
+ const fetchMock = mock(
84
+ async (input: RequestInfo | URL, init?: RequestInit) => {
85
+ const url = String(input);
86
+
87
+ if (url.endsWith("/internal/audio/capabilities")) {
88
+ return Response.json({
89
+ available: true,
90
+ providers: [{ provider: "openai", name: "OpenAI" }],
91
+ });
92
+ }
93
+
94
+ if (url.endsWith("/internal/audio/synthesize")) {
95
+ return new Response(Buffer.from("ogg-bytes"), {
96
+ status: 200,
97
+ headers: {
98
+ "Content-Type": "audio/ogg",
99
+ "X-Audio-Provider": "openai",
100
+ },
101
+ });
102
+ }
103
+
104
+ if (url.endsWith("/internal/files/upload")) {
105
+ const headers = new Headers(init?.headers);
106
+ const body = bodyToString(init?.body);
107
+
108
+ expect(init?.method).toBe("POST");
109
+ expect(headers.get("Authorization")).toBe("Bearer worker-token");
110
+ expect(headers.get("X-Channel-Id")).toBe("channel-1");
111
+ expect(headers.get("X-Conversation-Id")).toBe("conversation-1");
112
+ expect(headers.get("X-Voice-Message")).toBe("true");
113
+ expect(headers.get("Content-Type")).toContain("multipart/form-data");
114
+ expect(body).toContain("voice_response.ogg");
115
+ expect(body).toContain("Generated content");
116
+
117
+ return Response.json({ success: true, fileId: "file-2" });
118
+ }
119
+
120
+ throw new Error(`Unexpected fetch: ${url}`);
121
+ }
122
+ );
123
+
124
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
125
+
126
+ const result = await generateAudio(
127
+ {
128
+ gatewayUrl: "http://gateway",
129
+ workerToken: "worker-token",
130
+ channelId: "channel-1",
131
+ conversationId: "conversation-1",
132
+ platform: "telegram",
133
+ },
134
+ { text: "Hello there" }
135
+ );
136
+
137
+ expect(extractText(result as any)).toContain(
138
+ "Voice message sent successfully"
139
+ );
140
+ expect(fetchMock).toHaveBeenCalledTimes(3);
141
+ });
142
+ });