@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.
- package/dist/core/error-handler.d.ts +0 -4
- package/dist/core/error-handler.d.ts.map +1 -1
- package/dist/core/error-handler.js +4 -15
- package/dist/core/error-handler.js.map +1 -1
- package/dist/core/types.d.ts +1 -19
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js +0 -4
- package/dist/core/types.js.map +1 -1
- package/dist/core/workspace.d.ts +2 -11
- package/dist/core/workspace.d.ts.map +1 -1
- package/dist/core/workspace.js +14 -36
- package/dist/core/workspace.js.map +1 -1
- package/dist/embedded/just-bash-bootstrap.d.ts.map +1 -1
- package/dist/embedded/just-bash-bootstrap.js +60 -6
- package/dist/embedded/just-bash-bootstrap.js.map +1 -1
- package/dist/embedded/mcp-cli-commands.d.ts.map +1 -1
- package/dist/embedded/mcp-cli-commands.js +3 -38
- package/dist/embedded/mcp-cli-commands.js.map +1 -1
- package/dist/gateway/gateway-integration.js +4 -4
- package/dist/gateway/gateway-integration.js.map +1 -1
- package/dist/gateway/message-batcher.d.ts.map +1 -1
- package/dist/gateway/message-batcher.js +3 -5
- package/dist/gateway/message-batcher.js.map +1 -1
- package/dist/gateway/sse-client.d.ts +1 -0
- package/dist/gateway/sse-client.d.ts.map +1 -1
- package/dist/gateway/sse-client.js +52 -8
- package/dist/gateway/sse-client.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -24
- package/dist/index.js.map +1 -1
- package/dist/instructions/builder.d.ts.map +1 -1
- package/dist/instructions/builder.js +2 -1
- package/dist/instructions/builder.js.map +1 -1
- package/dist/openclaw/plugin-loader.d.ts.map +1 -1
- package/dist/openclaw/plugin-loader.js +8 -19
- package/dist/openclaw/plugin-loader.js.map +1 -1
- package/dist/openclaw/processor.d.ts.map +1 -1
- package/dist/openclaw/processor.js +2 -0
- package/dist/openclaw/processor.js.map +1 -1
- package/dist/openclaw/sandbox-leak.d.ts.map +1 -1
- package/dist/openclaw/sandbox-leak.js +1 -6
- package/dist/openclaw/sandbox-leak.js.map +1 -1
- package/dist/openclaw/session-context.d.ts.map +1 -1
- package/dist/openclaw/session-context.js +3 -0
- package/dist/openclaw/session-context.js.map +1 -1
- package/dist/openclaw/tool-policy.d.ts.map +1 -1
- package/dist/openclaw/tool-policy.js +5 -11
- package/dist/openclaw/tool-policy.js.map +1 -1
- package/dist/openclaw/worker.d.ts +0 -1
- package/dist/openclaw/worker.d.ts.map +1 -1
- package/dist/openclaw/worker.js +19 -85
- package/dist/openclaw/worker.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +3 -40
- package/dist/server.js.map +1 -1
- package/dist/shared/audio-provider-suggestions.d.ts.map +1 -1
- package/dist/shared/audio-provider-suggestions.js +4 -6
- package/dist/shared/audio-provider-suggestions.js.map +1 -1
- package/dist/shared/tool-implementations.d.ts.map +1 -1
- package/dist/shared/tool-implementations.js +99 -37
- package/dist/shared/tool-implementations.js.map +1 -1
- package/package.json +14 -4
- package/src/__tests__/audio-provider-suggestions.test.ts +199 -0
- package/src/__tests__/custom-tools.test.ts +92 -0
- package/src/__tests__/embedded-just-bash-bootstrap.test.ts +128 -0
- package/src/__tests__/embedded-mcp-cli-bash.test.ts +179 -0
- package/src/__tests__/embedded-tools.test.ts +744 -0
- package/src/__tests__/exec-sandbox-extra.test.ts +0 -0
- package/src/__tests__/exec-sandbox.test.ts +550 -0
- package/src/__tests__/generated-media.test.ts +142 -0
- package/src/__tests__/instructions.test.ts +60 -0
- package/src/__tests__/mcp-cli-commands-extra.test.ts +478 -0
- package/src/__tests__/mcp-cli-commands.test.ts +383 -0
- package/src/__tests__/mcp-tool-call.test.ts +423 -0
- package/src/__tests__/memory-flush-harden.test.ts +367 -0
- package/src/__tests__/memory-flush-runtime.test.ts +138 -0
- package/src/__tests__/memory-flush.test.ts +64 -0
- package/src/__tests__/message-batcher.test.ts +247 -0
- package/src/__tests__/model-resolver-harden.test.ts +197 -0
- package/src/__tests__/model-resolver.test.ts +156 -0
- package/src/__tests__/processor-harden.test.ts +259 -0
- package/src/__tests__/processor.test.ts +225 -0
- package/src/__tests__/replace-base-prompt-identity.test.ts +41 -0
- package/src/__tests__/sandbox-leak-harden.test.ts +200 -0
- package/src/__tests__/sandbox-leak.test.ts +167 -0
- package/src/__tests__/setup.ts +102 -0
- package/src/__tests__/sse-client-harden.test.ts +588 -0
- package/src/__tests__/sse-client.test.ts +90 -0
- package/src/__tests__/tool-implementations.test.ts +196 -0
- package/src/__tests__/tool-policy-edge-cases.test.ts +263 -0
- package/src/__tests__/tool-policy.test.ts +269 -0
- package/src/__tests__/worker.test.ts +89 -0
- package/src/core/error-handler.ts +47 -0
- package/src/core/project-scanner.ts +65 -0
- package/src/core/types.ts +94 -0
- package/src/core/workspace.ts +66 -0
- package/src/embedded/exec-sandbox.ts +372 -0
- package/src/embedded/just-bash-bootstrap.ts +575 -0
- package/src/embedded/mcp-cli-commands.ts +405 -0
- package/src/gateway/gateway-integration.ts +298 -0
- package/src/gateway/message-batcher.ts +123 -0
- package/src/gateway/sse-client.ts +988 -0
- package/src/gateway/types.ts +68 -0
- package/src/index.ts +123 -0
- package/src/instructions/builder.ts +44 -0
- package/src/instructions/providers.ts +27 -0
- package/src/modules/lifecycle.ts +92 -0
- package/src/openclaw/custom-tools.ts +315 -0
- package/src/openclaw/instructions.ts +36 -0
- package/src/openclaw/model-resolver.ts +150 -0
- package/src/openclaw/plugin-loader.ts +423 -0
- package/src/openclaw/processor.ts +199 -0
- package/src/openclaw/sandbox-leak.ts +100 -0
- package/src/openclaw/session-context.ts +323 -0
- package/src/openclaw/tool-policy.ts +241 -0
- package/src/openclaw/tools.ts +277 -0
- package/src/openclaw/worker.ts +1836 -0
- package/src/server.ts +330 -0
- package/src/shared/audio-provider-suggestions.ts +130 -0
- package/src/shared/processor-utils.ts +33 -0
- package/src/shared/provider-auth-hints.ts +68 -0
- package/src/shared/tool-display-config.ts +75 -0
- package/src/shared/tool-implementations.ts +981 -0
- 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
|
+
}
|