@lobu/worker 6.1.1 → 7.1.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 (124) hide show
  1. package/dist/core/error-handler.d.ts +0 -4
  2. package/dist/core/error-handler.d.ts.map +1 -1
  3. package/dist/core/error-handler.js +4 -15
  4. package/dist/core/error-handler.js.map +1 -1
  5. package/dist/core/types.d.ts +1 -19
  6. package/dist/core/types.d.ts.map +1 -1
  7. package/dist/core/types.js +0 -4
  8. package/dist/core/types.js.map +1 -1
  9. package/dist/core/workspace.d.ts +2 -11
  10. package/dist/core/workspace.d.ts.map +1 -1
  11. package/dist/core/workspace.js +14 -36
  12. package/dist/core/workspace.js.map +1 -1
  13. package/dist/embedded/just-bash-bootstrap.d.ts.map +1 -1
  14. package/dist/embedded/just-bash-bootstrap.js +60 -6
  15. package/dist/embedded/just-bash-bootstrap.js.map +1 -1
  16. package/dist/embedded/mcp-cli-commands.d.ts.map +1 -1
  17. package/dist/embedded/mcp-cli-commands.js +3 -38
  18. package/dist/embedded/mcp-cli-commands.js.map +1 -1
  19. package/dist/gateway/gateway-integration.js +4 -4
  20. package/dist/gateway/gateway-integration.js.map +1 -1
  21. package/dist/gateway/message-batcher.d.ts.map +1 -1
  22. package/dist/gateway/message-batcher.js +3 -5
  23. package/dist/gateway/message-batcher.js.map +1 -1
  24. package/dist/gateway/sse-client.d.ts +1 -0
  25. package/dist/gateway/sse-client.d.ts.map +1 -1
  26. package/dist/gateway/sse-client.js +52 -8
  27. package/dist/gateway/sse-client.js.map +1 -1
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +7 -24
  30. package/dist/index.js.map +1 -1
  31. package/dist/instructions/builder.d.ts.map +1 -1
  32. package/dist/instructions/builder.js +2 -1
  33. package/dist/instructions/builder.js.map +1 -1
  34. package/dist/openclaw/plugin-loader.d.ts.map +1 -1
  35. package/dist/openclaw/plugin-loader.js +8 -19
  36. package/dist/openclaw/plugin-loader.js.map +1 -1
  37. package/dist/openclaw/processor.d.ts.map +1 -1
  38. package/dist/openclaw/processor.js +2 -0
  39. package/dist/openclaw/processor.js.map +1 -1
  40. package/dist/openclaw/sandbox-leak.d.ts.map +1 -1
  41. package/dist/openclaw/sandbox-leak.js +1 -6
  42. package/dist/openclaw/sandbox-leak.js.map +1 -1
  43. package/dist/openclaw/session-context.d.ts.map +1 -1
  44. package/dist/openclaw/session-context.js +3 -0
  45. package/dist/openclaw/session-context.js.map +1 -1
  46. package/dist/openclaw/tool-policy.d.ts.map +1 -1
  47. package/dist/openclaw/tool-policy.js +5 -11
  48. package/dist/openclaw/tool-policy.js.map +1 -1
  49. package/dist/openclaw/worker.d.ts +0 -1
  50. package/dist/openclaw/worker.d.ts.map +1 -1
  51. package/dist/openclaw/worker.js +19 -85
  52. package/dist/openclaw/worker.js.map +1 -1
  53. package/dist/server.d.ts.map +1 -1
  54. package/dist/server.js +3 -40
  55. package/dist/server.js.map +1 -1
  56. package/dist/shared/audio-provider-suggestions.d.ts.map +1 -1
  57. package/dist/shared/audio-provider-suggestions.js +4 -6
  58. package/dist/shared/audio-provider-suggestions.js.map +1 -1
  59. package/dist/shared/tool-implementations.d.ts.map +1 -1
  60. package/dist/shared/tool-implementations.js +99 -37
  61. package/dist/shared/tool-implementations.js.map +1 -1
  62. package/package.json +14 -4
  63. package/src/__tests__/audio-provider-suggestions.test.ts +199 -0
  64. package/src/__tests__/custom-tools.test.ts +92 -0
  65. package/src/__tests__/embedded-just-bash-bootstrap.test.ts +128 -0
  66. package/src/__tests__/embedded-mcp-cli-bash.test.ts +179 -0
  67. package/src/__tests__/embedded-tools.test.ts +744 -0
  68. package/src/__tests__/exec-sandbox-extra.test.ts +0 -0
  69. package/src/__tests__/exec-sandbox.test.ts +550 -0
  70. package/src/__tests__/generated-media.test.ts +142 -0
  71. package/src/__tests__/instructions.test.ts +60 -0
  72. package/src/__tests__/mcp-cli-commands-extra.test.ts +478 -0
  73. package/src/__tests__/mcp-cli-commands.test.ts +383 -0
  74. package/src/__tests__/mcp-tool-call.test.ts +423 -0
  75. package/src/__tests__/memory-flush-harden.test.ts +367 -0
  76. package/src/__tests__/memory-flush-runtime.test.ts +138 -0
  77. package/src/__tests__/memory-flush.test.ts +64 -0
  78. package/src/__tests__/message-batcher.test.ts +247 -0
  79. package/src/__tests__/model-resolver-harden.test.ts +197 -0
  80. package/src/__tests__/model-resolver.test.ts +156 -0
  81. package/src/__tests__/processor-harden.test.ts +259 -0
  82. package/src/__tests__/processor.test.ts +225 -0
  83. package/src/__tests__/replace-base-prompt-identity.test.ts +41 -0
  84. package/src/__tests__/sandbox-leak-harden.test.ts +200 -0
  85. package/src/__tests__/sandbox-leak.test.ts +167 -0
  86. package/src/__tests__/setup.ts +102 -0
  87. package/src/__tests__/sse-client-harden.test.ts +588 -0
  88. package/src/__tests__/sse-client.test.ts +90 -0
  89. package/src/__tests__/tool-implementations.test.ts +196 -0
  90. package/src/__tests__/tool-policy-edge-cases.test.ts +263 -0
  91. package/src/__tests__/tool-policy.test.ts +269 -0
  92. package/src/__tests__/worker.test.ts +89 -0
  93. package/src/core/error-handler.ts +47 -0
  94. package/src/core/project-scanner.ts +65 -0
  95. package/src/core/types.ts +94 -0
  96. package/src/core/workspace.ts +66 -0
  97. package/src/embedded/exec-sandbox.ts +372 -0
  98. package/src/embedded/just-bash-bootstrap.ts +575 -0
  99. package/src/embedded/mcp-cli-commands.ts +405 -0
  100. package/src/gateway/gateway-integration.ts +298 -0
  101. package/src/gateway/message-batcher.ts +123 -0
  102. package/src/gateway/sse-client.ts +988 -0
  103. package/src/gateway/types.ts +68 -0
  104. package/src/index.ts +123 -0
  105. package/src/instructions/builder.ts +44 -0
  106. package/src/instructions/providers.ts +27 -0
  107. package/src/modules/lifecycle.ts +92 -0
  108. package/src/openclaw/custom-tools.ts +315 -0
  109. package/src/openclaw/instructions.ts +36 -0
  110. package/src/openclaw/model-resolver.ts +150 -0
  111. package/src/openclaw/plugin-loader.ts +423 -0
  112. package/src/openclaw/processor.ts +199 -0
  113. package/src/openclaw/sandbox-leak.ts +100 -0
  114. package/src/openclaw/session-context.ts +323 -0
  115. package/src/openclaw/tool-policy.ts +241 -0
  116. package/src/openclaw/tools.ts +277 -0
  117. package/src/openclaw/worker.ts +1836 -0
  118. package/src/server.ts +330 -0
  119. package/src/shared/audio-provider-suggestions.ts +130 -0
  120. package/src/shared/processor-utils.ts +33 -0
  121. package/src/shared/provider-auth-hints.ts +68 -0
  122. package/src/shared/tool-display-config.ts +75 -0
  123. package/src/shared/tool-implementations.ts +981 -0
  124. package/src/shared/worker-env-keys.ts +8 -0
@@ -0,0 +1,575 @@
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
+ // The agent can `export WORKER_TOKEN=...` inside just-bash to slip a
252
+ // value through `ctx.env`. Re-strip so spawned binaries (and anything
253
+ // that may echo or log env) never see a sensitive-shaped key, even an
254
+ // attacker-controlled one.
255
+ for (const key of SENSITIVE_WORKER_ENV_KEYS) {
256
+ delete envRecord[key];
257
+ }
258
+
259
+ // Pin HOME / TMPDIR to dedicated subdirs so tool dotfiles (~/.gitconfig,
260
+ // ~/.cache, ~/.config) and temp files don't collide with workspace
261
+ // contents. Sandbox profiles already deny outside-workspace writes;
262
+ // these keep agent-visible files clean.
263
+ //
264
+ // bwrap binds the host workspace at `/workspace` inside the namespace,
265
+ // so the in-namespace HOME/TMPDIR must use the bound path. sandbox-exec
266
+ // doesn't remap paths, so HOME/TMPDIR stay as host paths.
267
+ ensureSandboxDir(sandbox.workspaceDir, ".sandbox-home");
268
+ ensureSandboxDir(sandbox.workspaceDir, ".sandbox-tmp");
269
+ if (sandbox.strategy.kind === "bwrap") {
270
+ envRecord.HOME = "/workspace/.sandbox-home";
271
+ envRecord.TMPDIR = "/workspace/.sandbox-tmp";
272
+ } else {
273
+ envRecord.HOME = path.join(sandbox.workspaceDir, ".sandbox-home");
274
+ envRecord.TMPDIR = path.join(sandbox.workspaceDir, ".sandbox-tmp");
275
+ }
276
+
277
+ // Force gateway proxy env so a malicious agent can't override it via
278
+ // `export HTTP_PROXY=` to bypass the egress allowlist. NO_PROXY is
279
+ // stripped for the same reason.
280
+ if (process.env.HTTP_PROXY)
281
+ envRecord.HTTP_PROXY = process.env.HTTP_PROXY;
282
+ if (process.env.HTTPS_PROXY)
283
+ envRecord.HTTPS_PROXY = process.env.HTTPS_PROXY;
284
+ if (process.env.http_proxy)
285
+ envRecord.http_proxy = process.env.http_proxy;
286
+ if (process.env.https_proxy)
287
+ envRecord.https_proxy = process.env.https_proxy;
288
+ delete envRecord.NO_PROXY;
289
+ delete envRecord.no_proxy;
290
+
291
+ let hostCwd: string;
292
+ let bwrapCwd: string | undefined;
293
+ try {
294
+ hostCwd = resolveHostCwd(sandbox.workspaceDir, ctx.cwd ?? "/");
295
+ bwrapCwd =
296
+ sandbox.strategy.kind === "bwrap"
297
+ ? bwrapCwdForHostCwd(sandbox.workspaceDir, hostCwd)
298
+ : undefined;
299
+ } catch (e) {
300
+ return {
301
+ stdout: "",
302
+ stderr: e instanceof Error ? e.message : String(e),
303
+ exitCode: 1,
304
+ };
305
+ }
306
+
307
+ const invocation = buildBinaryInvocation(binaryPath, args, {
308
+ ...sandbox,
309
+ bwrapCwd,
310
+ });
311
+
312
+ return new Promise<{
313
+ stdout: string;
314
+ stderr: string;
315
+ exitCode: number;
316
+ }>((resolve) => {
317
+ execFile(
318
+ invocation.command,
319
+ invocation.args,
320
+ {
321
+ cwd: hostCwd,
322
+ env: envRecord as NodeJS.ProcessEnv,
323
+ maxBuffer: 10 * 1024 * 1024,
324
+ // Hung binaries (credential prompts, stalled network reads,
325
+ // waiting on stdin) must not freeze the agent turn forever.
326
+ timeout: 120_000,
327
+ killSignal: "SIGKILL",
328
+ },
329
+ (error, stdout, stderr) => {
330
+ // A signal-killed child leaves error.code null/undefined and sets
331
+ // error.signal — that must NOT be reported as exit code 0.
332
+ const err = error as
333
+ | (NodeJS.ErrnoException & {
334
+ signal?: NodeJS.Signals;
335
+ killed?: boolean;
336
+ })
337
+ | null;
338
+ let exitCode: number;
339
+ if (!err) {
340
+ exitCode = 0;
341
+ } else if (typeof err.code === "number") {
342
+ exitCode = err.code;
343
+ } else if (err.killed || err.signal) {
344
+ exitCode = 137;
345
+ } else {
346
+ exitCode = 1;
347
+ }
348
+ const timedOut =
349
+ !!err && (err.killed || err.signal === "SIGKILL");
350
+ resolve({
351
+ stdout: stdout || "",
352
+ stderr:
353
+ stderr ||
354
+ (timedOut
355
+ ? `command timed out after 120s and was killed`
356
+ : (err?.message ?? "")),
357
+ exitCode,
358
+ });
359
+ }
360
+ );
361
+ });
362
+ })
363
+ );
364
+ }
365
+
366
+ return commands;
367
+ }
368
+
369
+ interface EmbeddedBashOpsOptions {
370
+ /** Thread-specific workspace directory used as the sandbox filesystem root. */
371
+ workspaceDir?: string;
372
+ /**
373
+ * When provided together with `gw`, MCP servers are exposed as one
374
+ * `just-bash` custom command per server (e.g. `lobu search_memory
375
+ * <<<'{...}'`). Only applied when `mcpExposure === "cli"`. The ref's
376
+ * optional `refresh()` is invoked after successful auth operations so
377
+ * CLI handlers pick up freshly-discovered MCP tools without rebuilding Bash.
378
+ */
379
+ mcpRuntimeRef?: McpRuntimeRef;
380
+ gw?: GatewayParams;
381
+ /** `"tools"` (default) keeps today's first-class MCP tools. `"cli"` swaps to sandboxed bash CLIs. */
382
+ mcpExposure?: "tools" | "cli";
383
+ }
384
+
385
+ /**
386
+ * Convert an in-process MCP CLI handler into a just-bash `defineCommand` entry.
387
+ */
388
+ async function adaptMcpCliCommand(
389
+ cmd: McpCliCommand
390
+ ): Promise<ReturnType<typeof import("just-bash").defineCommand>> {
391
+ const { defineCommand } = await import("just-bash");
392
+ return defineCommand(cmd.name, async (args: string[], ctx) => {
393
+ const stdin = typeof ctx.stdin === "string" ? ctx.stdin : "";
394
+ const signal = ctx.signal as AbortSignal | undefined;
395
+ return cmd.execute(args, { stdin, signal });
396
+ });
397
+ }
398
+
399
+ /**
400
+ * Create a BashOperations adapter backed by a just-bash Bash instance.
401
+ * Reads configuration from environment variables.
402
+ */
403
+ export async function createEmbeddedBashOps(
404
+ options: EmbeddedBashOpsOptions = {}
405
+ ): Promise<BashOperations> {
406
+ const { Bash, ReadWriteFs } = await import("just-bash");
407
+
408
+ const rawWorkspaceDir =
409
+ options.workspaceDir || process.env.WORKSPACE_DIR || "/workspace";
410
+ // Canonicalize so the sandbox profile, ReadWriteFs root, bind mounts, and
411
+ // realpath checks all see the same resolved path. macOS TMPDIR routes through
412
+ // /var → /private/var symlinks; without realpath, the SBPL allow rule and
413
+ // execFile cwd disagree.
414
+ let workspaceDir = rawWorkspaceDir;
415
+ try {
416
+ fs.mkdirSync(rawWorkspaceDir, { recursive: true });
417
+ workspaceDir = fs.realpathSync(rawWorkspaceDir);
418
+ } catch {
419
+ // Fall through with the raw value; downstream calls surface real errors.
420
+ }
421
+ const bashFs = new ReadWriteFs({ root: workspaceDir });
422
+
423
+ // Parse allowed domains from env var (set by gateway).
424
+ // Defense-in-depth: the gateway is trusted, but a malformed env (non-array,
425
+ // non-string entries, embedded "/" or whitespace) would either crash
426
+ // `.flatMap(...)` or, worse, expand an "allow https://${domain}/" prefix
427
+ // into something attacker-shaped (`evil.com/ ` or `attacker.com/path`).
428
+ // Validate the parsed shape and the per-domain syntax explicitly.
429
+ const DOMAIN_PATTERN = /^[A-Za-z0-9.*_-]+(?::\d+)?$/;
430
+ let allowedDomains: string[] = [];
431
+ if (process.env.JUST_BASH_ALLOWED_DOMAINS) {
432
+ try {
433
+ const parsed: unknown = JSON.parse(process.env.JUST_BASH_ALLOWED_DOMAINS);
434
+ if (!Array.isArray(parsed)) {
435
+ throw new Error("expected a JSON array of domain strings");
436
+ }
437
+ const accepted: string[] = [];
438
+ for (const entry of parsed) {
439
+ if (typeof entry !== "string") continue;
440
+ const trimmed = entry.trim();
441
+ if (!trimmed) continue;
442
+ if (!DOMAIN_PATTERN.test(trimmed)) {
443
+ console.warn(
444
+ `[embedded] Ignoring invalid JUST_BASH_ALLOWED_DOMAINS entry: ${JSON.stringify(entry)}`
445
+ );
446
+ continue;
447
+ }
448
+ accepted.push(trimmed);
449
+ }
450
+ allowedDomains = accepted;
451
+ } catch (err) {
452
+ console.error(
453
+ `[embedded] Failed to parse JUST_BASH_ALLOWED_DOMAINS: ${
454
+ err instanceof Error ? err.message : String(err)
455
+ }`
456
+ );
457
+ }
458
+ }
459
+
460
+ const network =
461
+ allowedDomains.length > 0
462
+ ? {
463
+ allowedUrlPrefixes: allowedDomains.flatMap((domain: string) => [
464
+ `https://${domain}/`,
465
+ `http://${domain}/`,
466
+ ]),
467
+ allowedMethods: ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"] as (
468
+ | "GET"
469
+ | "HEAD"
470
+ | "POST"
471
+ | "PUT"
472
+ | "PATCH"
473
+ | "DELETE"
474
+ )[],
475
+ }
476
+ : undefined;
477
+
478
+ // Build MCP CLI commands first so that explicit MCP registrations win over
479
+ // any PATH-discovered binary with the same name (e.g. `lobu` is both an
480
+ // installed nix binary and an MCP server).
481
+ let mcpCliCommands: McpCliCommand[] = [];
482
+ if (options.mcpExposure === "cli" && options.mcpRuntimeRef && options.gw) {
483
+ mcpCliCommands = buildMcpCliCommands(options.mcpRuntimeRef, options.gw);
484
+ }
485
+ const mcpCliNames = new Set(mcpCliCommands.map((c) => c.name));
486
+
487
+ const sandboxStrategy = probeSandboxStrategy();
488
+ const allowUnsandboxedExec =
489
+ process.env.LOBU_ALLOW_UNSANDBOXED_EXEC === "1" ||
490
+ process.env.LOBU_ALLOW_UNSANDBOXED_EXEC === "true";
491
+
492
+ const registerSpawnedBinaries =
493
+ sandboxStrategy.kind !== "none" || allowUnsandboxedExec;
494
+
495
+ // Discover nix binaries and known CLI tools, register as custom commands.
496
+ // Strip names claimed by MCP CLIs so the MCP-backed handler takes precedence.
497
+ const binaries = registerSpawnedBinaries
498
+ ? discoverBinaries()
499
+ : new Map<string, string>();
500
+ for (const name of mcpCliNames) {
501
+ binaries.delete(name);
502
+ }
503
+ const sandboxCtx: SandboxContext = {
504
+ strategy: sandboxStrategy,
505
+ workspaceDir,
506
+ // Spawned binaries reach the network through HTTP_PROXY → gateway, which
507
+ // already enforces the per-agent domain allowlist. Letting the OS network
508
+ // namespace stay open lets curl/git/gh respect HTTP_PROXY normally.
509
+ allowNet: true,
510
+ };
511
+ const binaryCommands =
512
+ binaries.size > 0 ? await buildCustomCommands(binaries, sandboxCtx) : [];
513
+
514
+ if (sandboxStrategy.kind !== "none") {
515
+ console.log(`[embedded] exec sandbox active: kind=${sandboxStrategy.kind}`);
516
+ } else if (!allowUnsandboxedExec) {
517
+ console.warn(
518
+ `[embedded] Exec sandbox unavailable; not registering spawned binary ` +
519
+ `commands. Set LOBU_ALLOW_UNSANDBOXED_EXEC=1 to allow host-privileged ` +
520
+ `spawned binaries.`
521
+ );
522
+ }
523
+
524
+ const mcpCommandEntries = await Promise.all(
525
+ mcpCliCommands.map((c) => adaptMcpCliCommand(c))
526
+ );
527
+
528
+ const customCommands = [...mcpCommandEntries, ...binaryCommands];
529
+
530
+ if (binaries.size > 0) {
531
+ const names = [...binaries.keys()].slice(0, 20).join(", ");
532
+ const suffix = binaries.size > 20 ? `, ... (${binaries.size} total)` : "";
533
+ console.log(
534
+ `[embedded] Registered ${binaries.size} binary commands: ${names}${suffix}`
535
+ );
536
+ }
537
+ if (mcpCliCommands.length > 0) {
538
+ console.log(
539
+ `[embedded] Registered ${
540
+ mcpCliCommands.length
541
+ } MCP CLI commands: ${mcpCliCommands.map((c) => c.name).join(", ")}`
542
+ );
543
+ }
544
+
545
+ const bashInstance = new Bash({
546
+ fs: bashFs,
547
+ cwd: "/",
548
+ env: stripEnv(process.env, SENSITIVE_WORKER_ENV_KEYS),
549
+ executionLimits: EMBEDDED_BASH_LIMITS,
550
+ ...(network && { network }),
551
+ ...(customCommands.length > 0 && { customCommands }),
552
+ });
553
+
554
+ return {
555
+ async exec(command, cwd, { onData, signal, timeout }) {
556
+ const timeoutMs =
557
+ timeout !== undefined && timeout > 0 ? timeout * 1000 : undefined;
558
+
559
+ const result = await bashInstance.exec(command, {
560
+ cwd,
561
+ signal,
562
+ env: { TIMEOUT_MS: timeoutMs ? String(timeoutMs) : "" },
563
+ });
564
+
565
+ if (result.stdout) {
566
+ onData(Buffer.from(result.stdout));
567
+ }
568
+ if (result.stderr) {
569
+ onData(Buffer.from(result.stderr));
570
+ }
571
+
572
+ return { exitCode: result.exitCode };
573
+ },
574
+ };
575
+ }