@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,543 @@
1
+ /**
2
+ * Worker-side just-bash bootstrap for embedded deployment mode.
3
+ *
4
+ * Creates a just-bash Bash instance from environment variables and wraps it
5
+ * as a BashOperations interface for pi-coding-agent's bash tool.
6
+ *
7
+ * When nix binaries are detected on PATH (via nix-shell wrapper from gateway)
8
+ * or known CLI tools (e.g. lobu) are found, they are registered as
9
+ * just-bash customCommands that delegate to real exec.
10
+ */
11
+
12
+ import { execFile } from "node:child_process";
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+ import { stripEnv } from "@lobu/core";
16
+ import type { BashOperations } from "@mariozechner/pi-coding-agent";
17
+ import { SENSITIVE_WORKER_ENV_KEYS } from "../shared/worker-env-keys";
18
+ import type { GatewayParams } from "../shared/tool-implementations";
19
+ import {
20
+ type SandboxStrategy,
21
+ probeSandboxStrategy,
22
+ wrapInvocation,
23
+ } from "./exec-sandbox";
24
+ import type { McpCliCommand, McpRuntimeRef } from "./mcp-cli-commands";
25
+ import { buildMcpCliCommands } from "./mcp-cli-commands";
26
+
27
+ const EMBEDDED_BASH_LIMITS = {
28
+ maxCommandCount: 50_000,
29
+ maxLoopIterations: 50_000,
30
+ maxCallDepth: 50,
31
+ } as const;
32
+
33
+ export interface SandboxContext {
34
+ strategy: SandboxStrategy;
35
+ workspaceDir: string;
36
+ /** Whether the spawned binary may open sockets. just-bash's domain allowlist
37
+ * still gates the interpreter; this controls the OS network namespace. */
38
+ allowNet?: boolean;
39
+ /** Per-invocation cwd inside bwrap's /workspace namespace. */
40
+ bwrapCwd?: string;
41
+ }
42
+
43
+ export function buildBinaryInvocation(
44
+ binaryPath: string,
45
+ args: string[],
46
+ sandbox?: SandboxContext
47
+ ): { command: string; args: string[] } {
48
+ let inner: { command: string; args: string[] } = {
49
+ command: binaryPath,
50
+ args,
51
+ };
52
+ try {
53
+ const firstLine =
54
+ fs.readFileSync(binaryPath, "utf8").split("\n", 1)[0] || "";
55
+ if (firstLine === "#!/usr/bin/env node" || firstLine.endsWith("/node")) {
56
+ inner = { command: "node", args: [binaryPath, ...args] };
57
+ }
58
+ } catch {
59
+ // Fall back to executing the binary directly.
60
+ }
61
+
62
+ if (!sandbox) return inner;
63
+ return wrapInvocation(sandbox.strategy, inner, {
64
+ workspaceDir: sandbox.workspaceDir,
65
+ allowNet: sandbox.allowNet ?? true,
66
+ bwrapCwd: sandbox.bwrapCwd,
67
+ });
68
+ }
69
+
70
+ /**
71
+ * Binaries that are full code-execution capabilities. If they land on the
72
+ * just-bash allowlist, the depth/loop caps are moot — the agent can run
73
+ * arbitrary code through them. They are excluded by default; an agent that
74
+ * genuinely needs them must opt in via
75
+ * `LOBU_ALLOW_UNSANDBOXED_EXEC=1` (set per-agent in lobu.toml).
76
+ */
77
+ const UNSANDBOXED_INTERPRETERS = new Set<string>([
78
+ "node",
79
+ "nodejs",
80
+ "bun",
81
+ "deno",
82
+ "python",
83
+ "python3",
84
+ "ruby",
85
+ "perl",
86
+ "lua",
87
+ "bash",
88
+ "sh",
89
+ "zsh",
90
+ "fish",
91
+ "ash",
92
+ "dash",
93
+ "ksh",
94
+ "tcsh",
95
+ "csh",
96
+ "curl",
97
+ "wget",
98
+ "git",
99
+ "ssh",
100
+ "scp",
101
+ "rsync",
102
+ "nc",
103
+ "ncat",
104
+ "socat",
105
+ "telnet",
106
+ "nix",
107
+ "nix-build",
108
+ "nix-shell",
109
+ "nix-env",
110
+ "npm",
111
+ "npx",
112
+ "pnpm",
113
+ "yarn",
114
+ "pip",
115
+ "pip3",
116
+ "pipx",
117
+ "uv",
118
+ "poetry",
119
+ "gem",
120
+ "cargo",
121
+ "go",
122
+ ]);
123
+
124
+ /**
125
+ * Discover binaries to register as custom commands:
126
+ * 1. All executables from /nix/store/ PATH directories
127
+ * 2. Known CLI tools (lobu) from anywhere on PATH
128
+ *
129
+ * UNSANDBOXED_INTERPRETERS are filtered out unless the spawned worker has
130
+ * LOBU_ALLOW_UNSANDBOXED_EXEC=1 in its env (set explicitly per-agent for
131
+ * cases that genuinely need a full interpreter).
132
+ */
133
+ function discoverBinaries(): Map<string, string> {
134
+ const binaries = new Map<string, string>();
135
+ const pathDirs = (process.env.PATH || "").split(":");
136
+ const allowUnsandboxed =
137
+ process.env.LOBU_ALLOW_UNSANDBOXED_EXEC === "1" ||
138
+ process.env.LOBU_ALLOW_UNSANDBOXED_EXEC === "true";
139
+
140
+ const isAllowed = (name: string): boolean =>
141
+ allowUnsandboxed || !UNSANDBOXED_INTERPRETERS.has(name);
142
+
143
+ for (const dir of pathDirs) {
144
+ if (!dir.includes("/nix/store/")) continue;
145
+ try {
146
+ for (const entry of fs.readdirSync(dir)) {
147
+ if (!isAllowed(entry)) continue;
148
+ const fullPath = path.join(dir, entry);
149
+ try {
150
+ fs.accessSync(fullPath, fs.constants.X_OK);
151
+ if (!binaries.has(entry)) binaries.set(entry, fullPath);
152
+ } catch {
153
+ // not executable
154
+ }
155
+ }
156
+ } catch {
157
+ // directory not readable
158
+ }
159
+ }
160
+
161
+ // Discover known CLI tools from full PATH
162
+ for (const name of ["lobu"]) {
163
+ if (binaries.has(name)) continue;
164
+ if (!isAllowed(name)) continue;
165
+ for (const dir of pathDirs) {
166
+ const fullPath = path.join(dir, name);
167
+ try {
168
+ fs.accessSync(fullPath, fs.constants.X_OK);
169
+ binaries.set(name, fullPath);
170
+ break;
171
+ } catch {
172
+ // not found
173
+ }
174
+ }
175
+ }
176
+
177
+ return binaries;
178
+ }
179
+
180
+ /**
181
+ * Resolve a just-bash virtual cwd to a real on-disk path. just-bash's
182
+ * `CommandContext.cwd` is rooted at the `ReadWriteFs` root, but `child_process`
183
+ * needs a host path. We realpath both sides and verify the resolved cwd stays
184
+ * inside the workspace — defense in depth against a symlink that slipped past
185
+ * `ReadWriteFs` (e.g. if `allowSymlinks` is ever enabled upstream).
186
+ */
187
+ function resolveHostCwd(workspaceDir: string, virtualCwd: string): string {
188
+ const trimmed = virtualCwd.startsWith("/") ? virtualCwd.slice(1) : virtualCwd;
189
+ const candidate = trimmed ? path.join(workspaceDir, trimmed) : workspaceDir;
190
+ let realCwd: string;
191
+ let realWs: string;
192
+ try {
193
+ realCwd = fs.realpathSync(candidate);
194
+ realWs = fs.realpathSync(workspaceDir);
195
+ } catch {
196
+ return workspaceDir;
197
+ }
198
+ if (realCwd !== realWs && !realCwd.startsWith(realWs + path.sep)) {
199
+ throw new Error(
200
+ `[embedded] cwd ${JSON.stringify(virtualCwd)} resolves outside workspace`
201
+ );
202
+ }
203
+ return realCwd;
204
+ }
205
+
206
+ function ensureSandboxDir(workspaceDir: string, name: string): string {
207
+ const dir = path.join(workspaceDir, name);
208
+ try {
209
+ fs.mkdirSync(dir, { recursive: true });
210
+ } catch {
211
+ // best-effort; sandbox profile will surface real errors if it can't create
212
+ }
213
+ return dir;
214
+ }
215
+
216
+ function bwrapCwdForHostCwd(workspaceDir: string, hostCwd: string): string {
217
+ const rel = path.relative(workspaceDir, hostCwd);
218
+ if (!rel) return "/workspace";
219
+ if (rel.startsWith("..") || path.isAbsolute(rel)) {
220
+ throw new Error(
221
+ `[embedded] cwd ${JSON.stringify(hostCwd)} resolves outside workspace`
222
+ );
223
+ }
224
+ return path.posix.join("/workspace", ...rel.split(path.sep));
225
+ }
226
+
227
+ /**
228
+ * Create just-bash customCommands from a map of binary name → full path.
229
+ * Each custom command delegates to the real binary via child_process.execFile,
230
+ * wrapped in the active per-exec sandbox so spawned binaries cannot read
231
+ * outside the workspace or write outside it.
232
+ */
233
+ async function buildCustomCommands(
234
+ binaries: Map<string, string>,
235
+ sandbox: SandboxContext
236
+ ): Promise<ReturnType<typeof import("just-bash").defineCommand>[]> {
237
+ const { defineCommand } = await import("just-bash");
238
+ const commands = [];
239
+
240
+ for (const [name, binaryPath] of binaries) {
241
+ commands.push(
242
+ defineCommand(name, async (args: string[], ctx) => {
243
+ const envRecord = stripEnv(process.env, SENSITIVE_WORKER_ENV_KEYS);
244
+ if (ctx.env && typeof ctx.env.forEach === "function") {
245
+ ctx.env.forEach((v: string, k: string) => {
246
+ envRecord[k] = v;
247
+ });
248
+ } else if (ctx.env && typeof ctx.env === "object") {
249
+ Object.assign(envRecord, ctx.env);
250
+ }
251
+
252
+ // Pin HOME / TMPDIR to dedicated subdirs so tool dotfiles (~/.gitconfig,
253
+ // ~/.cache, ~/.config) and temp files don't collide with workspace
254
+ // contents. Sandbox profiles already deny outside-workspace writes;
255
+ // these keep agent-visible files clean.
256
+ //
257
+ // bwrap binds the host workspace at `/workspace` inside the namespace,
258
+ // so the in-namespace HOME/TMPDIR must use the bound path. sandbox-exec
259
+ // doesn't remap paths, so HOME/TMPDIR stay as host paths.
260
+ ensureSandboxDir(sandbox.workspaceDir, ".sandbox-home");
261
+ ensureSandboxDir(sandbox.workspaceDir, ".sandbox-tmp");
262
+ if (sandbox.strategy.kind === "bwrap") {
263
+ envRecord.HOME = "/workspace/.sandbox-home";
264
+ envRecord.TMPDIR = "/workspace/.sandbox-tmp";
265
+ } else {
266
+ envRecord.HOME = path.join(sandbox.workspaceDir, ".sandbox-home");
267
+ envRecord.TMPDIR = path.join(sandbox.workspaceDir, ".sandbox-tmp");
268
+ }
269
+
270
+ // Force gateway proxy env so a malicious agent can't override it via
271
+ // `export HTTP_PROXY=` to bypass the egress allowlist. NO_PROXY is
272
+ // stripped for the same reason.
273
+ if (process.env.HTTP_PROXY)
274
+ envRecord.HTTP_PROXY = process.env.HTTP_PROXY;
275
+ if (process.env.HTTPS_PROXY)
276
+ envRecord.HTTPS_PROXY = process.env.HTTPS_PROXY;
277
+ if (process.env.http_proxy)
278
+ envRecord.http_proxy = process.env.http_proxy;
279
+ if (process.env.https_proxy)
280
+ envRecord.https_proxy = process.env.https_proxy;
281
+ delete envRecord.NO_PROXY;
282
+ delete envRecord.no_proxy;
283
+
284
+ let hostCwd: string;
285
+ let bwrapCwd: string | undefined;
286
+ try {
287
+ hostCwd = resolveHostCwd(sandbox.workspaceDir, ctx.cwd ?? "/");
288
+ bwrapCwd =
289
+ sandbox.strategy.kind === "bwrap"
290
+ ? bwrapCwdForHostCwd(sandbox.workspaceDir, hostCwd)
291
+ : undefined;
292
+ } catch (e) {
293
+ return {
294
+ stdout: "",
295
+ stderr: e instanceof Error ? e.message : String(e),
296
+ exitCode: 1,
297
+ };
298
+ }
299
+
300
+ const invocation = buildBinaryInvocation(binaryPath, args, {
301
+ ...sandbox,
302
+ bwrapCwd,
303
+ });
304
+
305
+ return new Promise<{
306
+ stdout: string;
307
+ stderr: string;
308
+ exitCode: number;
309
+ }>((resolve) => {
310
+ execFile(
311
+ invocation.command,
312
+ invocation.args,
313
+ {
314
+ cwd: hostCwd,
315
+ env: envRecord as NodeJS.ProcessEnv,
316
+ maxBuffer: 10 * 1024 * 1024,
317
+ // Hung binaries (credential prompts, stalled network reads,
318
+ // waiting on stdin) must not freeze the agent turn forever.
319
+ timeout: 120_000,
320
+ killSignal: "SIGKILL",
321
+ },
322
+ (error, stdout, stderr) => {
323
+ // A signal-killed child leaves error.code null/undefined and sets
324
+ // error.signal — that must NOT be reported as exit code 0.
325
+ const err = error as
326
+ | (NodeJS.ErrnoException & {
327
+ signal?: NodeJS.Signals;
328
+ killed?: boolean;
329
+ })
330
+ | null;
331
+ let exitCode: number;
332
+ if (!err) {
333
+ exitCode = 0;
334
+ } else if (typeof err.code === "number") {
335
+ exitCode = err.code;
336
+ } else if (err.killed || err.signal) {
337
+ exitCode = 137;
338
+ } else {
339
+ exitCode = 1;
340
+ }
341
+ const timedOut =
342
+ !!err && (err.killed || err.signal === "SIGKILL");
343
+ resolve({
344
+ stdout: stdout || "",
345
+ stderr:
346
+ stderr ||
347
+ (timedOut
348
+ ? `command timed out after 120s and was killed`
349
+ : (err?.message ?? "")),
350
+ exitCode,
351
+ });
352
+ }
353
+ );
354
+ });
355
+ })
356
+ );
357
+ }
358
+
359
+ return commands;
360
+ }
361
+
362
+ interface EmbeddedBashOpsOptions {
363
+ /** Thread-specific workspace directory used as the sandbox filesystem root. */
364
+ workspaceDir?: string;
365
+ /**
366
+ * When provided together with `gw`, MCP servers are exposed as one
367
+ * `just-bash` custom command per server (e.g. `lobu search_memory
368
+ * <<<'{...}'`). Only applied when `mcpExposure === "cli"`. The ref's
369
+ * optional `refresh()` is invoked after successful auth operations so
370
+ * CLI handlers pick up freshly-discovered MCP tools without rebuilding Bash.
371
+ */
372
+ mcpRuntimeRef?: McpRuntimeRef;
373
+ gw?: GatewayParams;
374
+ /** `"tools"` (default) keeps today's first-class MCP tools. `"cli"` swaps to sandboxed bash CLIs. */
375
+ mcpExposure?: "tools" | "cli";
376
+ }
377
+
378
+ /**
379
+ * Convert an in-process MCP CLI handler into a just-bash `defineCommand` entry.
380
+ */
381
+ async function adaptMcpCliCommand(
382
+ cmd: McpCliCommand
383
+ ): Promise<ReturnType<typeof import("just-bash").defineCommand>> {
384
+ const { defineCommand } = await import("just-bash");
385
+ return defineCommand(cmd.name, async (args: string[], ctx) => {
386
+ const stdin = typeof ctx.stdin === "string" ? ctx.stdin : "";
387
+ const signal = ctx.signal as AbortSignal | undefined;
388
+ return cmd.execute(args, { stdin, signal });
389
+ });
390
+ }
391
+
392
+ /**
393
+ * Create a BashOperations adapter backed by a just-bash Bash instance.
394
+ * Reads configuration from environment variables.
395
+ */
396
+ export async function createEmbeddedBashOps(
397
+ options: EmbeddedBashOpsOptions = {}
398
+ ): Promise<BashOperations> {
399
+ const { Bash, ReadWriteFs } = await import("just-bash");
400
+
401
+ const rawWorkspaceDir =
402
+ options.workspaceDir || process.env.WORKSPACE_DIR || "/workspace";
403
+ // Canonicalize so the sandbox profile, ReadWriteFs root, bind mounts, and
404
+ // realpath checks all see the same resolved path. macOS TMPDIR routes through
405
+ // /var → /private/var symlinks; without realpath, the SBPL allow rule and
406
+ // execFile cwd disagree.
407
+ let workspaceDir = rawWorkspaceDir;
408
+ try {
409
+ fs.mkdirSync(rawWorkspaceDir, { recursive: true });
410
+ workspaceDir = fs.realpathSync(rawWorkspaceDir);
411
+ } catch {
412
+ // Fall through with the raw value; downstream calls surface real errors.
413
+ }
414
+ const bashFs = new ReadWriteFs({ root: workspaceDir });
415
+
416
+ // Parse allowed domains from env var (set by gateway)
417
+ let allowedDomains: string[] = [];
418
+ if (process.env.JUST_BASH_ALLOWED_DOMAINS) {
419
+ try {
420
+ allowedDomains = JSON.parse(process.env.JUST_BASH_ALLOWED_DOMAINS);
421
+ } catch {
422
+ console.error(
423
+ `[embedded] Failed to parse JUST_BASH_ALLOWED_DOMAINS: ${process.env.JUST_BASH_ALLOWED_DOMAINS}`
424
+ );
425
+ }
426
+ }
427
+
428
+ const network =
429
+ allowedDomains.length > 0
430
+ ? {
431
+ allowedUrlPrefixes: allowedDomains.flatMap((domain: string) => [
432
+ `https://${domain}/`,
433
+ `http://${domain}/`,
434
+ ]),
435
+ allowedMethods: ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"] as (
436
+ | "GET"
437
+ | "HEAD"
438
+ | "POST"
439
+ | "PUT"
440
+ | "PATCH"
441
+ | "DELETE"
442
+ )[],
443
+ }
444
+ : undefined;
445
+
446
+ // Build MCP CLI commands first so that explicit MCP registrations win over
447
+ // any PATH-discovered binary with the same name (e.g. `lobu` is both an
448
+ // installed nix binary and an MCP server).
449
+ let mcpCliCommands: McpCliCommand[] = [];
450
+ if (options.mcpExposure === "cli" && options.mcpRuntimeRef && options.gw) {
451
+ mcpCliCommands = buildMcpCliCommands(options.mcpRuntimeRef, options.gw);
452
+ }
453
+ const mcpCliNames = new Set(mcpCliCommands.map((c) => c.name));
454
+
455
+ const sandboxStrategy = probeSandboxStrategy();
456
+ const allowUnsandboxedExec =
457
+ process.env.LOBU_ALLOW_UNSANDBOXED_EXEC === "1" ||
458
+ process.env.LOBU_ALLOW_UNSANDBOXED_EXEC === "true";
459
+
460
+ const registerSpawnedBinaries =
461
+ sandboxStrategy.kind !== "none" || allowUnsandboxedExec;
462
+
463
+ // Discover nix binaries and known CLI tools, register as custom commands.
464
+ // Strip names claimed by MCP CLIs so the MCP-backed handler takes precedence.
465
+ const binaries = registerSpawnedBinaries
466
+ ? discoverBinaries()
467
+ : new Map<string, string>();
468
+ for (const name of mcpCliNames) {
469
+ binaries.delete(name);
470
+ }
471
+ const sandboxCtx: SandboxContext = {
472
+ strategy: sandboxStrategy,
473
+ workspaceDir,
474
+ // Spawned binaries reach the network through HTTP_PROXY → gateway, which
475
+ // already enforces the per-agent domain allowlist. Letting the OS network
476
+ // namespace stay open lets curl/git/gh respect HTTP_PROXY normally.
477
+ allowNet: true,
478
+ };
479
+ const binaryCommands =
480
+ binaries.size > 0 ? await buildCustomCommands(binaries, sandboxCtx) : [];
481
+
482
+ if (sandboxStrategy.kind !== "none") {
483
+ console.log(`[embedded] exec sandbox active: kind=${sandboxStrategy.kind}`);
484
+ } else if (!allowUnsandboxedExec) {
485
+ console.warn(
486
+ `[embedded] Exec sandbox unavailable; not registering spawned binary ` +
487
+ `commands. Set LOBU_ALLOW_UNSANDBOXED_EXEC=1 to allow host-privileged ` +
488
+ `spawned binaries.`
489
+ );
490
+ }
491
+
492
+ const mcpCommandEntries = await Promise.all(
493
+ mcpCliCommands.map((c) => adaptMcpCliCommand(c))
494
+ );
495
+
496
+ const customCommands = [...mcpCommandEntries, ...binaryCommands];
497
+
498
+ if (binaries.size > 0) {
499
+ const names = [...binaries.keys()].slice(0, 20).join(", ");
500
+ const suffix = binaries.size > 20 ? `, ... (${binaries.size} total)` : "";
501
+ console.log(
502
+ `[embedded] Registered ${binaries.size} binary commands: ${names}${suffix}`
503
+ );
504
+ }
505
+ if (mcpCliCommands.length > 0) {
506
+ console.log(
507
+ `[embedded] Registered ${
508
+ mcpCliCommands.length
509
+ } MCP CLI commands: ${mcpCliCommands.map((c) => c.name).join(", ")}`
510
+ );
511
+ }
512
+
513
+ const bashInstance = new Bash({
514
+ fs: bashFs,
515
+ cwd: "/",
516
+ env: stripEnv(process.env, SENSITIVE_WORKER_ENV_KEYS),
517
+ executionLimits: EMBEDDED_BASH_LIMITS,
518
+ ...(network && { network }),
519
+ ...(customCommands.length > 0 && { customCommands }),
520
+ });
521
+
522
+ return {
523
+ async exec(command, cwd, { onData, signal, timeout }) {
524
+ const timeoutMs =
525
+ timeout !== undefined && timeout > 0 ? timeout * 1000 : undefined;
526
+
527
+ const result = await bashInstance.exec(command, {
528
+ cwd,
529
+ signal,
530
+ env: { TIMEOUT_MS: timeoutMs ? String(timeoutMs) : "" },
531
+ });
532
+
533
+ if (result.stdout) {
534
+ onData(Buffer.from(result.stdout));
535
+ }
536
+ if (result.stderr) {
537
+ onData(Buffer.from(result.stderr));
538
+ }
539
+
540
+ return { exitCode: result.exitCode };
541
+ },
542
+ };
543
+ }