@mario.andreschak/mcp-bash 3.39.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/tools.js ADDED
@@ -0,0 +1,1493 @@
1
+ /**
2
+ * Built-in `bash` MCP server (issue #170).
3
+ *
4
+ * Cross-platform shell execution (grep, curl, …) on Windows/macOS/Linux. It
5
+ * reuses the proven spawn + timeout + process-tree-kill primitives from the
6
+ * legacy `terminal` tool (which this server replaces) and adds:
7
+ * - shell selection: PowerShell-first on Windows and `/bin/sh` on POSIX, or
8
+ * explicit `pwsh` / `bash` / `cmd` (failing before execution when invalid
9
+ * or unavailable),
10
+ * - optional CRLF→LF normalization of captured output,
11
+ * - background execution: start → status/wait → write_stdin → kill,
12
+ * - orphan cleanup: every live session is force-killed on FLUJO process exit,
13
+ * and idle finished sessions are swept after a TTL.
14
+ *
15
+ * Confinement + env hygiene (issue #175): the `cwd` is confined to the same
16
+ * effective roots as the built-in `filesystem` server (a HARD CEILING from
17
+ * `FLUJO_BASH_ROOTS`, falling back to `FLUJO_FS_ROOTS`, plus UI-persisted roots
18
+ * that may only narrow within it). This is a best-effort guardrail — a shell can
19
+ * `cd` elsewhere or use absolute paths, so `FLUJO_FS_ROOTS`/`FLUJO_BASH_ROOTS`
20
+ * and OS permissions remain the real boundary. Spawned commands also DO NOT
21
+ * inherit the full backend `process.env` by default (which would leak secrets);
22
+ * only a minimal allow-list is passed. Set `FLUJO_BASH_INHERIT_ENV=1` to restore
23
+ * full inheritance. Individual calls may pass explicit `env` overrides without
24
+ * enabling full inheritance.
25
+ *
26
+ * Every tool returns a machine-readable JSON envelope in a single text content
27
+ * block; failures come back as `isError: true` rather than thrown.
28
+ *
29
+ * MCP App contract (issue #330): ordinary run/background tools continue to use
30
+ * pipes, while the interactive terminal View uses a real OS pseudoterminal
31
+ * (ConPTY on Windows, forkpty on POSIX) with incremental reads, raw keyboard
32
+ * input, ANSI/VT output, and resize negotiation.
33
+ */
34
+ import path from 'node:path';
35
+ import fs from 'node:fs';
36
+ import { spawn } from 'node:child_process';
37
+ import { spawn as spawnPty } from '@lydell/node-pty';
38
+ import { ALLOW_PROTECTED_PATHS_ENV, createLogger, getDataDir, getHomeDir, isInside, isProtected, isProtectedPathsEnabled, killProcessTree, loadEffectiveRoots, } from './shared.js';
39
+ const BASH_SERVER_NAME = 'bash';
40
+ export const BASH_TERMINAL_APP_URI = 'ui://bash/terminal';
41
+ const log = createLogger('backend/services/mcp/internal/bashTools');
42
+ const DEFAULT_TIMEOUT_MS = 60_000;
43
+ const MAX_TIMEOUT_MS = 600_000;
44
+ const MAX_OUTPUT_CHARS = 100_000;
45
+ const MAX_SESSIONS = 25;
46
+ const MAX_TERMINAL_OUTPUT_CHARS = 2_000_000;
47
+ /**
48
+ * Lifecycle: live sessions end only by process exit, explicit kill, or Bash
49
+ * server shutdown. Finished owner-scoped records remain readable for ten
50
+ * minutes, then are reaped. An MCP View teardown may be an inline→dock handoff,
51
+ * so it is not treated as an owner disconnect signal.
52
+ */
53
+ const SESSION_TTL_MS = 10 * 60_000;
54
+ /**
55
+ * Locate an executable by name on `PATH` ourselves (rather than relying on
56
+ * `spawn`'s own resolution) so we can know in advance whether `pwsh`/`bash`
57
+ * are actually available and return a deterministic error instead of failing
58
+ * asynchronously with ENOENT (issue #225).
59
+ */
60
+ function getEnvCaseInsensitive(name) {
61
+ const direct = process.env[name];
62
+ if (direct !== undefined)
63
+ return direct;
64
+ const key = Object.keys(process.env).find((candidate) => candidate.toLowerCase() === name.toLowerCase());
65
+ return key ? process.env[key] : undefined;
66
+ }
67
+ function windowsExecutableExtensions() {
68
+ const inherited = (getEnvCaseInsensitive('PATHEXT') ?? '')
69
+ .split(';')
70
+ .map((extension) => extension.trim())
71
+ .filter(Boolean);
72
+ const required = ['.COM', '.EXE', '.BAT', '.CMD'];
73
+ return [...new Set([...required, ...inherited].map((extension) => (extension.startsWith('.') ? extension : `.${extension}`).toUpperCase()))];
74
+ }
75
+ function findExecutablesOnPath(name) {
76
+ const pathEnv = getEnvCaseInsensitive('PATH') ?? '';
77
+ const dirs = pathEnv
78
+ .split(path.delimiter)
79
+ .map((dir) => dir.trim().replace(/^"(.*)"$/, '$1'))
80
+ .filter(Boolean);
81
+ const exts = process.platform === 'win32'
82
+ ? windowsExecutableExtensions()
83
+ : [''];
84
+ const found = [];
85
+ for (const dir of dirs) {
86
+ for (const ext of exts) {
87
+ const candidate = path.join(dir, `${name}${ext}`);
88
+ try {
89
+ if (fs.statSync(candidate).isFile())
90
+ found.push(candidate);
91
+ }
92
+ catch {
93
+ /* not found here, keep looking */
94
+ }
95
+ }
96
+ }
97
+ return [...new Set(found.map((candidate) => path.resolve(candidate)))];
98
+ }
99
+ function findExecutableOnPath(name) {
100
+ return findExecutablesOnPath(name)[0] ?? null;
101
+ }
102
+ function firstExistingFile(candidates) {
103
+ const seen = new Set();
104
+ for (const rawCandidate of candidates) {
105
+ if (!rawCandidate)
106
+ continue;
107
+ const candidate = path.resolve(rawCandidate);
108
+ const key = process.platform === 'win32' ? candidate.toLowerCase() : candidate;
109
+ if (seen.has(key))
110
+ continue;
111
+ seen.add(key);
112
+ try {
113
+ if (fs.statSync(candidate).isFile())
114
+ return candidate;
115
+ }
116
+ catch {
117
+ /* not found here, keep looking */
118
+ }
119
+ }
120
+ return null;
121
+ }
122
+ /**
123
+ * Well-known install locations for Git for Windows' `bash.exe`. These are
124
+ * preferred over the legacy System32 WSL launcher, which may exist even when
125
+ * its Linux `/bin/bash` does not (issues #225 and #327).
126
+ */
127
+ function windowsGitBashCandidates() {
128
+ const roots = [
129
+ getEnvCaseInsensitive('ProgramFiles'),
130
+ getEnvCaseInsensitive('ProgramFiles(x86)'),
131
+ getEnvCaseInsensitive('LocalAppData'),
132
+ ]
133
+ .filter((v) => Boolean(v));
134
+ const git = findExecutableOnPath('git');
135
+ const gitRoot = git ? path.dirname(path.dirname(git)) : undefined;
136
+ return [
137
+ ...(gitRoot ? [
138
+ path.join(gitRoot, 'bin', 'bash.exe'),
139
+ path.join(gitRoot, 'usr', 'bin', 'bash.exe'),
140
+ ] : []),
141
+ ...roots.flatMap((root) => [
142
+ path.join(root, 'Git', 'bin', 'bash.exe'),
143
+ path.join(root, 'Git', 'usr', 'bin', 'bash.exe'),
144
+ path.join(root, 'Programs', 'Git', 'bin', 'bash.exe'),
145
+ path.join(root, 'Programs', 'Git', 'usr', 'bin', 'bash.exe'),
146
+ ]),
147
+ ];
148
+ }
149
+ function isWindowsWslBashLauncher(candidate) {
150
+ if (process.platform !== 'win32')
151
+ return false;
152
+ const systemRoot = getEnvCaseInsensitive('SystemRoot') ?? getEnvCaseInsensitive('windir');
153
+ if (!systemRoot)
154
+ return false;
155
+ return path.resolve(candidate).toLowerCase() === path.resolve(systemRoot, 'System32', 'bash.exe').toLowerCase();
156
+ }
157
+ let cachedBashPath;
158
+ function resolveBashExecutable() {
159
+ if (cachedBashPath && firstExistingFile([cachedBashPath]))
160
+ return cachedBashPath;
161
+ const candidates = process.platform === 'win32'
162
+ ? [
163
+ ...windowsGitBashCandidates(),
164
+ ...findExecutablesOnPath('bash').filter((candidate) => !isWindowsWslBashLauncher(candidate)),
165
+ ]
166
+ : findExecutablesOnPath('bash');
167
+ cachedBashPath = firstExistingFile(candidates);
168
+ return cachedBashPath;
169
+ }
170
+ function windowsPwshCandidates() {
171
+ const localAppData = getEnvCaseInsensitive('LocalAppData');
172
+ const programFiles = getEnvCaseInsensitive('ProgramFiles');
173
+ return [
174
+ ...(localAppData ? [path.join(localAppData, 'Microsoft', 'WindowsApps', 'pwsh.exe')] : []),
175
+ ...(programFiles ? [
176
+ path.join(programFiles, 'PowerShell', '7', 'pwsh.exe'),
177
+ path.join(programFiles, 'PowerShell', '7-preview', 'pwsh.exe'),
178
+ ] : []),
179
+ ];
180
+ }
181
+ let cachedPwshPath;
182
+ function resolvePwshExecutable() {
183
+ if (cachedPwshPath && firstExistingFile([cachedPwshPath]))
184
+ return cachedPwshPath;
185
+ cachedPwshPath = firstExistingFile([
186
+ ...findExecutablesOnPath('pwsh'),
187
+ ...(process.platform === 'win32' ? windowsPwshCandidates() : []),
188
+ ]);
189
+ return cachedPwshPath;
190
+ }
191
+ let cachedWindowsPowerShellPath;
192
+ function resolveWindowsPowerShellExecutable() {
193
+ if (cachedWindowsPowerShellPath && firstExistingFile([cachedWindowsPowerShellPath])) {
194
+ return cachedWindowsPowerShellPath;
195
+ }
196
+ const systemRoot = getEnvCaseInsensitive('SystemRoot') ?? getEnvCaseInsensitive('windir');
197
+ cachedWindowsPowerShellPath = firstExistingFile([
198
+ findExecutableOnPath('powershell'),
199
+ ...(systemRoot
200
+ ? [path.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')]
201
+ : []),
202
+ ]);
203
+ return cachedWindowsPowerShellPath;
204
+ }
205
+ function resolveCmdExecutable() {
206
+ if (process.platform !== 'win32')
207
+ return null;
208
+ return firstExistingFile([
209
+ getEnvCaseInsensitive('ComSpec'),
210
+ findExecutableOnPath('cmd'),
211
+ path.join(getEnvCaseInsensitive('SystemRoot') ?? 'C:\\Windows', 'System32', 'cmd.exe'),
212
+ ]);
213
+ }
214
+ /** Test-only: forget cached shell-executable lookups. */
215
+ export function _resetBashShellCacheForTests() {
216
+ cachedBashPath = undefined;
217
+ cachedPwshPath = undefined;
218
+ cachedWindowsPowerShellPath = undefined;
219
+ }
220
+ function sessions() {
221
+ if (!global.__flujo_bash_sessions)
222
+ global.__flujo_bash_sessions = new Map();
223
+ return global.__flujo_bash_sessions;
224
+ }
225
+ function terminalSessions() {
226
+ if (!global.__flujo_terminal_sessions)
227
+ global.__flujo_terminal_sessions = new Map();
228
+ return global.__flujo_terminal_sessions;
229
+ }
230
+ function foregroundChildren() {
231
+ if (!global.__flujo_bash_foreground_children)
232
+ global.__flujo_bash_foreground_children = new Set();
233
+ return global.__flujo_bash_foreground_children;
234
+ }
235
+ const ANONYMOUS_OWNER_SCOPE = 'legacy:anonymous';
236
+ function effectiveOwnerScope(ownerScope, callerNodeId) {
237
+ const explicit = ownerScope?.trim();
238
+ if (explicit)
239
+ return explicit;
240
+ const caller = callerNodeId?.trim();
241
+ return caller ? `caller:${caller}` : ANONYMOUS_OWNER_SCOPE;
242
+ }
243
+ function ownedSession(id, ownerScope) {
244
+ const session = sessions().get(id);
245
+ return session?.ownerScope === ownerScope ? session : undefined;
246
+ }
247
+ function terminalMeta(visibility = ['model', 'app']) {
248
+ return { ui: { resourceUri: BASH_TERMINAL_APP_URI, visibility } };
249
+ }
250
+ /** Kill every live session's process tree — used on FLUJO process exit. */
251
+ export function shutdownBashSessions() {
252
+ for (const child of foregroundChildren()) {
253
+ try {
254
+ killProcessTree(child);
255
+ }
256
+ catch {
257
+ /* best-effort */
258
+ }
259
+ }
260
+ for (const s of sessions().values()) {
261
+ if (s.running) {
262
+ try {
263
+ killProcessTree(s.child);
264
+ }
265
+ catch {
266
+ /* best-effort */
267
+ }
268
+ }
269
+ if (s.reapTimer)
270
+ clearTimeout(s.reapTimer);
271
+ }
272
+ for (const terminal of terminalSessions().values()) {
273
+ try {
274
+ if (terminal.running) {
275
+ terminal.running = false;
276
+ terminal.pty.kill();
277
+ }
278
+ }
279
+ catch {
280
+ /* best-effort */
281
+ }
282
+ if (terminal.reapTimer)
283
+ clearTimeout(terminal.reapTimer);
284
+ }
285
+ }
286
+ function registerExitCleanup() {
287
+ if (global.__flujo_bash_cleanup_registered)
288
+ return;
289
+ global.__flujo_bash_cleanup_registered = true;
290
+ const handler = () => shutdownBashSessions();
291
+ process.on('exit', handler);
292
+ process.on('SIGINT', handler);
293
+ process.on('SIGTERM', handler);
294
+ }
295
+ function textResult(payload, isError = false) {
296
+ return {
297
+ content: [{ type: 'text', text: typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2) }],
298
+ ...(isError ? { isError: true } : {}),
299
+ };
300
+ }
301
+ /** Env var names that supply the bash confinement ceiling (checked in order). */
302
+ const BASH_ROOT_ENV_VARS = ['FLUJO_BASH_ROOTS', 'FLUJO_FS_ROOTS'];
303
+ /**
304
+ * Resolve the working directory against the FLUJO data dir (for relative paths)
305
+ * and enforce the effective confinement roots when present. Throws on a
306
+ * confinement violation so callers surface a precise error (issue #175).
307
+ */
308
+ async function resolveCwd(input, roots) {
309
+ const dataDir = getDataDir();
310
+ const raw = typeof input === 'string' ? input.trim() : '';
311
+ const resolved = raw ? (path.isAbsolute(raw) ? path.resolve(raw) : path.resolve(dataDir, raw)) : dataDir;
312
+ // Optional defense-in-depth layer (issue #260). Configured roots win by
313
+ // default; users can opt into this stricter policy under Experimental Features.
314
+ if (await isProtectedPathsEnabled()) {
315
+ const prot = isProtected(resolved);
316
+ if (prot.denied) {
317
+ throw new Error(`cwd "${resolved}" is within a protected location (${prot.matchedRoot}) and is blocked by the FLUJO built-in server protected-path policy. ` +
318
+ `Disable "Protect sensitive home-directory paths" in Experimental Features or set ${ALLOW_PROTECTED_PATHS_ENV}=1 to override.`);
319
+ }
320
+ }
321
+ if (roots.length === 0 || !roots.some((root) => isInside(root, resolved))) {
322
+ throw new Error(`cwd "${resolved}" is outside the configured bash roots.`);
323
+ }
324
+ return resolved;
325
+ }
326
+ /**
327
+ * Best-effort advisory scan (issue #260, item 4) of a command string for
328
+ * absolute-looking paths that point OUTSIDE the configured roots or INTO a
329
+ * protected location. Returns human-readable warning strings; it NEVER blocks —
330
+ * a shell can reach anywhere regardless, so this is honest advice, not a
331
+ * boundary. Known limitation: shell variable expansions (e.g. `$HOME/AppData`)
332
+ * are not resolved.
333
+ */
334
+ function maskGlobOptionValues(command) {
335
+ const chars = [...command];
336
+ const tokens = [...command.matchAll(/"(?:\\.|[^"\\])*"|'[^']*'|[^\s]+/g)];
337
+ let maskNext = false;
338
+ for (const match of tokens) {
339
+ const raw = match[0];
340
+ const start = match.index ?? 0;
341
+ const unquoted = raw.length >= 2 && ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'")))
342
+ ? raw.slice(1, -1)
343
+ : raw;
344
+ let valueOffset = 0;
345
+ if (maskNext) {
346
+ maskNext = false;
347
+ }
348
+ else if (unquoted === '-g' || unquoted === '--glob' || unquoted === '--iglob') {
349
+ maskNext = true;
350
+ continue;
351
+ }
352
+ else {
353
+ if (!/^(?:--glob|--iglob)=/.test(unquoted))
354
+ continue;
355
+ valueOffset = raw.indexOf('=') + 1;
356
+ }
357
+ for (let i = start + valueOffset; i < start + raw.length; i += 1)
358
+ chars[i] = ' ';
359
+ }
360
+ return chars.join('');
361
+ }
362
+ async function scanCommandForExternalPaths(command, cwd, roots) {
363
+ const warnings = [];
364
+ const protectedPathsEnabled = await isProtectedPathsEnabled();
365
+ const seen = new Set();
366
+ // Ripgrep glob values are patterns, not filesystem paths. Mask only option
367
+ // values so a real path elsewhere in the same command is still inspected.
368
+ const commandForPathScan = maskGlobOptionValues(command);
369
+ // Absolute-looking tokens: Windows drive (X:\ or X:/), UNC (\\host\share),
370
+ // POSIX (/foo), and ~-prefixed home paths.
371
+ const tokenRe = /(?:[A-Za-z]:[\\/][^\s"']*|\\\\[^\s"']+|~\/[^\s"']*|(?<![\w.])\/[^\s"']+)/g;
372
+ const matches = (commandForPathScan.match(tokenRe) ?? []).filter(
373
+ // A single-letter slash token is a Windows command switch, not a POSIX path.
374
+ // Keep longer tokens so genuine POSIX absolute paths remain advisory notices.
375
+ (token) => !(process.platform === 'win32' && /^\/[A-Za-z]$/.test(token)));
376
+ const home = (() => {
377
+ try {
378
+ return getHomeDir();
379
+ }
380
+ catch {
381
+ return '';
382
+ }
383
+ })();
384
+ for (const rawToken of matches) {
385
+ let token = rawToken;
386
+ if (token.startsWith('~/') && home)
387
+ token = path.join(home, token.slice(2));
388
+ let resolved;
389
+ try {
390
+ resolved = path.isAbsolute(token) ? path.resolve(token) : path.resolve(cwd, token);
391
+ }
392
+ catch {
393
+ continue;
394
+ }
395
+ if (seen.has(resolved))
396
+ continue;
397
+ seen.add(resolved);
398
+ const prot = protectedPathsEnabled ? isProtected(resolved) : { denied: false };
399
+ if (prot.denied) {
400
+ warnings.push(`Command references "${rawToken}", which is inside a protected location (${prot.matchedRoot}).`);
401
+ }
402
+ else if (roots.length > 0 && !roots.some((root) => isInside(root, resolved))) {
403
+ warnings.push(`Command references "${rawToken}", which is outside the configured working roots.`);
404
+ }
405
+ }
406
+ return warnings;
407
+ }
408
+ /**
409
+ * A truthy env flag: "1", "true", "yes", "on" (case-insensitive).
410
+ */
411
+ function isTruthyEnv(value) {
412
+ return typeof value === 'string' && /^(1|true|yes|on)$/i.test(value.trim());
413
+ }
414
+ /**
415
+ * Names of environment variables that are safe (and often necessary) to pass to
416
+ * a spawned shell. Anything not on this list — notably API keys / secrets — is
417
+ * withheld by default so a command like `env` / `printenv` / `Get-ChildItem Env:`
418
+ * cannot read them back (issue #175). `LC_*` locale vars are allowed by prefix.
419
+ */
420
+ const ENV_ALLOWLIST = new Set([
421
+ 'path',
422
+ 'home', 'userprofile', 'homedrive', 'homepath',
423
+ 'appdata', 'localappdata', 'programdata',
424
+ 'tmp', 'temp', 'tmpdir',
425
+ 'lang', 'lc_all', 'term', 'colorterm', 'no_color',
426
+ 'shell', 'user', 'username', 'logname',
427
+ // Windows essentials so the default shell can even start.
428
+ 'systemroot', 'windir', 'comspec', 'pathext', 'systemdrive',
429
+ 'programfiles', 'programfiles(x86)',
430
+ 'number_of_processors', 'processor_architecture',
431
+ ]);
432
+ /**
433
+ * Returns the managed utilities directory path where Unix-like utilities
434
+ * (BusyBox applets on Windows) are installed. This directory is prepended to
435
+ * the child process PATH so that tools like grep, find, sed, awk are available.
436
+ */
437
+ function getManagedUtilsDir() {
438
+ return path.join(getDataDir(), 'bash-utils');
439
+ }
440
+ const CODEX_RIPGREP_TARGETS = {
441
+ win32: {
442
+ x64: ['@openai/codex-win32-x64', 'x86_64-pc-windows-msvc'],
443
+ arm64: ['@openai/codex-win32-arm64', 'aarch64-pc-windows-msvc'],
444
+ },
445
+ darwin: {
446
+ x64: ['@openai/codex-darwin-x64', 'x86_64-apple-darwin'],
447
+ arm64: ['@openai/codex-darwin-arm64', 'aarch64-apple-darwin'],
448
+ },
449
+ linux: {
450
+ x64: ['@openai/codex-linux-x64', 'x86_64-unknown-linux-musl'],
451
+ arm64: ['@openai/codex-linux-arm64', 'aarch64-unknown-linux-musl'],
452
+ },
453
+ };
454
+ /** Resolve the ripgrep binary already bundled with FLUJO's Codex dependency. */
455
+ function getBundledRipgrepDir() {
456
+ const target = CODEX_RIPGREP_TARGETS[process.platform]?.[process.arch];
457
+ if (!target)
458
+ return null;
459
+ try {
460
+ const packageJson = require.resolve(`${target[0]}/package.json`);
461
+ const dir = path.join(path.dirname(packageJson), 'vendor', target[1], 'codex-path');
462
+ const executable = path.join(dir, process.platform === 'win32' ? 'rg.exe' : 'rg');
463
+ return fs.statSync(executable).isFile() ? dir : null;
464
+ }
465
+ catch {
466
+ return null;
467
+ }
468
+ }
469
+ /**
470
+ * Build the child process environment. By default only the minimal allow-list is
471
+ * inherited (secrets never leave the backend). Explicit per-command overrides
472
+ * are then applied. Setting `FLUJO_BASH_INHERIT_ENV` to a truthy value restores
473
+ * full `process.env` inheritance for power users.
474
+ */
475
+ function buildChildEnv(overrides = {}) {
476
+ const out = isTruthyEnv(process.env.FLUJO_BASH_INHERIT_ENV)
477
+ ? { ...process.env }
478
+ : Object.fromEntries(Object.entries(process.env).filter(([key]) => ENV_ALLOWLIST.has(key.toLowerCase()) || /^LC_/i.test(key)));
479
+ const currentPath = getEnvCaseInsensitive('PATH') ?? '';
480
+ const utilityDirs = [getManagedUtilsDir(), getBundledRipgrepDir()]
481
+ .filter((dir) => Boolean(dir))
482
+ .filter((dir) => {
483
+ try {
484
+ return fs.statSync(dir).isDirectory();
485
+ }
486
+ catch {
487
+ return false;
488
+ }
489
+ });
490
+ out.PATH = [...utilityDirs, currentPath].filter(Boolean).join(path.delimiter);
491
+ if (process.platform === 'win32') {
492
+ // Node de-duplicates Windows environment keys case-insensitively; retain a
493
+ // single spelling so the augmented value is the one passed to the child.
494
+ for (const key of Object.keys(out)) {
495
+ if (key !== 'PATH' && key.toLowerCase() === 'path')
496
+ delete out[key];
497
+ if (key !== 'PATHEXT' && key.toLowerCase() === 'pathext')
498
+ delete out[key];
499
+ }
500
+ out.PATHEXT = windowsExecutableExtensions().join(';');
501
+ }
502
+ for (const [key, value] of Object.entries(overrides)) {
503
+ if (process.platform === 'win32') {
504
+ for (const existing of Object.keys(out)) {
505
+ if (existing !== key && existing.toLowerCase() === key.toLowerCase())
506
+ delete out[existing];
507
+ }
508
+ }
509
+ out[key] = value;
510
+ }
511
+ return out;
512
+ }
513
+ function validateEnv(input) {
514
+ if (input === undefined)
515
+ return { valid: true, env: {} };
516
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
517
+ return { valid: false, error: '"env" must be an object whose values are strings.' };
518
+ }
519
+ const env = {};
520
+ for (const [key, value] of Object.entries(input)) {
521
+ if (!key || key.includes('=') || key.includes('\0')) {
522
+ return { valid: false, error: `Invalid environment variable name: ${JSON.stringify(key)}.` };
523
+ }
524
+ if (typeof value !== 'string' || value.includes('\0')) {
525
+ return { valid: false, error: `Environment variable ${JSON.stringify(key)} must be a string without NUL characters.` };
526
+ }
527
+ env[key] = value;
528
+ }
529
+ return { valid: true, env };
530
+ }
531
+ /**
532
+ * Build the spawn arguments for the requested shell. Returns the command, argv
533
+ * and whether Node's `shell:true` wrapping applies. Shells are resolved
534
+ * to a concrete executable path up front (checking Git for Windows' well-known
535
+ * install locations for `bash` when it isn't on `PATH`). An unavailable
536
+ * explicitly requested shell is reported before any user command is executed.
537
+ */
538
+ function buildSpawn(command, shell) {
539
+ if (shell === 'pwsh') {
540
+ const resolved = resolvePwshExecutable();
541
+ if (resolved) {
542
+ return { file: resolved, args: ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', command], useShell: false, effectiveShell: 'pwsh' };
543
+ }
544
+ return { file: '', args: [], useShell: false, effectiveShell: 'pwsh', unavailableShell: 'pwsh' };
545
+ }
546
+ if (shell === 'bash') {
547
+ const resolved = resolveBashExecutable();
548
+ if (resolved) {
549
+ return { file: resolved, args: ['-c', command], useShell: false, effectiveShell: 'bash' };
550
+ }
551
+ return { file: '', args: [], useShell: false, effectiveShell: 'bash', unavailableShell: 'bash' };
552
+ }
553
+ if (shell === 'cmd') {
554
+ const resolved = resolveCmdExecutable();
555
+ if (resolved) {
556
+ return {
557
+ file: resolved,
558
+ args: ['/d', '/s', '/c', command],
559
+ useShell: false,
560
+ effectiveShell: 'cmd',
561
+ windowsVerbatimArguments: true,
562
+ };
563
+ }
564
+ return { file: '', args: [], useShell: false, effectiveShell: 'cmd', unavailableShell: 'cmd' };
565
+ }
566
+ if (process.platform === 'win32') {
567
+ const pwsh = resolvePwshExecutable();
568
+ if (pwsh) {
569
+ return { file: pwsh, args: ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', command], useShell: false, effectiveShell: 'pwsh' };
570
+ }
571
+ const windowsPowerShell = resolveWindowsPowerShellExecutable();
572
+ if (windowsPowerShell) {
573
+ return { file: windowsPowerShell, args: ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', command], useShell: false, effectiveShell: 'powershell' };
574
+ }
575
+ const cmd = resolveCmdExecutable();
576
+ if (cmd) {
577
+ return {
578
+ file: cmd,
579
+ args: ['/d', '/s', '/c', command],
580
+ useShell: false,
581
+ effectiveShell: 'cmd',
582
+ windowsVerbatimArguments: true,
583
+ };
584
+ }
585
+ return {
586
+ file: '',
587
+ args: [],
588
+ useShell: false,
589
+ effectiveShell: 'cmd',
590
+ startError: 'No usable PowerShell or cmd executable was found.',
591
+ };
592
+ }
593
+ const sh = firstExistingFile(['/bin/sh', findExecutableOnPath('sh')]);
594
+ if (sh)
595
+ return { file: sh, args: ['-c', command], useShell: false, effectiveShell: 'sh' };
596
+ return {
597
+ file: '',
598
+ args: [],
599
+ useShell: false,
600
+ effectiveShell: 'sh',
601
+ startError: 'No POSIX /bin/sh executable was found.',
602
+ };
603
+ }
604
+ function safeRequestedShellValue(input) {
605
+ try {
606
+ const serialized = JSON.stringify(input);
607
+ if (serialized !== undefined)
608
+ return JSON.parse(serialized);
609
+ }
610
+ catch {
611
+ // Fall through to a non-throwing string representation.
612
+ }
613
+ try {
614
+ return String(input);
615
+ }
616
+ catch {
617
+ return '<unrepresentable>';
618
+ }
619
+ }
620
+ function validateShell(input) {
621
+ if (input === undefined)
622
+ return { valid: true, shell: 'default' };
623
+ if (input === 'default' || input === 'pwsh' || input === 'bash' || input === 'cmd') {
624
+ return { valid: true, shell: input };
625
+ }
626
+ return { valid: false, requestedShell: safeRequestedShellValue(input) };
627
+ }
628
+ function invalidShellResult(requestedShell) {
629
+ return textResult({
630
+ error: 'Invalid shell request. Expected one of: "default", "pwsh", "bash", or "cmd".',
631
+ requestedShell,
632
+ }, true);
633
+ }
634
+ function startChild(command, cwd, shell, env) {
635
+ const { file, args, useShell, effectiveShell, unavailableShell, startError, windowsVerbatimArguments, } = buildSpawn(command, shell);
636
+ if (unavailableShell)
637
+ return { effectiveShell, unavailableShell };
638
+ if (startError)
639
+ return { effectiveShell, startError };
640
+ // POSIX: detached so killProcessTree can signal the whole group (see killProcessTree).
641
+ const detached = process.platform !== 'win32';
642
+ try {
643
+ const child = spawn(file, args, {
644
+ cwd,
645
+ shell: useShell,
646
+ env: buildChildEnv(env),
647
+ detached,
648
+ ...(windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}),
649
+ });
650
+ return { child, effectiveShell };
651
+ }
652
+ catch (err) {
653
+ return {
654
+ startError: err instanceof Error ? err.message : String(err),
655
+ effectiveShell,
656
+ };
657
+ }
658
+ }
659
+ function makeAppender(get, set) {
660
+ return (chunk) => {
661
+ let out = get();
662
+ if (out.length >= MAX_OUTPUT_CHARS) {
663
+ if (chunk)
664
+ set(out, true);
665
+ return;
666
+ }
667
+ out += chunk;
668
+ let truncated = false;
669
+ if (out.length > MAX_OUTPUT_CHARS) {
670
+ out = out.slice(0, MAX_OUTPUT_CHARS) + '\n…[output truncated]';
671
+ truncated = true;
672
+ }
673
+ set(out, truncated);
674
+ };
675
+ }
676
+ /** Resolve an interactive shell executable without wrapping a command string. */
677
+ function buildPtySpawn(shell) {
678
+ if (shell === 'pwsh') {
679
+ const file = resolvePwshExecutable();
680
+ return file
681
+ ? { file, args: ['-NoLogo'], effectiveShell: 'pwsh' }
682
+ : { file: '', args: [], effectiveShell: 'pwsh', unavailableShell: 'pwsh' };
683
+ }
684
+ if (shell === 'bash') {
685
+ const file = resolveBashExecutable();
686
+ return file
687
+ ? { file, args: ['--noprofile', '--norc', '-i'], effectiveShell: 'bash' }
688
+ : { file: '', args: [], effectiveShell: 'bash', unavailableShell: 'bash' };
689
+ }
690
+ if (shell === 'cmd') {
691
+ const file = resolveCmdExecutable();
692
+ return file
693
+ ? { file, args: ['/d', '/q'], effectiveShell: 'cmd' }
694
+ : { file: '', args: [], effectiveShell: 'cmd', unavailableShell: 'cmd' };
695
+ }
696
+ if (process.platform === 'win32') {
697
+ const pwsh = resolvePwshExecutable();
698
+ if (pwsh)
699
+ return { file: pwsh, args: ['-NoLogo'], effectiveShell: 'pwsh' };
700
+ const powershell = resolveWindowsPowerShellExecutable();
701
+ if (powershell)
702
+ return { file: powershell, args: ['-NoLogo'], effectiveShell: 'powershell' };
703
+ const cmd = resolveCmdExecutable();
704
+ if (cmd)
705
+ return { file: cmd, args: ['/d', '/q'], effectiveShell: 'cmd' };
706
+ return { file: '', args: [], effectiveShell: 'cmd', startError: 'No usable PowerShell or cmd executable was found.' };
707
+ }
708
+ const loginShell = getEnvCaseInsensitive('SHELL');
709
+ const bash = resolveBashExecutable();
710
+ const file = firstExistingFile([loginShell, bash, '/bin/sh', findExecutableOnPath('sh')]);
711
+ if (!file)
712
+ return { file: '', args: [], effectiveShell: 'sh', startError: 'No interactive POSIX shell was found.' };
713
+ const isBash = path.basename(file).toLowerCase().startsWith('bash');
714
+ return {
715
+ file,
716
+ args: isBash ? ['--noprofile', '--norc', '-i'] : ['-i'],
717
+ effectiveShell: isBash ? 'bash' : 'sh',
718
+ };
719
+ }
720
+ function createCommandProgressReporter(context) {
721
+ const report = context?.onProgress;
722
+ if (!report)
723
+ return { push: () => undefined, stop: async () => undefined };
724
+ const startedAt = Date.now();
725
+ const maxMessageChars = 4_000;
726
+ let progress = 0;
727
+ let pending = '';
728
+ let flushTimer;
729
+ let stopped = false;
730
+ let forwardedChars = 0;
731
+ let liveOutputTruncated = false;
732
+ let chain = Promise.resolve();
733
+ const deliver = (message) => {
734
+ if (!message)
735
+ return;
736
+ const snapshot = progress;
737
+ chain = chain
738
+ .then(() => report({ progress: snapshot, message }))
739
+ .catch((error) => {
740
+ log.debug('Could not deliver bash progress notification', error);
741
+ });
742
+ };
743
+ const flush = () => {
744
+ if (flushTimer)
745
+ clearTimeout(flushTimer);
746
+ flushTimer = undefined;
747
+ while (pending.length > 0) {
748
+ const message = pending.slice(0, maxMessageChars);
749
+ pending = pending.slice(message.length);
750
+ deliver(message);
751
+ }
752
+ };
753
+ const heartbeat = setInterval(() => {
754
+ if (stopped)
755
+ return;
756
+ if (pending)
757
+ flush();
758
+ else
759
+ deliver(`[command still running: ${Math.max(1, Math.round((Date.now() - startedAt) / 1000))}s]`);
760
+ }, 10_000);
761
+ heartbeat.unref?.();
762
+ return {
763
+ push: (chunk) => {
764
+ if (stopped || !chunk)
765
+ return;
766
+ progress += Buffer.byteLength(chunk, 'utf8');
767
+ const remaining = Math.max(0, MAX_OUTPUT_CHARS - forwardedChars);
768
+ if (remaining > 0) {
769
+ const accepted = chunk.slice(0, remaining);
770
+ pending += accepted;
771
+ forwardedChars += accepted.length;
772
+ }
773
+ if (chunk.length > remaining && !liveOutputTruncated) {
774
+ liveOutputTruncated = true;
775
+ pending += '\n…[live output truncated]';
776
+ }
777
+ if (pending.length >= maxMessageChars) {
778
+ flush();
779
+ }
780
+ else if (!flushTimer) {
781
+ flushTimer = setTimeout(flush, 75);
782
+ flushTimer.unref?.();
783
+ }
784
+ },
785
+ stop: async () => {
786
+ if (stopped) {
787
+ return;
788
+ }
789
+ stopped = true;
790
+ clearInterval(heartbeat);
791
+ flush();
792
+ let timer;
793
+ try {
794
+ await Promise.race([
795
+ chain,
796
+ new Promise((resolve) => {
797
+ timer = setTimeout(resolve, 1_000);
798
+ }),
799
+ ]);
800
+ }
801
+ finally {
802
+ if (timer)
803
+ clearTimeout(timer);
804
+ }
805
+ },
806
+ };
807
+ }
808
+ export function bashToolDefinitions() {
809
+ const shellProp = {
810
+ type: 'string',
811
+ enum: ['default', 'pwsh', 'bash', 'cmd'],
812
+ description: 'Command parser. "default" uses PowerShell on Windows and /bin/sh elsewhere. Use "pwsh", "bash", or "cmd" for explicit syntax; unavailable explicit shells return an error.',
813
+ };
814
+ const cwdProp = { type: 'string', description: 'Working directory. Relative paths resolve from the FLUJO data directory; configured roots still apply.' };
815
+ const envProp = {
816
+ type: 'object',
817
+ additionalProperties: { type: 'string' },
818
+ description: 'Environment variables to add or override for this command.',
819
+ };
820
+ return [
821
+ {
822
+ name: 'open_terminal',
823
+ description: 'Open a real interactive pseudoterminal (PTY/ConPTY) and display its MCP App. Returns an owner-scoped terminal sessionId.',
824
+ _meta: terminalMeta(['model', 'app']),
825
+ inputSchema: {
826
+ type: 'object',
827
+ properties: {
828
+ cwd: cwdProp,
829
+ shell: shellProp,
830
+ env: envProp,
831
+ cols: { type: 'number', description: 'Initial terminal columns (20-400, default 100).' },
832
+ rows: { type: 'number', description: 'Initial terminal rows (5-200, default 30).' },
833
+ },
834
+ },
835
+ },
836
+ {
837
+ name: 'terminal_read',
838
+ description: 'Read incremental ANSI/VT output from an owner-scoped interactive terminal session.',
839
+ _meta: terminalMeta(['app']),
840
+ inputSchema: {
841
+ type: 'object',
842
+ properties: {
843
+ sessionId: { type: 'string' },
844
+ cursor: { type: 'number', description: 'Output cursor returned by the previous read.' },
845
+ },
846
+ required: ['sessionId'],
847
+ },
848
+ },
849
+ {
850
+ name: 'terminal_write',
851
+ description: 'Write raw keyboard or pasted input to an owner-scoped interactive terminal PTY.',
852
+ _meta: terminalMeta(['app']),
853
+ inputSchema: {
854
+ type: 'object',
855
+ properties: { sessionId: { type: 'string' }, data: { type: 'string' } },
856
+ required: ['sessionId', 'data'],
857
+ },
858
+ },
859
+ {
860
+ name: 'terminal_resize',
861
+ description: 'Resize an owner-scoped interactive terminal PTY.',
862
+ _meta: terminalMeta(['app']),
863
+ inputSchema: {
864
+ type: 'object',
865
+ properties: { sessionId: { type: 'string' }, cols: { type: 'number' }, rows: { type: 'number' } },
866
+ required: ['sessionId', 'cols', 'rows'],
867
+ },
868
+ },
869
+ {
870
+ name: 'terminal_close',
871
+ description: 'Close an owner-scoped interactive terminal PTY.',
872
+ _meta: terminalMeta(['app']),
873
+ inputSchema: {
874
+ type: 'object',
875
+ properties: { sessionId: { type: 'string' } },
876
+ required: ['sessionId'],
877
+ },
878
+ },
879
+ {
880
+ name: 'terminal_list',
881
+ description: 'List interactive terminal PTY sessions owned by this MCP App scope.',
882
+ _meta: terminalMeta(['app']),
883
+ inputSchema: { type: 'object', properties: {} },
884
+ },
885
+ {
886
+ name: 'run',
887
+ description: 'Run one command to completion. Output is sent as live progress when supported and returned with the exit code; use start/status for a persistent background session.',
888
+ inputSchema: {
889
+ type: 'object',
890
+ properties: {
891
+ command: { type: 'string', description: 'Command text in the selected shell\'s syntax.' },
892
+ cwd: cwdProp,
893
+ shell: shellProp,
894
+ env: envProp,
895
+ timeout: { type: 'number', description: 'Timeout in seconds (default 60, max 600).' },
896
+ normalizeNewlines: { type: 'boolean', description: 'If true, CRLF/CR in the captured output are normalized to LF.' },
897
+ },
898
+ required: ['command'],
899
+ },
900
+ },
901
+ {
902
+ name: 'start',
903
+ description: 'Start an independent background command and return its sessionId. Multiple sessions may run in parallel; use status/wait, write_stdin, or kill.',
904
+ inputSchema: {
905
+ type: 'object',
906
+ properties: {
907
+ command: { type: 'string', description: 'The command line to execute in the background.' },
908
+ cwd: cwdProp,
909
+ shell: shellProp,
910
+ env: envProp,
911
+ },
912
+ required: ['command'],
913
+ },
914
+ },
915
+ {
916
+ name: 'status',
917
+ description: 'Return the current state of a background session: { sessionId, running, exitCode, output, truncated }.',
918
+ inputSchema: {
919
+ type: 'object',
920
+ properties: { sessionId: { type: 'string', description: 'The id returned by start.' } },
921
+ required: ['sessionId'],
922
+ },
923
+ },
924
+ {
925
+ name: 'wait',
926
+ description: 'Wait for a background session, sending new output as live progress when supported. The wait timeout does not kill the session.',
927
+ inputSchema: {
928
+ type: 'object',
929
+ properties: {
930
+ sessionId: { type: 'string', description: 'The id returned by start.' },
931
+ timeout: { type: 'number', description: 'Max seconds to wait (default 60, max 600).' },
932
+ },
933
+ required: ['sessionId'],
934
+ },
935
+ },
936
+ {
937
+ name: 'write_stdin',
938
+ description: 'Write a string to a running background session\'s stdin. Pass "newline": false to omit the trailing newline. Returns { sessionId, written }.',
939
+ inputSchema: {
940
+ type: 'object',
941
+ properties: {
942
+ sessionId: { type: 'string', description: 'The id returned by start.' },
943
+ data: { type: 'string', description: 'Text to write to stdin.' },
944
+ newline: { type: 'boolean', description: 'Append a trailing newline (default true).' },
945
+ },
946
+ required: ['sessionId', 'data'],
947
+ },
948
+ },
949
+ {
950
+ name: 'kill',
951
+ description: 'Kill a background session (and its whole process tree). Returns { sessionId, killed }.',
952
+ inputSchema: {
953
+ type: 'object',
954
+ properties: { sessionId: { type: 'string', description: 'The id returned by start.' } },
955
+ required: ['sessionId'],
956
+ },
957
+ },
958
+ {
959
+ name: 'list_sessions',
960
+ description: 'List background sessions owned by this caller scope. Returns { sessions: [{ sessionId, command, running, exitCode, startedAt, endedAt }] }.',
961
+ inputSchema: { type: 'object', properties: {} },
962
+ },
963
+ ];
964
+ }
965
+ function maybeNormalize(text, normalize) {
966
+ return normalize ? text.replace(/\r\n?/g, '\n') : text;
967
+ }
968
+ async function runTool(args, roots, context) {
969
+ const command = String(args?.command ?? '').trim();
970
+ if (!command)
971
+ return textResult({ error: 'Provide "command": a shell command line to run.' }, true);
972
+ const shellValidation = validateShell(args.shell);
973
+ if (!shellValidation.valid)
974
+ return invalidShellResult(shellValidation.requestedShell);
975
+ const requestedShell = shellValidation.shell;
976
+ const envValidation = validateEnv(args.env);
977
+ if (!envValidation.valid)
978
+ return textResult({ error: envValidation.error }, true);
979
+ if (context?.signal?.aborted) {
980
+ return textResult({ error: 'Command cancelled before it started.', cancelled: true, requestedShell }, true);
981
+ }
982
+ const cwd = await resolveCwd(args.cwd, roots);
983
+ const warnings = await scanCommandForExternalPaths(command, cwd, roots);
984
+ const warn = warnings.length ? { warnings } : {};
985
+ const normalize = args.normalizeNewlines === true;
986
+ const timeoutSec = typeof args.timeout === 'number' && args.timeout > 0 ? args.timeout : DEFAULT_TIMEOUT_MS / 1000;
987
+ const timeoutMs = Math.min(timeoutSec * 1000, MAX_TIMEOUT_MS);
988
+ registerExitCleanup();
989
+ return await new Promise((resolve) => {
990
+ let output = '';
991
+ let truncated = false;
992
+ let settled = false;
993
+ const append = makeAppender(() => output, (v, t) => { output = v; truncated = t || truncated; });
994
+ const progress = createCommandProgressReporter(context);
995
+ const { child, startError, effectiveShell, unavailableShell } = startChild(command, cwd, requestedShell, envValidation.env);
996
+ if (unavailableShell) {
997
+ void progress.stop().then(() => {
998
+ resolve(textResult({ error: `Requested shell "${unavailableShell}" is unavailable or could not be resolved.`, cwd, shell: unavailableShell }, true));
999
+ });
1000
+ return;
1001
+ }
1002
+ if (startError || !child) {
1003
+ void progress.stop().then(() => {
1004
+ resolve(textResult({ error: `Failed to start command (${effectiveShell}): ${startError ?? 'unknown error'}`, cwd, shell: effectiveShell }, true));
1005
+ });
1006
+ return;
1007
+ }
1008
+ foregroundChildren().add(child);
1009
+ let cancelEscalation;
1010
+ let timer;
1011
+ const onAbort = () => {
1012
+ cancelEscalation = killProcessTree(child);
1013
+ const finalOut = maybeNormalize(output, normalize);
1014
+ void finish(textResult({
1015
+ error: 'Command cancelled.',
1016
+ cancelled: true,
1017
+ cwd,
1018
+ requestedShell,
1019
+ shell: effectiveShell,
1020
+ exitCode: null,
1021
+ truncated,
1022
+ output: finalOut,
1023
+ ...warn,
1024
+ }, true), true);
1025
+ };
1026
+ const finish = async (result, keepKillEscalation = false) => {
1027
+ if (settled)
1028
+ return;
1029
+ settled = true;
1030
+ if (timer)
1031
+ clearTimeout(timer);
1032
+ context?.signal?.removeEventListener('abort', onAbort);
1033
+ if (!keepKillEscalation)
1034
+ cancelEscalation?.();
1035
+ await progress.stop();
1036
+ resolve(result);
1037
+ };
1038
+ timer = setTimeout(() => {
1039
+ cancelEscalation = killProcessTree(child);
1040
+ const finalOut = maybeNormalize(output, normalize);
1041
+ void finish(textResult({
1042
+ timedOut: true,
1043
+ cwd,
1044
+ requestedShell,
1045
+ shell: effectiveShell,
1046
+ exitCode: null,
1047
+ truncated,
1048
+ output: `${finalOut}${finalOut ? '\n' : ''}[killed after ${timeoutMs / 1000}s timeout]`,
1049
+ ...warn,
1050
+ }, true), true);
1051
+ }, timeoutMs);
1052
+ child.stdout?.on('data', (d) => {
1053
+ const chunk = d.toString();
1054
+ append(chunk);
1055
+ progress.push(maybeNormalize(chunk, normalize));
1056
+ });
1057
+ child.stderr?.on('data', (d) => {
1058
+ const chunk = d.toString();
1059
+ append(chunk);
1060
+ progress.push(maybeNormalize(chunk, normalize));
1061
+ });
1062
+ child.on('error', (err) => {
1063
+ // ENOENT here means the resolved executable vanished between resolution and spawn.
1064
+ foregroundChildren().delete(child);
1065
+ append(`\n${err.message}`);
1066
+ void finish(textResult({ error: `Command failed to start (${effectiveShell}): ${err.message}`, cwd, shell: effectiveShell, output: maybeNormalize(output, normalize) }, true));
1067
+ });
1068
+ child.on('close', (code) => {
1069
+ foregroundChildren().delete(child);
1070
+ cancelEscalation?.();
1071
+ if (settled)
1072
+ return;
1073
+ const finalOut = maybeNormalize(output, normalize);
1074
+ void finish(textResult({ exitCode: code, cwd, requestedShell, shell: effectiveShell, truncated, output: finalOut, ...warn }, code !== 0));
1075
+ });
1076
+ if (context?.signal?.aborted)
1077
+ onAbort();
1078
+ else
1079
+ context?.signal?.addEventListener('abort', onAbort, { once: true });
1080
+ });
1081
+ }
1082
+ function scheduleReap(session) {
1083
+ session.reapTimer = setTimeout(() => {
1084
+ sessions().delete(session.id);
1085
+ }, SESSION_TTL_MS);
1086
+ // Do not keep the event loop alive just to reap a session.
1087
+ session.reapTimer.unref?.();
1088
+ }
1089
+ async function startTool(args, roots, ownerScope, context) {
1090
+ const command = String(args?.command ?? '').trim();
1091
+ if (!command)
1092
+ return textResult({ error: 'Provide "command": a shell command line to run.' }, true);
1093
+ const shellValidation = validateShell(args.shell);
1094
+ if (!shellValidation.valid)
1095
+ return invalidShellResult(shellValidation.requestedShell);
1096
+ const requestedShell = shellValidation.shell;
1097
+ const envValidation = validateEnv(args.env);
1098
+ if (!envValidation.valid)
1099
+ return textResult({ error: envValidation.error }, true);
1100
+ if (context?.signal?.aborted) {
1101
+ return textResult({ error: 'Command cancelled before it started.', cancelled: true, requestedShell }, true);
1102
+ }
1103
+ // Sweep finished sessions before enforcing the cap so a long-lived process
1104
+ // doesn't get blocked by stale completed entries.
1105
+ const table = sessions();
1106
+ if (table.size >= MAX_SESSIONS) {
1107
+ for (const [id, s] of table) {
1108
+ if (!s.running)
1109
+ table.delete(id);
1110
+ }
1111
+ }
1112
+ if (table.size >= MAX_SESSIONS) {
1113
+ return textResult({ error: `Too many active background sessions (max ${MAX_SESSIONS}). Kill some first.` }, true);
1114
+ }
1115
+ registerExitCleanup();
1116
+ const cwd = await resolveCwd(args.cwd, roots);
1117
+ const warnings = await scanCommandForExternalPaths(command, cwd, roots);
1118
+ const warn = warnings.length ? { warnings } : {};
1119
+ const { child, startError, effectiveShell, unavailableShell } = startChild(command, cwd, requestedShell, envValidation.env);
1120
+ if (unavailableShell) {
1121
+ return textResult({ error: `Requested shell "${unavailableShell}" is unavailable or could not be resolved.`, cwd, shell: unavailableShell }, true);
1122
+ }
1123
+ if (startError || !child) {
1124
+ return textResult({ error: `Failed to start command (${effectiveShell}): ${startError ?? 'unknown error'}`, cwd, shell: effectiveShell }, true);
1125
+ }
1126
+ const id = `bash-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1127
+ const session = {
1128
+ id,
1129
+ ownerScope,
1130
+ command,
1131
+ cwd,
1132
+ child,
1133
+ output: '',
1134
+ truncated: false,
1135
+ running: true,
1136
+ exitCode: null,
1137
+ startedAt: Date.now(),
1138
+ };
1139
+ table.set(id, session);
1140
+ const append = makeAppender(() => session.output, (v, t) => { session.output = v; session.truncated = t || session.truncated; });
1141
+ child.stdout?.on('data', (d) => append(d.toString()));
1142
+ child.stderr?.on('data', (d) => append(d.toString()));
1143
+ child.on('error', (err) => {
1144
+ append(`\n${err.message}`);
1145
+ session.running = false;
1146
+ session.endedAt = Date.now();
1147
+ scheduleReap(session);
1148
+ });
1149
+ child.on('close', (code) => {
1150
+ session.running = false;
1151
+ session.exitCode = code;
1152
+ session.endedAt = Date.now();
1153
+ scheduleReap(session);
1154
+ });
1155
+ return textResult({ sessionId: id, cwd, requestedShell, shell: effectiveShell, ...warn });
1156
+ }
1157
+ function snapshot(session, extra = {}) {
1158
+ return {
1159
+ sessionId: session.id,
1160
+ running: session.running,
1161
+ exitCode: session.exitCode,
1162
+ output: session.output,
1163
+ truncated: session.truncated,
1164
+ ...extra,
1165
+ };
1166
+ }
1167
+ function statusTool(args, ownerScope) {
1168
+ const id = String(args?.sessionId ?? '');
1169
+ const session = ownedSession(id, ownerScope);
1170
+ if (!session)
1171
+ return textResult({ error: `No background session with id "${id}".` }, true);
1172
+ return textResult(snapshot(session));
1173
+ }
1174
+ async function waitTool(args, ownerScope, context) {
1175
+ const id = String(args?.sessionId ?? '');
1176
+ const session = ownedSession(id, ownerScope);
1177
+ if (!session)
1178
+ return textResult({ error: `No background session with id "${id}".` }, true);
1179
+ if (!session.running)
1180
+ return textResult(snapshot(session, { timedOut: false }));
1181
+ if (context?.signal?.aborted) {
1182
+ return textResult(snapshot(session, { timedOut: false, cancelled: true }), true);
1183
+ }
1184
+ const timeoutSec = typeof args.timeout === 'number' && args.timeout > 0 ? args.timeout : DEFAULT_TIMEOUT_MS / 1000;
1185
+ const timeoutMs = Math.min(timeoutSec * 1000, MAX_TIMEOUT_MS);
1186
+ const progress = createCommandProgressReporter(context);
1187
+ const outcome = await new Promise((resolve) => {
1188
+ let settled = false;
1189
+ let pollTimer;
1190
+ let timeoutTimer;
1191
+ let outputOffset = 0;
1192
+ const finish = async (value) => {
1193
+ if (settled)
1194
+ return;
1195
+ settled = true;
1196
+ if (pollTimer)
1197
+ clearTimeout(pollTimer);
1198
+ if (timeoutTimer)
1199
+ clearTimeout(timeoutTimer);
1200
+ context?.signal?.removeEventListener('abort', onAbort);
1201
+ if (session.output.length > outputOffset)
1202
+ progress.push(session.output.slice(outputOffset));
1203
+ await progress.stop();
1204
+ resolve(value);
1205
+ };
1206
+ const onAbort = () => void finish('cancelled');
1207
+ const poll = () => {
1208
+ if (session.output.length > outputOffset) {
1209
+ progress.push(session.output.slice(outputOffset));
1210
+ outputOffset = session.output.length;
1211
+ }
1212
+ if (!session.running) {
1213
+ void finish('completed');
1214
+ return;
1215
+ }
1216
+ pollTimer = setTimeout(poll, 100);
1217
+ };
1218
+ timeoutTimer = setTimeout(() => void finish('timedOut'), timeoutMs);
1219
+ context?.signal?.addEventListener('abort', onAbort, { once: true });
1220
+ poll();
1221
+ });
1222
+ return textResult(snapshot(session, {
1223
+ timedOut: outcome === 'timedOut',
1224
+ ...(outcome === 'cancelled' ? { cancelled: true } : {}),
1225
+ }), outcome === 'cancelled');
1226
+ }
1227
+ function writeStdinTool(args, ownerScope) {
1228
+ const id = String(args?.sessionId ?? '');
1229
+ const session = ownedSession(id, ownerScope);
1230
+ if (!session)
1231
+ return textResult({ error: `No background session with id "${id}".` }, true);
1232
+ if (!session.running)
1233
+ return textResult({ error: `Session "${id}" has already exited.` }, true);
1234
+ const data = typeof args.data === 'string' ? args.data : '';
1235
+ const withNewline = args.newline === false ? data : `${data}\n`;
1236
+ try {
1237
+ session.child.stdin?.write(withNewline);
1238
+ }
1239
+ catch (err) {
1240
+ return textResult({ error: `Failed to write to stdin: ${err instanceof Error ? err.message : String(err)}` }, true);
1241
+ }
1242
+ return textResult({ sessionId: id, written: Buffer.byteLength(withNewline, 'utf8') });
1243
+ }
1244
+ function killTool(args, ownerScope) {
1245
+ const id = String(args?.sessionId ?? '');
1246
+ const session = ownedSession(id, ownerScope);
1247
+ if (!session)
1248
+ return textResult({ error: `No background session with id "${id}".` }, true);
1249
+ if (session.running) {
1250
+ session.cancelEscalation = killProcessTree(session.child);
1251
+ }
1252
+ return textResult({ sessionId: id, killed: true });
1253
+ }
1254
+ function listSessionsTool(ownerScope) {
1255
+ const list = Array.from(sessions().values())
1256
+ .filter((s) => s.ownerScope === ownerScope)
1257
+ .map((s) => ({
1258
+ sessionId: s.id,
1259
+ command: s.command,
1260
+ running: s.running,
1261
+ exitCode: s.exitCode,
1262
+ startedAt: new Date(s.startedAt).toISOString(),
1263
+ endedAt: s.endedAt ? new Date(s.endedAt).toISOString() : undefined,
1264
+ }));
1265
+ return textResult({ sessions: list });
1266
+ }
1267
+ function terminalSnapshot(session) {
1268
+ return {
1269
+ sessionId: session.id,
1270
+ shell: session.shell,
1271
+ cwd: session.cwd,
1272
+ running: session.running,
1273
+ exitCode: session.exitCode,
1274
+ cursor: session.nextCursor,
1275
+ };
1276
+ }
1277
+ function scheduleTerminalReap(session) {
1278
+ session.reapTimer = setTimeout(() => terminalSessions().delete(session.id), SESSION_TTL_MS);
1279
+ session.reapTimer.unref?.();
1280
+ }
1281
+ function appendTerminalOutput(session, chunk) {
1282
+ if (!chunk)
1283
+ return;
1284
+ session.output += chunk;
1285
+ session.nextCursor += chunk.length;
1286
+ if (session.output.length > MAX_TERMINAL_OUTPUT_CHARS) {
1287
+ const remove = session.output.length - MAX_TERMINAL_OUTPUT_CHARS;
1288
+ session.output = session.output.slice(remove);
1289
+ session.outputStart += remove;
1290
+ }
1291
+ }
1292
+ async function openTerminalTool(args, roots, ownerScope) {
1293
+ const shellValidation = validateShell(args.shell);
1294
+ if (!shellValidation.valid)
1295
+ return invalidShellResult(shellValidation.requestedShell);
1296
+ const envValidation = validateEnv(args.env);
1297
+ if (!envValidation.valid)
1298
+ return textResult({ error: envValidation.error }, true);
1299
+ const ownedRunning = Array.from(terminalSessions().values())
1300
+ .filter((session) => session.ownerScope === ownerScope && session.running);
1301
+ if (ownedRunning.length >= MAX_SESSIONS) {
1302
+ return textResult({ error: `Too many active terminal sessions (max ${MAX_SESSIONS}). Close one first.` }, true);
1303
+ }
1304
+ const cwd = await resolveCwd(args.cwd, roots);
1305
+ const plan = buildPtySpawn(shellValidation.shell);
1306
+ if (plan.unavailableShell) {
1307
+ return textResult({ error: `Requested shell "${plan.unavailableShell}" is unavailable or could not be resolved.` }, true);
1308
+ }
1309
+ if (plan.startError)
1310
+ return textResult({ error: plan.startError }, true);
1311
+ const cols = Math.max(20, Math.min(400, Math.floor(typeof args.cols === 'number' ? args.cols : 100)));
1312
+ const rows = Math.max(5, Math.min(200, Math.floor(typeof args.rows === 'number' ? args.rows : 30)));
1313
+ let pty;
1314
+ try {
1315
+ pty = spawnPty(plan.file, plan.args, {
1316
+ name: 'xterm-256color',
1317
+ cols,
1318
+ rows,
1319
+ cwd,
1320
+ env: {
1321
+ ...buildChildEnv(envValidation.env),
1322
+ TERM: 'xterm-256color',
1323
+ COLORTERM: 'truecolor',
1324
+ },
1325
+ });
1326
+ }
1327
+ catch (error) {
1328
+ return textResult({
1329
+ error: `Failed to open pseudoterminal (${plan.effectiveShell}): ${error instanceof Error ? error.message : String(error)}`,
1330
+ cwd,
1331
+ shell: plan.effectiveShell,
1332
+ }, true);
1333
+ }
1334
+ registerExitCleanup();
1335
+ const id = `terminal-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1336
+ const session = {
1337
+ id,
1338
+ ownerScope,
1339
+ shell: plan.effectiveShell,
1340
+ cwd,
1341
+ pty,
1342
+ output: '',
1343
+ outputStart: 0,
1344
+ nextCursor: 0,
1345
+ running: true,
1346
+ exitCode: null,
1347
+ startedAt: Date.now(),
1348
+ };
1349
+ terminalSessions().set(id, session);
1350
+ pty.onData((chunk) => appendTerminalOutput(session, chunk));
1351
+ pty.onExit(({ exitCode }) => {
1352
+ session.running = false;
1353
+ session.exitCode = exitCode;
1354
+ session.endedAt = Date.now();
1355
+ scheduleTerminalReap(session);
1356
+ });
1357
+ return textResult({ ...terminalSnapshot(session), cols, rows });
1358
+ }
1359
+ function ownedTerminal(id, ownerScope) {
1360
+ const session = terminalSessions().get(id);
1361
+ return session?.ownerScope === ownerScope ? session : undefined;
1362
+ }
1363
+ function readTerminalTool(args, ownerScope) {
1364
+ const id = String(args.sessionId ?? '');
1365
+ const session = ownedTerminal(id, ownerScope);
1366
+ if (!session)
1367
+ return textResult({ error: `No terminal session with id "${id}".` }, true);
1368
+ const requested = Number.isFinite(args.cursor) ? Math.max(0, Math.floor(Number(args.cursor))) : session.outputStart;
1369
+ const reset = requested < session.outputStart || requested > session.nextCursor;
1370
+ const cursor = reset ? session.outputStart : requested;
1371
+ return textResult({
1372
+ ...terminalSnapshot(session),
1373
+ chunk: session.output.slice(cursor - session.outputStart),
1374
+ nextCursor: session.nextCursor,
1375
+ reset,
1376
+ });
1377
+ }
1378
+ function writeTerminalTool(args, ownerScope) {
1379
+ const id = String(args.sessionId ?? '');
1380
+ const session = ownedTerminal(id, ownerScope);
1381
+ if (!session)
1382
+ return textResult({ error: `No terminal session with id "${id}".` }, true);
1383
+ if (!session.running)
1384
+ return textResult({ error: `Terminal session "${id}" has exited.` }, true);
1385
+ const data = typeof args.data === 'string' ? args.data : '';
1386
+ if (data.length > 65_536)
1387
+ return textResult({ error: 'Terminal input is limited to 65,536 characters per write.' }, true);
1388
+ try {
1389
+ session.pty.write(data);
1390
+ return textResult({ sessionId: id, written: Buffer.byteLength(data, 'utf8') });
1391
+ }
1392
+ catch (error) {
1393
+ return textResult({ error: `Failed to write terminal input: ${error instanceof Error ? error.message : String(error)}` }, true);
1394
+ }
1395
+ }
1396
+ function resizeTerminalTool(args, ownerScope) {
1397
+ const id = String(args.sessionId ?? '');
1398
+ const session = ownedTerminal(id, ownerScope);
1399
+ if (!session)
1400
+ return textResult({ error: `No terminal session with id "${id}".` }, true);
1401
+ const cols = Math.max(20, Math.min(400, Math.floor(Number(args.cols))));
1402
+ const rows = Math.max(5, Math.min(200, Math.floor(Number(args.rows))));
1403
+ if (!Number.isFinite(cols) || !Number.isFinite(rows))
1404
+ return textResult({ error: 'cols and rows must be finite numbers.' }, true);
1405
+ try {
1406
+ if (session.running)
1407
+ session.pty.resize(cols, rows);
1408
+ return textResult({ sessionId: id, cols, rows });
1409
+ }
1410
+ catch (error) {
1411
+ return textResult({ error: `Failed to resize terminal: ${error instanceof Error ? error.message : String(error)}` }, true);
1412
+ }
1413
+ }
1414
+ function closeTerminalTool(args, ownerScope) {
1415
+ const id = String(args.sessionId ?? '');
1416
+ const session = ownedTerminal(id, ownerScope);
1417
+ if (!session)
1418
+ return textResult({ error: `No terminal session with id "${id}".` }, true);
1419
+ try {
1420
+ if (session.running) {
1421
+ session.running = false;
1422
+ session.pty.kill();
1423
+ }
1424
+ return textResult({ sessionId: id, closed: true });
1425
+ }
1426
+ catch (error) {
1427
+ return textResult({ error: `Failed to close terminal: ${error instanceof Error ? error.message : String(error)}` }, true);
1428
+ }
1429
+ }
1430
+ function listTerminalsTool(ownerScope) {
1431
+ return textResult({
1432
+ sessions: Array.from(terminalSessions().values())
1433
+ .filter((session) => session.ownerScope === ownerScope)
1434
+ .map((session) => ({
1435
+ ...terminalSnapshot(session),
1436
+ startedAt: new Date(session.startedAt).toISOString(),
1437
+ endedAt: session.endedAt ? new Date(session.endedAt).toISOString() : undefined,
1438
+ })),
1439
+ });
1440
+ }
1441
+ export async function bashCallTool(toolName, args, callerNodeId, ownerScope, context) {
1442
+ try {
1443
+ const scope = effectiveOwnerScope(ownerScope, callerNodeId);
1444
+ switch (toolName) {
1445
+ case 'open_terminal': {
1446
+ const roots = await loadEffectiveRoots(BASH_SERVER_NAME, BASH_ROOT_ENV_VARS, callerNodeId);
1447
+ return await openTerminalTool(args, roots, scope);
1448
+ }
1449
+ case 'terminal_read':
1450
+ return readTerminalTool(args, scope);
1451
+ case 'terminal_write':
1452
+ return writeTerminalTool(args, scope);
1453
+ case 'terminal_resize':
1454
+ return resizeTerminalTool(args, scope);
1455
+ case 'terminal_close':
1456
+ return closeTerminalTool(args, scope);
1457
+ case 'terminal_list':
1458
+ return listTerminalsTool(scope);
1459
+ case 'run': {
1460
+ const roots = await loadEffectiveRoots(BASH_SERVER_NAME, BASH_ROOT_ENV_VARS, callerNodeId);
1461
+ return await runTool(args, roots, context);
1462
+ }
1463
+ case 'start': {
1464
+ const roots = await loadEffectiveRoots(BASH_SERVER_NAME, BASH_ROOT_ENV_VARS, callerNodeId);
1465
+ return await startTool(args, roots, scope, context);
1466
+ }
1467
+ case 'status':
1468
+ return statusTool(args, scope);
1469
+ case 'wait':
1470
+ return await waitTool(args, scope, context);
1471
+ case 'write_stdin':
1472
+ return writeStdinTool(args, scope);
1473
+ case 'kill':
1474
+ return killTool(args, scope);
1475
+ case 'list_sessions':
1476
+ return listSessionsTool(scope);
1477
+ default:
1478
+ return textResult({ error: `Unknown tool on the built-in bash server: ${toolName}` }, true);
1479
+ }
1480
+ }
1481
+ catch (err) {
1482
+ log.warn('bashCallTool failed', { toolName, err });
1483
+ return textResult({ error: `Tool failed: ${err instanceof Error ? err.message : String(err)}` }, true);
1484
+ }
1485
+ }
1486
+ /** Test-only: force-kill and clear all sessions. */
1487
+ export function _resetBashSessionsForTests() {
1488
+ shutdownBashSessions();
1489
+ foregroundChildren().clear();
1490
+ sessions().clear();
1491
+ terminalSessions().clear();
1492
+ }
1493
+ //# sourceMappingURL=tools.js.map