@mario.andreschak/mcp-bash 3.42.1 → 3.44.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/index.js +26 -5
- package/dist/index.js.map +1 -1
- package/dist/resources.js +242 -242
- package/dist/tools.d.ts +114 -1
- package/dist/tools.js +1049 -75
- package/dist/tools.js.map +1 -1
- package/package.json +1 -1
package/dist/tools.js
CHANGED
|
@@ -40,17 +40,71 @@ const BASH_SERVER_NAME = 'bash';
|
|
|
40
40
|
export const BASH_TERMINAL_APP_URI = 'ui://bash/terminal';
|
|
41
41
|
const log = createLogger('backend/services/mcp/internal/bashTools');
|
|
42
42
|
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
43
|
-
|
|
43
|
+
/**
|
|
44
|
+
* Foreground commands and `wait` calls may wrap builds, renders, migrations,
|
|
45
|
+
* and other jobs that take far longer than the historical ten-minute ceiling.
|
|
46
|
+
* Keep a finite default safety ceiling, aligned with the background-session
|
|
47
|
+
* lifetime, while allowing deployments to lower or raise it. Passing -1
|
|
48
|
+
* disables this Bash-side timer; cancellation and shutdown still clean up.
|
|
49
|
+
*/
|
|
50
|
+
const DEFAULT_MAX_TIMEOUT_MS = 12 * 60 * 60_000;
|
|
44
51
|
const MAX_OUTPUT_CHARS = 100_000;
|
|
45
52
|
const MAX_SESSIONS = 25;
|
|
46
53
|
const MAX_TERMINAL_OUTPUT_CHARS = 2_000_000;
|
|
47
54
|
/**
|
|
48
|
-
* Lifecycle: live
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
55
|
+
* Lifecycle (issue #413): a live session ends by process exit, explicit kill,
|
|
56
|
+
* OWNER RELEASE when its run finishes, idle/max-lifetime expiry, or Bash server
|
|
57
|
+
* shutdown. Finished owner-scoped records remain readable for ten minutes, then
|
|
58
|
+
* are reaped. An MCP View teardown may be an inline-to-dock handoff, so it is
|
|
59
|
+
* still not treated as an owner disconnect signal.
|
|
60
|
+
*
|
|
61
|
+
* Owner release is what closes the original leak: nothing used to tell this
|
|
62
|
+
* server that the run which started a background command had ended, so a
|
|
63
|
+
* `start`ed process (and its whole descendant tree) outlived its run and stayed
|
|
64
|
+
* resident for the lifetime of the FLUJO process.
|
|
52
65
|
*/
|
|
53
66
|
const SESSION_TTL_MS = 10 * 60_000;
|
|
67
|
+
/**
|
|
68
|
+
* Host-wide cap across background AND terminal sessions. The per-owner cap alone
|
|
69
|
+
* could not bound the machine: N concurrent runs each stayed under their own
|
|
70
|
+
* limit while together they forked an unbounded number of live trees.
|
|
71
|
+
*/
|
|
72
|
+
const MAX_LIVE_SESSIONS_HOST = 50;
|
|
73
|
+
/** A live session with no reads/writes/output for this long is expired. */
|
|
74
|
+
const SESSION_IDLE_TIMEOUT_MS = 60 * 60_000;
|
|
75
|
+
/** Absolute ceiling on a live session's age, idle or not. */
|
|
76
|
+
const SESSION_MAX_LIFETIME_MS = 12 * 60 * 60_000;
|
|
77
|
+
/** How often the expiry sweep runs. */
|
|
78
|
+
const SESSION_SWEEP_INTERVAL_MS = 60_000;
|
|
79
|
+
function positiveEnvNumber(name, fallback) {
|
|
80
|
+
const raw = Number(process.env[name]);
|
|
81
|
+
return Number.isFinite(raw) && raw > 0 ? raw : fallback;
|
|
82
|
+
}
|
|
83
|
+
function maxLiveSessionsHost() {
|
|
84
|
+
return Math.floor(positiveEnvNumber('FLUJO_BASH_MAX_LIVE_SESSIONS', MAX_LIVE_SESSIONS_HOST));
|
|
85
|
+
}
|
|
86
|
+
function sessionIdleTimeoutMs() {
|
|
87
|
+
return positiveEnvNumber('FLUJO_BASH_SESSION_IDLE_MS', SESSION_IDLE_TIMEOUT_MS);
|
|
88
|
+
}
|
|
89
|
+
function sessionMaxLifetimeMs() {
|
|
90
|
+
return positiveEnvNumber('FLUJO_BASH_SESSION_MAX_LIFETIME_MS', SESSION_MAX_LIFETIME_MS);
|
|
91
|
+
}
|
|
92
|
+
function commandMaxTimeoutMs() {
|
|
93
|
+
return positiveEnvNumber('FLUJO_BASH_COMMAND_MAX_TIMEOUT_MS', DEFAULT_MAX_TIMEOUT_MS);
|
|
94
|
+
}
|
|
95
|
+
/** `undefined` means no Bash-side timeout (the explicit timeout=-1 contract). */
|
|
96
|
+
function resolveCommandTimeoutMs(value) {
|
|
97
|
+
if (value === -1)
|
|
98
|
+
return undefined;
|
|
99
|
+
const requested = typeof value === 'number' && Number.isFinite(value) && value > 0
|
|
100
|
+
? value * 1_000
|
|
101
|
+
: DEFAULT_TIMEOUT_MS;
|
|
102
|
+
return Math.min(requested, commandMaxTimeoutMs());
|
|
103
|
+
}
|
|
104
|
+
/** Test seam for the public timeout contract; not used by the runtime. */
|
|
105
|
+
export function _resolveCommandTimeoutMsForTests(value) {
|
|
106
|
+
return resolveCommandTimeoutMs(value);
|
|
107
|
+
}
|
|
54
108
|
/**
|
|
55
109
|
* Locate an executable by name on `PATH` ourselves (rather than relying on
|
|
56
110
|
* `spawn`'s own resolution) so we can know in advance whether `pwsh`/`bash`
|
|
@@ -150,9 +204,14 @@ function isWindowsWslBashLauncher(candidate) {
|
|
|
150
204
|
if (process.platform !== 'win32')
|
|
151
205
|
return false;
|
|
152
206
|
const systemRoot = getEnvCaseInsensitive('SystemRoot') ?? getEnvCaseInsensitive('windir');
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
207
|
+
const resolved = path.resolve(candidate).toLowerCase();
|
|
208
|
+
// The legacy WSL relay may be reached through System32, SysWOW64, or a
|
|
209
|
+
// SystemRoot-relative shim. It is not a usable POSIX interpreter without a
|
|
210
|
+
// provisioned distro, so never select it as the Bash MCP's `bash` shell.
|
|
211
|
+
if (systemRoot && resolved.startsWith(`${path.resolve(systemRoot).toLowerCase()}${path.sep}`)
|
|
212
|
+
&& path.basename(resolved) === 'bash.exe')
|
|
213
|
+
return true;
|
|
214
|
+
return /[\\/]WindowsApps[\\/].*wsl.*[\\/]bash\.exe$/i.test(resolved);
|
|
156
215
|
}
|
|
157
216
|
let cachedBashPath;
|
|
158
217
|
function resolveBashExecutable() {
|
|
@@ -211,11 +270,96 @@ function resolveCmdExecutable() {
|
|
|
211
270
|
path.join(getEnvCaseInsensitive('SystemRoot') ?? 'C:\\Windows', 'System32', 'cmd.exe'),
|
|
212
271
|
]);
|
|
213
272
|
}
|
|
273
|
+
function resolveShExecutable() {
|
|
274
|
+
if (process.platform === 'win32')
|
|
275
|
+
return findExecutableOnPath('sh');
|
|
276
|
+
return firstExistingFile(['/bin/sh', findExecutableOnPath('sh')]);
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Interpreters and utilities probed by `shell_info` (issue #364) so a caller can
|
|
280
|
+
* discover the machine up front instead of rediscovering it by failed commands.
|
|
281
|
+
*/
|
|
282
|
+
const PROBED_BINARIES = [
|
|
283
|
+
'python', 'python3', 'node', 'npm', 'npx', 'git', 'rg',
|
|
284
|
+
'ffmpeg', 'ffprobe', 'curl', 'tar', 'grep', 'sed', 'awk', 'head', 'tail',
|
|
285
|
+
];
|
|
286
|
+
/**
|
|
287
|
+
* A zero-byte Windows Store app-execution alias (typically `python3.exe` under
|
|
288
|
+
* `WindowsApps`) resolves on PATH but only opens the Store when executed.
|
|
289
|
+
*/
|
|
290
|
+
function isWindowsStoreAliasStub(candidate) {
|
|
291
|
+
if (!candidate || process.platform !== 'win32')
|
|
292
|
+
return false;
|
|
293
|
+
if (!/[\\/]WindowsApps[\\/]/i.test(candidate))
|
|
294
|
+
return false;
|
|
295
|
+
try {
|
|
296
|
+
return fs.statSync(candidate).size === 0;
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
return true;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
function shellEntry(shell, resolved) {
|
|
303
|
+
return { shell, available: Boolean(resolved), path: resolved };
|
|
304
|
+
}
|
|
305
|
+
let cachedShellInfo;
|
|
306
|
+
/** Describe the shells and interpreters actually available on this machine. */
|
|
307
|
+
export function collectShellInfo() {
|
|
308
|
+
if (cachedShellInfo)
|
|
309
|
+
return cachedShellInfo;
|
|
310
|
+
const defaultPlan = buildSpawn('', 'default');
|
|
311
|
+
const shells = [
|
|
312
|
+
shellEntry('pwsh', resolvePwshExecutable()),
|
|
313
|
+
shellEntry('powershell', resolveWindowsPowerShellExecutable()),
|
|
314
|
+
shellEntry('bash', resolveBashExecutable()),
|
|
315
|
+
shellEntry('cmd', resolveCmdExecutable()),
|
|
316
|
+
shellEntry('sh', resolveShExecutable()),
|
|
317
|
+
];
|
|
318
|
+
const binaries = PROBED_BINARIES.map((name) => {
|
|
319
|
+
const resolved = findExecutableOnPath(name);
|
|
320
|
+
if (isWindowsStoreAliasStub(resolved)) {
|
|
321
|
+
return { name, found: false, path: resolved, alias: true };
|
|
322
|
+
}
|
|
323
|
+
return { name, found: Boolean(resolved), path: resolved };
|
|
324
|
+
});
|
|
325
|
+
const notes = [
|
|
326
|
+
'Tool output merges stdout and stderr into one "output" field; "isError" reflects the process exit code only.',
|
|
327
|
+
`The "default" shell on this machine is "${defaultPlan.effectiveShell}".`,
|
|
328
|
+
'Pass shell:"bash" for POSIX syntax (&&, pipes into head/grep, $(…)); an unavailable explicit shell fails fast.',
|
|
329
|
+
];
|
|
330
|
+
if (!shells.find((entry) => entry.shell === 'pwsh')?.available
|
|
331
|
+
&& shells.find((entry) => entry.shell === 'powershell')?.available) {
|
|
332
|
+
notes.push('Only Windows PowerShell 5.1 is installed: "&&" and "||" are not valid statement separators there.');
|
|
333
|
+
}
|
|
334
|
+
cachedShellInfo = {
|
|
335
|
+
platform: process.platform,
|
|
336
|
+
defaultShell: defaultPlan.effectiveShell,
|
|
337
|
+
defaultShellPath: defaultPlan.file || null,
|
|
338
|
+
shells,
|
|
339
|
+
binaries,
|
|
340
|
+
notes,
|
|
341
|
+
};
|
|
342
|
+
return cachedShellInfo;
|
|
343
|
+
}
|
|
344
|
+
function availableShellNames() {
|
|
345
|
+
return collectShellInfo().shells.filter((entry) => entry.available).map((entry) => entry.shell);
|
|
346
|
+
}
|
|
347
|
+
function unavailableShellHint(shell) {
|
|
348
|
+
if (shell === 'bash' && process.platform === 'win32'
|
|
349
|
+
&& findExecutablesOnPath('bash').some(isWindowsWslBashLauncher)) {
|
|
350
|
+
return 'Only the WSL bash launcher was found and no Linux distribution provides /bin/bash. Install Git for Windows (Git Bash) or a WSL distro.';
|
|
351
|
+
}
|
|
352
|
+
return 'Call "shell_info" to see which shells and interpreters exist on this machine.';
|
|
353
|
+
}
|
|
354
|
+
function shellInfoTool() {
|
|
355
|
+
return textResult(collectShellInfo());
|
|
356
|
+
}
|
|
214
357
|
/** Test-only: forget cached shell-executable lookups. */
|
|
215
358
|
export function _resetBashShellCacheForTests() {
|
|
216
359
|
cachedBashPath = undefined;
|
|
217
360
|
cachedPwshPath = undefined;
|
|
218
361
|
cachedWindowsPowerShellPath = undefined;
|
|
362
|
+
cachedShellInfo = undefined;
|
|
219
363
|
}
|
|
220
364
|
function sessions() {
|
|
221
365
|
if (!global.__flujo_bash_sessions)
|
|
@@ -244,11 +388,203 @@ function ownedSession(id, ownerScope) {
|
|
|
244
388
|
const session = sessions().get(id);
|
|
245
389
|
return session?.ownerScope === ownerScope ? session : undefined;
|
|
246
390
|
}
|
|
391
|
+
/** Mark a session as active so the idle sweep leaves it alone. */
|
|
392
|
+
function touchSession(session) {
|
|
393
|
+
session.lastActivityAt = Date.now();
|
|
394
|
+
}
|
|
395
|
+
/** Live sessions across BOTH tables — the quantity the host cap bounds. */
|
|
396
|
+
function liveSessionCount() {
|
|
397
|
+
let count = 0;
|
|
398
|
+
for (const session of sessions().values())
|
|
399
|
+
if (session.running)
|
|
400
|
+
count += 1;
|
|
401
|
+
for (const terminal of terminalSessions().values())
|
|
402
|
+
if (terminal.running)
|
|
403
|
+
count += 1;
|
|
404
|
+
return count;
|
|
405
|
+
}
|
|
406
|
+
/** Live sessions for one owner across both tables. */
|
|
407
|
+
function ownerLiveSessionCount(ownerScope) {
|
|
408
|
+
let count = 0;
|
|
409
|
+
for (const session of sessions().values()) {
|
|
410
|
+
if (session.running && session.ownerScope === ownerScope)
|
|
411
|
+
count += 1;
|
|
412
|
+
}
|
|
413
|
+
for (const terminal of terminalSessions().values()) {
|
|
414
|
+
if (terminal.running && terminal.ownerScope === ownerScope)
|
|
415
|
+
count += 1;
|
|
416
|
+
}
|
|
417
|
+
return count;
|
|
418
|
+
}
|
|
419
|
+
/** Terminate one background session's whole process tree and drop its timers. */
|
|
420
|
+
function terminateSession(session) {
|
|
421
|
+
if (session.running) {
|
|
422
|
+
try {
|
|
423
|
+
session.cancelEscalation = killProcessTree(session.child);
|
|
424
|
+
}
|
|
425
|
+
catch {
|
|
426
|
+
/* best-effort */
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
if (session.reapTimer) {
|
|
430
|
+
clearTimeout(session.reapTimer);
|
|
431
|
+
session.reapTimer = undefined;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
/** Terminate one PTY session and drop its timers. */
|
|
435
|
+
function terminateTerminal(terminal) {
|
|
436
|
+
try {
|
|
437
|
+
if (terminal.running) {
|
|
438
|
+
terminal.running = false;
|
|
439
|
+
terminal.pty.kill();
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
catch {
|
|
443
|
+
/* best-effort */
|
|
444
|
+
}
|
|
445
|
+
if (terminal.reapTimer) {
|
|
446
|
+
clearTimeout(terminal.reapTimer);
|
|
447
|
+
terminal.reapTimer = undefined;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* Atomically remove and terminate every NON-DETACHED session of one owner.
|
|
452
|
+
*
|
|
453
|
+
* Called when the owning FLUJO run reaches a terminal state. Removing the map
|
|
454
|
+
* entry BEFORE killing means a concurrent release (or a second call from a retry
|
|
455
|
+
* path) cannot observe the same session twice, so the operation is idempotent -
|
|
456
|
+
* repeated releases of the same scope are a no-op instead of a double kill
|
|
457
|
+
* against a possibly-recycled pid.
|
|
458
|
+
*/
|
|
459
|
+
export function releaseOwnerScope(ownerScope) {
|
|
460
|
+
const result = {
|
|
461
|
+
ownerScope,
|
|
462
|
+
killedSessions: [],
|
|
463
|
+
closedTerminals: [],
|
|
464
|
+
retainedDetached: [],
|
|
465
|
+
};
|
|
466
|
+
for (const [id, session] of Array.from(sessions())) {
|
|
467
|
+
if (session.ownerScope !== ownerScope)
|
|
468
|
+
continue;
|
|
469
|
+
if (session.detached) {
|
|
470
|
+
if (session.running)
|
|
471
|
+
result.retainedDetached.push(id);
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
sessions().delete(id);
|
|
475
|
+
if (session.running)
|
|
476
|
+
result.killedSessions.push(id);
|
|
477
|
+
terminateSession(session);
|
|
478
|
+
}
|
|
479
|
+
for (const [id, terminal] of Array.from(terminalSessions())) {
|
|
480
|
+
if (terminal.ownerScope !== ownerScope)
|
|
481
|
+
continue;
|
|
482
|
+
if (terminal.detached) {
|
|
483
|
+
if (terminal.running)
|
|
484
|
+
result.retainedDetached.push(id);
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
terminalSessions().delete(id);
|
|
488
|
+
if (terminal.running)
|
|
489
|
+
result.closedTerminals.push(id);
|
|
490
|
+
terminateTerminal(terminal);
|
|
491
|
+
}
|
|
492
|
+
if (result.killedSessions.length || result.closedTerminals.length) {
|
|
493
|
+
log.info('Released owner-scoped bash sessions', {
|
|
494
|
+
ownerScope,
|
|
495
|
+
killed: result.killedSessions.length,
|
|
496
|
+
closedTerminals: result.closedTerminals.length,
|
|
497
|
+
retainedDetached: result.retainedDetached.length,
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
return result;
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Expire live sessions that exceeded the idle timeout or the absolute lifetime.
|
|
504
|
+
*
|
|
505
|
+
* Detached sessions are exempt from IDLE expiry (being idle is often the point of
|
|
506
|
+
* a detached watcher) but NOT from the absolute lifetime ceiling - otherwise
|
|
507
|
+
* `detached` would be an unbounded leak with extra steps.
|
|
508
|
+
*/
|
|
509
|
+
export function expireStaleSessions(now = Date.now()) {
|
|
510
|
+
const idleLimit = sessionIdleTimeoutMs();
|
|
511
|
+
const lifetimeLimit = sessionMaxLifetimeMs();
|
|
512
|
+
const expired = [];
|
|
513
|
+
for (const [id, session] of Array.from(sessions())) {
|
|
514
|
+
if (!session.running)
|
|
515
|
+
continue;
|
|
516
|
+
const tooOld = now - session.startedAt >= lifetimeLimit;
|
|
517
|
+
const tooIdle = !session.detached && now - session.lastActivityAt >= idleLimit;
|
|
518
|
+
if (!tooOld && !tooIdle)
|
|
519
|
+
continue;
|
|
520
|
+
sessions().delete(id);
|
|
521
|
+
terminateSession(session);
|
|
522
|
+
expired.push(id);
|
|
523
|
+
}
|
|
524
|
+
for (const [id, terminal] of Array.from(terminalSessions())) {
|
|
525
|
+
if (!terminal.running)
|
|
526
|
+
continue;
|
|
527
|
+
const tooOld = now - terminal.startedAt >= lifetimeLimit;
|
|
528
|
+
const tooIdle = !terminal.detached && now - terminal.lastActivityAt >= idleLimit;
|
|
529
|
+
if (!tooOld && !tooIdle)
|
|
530
|
+
continue;
|
|
531
|
+
terminalSessions().delete(id);
|
|
532
|
+
terminateTerminal(terminal);
|
|
533
|
+
expired.push(id);
|
|
534
|
+
}
|
|
535
|
+
if (expired.length)
|
|
536
|
+
log.info('Expired stale bash sessions', { count: expired.length });
|
|
537
|
+
return expired;
|
|
538
|
+
}
|
|
539
|
+
/** Bounded per-session diagnostics: identity/state only, never output. */
|
|
540
|
+
export function bashSessionDiagnostics() {
|
|
541
|
+
const now = Date.now();
|
|
542
|
+
const rows = [];
|
|
543
|
+
for (const session of sessions().values()) {
|
|
544
|
+
rows.push({
|
|
545
|
+
sessionId: session.id,
|
|
546
|
+
type: 'background',
|
|
547
|
+
ownerScope: session.ownerScope,
|
|
548
|
+
running: session.running,
|
|
549
|
+
detached: session.detached === true,
|
|
550
|
+
ageMs: now - session.startedAt,
|
|
551
|
+
idleMs: now - session.lastActivityAt,
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
for (const terminal of terminalSessions().values()) {
|
|
555
|
+
rows.push({
|
|
556
|
+
sessionId: terminal.id,
|
|
557
|
+
type: 'terminal',
|
|
558
|
+
ownerScope: terminal.ownerScope,
|
|
559
|
+
running: terminal.running,
|
|
560
|
+
detached: terminal.detached === true,
|
|
561
|
+
ageMs: now - terminal.startedAt,
|
|
562
|
+
idleMs: now - terminal.lastActivityAt,
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
return {
|
|
566
|
+
liveSessions: liveSessionCount(),
|
|
567
|
+
maxLiveSessions: maxLiveSessionsHost(),
|
|
568
|
+
sessions: rows,
|
|
569
|
+
};
|
|
570
|
+
}
|
|
247
571
|
function terminalMeta(visibility = ['model', 'app']) {
|
|
248
572
|
return { ui: { resourceUri: BASH_TERMINAL_APP_URI, visibility } };
|
|
249
573
|
}
|
|
250
|
-
/**
|
|
574
|
+
/**
|
|
575
|
+
* Kill every live session's process tree - used on Bash server shutdown and on
|
|
576
|
+
* FLUJO process exit.
|
|
577
|
+
*
|
|
578
|
+
* Idempotent and DETACHED-INCLUSIVE: a detached session survives owner release,
|
|
579
|
+
* but nothing survives the death of the server that owns its pipes. Timers are
|
|
580
|
+
* cleared too, so a repeated call cannot leave a handle keeping the event loop
|
|
581
|
+
* alive after "shutdown".
|
|
582
|
+
*/
|
|
251
583
|
export function shutdownBashSessions() {
|
|
584
|
+
if (global.__flujo_bash_sweep_timer) {
|
|
585
|
+
clearInterval(global.__flujo_bash_sweep_timer);
|
|
586
|
+
global.__flujo_bash_sweep_timer = undefined;
|
|
587
|
+
}
|
|
252
588
|
for (const child of foregroundChildren()) {
|
|
253
589
|
try {
|
|
254
590
|
killProcessTree(child);
|
|
@@ -266,8 +602,10 @@ export function shutdownBashSessions() {
|
|
|
266
602
|
/* best-effort */
|
|
267
603
|
}
|
|
268
604
|
}
|
|
269
|
-
if (s.reapTimer)
|
|
605
|
+
if (s.reapTimer) {
|
|
270
606
|
clearTimeout(s.reapTimer);
|
|
607
|
+
s.reapTimer = undefined;
|
|
608
|
+
}
|
|
271
609
|
}
|
|
272
610
|
for (const terminal of terminalSessions().values()) {
|
|
273
611
|
try {
|
|
@@ -279,11 +617,27 @@ export function shutdownBashSessions() {
|
|
|
279
617
|
catch {
|
|
280
618
|
/* best-effort */
|
|
281
619
|
}
|
|
282
|
-
if (terminal.reapTimer)
|
|
620
|
+
if (terminal.reapTimer) {
|
|
283
621
|
clearTimeout(terminal.reapTimer);
|
|
622
|
+
terminal.reapTimer = undefined;
|
|
623
|
+
}
|
|
284
624
|
}
|
|
285
625
|
}
|
|
286
626
|
function registerExitCleanup() {
|
|
627
|
+
if (!global.__flujo_bash_sweep_timer) {
|
|
628
|
+
// Idle/max-lifetime expiry needs a periodic sweep: a session that simply
|
|
629
|
+
// stops producing output never fires an event we could hang the check on.
|
|
630
|
+
const timer = setInterval(() => {
|
|
631
|
+
try {
|
|
632
|
+
expireStaleSessions();
|
|
633
|
+
}
|
|
634
|
+
catch {
|
|
635
|
+
/* a sweep failure must never crash the server */
|
|
636
|
+
}
|
|
637
|
+
}, SESSION_SWEEP_INTERVAL_MS);
|
|
638
|
+
timer.unref?.();
|
|
639
|
+
global.__flujo_bash_sweep_timer = timer;
|
|
640
|
+
}
|
|
287
641
|
if (global.__flujo_bash_cleanup_registered)
|
|
288
642
|
return;
|
|
289
643
|
global.__flujo_bash_cleanup_registered = true;
|
|
@@ -323,6 +677,27 @@ async function resolveCwd(input, roots) {
|
|
|
323
677
|
}
|
|
324
678
|
return resolved;
|
|
325
679
|
}
|
|
680
|
+
/**
|
|
681
|
+
* Resolve an `outputFile` spool target under the same confinement rules as
|
|
682
|
+
* `cwd` (issue #364) and make sure its parent directory exists.
|
|
683
|
+
*/
|
|
684
|
+
async function resolveOutputFile(input, cwd, roots) {
|
|
685
|
+
const raw = typeof input === 'string' ? input.trim() : '';
|
|
686
|
+
if (!raw)
|
|
687
|
+
throw new Error('"outputFile" must be a non-empty path.');
|
|
688
|
+
const resolved = path.isAbsolute(raw) ? path.resolve(raw) : path.resolve(cwd, raw);
|
|
689
|
+
if (await isProtectedPathsEnabled()) {
|
|
690
|
+
const prot = isProtected(resolved);
|
|
691
|
+
if (prot.denied) {
|
|
692
|
+
throw new Error(`outputFile "${resolved}" is within a protected location (${prot.matchedRoot}).`);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
if (roots.length === 0 || !roots.some((root) => isInside(root, resolved))) {
|
|
696
|
+
throw new Error(`outputFile "${resolved}" is outside the configured bash roots.`);
|
|
697
|
+
}
|
|
698
|
+
await fs.promises.mkdir(path.dirname(resolved), { recursive: true });
|
|
699
|
+
return resolved;
|
|
700
|
+
}
|
|
326
701
|
/**
|
|
327
702
|
* Best-effort advisory scan (issue #260, item 4) of a command string for
|
|
328
703
|
* absolute-looking paths that point OUTSIDE the configured roots or INTO a
|
|
@@ -359,6 +734,47 @@ function maskGlobOptionValues(command) {
|
|
|
359
734
|
}
|
|
360
735
|
return chars.join('');
|
|
361
736
|
}
|
|
737
|
+
/**
|
|
738
|
+
* Windows utilities that take `/x`-style switches (issue #314, item D). A slash
|
|
739
|
+
* token is only demoted from "absolute path" to "switch" when one of these
|
|
740
|
+
* appears EARLIER IN THE SAME command segment, so `cd … && dir /b && rg …` and
|
|
741
|
+
* `echo dir /b` stay quiet while `echo /.git/config` keeps its advisory.
|
|
742
|
+
* Deliberately excludes names that are also common POSIX utilities (`find`,
|
|
743
|
+
* `sort`, `type`, `more`, `mkdir`, `rmdir`) — suppressing their arguments would
|
|
744
|
+
* silence genuine path advisories such as `find /etc -name passwd`.
|
|
745
|
+
*/
|
|
746
|
+
const WINDOWS_SWITCH_UTILITIES = new Set([
|
|
747
|
+
'dir', 'xcopy', 'robocopy', 'copy', 'move', 'del', 'erase', 'attrib', 'icacls', 'takeown',
|
|
748
|
+
'findstr', 'tasklist', 'taskkill', 'sc', 'reg', 'net', 'subst', 'where', 'ren', 'rename',
|
|
749
|
+
'md', 'rd', 'tree', 'fc', 'comp', 'cacls', 'schtasks', 'chkdsk', 'sfc', 'shutdown',
|
|
750
|
+
'wmic', 'diskpart', 'label', 'vol', 'mklink', 'assoc', 'ftype', 'gpupdate', 'powercfg',
|
|
751
|
+
]);
|
|
752
|
+
/**
|
|
753
|
+
* A cmd-style switch: `/b`, `/ad`, `/mir`, `/e:on`. Never matches a nested path
|
|
754
|
+
* (no interior separator), so `/etc/passwd` is untouched.
|
|
755
|
+
*/
|
|
756
|
+
const WINDOWS_SWITCH_RE = /^\/[A-Za-z][A-Za-z0-9?*-]{0,15}(?::[^\s"']*)?$/;
|
|
757
|
+
/** Text of the command segment (split on shell separators) preceding `index`. */
|
|
758
|
+
function segmentTextBefore(command, index) {
|
|
759
|
+
const separators = /&&|\|\||[|;&\n]/g;
|
|
760
|
+
let start = 0;
|
|
761
|
+
let match;
|
|
762
|
+
while ((match = separators.exec(command)) !== null) {
|
|
763
|
+
if (match.index >= index)
|
|
764
|
+
break;
|
|
765
|
+
start = match.index + match[0].length;
|
|
766
|
+
}
|
|
767
|
+
return command.slice(start, index);
|
|
768
|
+
}
|
|
769
|
+
/** True when a slash-switch utility heads the segment containing `index`. */
|
|
770
|
+
function windowsSwitchUtilityPrecedes(command, index) {
|
|
771
|
+
for (const word of segmentTextBefore(command, index).match(/[^\s"'<>()]+/g) ?? []) {
|
|
772
|
+
const name = word.replace(/^.*[\\/]/, '').toLowerCase().replace(/\.(?:exe|com|bat|cmd)$/, '');
|
|
773
|
+
if (WINDOWS_SWITCH_UTILITIES.has(name))
|
|
774
|
+
return true;
|
|
775
|
+
}
|
|
776
|
+
return false;
|
|
777
|
+
}
|
|
362
778
|
async function scanCommandForExternalPaths(command, cwd, roots) {
|
|
363
779
|
const warnings = [];
|
|
364
780
|
const protectedPathsEnabled = await isProtectedPathsEnabled();
|
|
@@ -369,10 +785,19 @@ async function scanCommandForExternalPaths(command, cwd, roots) {
|
|
|
369
785
|
// Absolute-looking tokens: Windows drive (X:\ or X:/), UNC (\\host\share),
|
|
370
786
|
// POSIX (/foo), and ~-prefixed home paths.
|
|
371
787
|
const tokenRe = /(?:[A-Za-z]:[\\/][^\s"']*|\\\\[^\s"']+|~\/[^\s"']*|(?<![\w.])\/[^\s"']+)/g;
|
|
372
|
-
const matches =
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
788
|
+
const matches = [...commandForPathScan.matchAll(tokenRe)]
|
|
789
|
+
.filter((match) => {
|
|
790
|
+
if (process.platform !== 'win32')
|
|
791
|
+
return true;
|
|
792
|
+
// Two independent conditions must hold before a token is dropped: it must
|
|
793
|
+
// LOOK like a cmd switch, and a slash-switch utility must appear earlier
|
|
794
|
+
// in the SAME segment (`… && dir /b && …`). This keeps `/etc/passwd` and
|
|
795
|
+
// other genuine POSIX paths advisory.
|
|
796
|
+
if (!WINDOWS_SWITCH_RE.test(match[0]))
|
|
797
|
+
return true;
|
|
798
|
+
return !windowsSwitchUtilityPrecedes(commandForPathScan, match.index ?? 0);
|
|
799
|
+
})
|
|
800
|
+
.map((match) => match[0]);
|
|
376
801
|
const home = (() => {
|
|
377
802
|
try {
|
|
378
803
|
return getHomeDir();
|
|
@@ -499,6 +924,17 @@ function buildChildEnv(overrides = {}) {
|
|
|
499
924
|
}
|
|
500
925
|
out.PATHEXT = windowsExecutableExtensions().join(';');
|
|
501
926
|
}
|
|
927
|
+
// Deterministic text encoding and number formatting for children (issue #364):
|
|
928
|
+
// without this, interpreters emit locale-dependent decimal commas and non-UTF-8
|
|
929
|
+
// bytes that come back as mojibake. Explicit per-call `env` still wins below.
|
|
930
|
+
if (out.PYTHONIOENCODING === undefined)
|
|
931
|
+
out.PYTHONIOENCODING = 'utf-8';
|
|
932
|
+
if (process.platform !== 'win32') {
|
|
933
|
+
if (!out.LANG)
|
|
934
|
+
out.LANG = 'C.UTF-8';
|
|
935
|
+
if (!out.LC_ALL)
|
|
936
|
+
out.LC_ALL = 'C.UTF-8';
|
|
937
|
+
}
|
|
502
938
|
for (const [key, value] of Object.entries(overrides)) {
|
|
503
939
|
if (process.platform === 'win32') {
|
|
504
940
|
for (const existing of Object.keys(out)) {
|
|
@@ -528,6 +964,35 @@ function validateEnv(input) {
|
|
|
528
964
|
}
|
|
529
965
|
return { valid: true, env };
|
|
530
966
|
}
|
|
967
|
+
/**
|
|
968
|
+
* PowerShell prelude (issue #364). It makes three platform traps go away for
|
|
969
|
+
* every command we run:
|
|
970
|
+
* - stderr from a native binary no longer turns into a terminating error, so
|
|
971
|
+
* the reported exit code is the native process' own,
|
|
972
|
+
* - console output is forced to UTF-8 (otherwise logs come back as UTF-16 or
|
|
973
|
+
* OEM mojibake),
|
|
974
|
+
* - the culture is pinned to invariant, so numbers never use a decimal comma.
|
|
975
|
+
* The epilogue re-surfaces the native exit code (`$LASTEXITCODE`), falling back
|
|
976
|
+
* to `$?` when no native command ran.
|
|
977
|
+
*/
|
|
978
|
+
const POWERSHELL_PRELUDE = [
|
|
979
|
+
"$ErrorActionPreference='Continue'",
|
|
980
|
+
"$ProgressPreference='SilentlyContinue'",
|
|
981
|
+
'try { [Console]::OutputEncoding=[Text.Encoding]::UTF8; $OutputEncoding=[Text.Encoding]::UTF8 } catch {}',
|
|
982
|
+
'try { [Threading.Thread]::CurrentThread.CurrentCulture=[Globalization.CultureInfo]::InvariantCulture;'
|
|
983
|
+
+ ' [Threading.Thread]::CurrentThread.CurrentUICulture=[Globalization.CultureInfo]::InvariantCulture } catch {}',
|
|
984
|
+
].join('; ');
|
|
985
|
+
const POWERSHELL_EPILOGUE = 'exit $(if ($null -ne $LASTEXITCODE) { $LASTEXITCODE } elseif ($?) { 0 } else { 1 })';
|
|
986
|
+
export function wrapPowerShellCommand(command) {
|
|
987
|
+
return `${POWERSHELL_PRELUDE}\n${command}\n${POWERSHELL_EPILOGUE}`;
|
|
988
|
+
}
|
|
989
|
+
/** `chcp 65001` forces UTF-8 output for cmd.exe children (issue #364). */
|
|
990
|
+
export function wrapCmdCommand(command) {
|
|
991
|
+
return `chcp 65001>nul & ${command}`;
|
|
992
|
+
}
|
|
993
|
+
function powerShellArgs(command) {
|
|
994
|
+
return ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', wrapPowerShellCommand(command)];
|
|
995
|
+
}
|
|
531
996
|
/**
|
|
532
997
|
* Build the spawn arguments for the requested shell. Returns the command, argv
|
|
533
998
|
* and whether Node's `shell:true` wrapping applies. Shells are resolved
|
|
@@ -539,7 +1004,20 @@ function buildSpawn(command, shell) {
|
|
|
539
1004
|
if (shell === 'pwsh') {
|
|
540
1005
|
const resolved = resolvePwshExecutable();
|
|
541
1006
|
if (resolved) {
|
|
542
|
-
return { file: resolved, args:
|
|
1007
|
+
return { file: resolved, args: powerShellArgs(command), useShell: false, effectiveShell: 'pwsh' };
|
|
1008
|
+
}
|
|
1009
|
+
const windowsPowerShell = resolveWindowsPowerShellExecutable();
|
|
1010
|
+
if (windowsPowerShell) {
|
|
1011
|
+
return {
|
|
1012
|
+
file: windowsPowerShell,
|
|
1013
|
+
args: powerShellArgs(command),
|
|
1014
|
+
useShell: false,
|
|
1015
|
+
effectiveShell: 'powershell',
|
|
1016
|
+
shellSubstitution: {
|
|
1017
|
+
requested: 'pwsh', used: 'powershell',
|
|
1018
|
+
reason: 'PowerShell 7 (pwsh) is not installed on this machine.',
|
|
1019
|
+
},
|
|
1020
|
+
};
|
|
543
1021
|
}
|
|
544
1022
|
return { file: '', args: [], useShell: false, effectiveShell: 'pwsh', unavailableShell: 'pwsh' };
|
|
545
1023
|
}
|
|
@@ -555,7 +1033,7 @@ function buildSpawn(command, shell) {
|
|
|
555
1033
|
if (resolved) {
|
|
556
1034
|
return {
|
|
557
1035
|
file: resolved,
|
|
558
|
-
args: ['/d', '/s', '/c', command],
|
|
1036
|
+
args: ['/d', '/s', '/c', wrapCmdCommand(command)],
|
|
559
1037
|
useShell: false,
|
|
560
1038
|
effectiveShell: 'cmd',
|
|
561
1039
|
windowsVerbatimArguments: true,
|
|
@@ -566,17 +1044,17 @@ function buildSpawn(command, shell) {
|
|
|
566
1044
|
if (process.platform === 'win32') {
|
|
567
1045
|
const pwsh = resolvePwshExecutable();
|
|
568
1046
|
if (pwsh) {
|
|
569
|
-
return { file: pwsh, args:
|
|
1047
|
+
return { file: pwsh, args: powerShellArgs(command), useShell: false, effectiveShell: 'pwsh' };
|
|
570
1048
|
}
|
|
571
1049
|
const windowsPowerShell = resolveWindowsPowerShellExecutable();
|
|
572
1050
|
if (windowsPowerShell) {
|
|
573
|
-
return { file: windowsPowerShell, args:
|
|
1051
|
+
return { file: windowsPowerShell, args: powerShellArgs(command), useShell: false, effectiveShell: 'powershell' };
|
|
574
1052
|
}
|
|
575
1053
|
const cmd = resolveCmdExecutable();
|
|
576
1054
|
if (cmd) {
|
|
577
1055
|
return {
|
|
578
1056
|
file: cmd,
|
|
579
|
-
args: ['/d', '/s', '/c', command],
|
|
1057
|
+
args: ['/d', '/s', '/c', wrapCmdCommand(command)],
|
|
580
1058
|
useShell: false,
|
|
581
1059
|
effectiveShell: 'cmd',
|
|
582
1060
|
windowsVerbatimArguments: true,
|
|
@@ -632,7 +1110,7 @@ function invalidShellResult(requestedShell) {
|
|
|
632
1110
|
}, true);
|
|
633
1111
|
}
|
|
634
1112
|
function startChild(command, cwd, shell, env) {
|
|
635
|
-
const { file, args, useShell, effectiveShell, unavailableShell, startError, windowsVerbatimArguments, } = buildSpawn(command, shell);
|
|
1113
|
+
const { file, args, useShell, effectiveShell, unavailableShell, startError, windowsVerbatimArguments, shellSubstitution, } = buildSpawn(command, shell);
|
|
636
1114
|
if (unavailableShell)
|
|
637
1115
|
return { effectiveShell, unavailableShell };
|
|
638
1116
|
if (startError)
|
|
@@ -647,7 +1125,7 @@ function startChild(command, cwd, shell, env) {
|
|
|
647
1125
|
detached,
|
|
648
1126
|
...(windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}),
|
|
649
1127
|
});
|
|
650
|
-
return { child, effectiveShell };
|
|
1128
|
+
return { child, effectiveShell, shellSubstitution };
|
|
651
1129
|
}
|
|
652
1130
|
catch (err) {
|
|
653
1131
|
return {
|
|
@@ -656,21 +1134,212 @@ function startChild(command, cwd, shell, env) {
|
|
|
656
1134
|
};
|
|
657
1135
|
}
|
|
658
1136
|
}
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
1137
|
+
/**
|
|
1138
|
+
* Bounded output accumulator (issue #364). Retention is head + tail rather than
|
|
1139
|
+
* head-only, so the command echo at the start AND the final result at the end
|
|
1140
|
+
* both survive an output flood (e.g. a 50k-line ffmpeg progress log); the
|
|
1141
|
+
* omitted middle is reported explicitly.
|
|
1142
|
+
*/
|
|
1143
|
+
function makeAppender(get, set, maxChars = MAX_OUTPUT_CHARS) {
|
|
1144
|
+
const limit = Math.max(1_000, Math.min(Math.floor(maxChars) || MAX_OUTPUT_CHARS, MAX_OUTPUT_CHARS));
|
|
1145
|
+
const headLimit = Math.max(1, Math.floor(limit * 0.3));
|
|
1146
|
+
const tailLimit = Math.max(1, limit - headLimit);
|
|
1147
|
+
let head = '';
|
|
1148
|
+
let tail = '';
|
|
1149
|
+
let dropped = 0;
|
|
1150
|
+
let total = 0;
|
|
1151
|
+
const compose = () => (dropped > 0
|
|
1152
|
+
? head + '\n…[' + dropped + ' characters of output omitted]…\n' + tail
|
|
1153
|
+
: head + tail);
|
|
1154
|
+
const append = ((chunk) => {
|
|
1155
|
+
if (!chunk)
|
|
665
1156
|
return;
|
|
1157
|
+
total += chunk.length;
|
|
1158
|
+
let rest = chunk;
|
|
1159
|
+
if (head.length < headLimit) {
|
|
1160
|
+
const take = Math.min(headLimit - head.length, rest.length);
|
|
1161
|
+
head += rest.slice(0, take);
|
|
1162
|
+
rest = rest.slice(take);
|
|
666
1163
|
}
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
1164
|
+
if (rest) {
|
|
1165
|
+
tail += rest;
|
|
1166
|
+
if (tail.length > tailLimit) {
|
|
1167
|
+
const remove = tail.length - tailLimit;
|
|
1168
|
+
tail = tail.slice(remove);
|
|
1169
|
+
dropped += remove;
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
set(compose(), dropped > 0);
|
|
1173
|
+
});
|
|
1174
|
+
append.totalChars = () => total;
|
|
1175
|
+
append.droppedChars = () => dropped;
|
|
1176
|
+
// `get` is retained for call-site symmetry with the previous signature.
|
|
1177
|
+
void get;
|
|
1178
|
+
return append;
|
|
1179
|
+
}
|
|
1180
|
+
/**
|
|
1181
|
+
* Shell-dialect trap detection (issue #364). Commands are NEVER rewritten —
|
|
1182
|
+
* translating shell text between dialects is far riskier than executing what
|
|
1183
|
+
* the caller wrote — so every finding is returned as an advisory
|
|
1184
|
+
* `dialectWarnings` entry with a concrete suggested fix.
|
|
1185
|
+
*/
|
|
1186
|
+
const POSIX_ONLY_BINARIES = new Set([
|
|
1187
|
+
'head', 'tail', 'grep', 'sed', 'awk', 'wc', 'cat', 'cut', 'tr', 'uniq', 'sort', 'xargs', 'touch', 'which',
|
|
1188
|
+
'rg', 'jq', 'find', 'ls', 'pwd', 'less', 'du', 'df',
|
|
1189
|
+
]);
|
|
1190
|
+
const SHELL_BUILTINS = new Set([
|
|
1191
|
+
'cd', 'echo', 'exit', 'export', 'set', 'unset', 'if', 'then', 'else', 'fi', 'for', 'while', 'do', 'done',
|
|
1192
|
+
'true', 'false', 'test', 'type', 'alias', 'function', 'return', 'shift', 'source', '.', 'dir', 'cls',
|
|
1193
|
+
'copy', 'del', 'erase', 'md', 'mkdir', 'rd', 'rmdir', 'move', 'ren', 'rename', 'pushd', 'popd',
|
|
1194
|
+
]);
|
|
1195
|
+
function isMissingCommandCandidate(head, shell) {
|
|
1196
|
+
const name = path.basename(head).toLowerCase().replace(/\.exe$/, '');
|
|
1197
|
+
if (!name || SHELL_BUILTINS.has(name) || /^[-/$]/.test(head) || /^[A-Za-z_][\w-]*=/.test(head))
|
|
1198
|
+
return false;
|
|
1199
|
+
if (/^[A-Za-z]+-[A-Za-z][A-Za-z-]*$/.test(head) && (shell === 'pwsh' || shell === 'powershell'))
|
|
1200
|
+
return false;
|
|
1201
|
+
return !/[\\/]/.test(head) && !/^\.?\.?$/.test(head);
|
|
1202
|
+
}
|
|
1203
|
+
/** Blank out quoted spans so operators inside string literals never trip us. */
|
|
1204
|
+
function stripQuotedSegments(command) {
|
|
1205
|
+
return command.replace(/"(?:\\.|[^"\\])*"|'[^']*'/g, (match) => ' '.repeat(match.length));
|
|
1206
|
+
}
|
|
1207
|
+
function commandSegmentHeads(stripped) {
|
|
1208
|
+
return stripped
|
|
1209
|
+
.split(/&&|\|\||[|;\n]/)
|
|
1210
|
+
.map((segment) => segment.trim().split(/\s+/)[0] ?? '')
|
|
1211
|
+
.map((head) => head.replace(/^[('"`]+/, ''))
|
|
1212
|
+
.filter(Boolean);
|
|
1213
|
+
}
|
|
1214
|
+
/** True when the command relies on POSIX `&&` / `||` statement chaining. */
|
|
1215
|
+
export function commandUsesPosixChaining(command) {
|
|
1216
|
+
return /&&|\|\|/.test(stripQuotedSegments(command));
|
|
1217
|
+
}
|
|
1218
|
+
export function detectDialectMismatch(command, shell, isAvailable = (name) => Boolean(findExecutableOnPath(name))) {
|
|
1219
|
+
const warnings = [];
|
|
1220
|
+
const stripped = stripQuotedSegments(command);
|
|
1221
|
+
const heads = commandSegmentHeads(stripped);
|
|
1222
|
+
const posixShell = shell === 'bash' || shell === 'sh';
|
|
1223
|
+
if (!posixShell) {
|
|
1224
|
+
for (const head of new Set(heads)) {
|
|
1225
|
+
const name = path.basename(head).toLowerCase().replace(/\.exe$/, '');
|
|
1226
|
+
if (!isMissingCommandCandidate(head, shell) || isAvailable(name))
|
|
1227
|
+
continue;
|
|
1228
|
+
if (POSIX_ONLY_BINARIES.has(name)) {
|
|
1229
|
+
warnings.push(`"${name}" is a POSIX utility and is not available as an executable on this machine; `
|
|
1230
|
+
+ `under ${shell} it resolves to nothing (or to an unrelated alias). Pass shell:"bash" or use the native equivalent.`);
|
|
1231
|
+
}
|
|
1232
|
+
else {
|
|
1233
|
+
warnings.push(`"${name}" was not found on PATH; under ${shell} this will fail with a "command not found" error. `
|
|
1234
|
+
+ 'Install it, use the native equivalent, or call "shell_info".');
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
if (shell === 'powershell' && /&&|\|\|/.test(stripped)) {
|
|
1239
|
+
warnings.push('"&&" and "||" are not statement separators in Windows PowerShell 5.1 (they are a parse error). '
|
|
1240
|
+
+ 'Use ";" for unconditional chaining, or pass shell:"bash" / shell:"pwsh".');
|
|
1241
|
+
}
|
|
1242
|
+
if (shell === 'pwsh' || shell === 'powershell') {
|
|
1243
|
+
if (/\$\(\s*([A-Za-z_][\w-]*)/.test(stripped)) {
|
|
1244
|
+
const inner = /\$\(\s*([A-Za-z_][\w-]*)/.exec(stripped)?.[1] ?? '';
|
|
1245
|
+
if (POSIX_ONLY_BINARIES.has(inner.toLowerCase()) && !isAvailable(inner.toLowerCase())) {
|
|
1246
|
+
warnings.push(`"$(${inner} …)" is POSIX command substitution; PowerShell evaluates it as a subexpression `
|
|
1247
|
+
+ 'and cannot run that utility. Pass shell:"bash".');
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
if ((stripped.match(/`/g) ?? []).length >= 2) {
|
|
1251
|
+
warnings.push('Backticks are the line-continuation/escape character in PowerShell, not command substitution. '
|
|
1252
|
+
+ 'Use "$(…)" for PowerShell, or pass shell:"bash".');
|
|
1253
|
+
}
|
|
1254
|
+
if (/2>&1/.test(stripped)) {
|
|
1255
|
+
warnings.push('No "2>&1" redirection is needed: this tool already merges stdout and stderr into one "output" field, '
|
|
1256
|
+
+ 'and "isError" is derived from the exit code only.');
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
if (shell === 'cmd') {
|
|
1260
|
+
if (/'/.test(command.replace(/"(?:\\.|[^"\\])*"/g, ''))) {
|
|
1261
|
+
warnings.push('cmd.exe does not treat single quotes as string delimiters; use double quotes.');
|
|
1262
|
+
}
|
|
1263
|
+
if (/\$\(|`/.test(stripped)) {
|
|
1264
|
+
warnings.push('Command substitution ($(…) / backticks) is not supported by cmd.exe. Pass shell:"bash" or shell:"pwsh".');
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
if (posixShell && /\b(?:Get|Set|New|Remove|Write|Select|Out|Format|Test|Invoke|Start|Stop)-[A-Z][A-Za-z]+\b/.test(stripped)) {
|
|
1268
|
+
warnings.push('This looks like a PowerShell cmdlet but the command is running under a POSIX shell. Pass shell:"pwsh".');
|
|
1269
|
+
}
|
|
1270
|
+
return warnings;
|
|
1271
|
+
}
|
|
1272
|
+
function missingExecutableHint(exitCode, warnings) {
|
|
1273
|
+
if ((exitCode === 9009 || exitCode === 255 || exitCode === 1)
|
|
1274
|
+
&& warnings.some((warning) => /not found on PATH|not available as an executable/.test(warning))) {
|
|
1275
|
+
return { hint: 'A referenced executable was not found — see dialectWarnings.' };
|
|
1276
|
+
}
|
|
1277
|
+
return {};
|
|
1278
|
+
}
|
|
1279
|
+
/** Commands that typically hang forever waiting on a pager or interactive prompt. */
|
|
1280
|
+
export function detectInteractiveHangRisk(command) {
|
|
1281
|
+
const hints = [];
|
|
1282
|
+
const stripped = stripQuotedSegments(command);
|
|
1283
|
+
if (/\bgit\b(?![^|;]*--no-pager)[^|;]*\b(?:log|diff|show|branch)\b/.test(stripped)) {
|
|
1284
|
+
hints.push('git paginates by default; use "git --no-pager …" (or append "| cat") for non-interactive runs.');
|
|
1285
|
+
}
|
|
1286
|
+
if (/(^|[|;\s])(?:less|more)(\s|$)/.test(stripped)) {
|
|
1287
|
+
hints.push('"less"/"more" are interactive pagers and will never exit here; drop them.');
|
|
1288
|
+
}
|
|
1289
|
+
if (/Format-(?:Table|List)\b/.test(stripped) && !/Out-String/.test(stripped)) {
|
|
1290
|
+
hints.push('Pipe Format-Table/Format-List into "| Out-String" so PowerShell does not wait on the host formatter.');
|
|
1291
|
+
}
|
|
1292
|
+
if (/(^|[|;\s])(?:npm|yarn|pnpm)\s+(?:init|login)(\s|$)/.test(stripped)) {
|
|
1293
|
+
hints.push('This command prompts interactively; add its non-interactive flag (e.g. "npm init -y").');
|
|
1294
|
+
}
|
|
1295
|
+
return hints;
|
|
1296
|
+
}
|
|
1297
|
+
function validateEncoding(input) {
|
|
1298
|
+
if (input === undefined)
|
|
1299
|
+
return 'auto';
|
|
1300
|
+
if (input === 'utf8' || input === 'utf16le' || input === 'auto')
|
|
1301
|
+
return input;
|
|
1302
|
+
return null;
|
|
1303
|
+
}
|
|
1304
|
+
function looksLikeUtf16le(buf) {
|
|
1305
|
+
const len = Math.min(buf.length, 64);
|
|
1306
|
+
if (len < 4)
|
|
1307
|
+
return false;
|
|
1308
|
+
let nuls = 0;
|
|
1309
|
+
for (let i = 0; i < len; i += 1)
|
|
1310
|
+
if (buf[i] === 0)
|
|
1311
|
+
nuls += 1;
|
|
1312
|
+
return nuls / len > 0.3;
|
|
1313
|
+
}
|
|
1314
|
+
/**
|
|
1315
|
+
* Decode child output deterministically (issue #364). Windows tooling happily
|
|
1316
|
+
* writes UTF-16LE (e.g. PowerShell redirection into a log) which the default
|
|
1317
|
+
* UTF-8 decode turns into NUL-riddled mojibake. `auto` sniffs a BOM or a high
|
|
1318
|
+
* NUL ratio in the first chunk and then stays with that choice.
|
|
1319
|
+
*/
|
|
1320
|
+
export function createStreamDecoder(mode = 'auto') {
|
|
1321
|
+
let resolved = mode === 'auto' ? null : mode;
|
|
1322
|
+
let first = true;
|
|
1323
|
+
return (chunk) => {
|
|
1324
|
+
if (typeof chunk === 'string')
|
|
1325
|
+
return chunk;
|
|
1326
|
+
let buf = chunk;
|
|
1327
|
+
if (first) {
|
|
1328
|
+
if (buf.length >= 2 && buf[0] === 0xff && buf[1] === 0xfe) {
|
|
1329
|
+
if (resolved === null)
|
|
1330
|
+
resolved = 'utf16le';
|
|
1331
|
+
if (resolved === 'utf16le')
|
|
1332
|
+
buf = buf.subarray(2);
|
|
1333
|
+
}
|
|
1334
|
+
else if (resolved === null) {
|
|
1335
|
+
resolved = looksLikeUtf16le(buf) ? 'utf16le' : 'utf8';
|
|
1336
|
+
}
|
|
1337
|
+
first = false;
|
|
672
1338
|
}
|
|
673
|
-
|
|
1339
|
+
let text = buf.toString(resolved ?? 'utf8');
|
|
1340
|
+
if (text.charCodeAt(0) === 0xfeff)
|
|
1341
|
+
text = text.slice(1);
|
|
1342
|
+
return text;
|
|
674
1343
|
};
|
|
675
1344
|
}
|
|
676
1345
|
/** Resolve an interactive shell executable without wrapping a command string. */
|
|
@@ -723,6 +1392,9 @@ function createCommandProgressReporter(context) {
|
|
|
723
1392
|
return { push: () => undefined, stop: async () => undefined };
|
|
724
1393
|
const startedAt = Date.now();
|
|
725
1394
|
const maxMessageChars = 4_000;
|
|
1395
|
+
// MCP requires `progress` to increase with every notification. It is a
|
|
1396
|
+
// notification sequence here (not a byte count), because silent-command
|
|
1397
|
+
// heartbeats must advance it too.
|
|
726
1398
|
let progress = 0;
|
|
727
1399
|
let pending = '';
|
|
728
1400
|
let flushTimer;
|
|
@@ -733,7 +1405,7 @@ function createCommandProgressReporter(context) {
|
|
|
733
1405
|
const deliver = (message) => {
|
|
734
1406
|
if (!message)
|
|
735
1407
|
return;
|
|
736
|
-
const snapshot = progress;
|
|
1408
|
+
const snapshot = ++progress;
|
|
737
1409
|
chain = chain
|
|
738
1410
|
.then(() => report({ progress: snapshot, message }))
|
|
739
1411
|
.catch((error) => {
|
|
@@ -763,7 +1435,6 @@ function createCommandProgressReporter(context) {
|
|
|
763
1435
|
push: (chunk) => {
|
|
764
1436
|
if (stopped || !chunk)
|
|
765
1437
|
return;
|
|
766
|
-
progress += Buffer.byteLength(chunk, 'utf8');
|
|
767
1438
|
const remaining = Math.max(0, MAX_OUTPUT_CHARS - forwardedChars);
|
|
768
1439
|
if (remaining > 0) {
|
|
769
1440
|
const accepted = chunk.slice(0, remaining);
|
|
@@ -817,7 +1488,25 @@ export function bashToolDefinitions() {
|
|
|
817
1488
|
additionalProperties: { type: 'string' },
|
|
818
1489
|
description: 'Environment variables to add or override for this command.',
|
|
819
1490
|
};
|
|
1491
|
+
const encodingProp = {
|
|
1492
|
+
type: 'string',
|
|
1493
|
+
enum: ['auto', 'utf8', 'utf16le'],
|
|
1494
|
+
description: 'How to decode child output. "auto" (default) detects UTF-16LE (BOM or NUL-heavy) and otherwise uses UTF-8.',
|
|
1495
|
+
};
|
|
1496
|
+
const maxOutputCharsProp = {
|
|
1497
|
+
type: 'number',
|
|
1498
|
+
description: 'Cap the returned output (default and hard maximum 100000 characters). When exceeded, the head and tail are kept and the omitted middle is reported.',
|
|
1499
|
+
};
|
|
1500
|
+
const outputFileProp = {
|
|
1501
|
+
type: 'string',
|
|
1502
|
+
description: 'Spool the full merged output to this file (confined to the configured roots) while the result stays bounded.',
|
|
1503
|
+
};
|
|
820
1504
|
return [
|
|
1505
|
+
{
|
|
1506
|
+
name: 'shell_info',
|
|
1507
|
+
description: 'Describe this machine before running anything: platform, which shell "default" resolves to, which of pwsh/powershell/bash/cmd/sh are available, and whether common interpreters (python, node, git, ffmpeg, …) are on PATH. Call this instead of discovering the environment through failed commands.',
|
|
1508
|
+
inputSchema: { type: 'object', properties: {} },
|
|
1509
|
+
},
|
|
821
1510
|
{
|
|
822
1511
|
name: 'open_terminal',
|
|
823
1512
|
description: 'Open a real interactive pseudoterminal (PTY/ConPTY) and display its MCP App. Returns an owner-scoped terminal sessionId.',
|
|
@@ -830,6 +1519,10 @@ export function bashToolDefinitions() {
|
|
|
830
1519
|
env: envProp,
|
|
831
1520
|
cols: { type: 'number', description: 'Initial terminal columns (20-400, default 100).' },
|
|
832
1521
|
rows: { type: 'number', description: 'Initial terminal rows (5-200, default 30).' },
|
|
1522
|
+
detached: {
|
|
1523
|
+
type: 'boolean',
|
|
1524
|
+
description: 'Keep the terminal alive after the run that opened it finishes (default false). Still bounded by the host session cap and the absolute max lifetime.',
|
|
1525
|
+
},
|
|
833
1526
|
},
|
|
834
1527
|
},
|
|
835
1528
|
},
|
|
@@ -884,7 +1577,9 @@ export function bashToolDefinitions() {
|
|
|
884
1577
|
},
|
|
885
1578
|
{
|
|
886
1579
|
name: 'run',
|
|
887
|
-
description: 'Run one command to completion.
|
|
1580
|
+
description: 'Run one command to completion. "output" is stdout and stderr MERGED (no 2>&1 needed) and "isError" reflects the process exit code only — text on stderr is not a failure. '
|
|
1581
|
+
+ 'On an unknown machine call "shell_info" first; prefer shell:"bash" for POSIX syntax (&&, pipes into head/grep). Output is UTF-8 decoded and PowerShell runs with invariant culture, so numbers and text are stable. '
|
|
1582
|
+
+ 'Use start/status for a persistent background session.',
|
|
888
1583
|
inputSchema: {
|
|
889
1584
|
type: 'object',
|
|
890
1585
|
properties: {
|
|
@@ -892,15 +1587,22 @@ export function bashToolDefinitions() {
|
|
|
892
1587
|
cwd: cwdProp,
|
|
893
1588
|
shell: shellProp,
|
|
894
1589
|
env: envProp,
|
|
895
|
-
timeout: {
|
|
1590
|
+
timeout: {
|
|
1591
|
+
type: 'number',
|
|
1592
|
+
description: 'Bash-side timeout in seconds (default 60; -1 disables it; positive values are capped at 12 hours by default). '
|
|
1593
|
+
+ 'While running, output and 10-second liveness heartbeats are sent as MCP progress. On timeout the whole process tree is killed.',
|
|
1594
|
+
},
|
|
896
1595
|
normalizeNewlines: { type: 'boolean', description: 'If true, CRLF/CR in the captured output are normalized to LF.' },
|
|
1596
|
+
encoding: encodingProp,
|
|
1597
|
+
maxOutputChars: maxOutputCharsProp,
|
|
1598
|
+
outputFile: outputFileProp,
|
|
897
1599
|
},
|
|
898
1600
|
required: ['command'],
|
|
899
1601
|
},
|
|
900
1602
|
},
|
|
901
1603
|
{
|
|
902
1604
|
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.',
|
|
1605
|
+
description: 'Start an independent background command and return its sessionId. Output is stdout and stderr merged, decoded as UTF-8. Multiple sessions may run in parallel; use status/wait, write_stdin, or kill.',
|
|
904
1606
|
inputSchema: {
|
|
905
1607
|
type: 'object',
|
|
906
1608
|
properties: {
|
|
@@ -908,6 +1610,13 @@ export function bashToolDefinitions() {
|
|
|
908
1610
|
cwd: cwdProp,
|
|
909
1611
|
shell: shellProp,
|
|
910
1612
|
env: envProp,
|
|
1613
|
+
encoding: encodingProp,
|
|
1614
|
+
maxOutputChars: maxOutputCharsProp,
|
|
1615
|
+
outputFile: outputFileProp,
|
|
1616
|
+
detached: {
|
|
1617
|
+
type: 'boolean',
|
|
1618
|
+
description: 'Survive the end of the run that started it (default false). Detached sessions are still bounded by the host session cap and the absolute max lifetime, and are always killed when the bash server shuts down.',
|
|
1619
|
+
},
|
|
911
1620
|
},
|
|
912
1621
|
required: ['command'],
|
|
913
1622
|
},
|
|
@@ -928,7 +1637,10 @@ export function bashToolDefinitions() {
|
|
|
928
1637
|
type: 'object',
|
|
929
1638
|
properties: {
|
|
930
1639
|
sessionId: { type: 'string', description: 'The id returned by start.' },
|
|
931
|
-
timeout: {
|
|
1640
|
+
timeout: {
|
|
1641
|
+
type: 'number',
|
|
1642
|
+
description: 'Max seconds to wait (default 60; -1 waits until completion or cancellation; positive values are capped at 12 hours by default).',
|
|
1643
|
+
},
|
|
932
1644
|
},
|
|
933
1645
|
required: ['sessionId'],
|
|
934
1646
|
},
|
|
@@ -957,7 +1669,12 @@ export function bashToolDefinitions() {
|
|
|
957
1669
|
},
|
|
958
1670
|
{
|
|
959
1671
|
name: 'list_sessions',
|
|
960
|
-
description: 'List background sessions owned by this caller scope. Returns { sessions: [{ sessionId, command, running, exitCode, startedAt, endedAt }] }.',
|
|
1672
|
+
description: 'List background sessions owned by this caller scope. Returns { sessions: [{ sessionId, command, running, exitCode, detached, startedAt, endedAt }] }.',
|
|
1673
|
+
inputSchema: { type: 'object', properties: {} },
|
|
1674
|
+
},
|
|
1675
|
+
{
|
|
1676
|
+
name: 'release_owner',
|
|
1677
|
+
description: 'Kill and forget every non-detached background and terminal session owned by this caller scope. Idempotent. Sessions started with "detached": true are retained. FLUJO calls this when a run finishes; the scope is host-derived, so a caller can only release its own sessions. Returns { ownerScope, killedSessions, closedTerminals, retainedDetached }.',
|
|
961
1678
|
inputSchema: { type: 'object', properties: {} },
|
|
962
1679
|
},
|
|
963
1680
|
];
|
|
@@ -965,6 +1682,68 @@ export function bashToolDefinitions() {
|
|
|
965
1682
|
function maybeNormalize(text, normalize) {
|
|
966
1683
|
return normalize ? text.replace(/\r\n?/g, '\n') : text;
|
|
967
1684
|
}
|
|
1685
|
+
/**
|
|
1686
|
+
* Only when the caller said `shell: "default"` may we pick a different shell
|
|
1687
|
+
* (issue #364): a command chained with POSIX `&&`/`||` cannot even parse under
|
|
1688
|
+
* Windows PowerShell 5.1, so prefer a shell that can run it. An explicitly
|
|
1689
|
+
* requested shell is never overridden.
|
|
1690
|
+
*/
|
|
1691
|
+
function selectShell(command, requested) {
|
|
1692
|
+
if (requested !== 'default')
|
|
1693
|
+
return { shell: requested, autoSelected: false };
|
|
1694
|
+
const plan = buildSpawn(command, 'default');
|
|
1695
|
+
if (plan.effectiveShell === 'powershell' && commandUsesPosixChaining(command)) {
|
|
1696
|
+
if (resolvePwshExecutable())
|
|
1697
|
+
return { shell: 'pwsh', autoSelected: true };
|
|
1698
|
+
if (resolveBashExecutable())
|
|
1699
|
+
return { shell: 'bash', autoSelected: true };
|
|
1700
|
+
}
|
|
1701
|
+
return { shell: 'default', autoSelected: false };
|
|
1702
|
+
}
|
|
1703
|
+
/** Optional per-call output limits shared by `run` and `start`. */
|
|
1704
|
+
function resolveMaxOutputChars(input) {
|
|
1705
|
+
return typeof input === 'number' && input > 0
|
|
1706
|
+
? Math.min(Math.floor(input), MAX_OUTPUT_CHARS)
|
|
1707
|
+
: MAX_OUTPUT_CHARS;
|
|
1708
|
+
}
|
|
1709
|
+
/** Spool the full merged output to a file while the tool result stays bounded. */
|
|
1710
|
+
function createOutputSpool(file) {
|
|
1711
|
+
if (!file)
|
|
1712
|
+
return { write: () => undefined, close: () => undefined, bytes: () => 0, error: () => undefined };
|
|
1713
|
+
let bytes = 0;
|
|
1714
|
+
let failure;
|
|
1715
|
+
let stream;
|
|
1716
|
+
try {
|
|
1717
|
+
stream = fs.createWriteStream(file, { encoding: 'utf8' });
|
|
1718
|
+
stream.on('error', (err) => { failure = err instanceof Error ? err.message : String(err); });
|
|
1719
|
+
}
|
|
1720
|
+
catch (err) {
|
|
1721
|
+
failure = err instanceof Error ? err.message : String(err);
|
|
1722
|
+
}
|
|
1723
|
+
return {
|
|
1724
|
+
write: (chunk) => {
|
|
1725
|
+
if (!stream || failure || !chunk)
|
|
1726
|
+
return;
|
|
1727
|
+
try {
|
|
1728
|
+
stream.write(chunk);
|
|
1729
|
+
bytes += Buffer.byteLength(chunk, 'utf8');
|
|
1730
|
+
}
|
|
1731
|
+
catch (err) {
|
|
1732
|
+
failure = err instanceof Error ? err.message : String(err);
|
|
1733
|
+
}
|
|
1734
|
+
},
|
|
1735
|
+
close: () => {
|
|
1736
|
+
try {
|
|
1737
|
+
stream?.end();
|
|
1738
|
+
}
|
|
1739
|
+
catch {
|
|
1740
|
+
/* best-effort */
|
|
1741
|
+
}
|
|
1742
|
+
},
|
|
1743
|
+
bytes: () => bytes,
|
|
1744
|
+
error: () => failure,
|
|
1745
|
+
};
|
|
1746
|
+
}
|
|
968
1747
|
async function runTool(args, roots, context) {
|
|
969
1748
|
const command = String(args?.command ?? '').trim();
|
|
970
1749
|
if (!command)
|
|
@@ -979,27 +1758,68 @@ async function runTool(args, roots, context) {
|
|
|
979
1758
|
if (context?.signal?.aborted) {
|
|
980
1759
|
return textResult({ error: 'Command cancelled before it started.', cancelled: true, requestedShell }, true);
|
|
981
1760
|
}
|
|
1761
|
+
const encodingMode = validateEncoding(args.encoding);
|
|
1762
|
+
if (!encodingMode) {
|
|
1763
|
+
return textResult({ error: '"encoding" must be one of "utf8", "utf16le", or "auto".' }, true);
|
|
1764
|
+
}
|
|
982
1765
|
const cwd = await resolveCwd(args.cwd, roots);
|
|
983
1766
|
const warnings = await scanCommandForExternalPaths(command, cwd, roots);
|
|
984
1767
|
const warn = warnings.length ? { warnings } : {};
|
|
985
1768
|
const normalize = args.normalizeNewlines === true;
|
|
986
|
-
const
|
|
987
|
-
const
|
|
1769
|
+
const timeoutMs = resolveCommandTimeoutMs(args.timeout);
|
|
1770
|
+
const maxOutputChars = resolveMaxOutputChars(args.maxOutputChars);
|
|
1771
|
+
let outputFilePath;
|
|
1772
|
+
if (args.outputFile !== undefined) {
|
|
1773
|
+
try {
|
|
1774
|
+
outputFilePath = await resolveOutputFile(args.outputFile, cwd, roots);
|
|
1775
|
+
}
|
|
1776
|
+
catch (err) {
|
|
1777
|
+
return textResult({ error: err instanceof Error ? err.message : String(err), cwd }, true);
|
|
1778
|
+
}
|
|
1779
|
+
}
|
|
1780
|
+
const selection = selectShell(command, requestedShell);
|
|
988
1781
|
registerExitCleanup();
|
|
1782
|
+
const startedAt = Date.now();
|
|
989
1783
|
return await new Promise((resolve) => {
|
|
990
1784
|
let output = '';
|
|
991
1785
|
let truncated = false;
|
|
992
1786
|
let settled = false;
|
|
993
|
-
|
|
1787
|
+
let stderrChars = 0;
|
|
1788
|
+
const append = makeAppender(() => output, (v, t) => { output = v; truncated = t || truncated; }, maxOutputChars);
|
|
1789
|
+
const spool = createOutputSpool(outputFilePath);
|
|
1790
|
+
const decodeStdout = createStreamDecoder(encodingMode);
|
|
1791
|
+
const decodeStderr = createStreamDecoder(encodingMode);
|
|
994
1792
|
const progress = createCommandProgressReporter(context);
|
|
995
|
-
const { child, startError, effectiveShell, unavailableShell } = startChild(command, cwd,
|
|
1793
|
+
const { child, startError, effectiveShell, unavailableShell, shellSubstitution } = startChild(command, cwd, selection.shell, envValidation.env);
|
|
1794
|
+
const dialectWarnings = detectDialectMismatch(command, effectiveShell);
|
|
1795
|
+
const dialect = dialectWarnings.length ? { dialectWarnings } : {};
|
|
1796
|
+
const auto = selection.autoSelected ? { shellAutoSelected: true } : {};
|
|
1797
|
+
const substitution = shellSubstitution ? { shellSubstitution } : {};
|
|
1798
|
+
const spoolInfo = () => (outputFilePath
|
|
1799
|
+
? { outputFile: outputFilePath, outputBytes: spool.bytes(), ...(spool.error() ? { outputFileError: spool.error() } : {}) }
|
|
1800
|
+
: {});
|
|
1801
|
+
const outputStats = () => ({
|
|
1802
|
+
truncated,
|
|
1803
|
+
stderrChars,
|
|
1804
|
+
outputChars: append.totalChars(),
|
|
1805
|
+
...(append.droppedChars() > 0 ? { omittedChars: append.droppedChars() } : {}),
|
|
1806
|
+
});
|
|
996
1807
|
if (unavailableShell) {
|
|
1808
|
+
spool.close();
|
|
997
1809
|
void progress.stop().then(() => {
|
|
998
|
-
resolve(textResult({
|
|
1810
|
+
resolve(textResult({
|
|
1811
|
+
error: `Requested shell "${unavailableShell}" is unavailable or could not be resolved.`,
|
|
1812
|
+
cwd,
|
|
1813
|
+
requestedShell,
|
|
1814
|
+
shell: unavailableShell,
|
|
1815
|
+
availableShells: availableShellNames(),
|
|
1816
|
+
hint: unavailableShellHint(unavailableShell),
|
|
1817
|
+
}, true));
|
|
999
1818
|
});
|
|
1000
1819
|
return;
|
|
1001
1820
|
}
|
|
1002
1821
|
if (startError || !child) {
|
|
1822
|
+
spool.close();
|
|
1003
1823
|
void progress.stop().then(() => {
|
|
1004
1824
|
resolve(textResult({ error: `Failed to start command (${effectiveShell}): ${startError ?? 'unknown error'}`, cwd, shell: effectiveShell }, true));
|
|
1005
1825
|
});
|
|
@@ -1018,8 +1838,12 @@ async function runTool(args, roots, context) {
|
|
|
1018
1838
|
requestedShell,
|
|
1019
1839
|
shell: effectiveShell,
|
|
1020
1840
|
exitCode: null,
|
|
1021
|
-
truncated,
|
|
1022
1841
|
output: finalOut,
|
|
1842
|
+
...outputStats(),
|
|
1843
|
+
...spoolInfo(),
|
|
1844
|
+
...dialect,
|
|
1845
|
+
...substitution,
|
|
1846
|
+
...auto,
|
|
1023
1847
|
...warn,
|
|
1024
1848
|
}, true), true);
|
|
1025
1849
|
};
|
|
@@ -1032,31 +1856,48 @@ async function runTool(args, roots, context) {
|
|
|
1032
1856
|
context?.signal?.removeEventListener('abort', onAbort);
|
|
1033
1857
|
if (!keepKillEscalation)
|
|
1034
1858
|
cancelEscalation?.();
|
|
1859
|
+
spool.close();
|
|
1035
1860
|
await progress.stop();
|
|
1036
1861
|
resolve(result);
|
|
1037
1862
|
};
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1863
|
+
if (timeoutMs !== undefined) {
|
|
1864
|
+
timer = setTimeout(() => {
|
|
1865
|
+
cancelEscalation = killProcessTree(child);
|
|
1866
|
+
const finalOut = maybeNormalize(output, normalize);
|
|
1867
|
+
const hangHints = detectInteractiveHangRisk(command);
|
|
1868
|
+
void finish(textResult({
|
|
1869
|
+
timedOut: true,
|
|
1870
|
+
cwd,
|
|
1871
|
+
requestedShell,
|
|
1872
|
+
shell: effectiveShell,
|
|
1873
|
+
exitCode: null,
|
|
1874
|
+
timeoutMs,
|
|
1875
|
+
elapsedMs: Date.now() - startedAt,
|
|
1876
|
+
killedProcessTree: true,
|
|
1877
|
+
suggestion: `Command exceeded ${timeoutMs / 1000}s and its process tree was killed. `
|
|
1878
|
+
+ `Raise "timeout" (positive values cap at ${commandMaxTimeoutMs() / 1000}s), pass -1, or use "start" + "wait" for a persistent job.`,
|
|
1879
|
+
output: `${finalOut}${finalOut ? '\n' : ''}[killed after ${timeoutMs / 1000}s timeout]`,
|
|
1880
|
+
...outputStats(),
|
|
1881
|
+
...spoolInfo(),
|
|
1882
|
+
...(hangHints.length ? { hangHints } : {}),
|
|
1883
|
+
...dialect,
|
|
1884
|
+
...substitution,
|
|
1885
|
+
...auto,
|
|
1886
|
+
...warn,
|
|
1887
|
+
}, true), true);
|
|
1888
|
+
}, timeoutMs);
|
|
1889
|
+
}
|
|
1052
1890
|
child.stdout?.on('data', (d) => {
|
|
1053
|
-
const chunk = d
|
|
1891
|
+
const chunk = decodeStdout(d);
|
|
1054
1892
|
append(chunk);
|
|
1893
|
+
spool.write(chunk);
|
|
1055
1894
|
progress.push(maybeNormalize(chunk, normalize));
|
|
1056
1895
|
});
|
|
1057
1896
|
child.stderr?.on('data', (d) => {
|
|
1058
|
-
const chunk = d
|
|
1897
|
+
const chunk = decodeStderr(d);
|
|
1898
|
+
stderrChars += chunk.length;
|
|
1059
1899
|
append(chunk);
|
|
1900
|
+
spool.write(chunk);
|
|
1060
1901
|
progress.push(maybeNormalize(chunk, normalize));
|
|
1061
1902
|
});
|
|
1062
1903
|
child.on('error', (err) => {
|
|
@@ -1071,7 +1912,20 @@ async function runTool(args, roots, context) {
|
|
|
1071
1912
|
if (settled)
|
|
1072
1913
|
return;
|
|
1073
1914
|
const finalOut = maybeNormalize(output, normalize);
|
|
1074
|
-
void finish(textResult({
|
|
1915
|
+
void finish(textResult({
|
|
1916
|
+
exitCode: code,
|
|
1917
|
+
cwd,
|
|
1918
|
+
requestedShell,
|
|
1919
|
+
shell: effectiveShell,
|
|
1920
|
+
output: finalOut,
|
|
1921
|
+
...outputStats(),
|
|
1922
|
+
...spoolInfo(),
|
|
1923
|
+
...dialect,
|
|
1924
|
+
...missingExecutableHint(code, dialectWarnings),
|
|
1925
|
+
...substitution,
|
|
1926
|
+
...auto,
|
|
1927
|
+
...warn,
|
|
1928
|
+
}, code !== 0));
|
|
1075
1929
|
});
|
|
1076
1930
|
if (context?.signal?.aborted)
|
|
1077
1931
|
onAbort();
|
|
@@ -1112,13 +1966,52 @@ async function startTool(args, roots, ownerScope, context) {
|
|
|
1112
1966
|
if (table.size >= MAX_SESSIONS) {
|
|
1113
1967
|
return textResult({ error: `Too many active background sessions (max ${MAX_SESSIONS}). Kill some first.` }, true);
|
|
1114
1968
|
}
|
|
1969
|
+
// Issue #413: the HOST-wide live cap spans background and terminal sessions.
|
|
1970
|
+
// A per-owner limit alone let N concurrent runs each stay legal while together
|
|
1971
|
+
// forking an unbounded number of live process trees on one machine. Expire
|
|
1972
|
+
// stale sessions first so the cap is measured against genuinely live work.
|
|
1973
|
+
expireStaleSessions();
|
|
1974
|
+
const hostLimit = maxLiveSessionsHost();
|
|
1975
|
+
if (liveSessionCount() >= hostLimit) {
|
|
1976
|
+
return textResult({
|
|
1977
|
+
error: `Too many live sessions on this host (max ${hostLimit} across background and terminal sessions). Wait for one to finish or kill one first.`,
|
|
1978
|
+
liveSessions: liveSessionCount(),
|
|
1979
|
+
}, true);
|
|
1980
|
+
}
|
|
1981
|
+
if (ownerLiveSessionCount(ownerScope) >= MAX_SESSIONS) {
|
|
1982
|
+
return textResult({ error: `Too many live sessions for this caller (max ${MAX_SESSIONS}). Kill some first.` }, true);
|
|
1983
|
+
}
|
|
1984
|
+
// Surviving the owning run must be an explicit choice (see releaseOwnerScope).
|
|
1985
|
+
const detached = args.detached === true || args.persistAfterRun === true;
|
|
1115
1986
|
registerExitCleanup();
|
|
1987
|
+
const encodingMode = validateEncoding(args.encoding);
|
|
1988
|
+
if (!encodingMode) {
|
|
1989
|
+
return textResult({ error: '"encoding" must be one of "utf8", "utf16le", or "auto".' }, true);
|
|
1990
|
+
}
|
|
1116
1991
|
const cwd = await resolveCwd(args.cwd, roots);
|
|
1117
1992
|
const warnings = await scanCommandForExternalPaths(command, cwd, roots);
|
|
1118
1993
|
const warn = warnings.length ? { warnings } : {};
|
|
1119
|
-
const
|
|
1994
|
+
const maxOutputChars = resolveMaxOutputChars(args.maxOutputChars);
|
|
1995
|
+
let outputFilePath;
|
|
1996
|
+
if (args.outputFile !== undefined) {
|
|
1997
|
+
try {
|
|
1998
|
+
outputFilePath = await resolveOutputFile(args.outputFile, cwd, roots);
|
|
1999
|
+
}
|
|
2000
|
+
catch (err) {
|
|
2001
|
+
return textResult({ error: err instanceof Error ? err.message : String(err), cwd }, true);
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
const selection = selectShell(command, requestedShell);
|
|
2005
|
+
const { child, startError, effectiveShell, unavailableShell, shellSubstitution } = startChild(command, cwd, selection.shell, envValidation.env);
|
|
1120
2006
|
if (unavailableShell) {
|
|
1121
|
-
return textResult({
|
|
2007
|
+
return textResult({
|
|
2008
|
+
error: `Requested shell "${unavailableShell}" is unavailable or could not be resolved.`,
|
|
2009
|
+
cwd,
|
|
2010
|
+
requestedShell,
|
|
2011
|
+
shell: unavailableShell,
|
|
2012
|
+
availableShells: availableShellNames(),
|
|
2013
|
+
hint: unavailableShellHint(unavailableShell),
|
|
2014
|
+
}, true);
|
|
1122
2015
|
}
|
|
1123
2016
|
if (startError || !child) {
|
|
1124
2017
|
return textResult({ error: `Failed to start command (${effectiveShell}): ${startError ?? 'unknown error'}`, cwd, shell: effectiveShell }, true);
|
|
@@ -1135,11 +2028,27 @@ async function startTool(args, roots, ownerScope, context) {
|
|
|
1135
2028
|
running: true,
|
|
1136
2029
|
exitCode: null,
|
|
1137
2030
|
startedAt: Date.now(),
|
|
2031
|
+
lastActivityAt: Date.now(),
|
|
2032
|
+
...(detached ? { detached: true } : {}),
|
|
1138
2033
|
};
|
|
1139
2034
|
table.set(id, session);
|
|
1140
|
-
const append = makeAppender(() => session.output, (v, t) => { session.output = v; session.truncated = t || session.truncated; });
|
|
1141
|
-
|
|
1142
|
-
|
|
2035
|
+
const append = makeAppender(() => session.output, (v, t) => { session.output = v; session.truncated = t || session.truncated; }, maxOutputChars);
|
|
2036
|
+
const spool = createOutputSpool(outputFilePath);
|
|
2037
|
+
const decodeStdout = createStreamDecoder(encodingMode);
|
|
2038
|
+
const decodeStderr = createStreamDecoder(encodingMode);
|
|
2039
|
+
child.stdout?.on('data', (d) => {
|
|
2040
|
+
const chunk = decodeStdout(d);
|
|
2041
|
+
touchSession(session);
|
|
2042
|
+
append(chunk);
|
|
2043
|
+
spool.write(chunk);
|
|
2044
|
+
});
|
|
2045
|
+
child.stderr?.on('data', (d) => {
|
|
2046
|
+
const chunk = decodeStderr(d);
|
|
2047
|
+
session.stderrChars = (session.stderrChars ?? 0) + chunk.length;
|
|
2048
|
+
touchSession(session);
|
|
2049
|
+
append(chunk);
|
|
2050
|
+
spool.write(chunk);
|
|
2051
|
+
});
|
|
1143
2052
|
child.on('error', (err) => {
|
|
1144
2053
|
append(`\n${err.message}`);
|
|
1145
2054
|
session.running = false;
|
|
@@ -1150,9 +2059,23 @@ async function startTool(args, roots, ownerScope, context) {
|
|
|
1150
2059
|
session.running = false;
|
|
1151
2060
|
session.exitCode = code;
|
|
1152
2061
|
session.endedAt = Date.now();
|
|
2062
|
+
spool.close();
|
|
1153
2063
|
scheduleReap(session);
|
|
1154
2064
|
});
|
|
1155
|
-
|
|
2065
|
+
const dialectWarnings = detectDialectMismatch(command, effectiveShell);
|
|
2066
|
+
session.missingExecutableWarning = dialectWarnings.some((warning) => /not found on PATH|not available as an executable/.test(warning));
|
|
2067
|
+
return textResult({
|
|
2068
|
+
sessionId: id,
|
|
2069
|
+
cwd,
|
|
2070
|
+
requestedShell,
|
|
2071
|
+
shell: effectiveShell,
|
|
2072
|
+
...(detached ? { detached: true } : {}),
|
|
2073
|
+
...(shellSubstitution ? { shellSubstitution } : {}),
|
|
2074
|
+
...(selection.autoSelected ? { shellAutoSelected: true } : {}),
|
|
2075
|
+
...(dialectWarnings.length ? { dialectWarnings } : {}),
|
|
2076
|
+
...(outputFilePath ? { outputFile: outputFilePath } : {}),
|
|
2077
|
+
...warn,
|
|
2078
|
+
});
|
|
1156
2079
|
}
|
|
1157
2080
|
function snapshot(session, extra = {}) {
|
|
1158
2081
|
return {
|
|
@@ -1161,6 +2084,9 @@ function snapshot(session, extra = {}) {
|
|
|
1161
2084
|
exitCode: session.exitCode,
|
|
1162
2085
|
output: session.output,
|
|
1163
2086
|
truncated: session.truncated,
|
|
2087
|
+
stderrChars: session.stderrChars ?? 0,
|
|
2088
|
+
...(session.missingExecutableWarning && (session.exitCode === 9009 || session.exitCode === 255 || session.exitCode === 1)
|
|
2089
|
+
? { hint: 'A referenced executable was not found — see dialectWarnings.' } : {}),
|
|
1164
2090
|
...extra,
|
|
1165
2091
|
};
|
|
1166
2092
|
}
|
|
@@ -1181,8 +2107,7 @@ async function waitTool(args, ownerScope, context) {
|
|
|
1181
2107
|
if (context?.signal?.aborted) {
|
|
1182
2108
|
return textResult(snapshot(session, { timedOut: false, cancelled: true }), true);
|
|
1183
2109
|
}
|
|
1184
|
-
const
|
|
1185
|
-
const timeoutMs = Math.min(timeoutSec * 1000, MAX_TIMEOUT_MS);
|
|
2110
|
+
const timeoutMs = resolveCommandTimeoutMs(args.timeout);
|
|
1186
2111
|
const progress = createCommandProgressReporter(context);
|
|
1187
2112
|
const outcome = await new Promise((resolve) => {
|
|
1188
2113
|
let settled = false;
|
|
@@ -1215,7 +2140,9 @@ async function waitTool(args, ownerScope, context) {
|
|
|
1215
2140
|
}
|
|
1216
2141
|
pollTimer = setTimeout(poll, 100);
|
|
1217
2142
|
};
|
|
1218
|
-
|
|
2143
|
+
if (timeoutMs !== undefined) {
|
|
2144
|
+
timeoutTimer = setTimeout(() => void finish('timedOut'), timeoutMs);
|
|
2145
|
+
}
|
|
1219
2146
|
context?.signal?.addEventListener('abort', onAbort, { once: true });
|
|
1220
2147
|
poll();
|
|
1221
2148
|
});
|
|
@@ -1249,6 +2176,7 @@ function killTool(args, ownerScope) {
|
|
|
1249
2176
|
if (session.running) {
|
|
1250
2177
|
session.cancelEscalation = killProcessTree(session.child);
|
|
1251
2178
|
}
|
|
2179
|
+
touchSession(session);
|
|
1252
2180
|
return textResult({ sessionId: id, killed: true });
|
|
1253
2181
|
}
|
|
1254
2182
|
function listSessionsTool(ownerScope) {
|
|
@@ -1259,11 +2187,28 @@ function listSessionsTool(ownerScope) {
|
|
|
1259
2187
|
command: s.command,
|
|
1260
2188
|
running: s.running,
|
|
1261
2189
|
exitCode: s.exitCode,
|
|
2190
|
+
detached: s.detached === true,
|
|
1262
2191
|
startedAt: new Date(s.startedAt).toISOString(),
|
|
1263
2192
|
endedAt: s.endedAt ? new Date(s.endedAt).toISOString() : undefined,
|
|
1264
2193
|
}));
|
|
1265
2194
|
return textResult({ sessions: list });
|
|
1266
2195
|
}
|
|
2196
|
+
/**
|
|
2197
|
+
* Release every non-detached session of the CALLING scope.
|
|
2198
|
+
*
|
|
2199
|
+
* The scope is host-derived (`_meta.flujo.ownerScope`), never a tool argument, so
|
|
2200
|
+
* a caller can only ever release its own sessions - releasing by arbitrary scope
|
|
2201
|
+
* would be a cross-run kill primitive.
|
|
2202
|
+
*/
|
|
2203
|
+
function releaseOwnerTool(ownerScope) {
|
|
2204
|
+
const released = releaseOwnerScope(ownerScope);
|
|
2205
|
+
return textResult({
|
|
2206
|
+
ownerScope: released.ownerScope,
|
|
2207
|
+
killedSessions: released.killedSessions,
|
|
2208
|
+
closedTerminals: released.closedTerminals,
|
|
2209
|
+
retainedDetached: released.retainedDetached,
|
|
2210
|
+
});
|
|
2211
|
+
}
|
|
1267
2212
|
function terminalSnapshot(session) {
|
|
1268
2213
|
return {
|
|
1269
2214
|
sessionId: session.id,
|
|
@@ -1301,10 +2246,28 @@ async function openTerminalTool(args, roots, ownerScope) {
|
|
|
1301
2246
|
if (ownedRunning.length >= MAX_SESSIONS) {
|
|
1302
2247
|
return textResult({ error: `Too many active terminal sessions (max ${MAX_SESSIONS}). Close one first.` }, true);
|
|
1303
2248
|
}
|
|
2249
|
+
// Issue #413: PTY sessions count against the SAME host-wide live cap as
|
|
2250
|
+
// background sessions - a terminal is just as much a live process tree.
|
|
2251
|
+
expireStaleSessions();
|
|
2252
|
+
const hostLimit = maxLiveSessionsHost();
|
|
2253
|
+
if (liveSessionCount() >= hostLimit) {
|
|
2254
|
+
return textResult({
|
|
2255
|
+
error: `Too many live sessions on this host (max ${hostLimit} across background and terminal sessions). Close one first.`,
|
|
2256
|
+
liveSessions: liveSessionCount(),
|
|
2257
|
+
}, true);
|
|
2258
|
+
}
|
|
2259
|
+
if (ownerLiveSessionCount(ownerScope) >= MAX_SESSIONS) {
|
|
2260
|
+
return textResult({ error: `Too many live sessions for this caller (max ${MAX_SESSIONS}). Close one first.` }, true);
|
|
2261
|
+
}
|
|
2262
|
+
registerExitCleanup();
|
|
1304
2263
|
const cwd = await resolveCwd(args.cwd, roots);
|
|
1305
2264
|
const plan = buildPtySpawn(shellValidation.shell);
|
|
1306
2265
|
if (plan.unavailableShell) {
|
|
1307
|
-
return textResult({
|
|
2266
|
+
return textResult({
|
|
2267
|
+
error: `Requested shell "${plan.unavailableShell}" is unavailable or could not be resolved.`,
|
|
2268
|
+
availableShells: availableShellNames(),
|
|
2269
|
+
hint: 'Call "shell_info" to see which shells and interpreters exist on this machine.',
|
|
2270
|
+
}, true);
|
|
1308
2271
|
}
|
|
1309
2272
|
if (plan.startError)
|
|
1310
2273
|
return textResult({ error: plan.startError }, true);
|
|
@@ -1345,9 +2308,14 @@ async function openTerminalTool(args, roots, ownerScope) {
|
|
|
1345
2308
|
running: true,
|
|
1346
2309
|
exitCode: null,
|
|
1347
2310
|
startedAt: Date.now(),
|
|
2311
|
+
lastActivityAt: Date.now(),
|
|
2312
|
+
...(args.detached === true || args.persistAfterRun === true ? { detached: true } : {}),
|
|
1348
2313
|
};
|
|
1349
2314
|
terminalSessions().set(id, session);
|
|
1350
|
-
pty.onData((chunk) =>
|
|
2315
|
+
pty.onData((chunk) => {
|
|
2316
|
+
touchSession(session);
|
|
2317
|
+
appendTerminalOutput(session, chunk);
|
|
2318
|
+
});
|
|
1351
2319
|
pty.onExit(({ exitCode }) => {
|
|
1352
2320
|
session.running = false;
|
|
1353
2321
|
session.exitCode = exitCode;
|
|
@@ -1365,6 +2333,7 @@ function readTerminalTool(args, ownerScope) {
|
|
|
1365
2333
|
const session = ownedTerminal(id, ownerScope);
|
|
1366
2334
|
if (!session)
|
|
1367
2335
|
return textResult({ error: `No terminal session with id "${id}".` }, true);
|
|
2336
|
+
touchSession(session);
|
|
1368
2337
|
const requested = Number.isFinite(args.cursor) ? Math.max(0, Math.floor(Number(args.cursor))) : session.outputStart;
|
|
1369
2338
|
const reset = requested < session.outputStart || requested > session.nextCursor;
|
|
1370
2339
|
const cursor = reset ? session.outputStart : requested;
|
|
@@ -1386,6 +2355,7 @@ function writeTerminalTool(args, ownerScope) {
|
|
|
1386
2355
|
if (data.length > 65_536)
|
|
1387
2356
|
return textResult({ error: 'Terminal input is limited to 65,536 characters per write.' }, true);
|
|
1388
2357
|
try {
|
|
2358
|
+
touchSession(session);
|
|
1389
2359
|
session.pty.write(data);
|
|
1390
2360
|
return textResult({ sessionId: id, written: Buffer.byteLength(data, 'utf8') });
|
|
1391
2361
|
}
|
|
@@ -1456,6 +2426,8 @@ export async function bashCallTool(toolName, args, callerNodeId, ownerScope, con
|
|
|
1456
2426
|
return closeTerminalTool(args, scope);
|
|
1457
2427
|
case 'terminal_list':
|
|
1458
2428
|
return listTerminalsTool(scope);
|
|
2429
|
+
case 'shell_info':
|
|
2430
|
+
return shellInfoTool();
|
|
1459
2431
|
case 'run': {
|
|
1460
2432
|
const roots = await loadEffectiveRoots(BASH_SERVER_NAME, BASH_ROOT_ENV_VARS, callerNodeId);
|
|
1461
2433
|
return await runTool(args, roots, context);
|
|
@@ -1474,6 +2446,8 @@ export async function bashCallTool(toolName, args, callerNodeId, ownerScope, con
|
|
|
1474
2446
|
return killTool(args, scope);
|
|
1475
2447
|
case 'list_sessions':
|
|
1476
2448
|
return listSessionsTool(scope);
|
|
2449
|
+
case 'release_owner':
|
|
2450
|
+
return releaseOwnerTool(scope);
|
|
1477
2451
|
default:
|
|
1478
2452
|
return textResult({ error: `Unknown tool on the built-in bash server: ${toolName}` }, true);
|
|
1479
2453
|
}
|