tavern-harness 0.1.14 → 0.1.16

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/CHANGELOG.md CHANGED
@@ -3,6 +3,78 @@
3
3
  > 本文件由 `scripts/changelog.mjs` 从 `changelog/v*.md` 生成,请勿手动编辑。
4
4
  > 版本对比链接见文末;源文件位于仓库 `changelog/` 目录。
5
5
 
6
+ ## [0.1.16] - 2026-10-25
7
+
8
+ ### 中文
9
+
10
+ #### 修复
11
+
12
+ - 修复工作区文件管理器在触屏设备上删除按钮不可见、文件夹无法删除的问题:删除按钮现在始终显示,文件夹及其内容可以一并删除,`public` 共享链接仍不可删除
13
+
14
+ #### 内部
15
+
16
+ - 工作区删除接口支持递归删除目录并拒绝删除 `public` 软链接,补充对应测试
17
+
18
+ ### English
19
+
20
+ #### Fixed
21
+
22
+ - Fixed the delete button staying hidden on touch devices and folders not being deletable in the workspace file manager: the button is now always visible, folders and their contents can be removed together, and the `public` shared link remains protected
23
+
24
+ #### Internal
25
+
26
+ - The workspace delete endpoint now removes directories recursively and refuses to delete the `public` symlink, with tests
27
+
28
+ ## [0.1.15] - 2026-10-25
29
+
30
+ ### 中文
31
+
32
+ #### 新增
33
+
34
+ - 本地命令支持管道与重定向:可用 `|`、`<`、`>`、`>>`、`2>`、`2>&1` 组合命令,例如 `cat log.txt | grep error`;读写路径仍限制在会话工作区内,变量展开、命令替换、heredoc、后台执行与交互式命令依旧不支持
35
+ - 工作区文件管理器新增「在文件管理器中打开」:一键在运行 Tavern Harness 的设备上打开当前会话的工作目录;通过局域网从手机访问时会提示仅限本机
36
+
37
+ #### 改进
38
+
39
+ - HTML 预览支持多文件应用:HTML 现在可以加载同目录下的脚本、样式、图片和字体,沙箱内的指针锁定等交互也可正常使用
40
+ - 大工具参数的流式生成更流畅:参数较大时自动降低草稿刷新与本地写入频率,只保留最新快照,减少界面卡顿和写入积压
41
+ - 画中画预览更易操作:可从四角任意缩放、拖动标题栏移动窗口,移动端初始窗口尺寸更合适
42
+
43
+ #### 修复
44
+
45
+ - 修复模型输出中的原始 HTML 被当成页面结构渲染的问题:现在按纯文本显示,消息排版不再被意外改变
46
+ - 修复生成大型工具参数(例如写入大文件)耗时较长时,已建立的响应可能因 60 秒无数据而中断的问题;现在只有建立连接受 20 秒超时限制,生成过程可随时手动停止
47
+
48
+ #### 内部
49
+
50
+ - 新增 `/preview_read` 端点与 Vite 预览中间件:按会话工作区读取预览资源,上限 8MB,并按扩展名返回正确的 MIME 类型
51
+ - 命令执行改为流式连接管道各段与重定向文件,超时或出错时结束整个进程组
52
+ - 补充管道、重定向与预览端点的测试,并修正生成技能的工具与参数说明
53
+
54
+ ### English
55
+
56
+ #### Added
57
+
58
+ - Local commands now support pipes and redirection: combine commands with `|`, `<`, `>`, `>>`, `2>`, and `2>&1`, for example `cat log.txt | grep error`; read and write paths stay inside the session workspace, and expansion, command substitution, heredocs, background jobs, and interactive commands remain unsupported
59
+ - The workspace file manager has an "Open in file manager" button that opens the current conversation's workspace on the machine running Tavern Harness; when the app is opened over the LAN from a phone, it explains that this only works on the host device
60
+
61
+ #### Changed
62
+
63
+ - HTML previews now support multi-file apps: HTML can load same-directory scripts, styles, images, and fonts, and sandboxed interactions such as pointer lock work
64
+ - Smoother streaming for large tool arguments: once an argument gets large, draft updates and local writes happen less often and only the latest snapshot is kept, reducing UI stalls and write backlogs
65
+ - Picture-in-picture previews are easier to use: resize from any corner, drag the title bar to move the window, and the initial window size fits mobile screens better
66
+
67
+ #### Fixed
68
+
69
+ - Fixed raw HTML in model output being rendered as page structure: it now appears as plain text, so message layout is no longer altered unexpectedly
70
+ - Fixed connected responses being interrupted after 60 seconds without data while generating large tool arguments (such as writing a big file); only establishing the connection is still subject to a 20-second timeout, and generation can be stopped manually at any time
71
+
72
+ #### Internal
73
+
74
+ - Added a `/preview_read` endpoint and Vite preview middleware that reads preview assets from the session workspace, caps them at 8MB, and serves the correct MIME type by extension
75
+ - Command execution now streams between pipeline stages and to and from redirected files, ending the whole process group on timeout or failure
76
+ - Added tests for pipes, redirection, and the preview endpoint, and clarified generated-skill tool and parameter descriptions
77
+
6
78
  ## [0.1.14] - 2026-10-24
7
79
 
8
80
  ### 中文
@@ -394,6 +466,8 @@
394
466
  - Vite + React + Zustand + Dexie stack with browser-side local persistence
395
467
 
396
468
  <!-- 链接定义 -->
469
+ [0.1.16]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.15...v0.1.16
470
+ [0.1.15]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.14...v0.1.15
397
471
  [0.1.14]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.13...v0.1.14
398
472
  [0.1.13]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.12...v0.1.13
399
473
  [0.1.12]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.11...v0.1.12
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tavern-harness",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
4
4
  "description": "Tavern Harness — 酒馆模式下的本地 AI Agent 助手(MyAgent-Android 的 Web 复刻)",
5
5
  "private": false,
6
6
  "type": "module",
@@ -9,9 +9,9 @@
9
9
  // - 仅监听 127.0.0.1(不回显到局域网)
10
10
  // - 白名单命令直接执行,无需确认
11
11
  // - 任一非白名单命令均需用户批准并回传服务端签发的一次性确认票据
12
- // - 支持受控的 &&、||、; 连接符;无 shell 解释器(spawn shell:false),
13
- // 不支持管道、重定向、变量展开或命令替换
14
- // - 单条命令 5s 超时,超时即 kill 进程树
12
+ // - 支持受控的 &&、||、;、管道及 < / > / >> 重定向;无 shell 解释器(spawn shell:false),
13
+ // 不支持变量展开、命令替换或其它 shell 语法
14
+ // - 每个命令组 5s 超时,超时即 kill 进程树
15
15
  // - 输出截断 64KB,合并 stderr
16
16
  //
17
17
  // 启动:node sandbox-server.mjs [port] (默认 17891)
@@ -19,14 +19,15 @@
19
19
  // ============================================================
20
20
 
21
21
  import { createServer } from 'node:http';
22
- import { spawn, spawnSync } from 'node:child_process';
22
+ import { spawn, spawnSync, execFile } from 'node:child_process';
23
23
  import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
24
24
  import {
25
- writeFileSync, existsSync, lstatSync, readlinkSync, readFile, writeFile, mkdirSync, mkdtempSync, readdir, statSync, symlinkSync, realpathSync, rmSync, unlink, cpSync,
25
+ writeFileSync, existsSync, lstatSync, readlinkSync, readFile, writeFile, mkdirSync, mkdtempSync, readdir, statSync, symlinkSync, realpathSync, rmSync, unlink, cpSync, createReadStream, createWriteStream,
26
26
  } from 'node:fs';
27
27
  import { tmpdir, homedir } from 'node:os';
28
28
  import { join, dirname, resolve, sep, delimiter, isAbsolute } from 'node:path';
29
29
  import { fileURLToPath } from 'node:url';
30
+ import { PassThrough } from 'node:stream';
30
31
 
31
32
  const __dirname = dirname(fileURLToPath(import.meta.url));
32
33
  const PORT = Number(process.argv[2] || process.env.SANDBOX_PORT || 17891);
@@ -169,22 +170,151 @@ function parseCommandLine(line) {
169
170
  quote = ch;
170
171
  continue;
171
172
  }
172
- const operator = line.startsWith('&&', i) ? '&&' : line.startsWith('||', i) ? '||' : ch === ';' ? ';' : null;
173
+ const operator = line.startsWith('&&', i) ? '&&' : line.startsWith('||', i) ? '||' : ch === ';' ? ';' : ch === '\n' ? ';' : null;
173
174
  if (!operator) continue;
174
175
  const command = line.slice(start, i).trim();
175
- if (!command) throw new Error(`Missing command before operator ${operator}`);
176
- commands.push(command);
177
- operators.push(operator);
176
+ if (!command) {
177
+ if (ch === '\n') {
178
+ start = i + 1;
179
+ continue;
180
+ }
181
+ throw new Error(`Missing command before operator ${operator}`);
182
+ }
183
+ if (!command.startsWith('#')) {
184
+ commands.push(command);
185
+ operators.push(operator);
186
+ }
178
187
  i += operator.length - 1;
179
188
  start = i + 1;
180
189
  }
181
190
  if (quote) throw new Error('Command contains an unclosed quote');
182
191
  const command = line.slice(start).trim();
183
- if (!command) throw new Error('Missing command after operator');
184
- commands.push(command);
192
+ if (!command) {
193
+ if (commands.length === 0) return { commands, operators: [] };
194
+ throw new Error('Missing command after operator');
195
+ }
196
+ if (!command.startsWith('#')) commands.push(command);
185
197
  return { commands, operators };
186
198
  }
187
199
 
200
+ /** 在保留引号和转义的前提下,将受支持的重定向操作符变为独立 token。 */
201
+ function separateRedirectOperators(value) {
202
+ let out = '';
203
+ let quote = null;
204
+ let escaped = false;
205
+ for (let i = 0; i < value.length; i++) {
206
+ const ch = value[i];
207
+ if (escaped) {
208
+ escaped = false;
209
+ out += ch;
210
+ continue;
211
+ }
212
+ if (ch === '\\') {
213
+ escaped = true;
214
+ out += ch;
215
+ continue;
216
+ }
217
+ if (quote) {
218
+ if (ch === quote) quote = null;
219
+ out += ch;
220
+ continue;
221
+ }
222
+ if (ch === "'" || ch === '"') {
223
+ quote = ch;
224
+ out += ch;
225
+ continue;
226
+ }
227
+ if (ch === '2' && value[i + 1] === '>') {
228
+ const operator = value.startsWith('2>>', i) ? '2>>' : value.startsWith('2>&1', i) ? '2>&1' : '2>';
229
+ out += ` ${operator} `;
230
+ i += operator.length - 1;
231
+ continue;
232
+ }
233
+ if (ch === '<' || ch === '>') {
234
+ const operator = ch === '>' && value[i + 1] === '>' ? '>>' : ch;
235
+ out += ` ${operator} `;
236
+ i += operator.length - 1;
237
+ continue;
238
+ }
239
+ out += ch;
240
+ }
241
+ return out;
242
+ }
243
+
244
+ /** 解析单个命令组中的管道和受控重定向。 */
245
+ function parseCommandGroup(group) {
246
+ const segments = [];
247
+ let start = 0;
248
+ let quote = null;
249
+ let escaped = false;
250
+ for (let i = 0; i < group.length; i++) {
251
+ const ch = group[i];
252
+ if (escaped) {
253
+ escaped = false;
254
+ continue;
255
+ }
256
+ if (ch === '\\') {
257
+ escaped = true;
258
+ continue;
259
+ }
260
+ if (quote) {
261
+ if (ch === quote) quote = null;
262
+ continue;
263
+ }
264
+ if (ch === "'" || ch === '"') {
265
+ quote = ch;
266
+ continue;
267
+ }
268
+ if (ch !== '|') continue;
269
+ const segment = group.slice(start, i).trim();
270
+ if (!segment) throw new Error('Missing command before pipe');
271
+ segments.push(segment);
272
+ start = i + 1;
273
+ }
274
+ if (quote) throw new Error('Command contains an unclosed quote');
275
+ const lastSegment = group.slice(start).trim();
276
+ if (!lastSegment) throw new Error('Missing command after pipe');
277
+ segments.push(lastSegment);
278
+
279
+ return segments.map((segment) => {
280
+ const tokens = tokenize(separateRedirectOperators(segment));
281
+ const args = [];
282
+ let input = null;
283
+ let output = null;
284
+ let errorOutput = null;
285
+ let mergeErrorToOutput = false;
286
+ for (let i = 0; i < tokens.length; i++) {
287
+ const token = tokens[i];
288
+ const isRedirect = ['<', '>', '>>', '2>', '2>>', '2>&1'].includes(token);
289
+ if (!isRedirect) {
290
+ args.push(token);
291
+ continue;
292
+ }
293
+ if (token === '2>&1') {
294
+ if (mergeErrorToOutput || errorOutput) throw new Error('Multiple standard error redirections are unsupported');
295
+ mergeErrorToOutput = true;
296
+ continue;
297
+ }
298
+ const path = tokens[++i];
299
+ if (!path || ['<', '>', '>>', '2>', '2>>', '2>&1'].includes(path)) {
300
+ throw new Error(`Missing path after ${token}`);
301
+ }
302
+ if (token === '<') {
303
+ if (input) throw new Error('Multiple input redirections are unsupported');
304
+ input = path;
305
+ } else if (token === '>' || token === '>>') {
306
+ if (output) throw new Error('Multiple output redirections are unsupported');
307
+ output = { path, append: token === '>>' };
308
+ } else {
309
+ if (errorOutput || mergeErrorToOutput) throw new Error('Multiple standard error redirections are unsupported');
310
+ errorOutput = { path, append: token === '2>>' };
311
+ }
312
+ }
313
+ if (!args.length) throw new Error('Missing command');
314
+ return { cmd: args[0], args: args.slice(1), input, output, errorOutput, mergeErrorToOutput };
315
+ });
316
+ }
317
+
188
318
  // ---- 白名单:可直接执行;名单外命令统一请求用户许可 ----
189
319
  const SHELL_ALLOWLIST_GROUPS = {
190
320
  basicInfo: [
@@ -445,6 +575,33 @@ const server = createServer(async (req, res) => {
445
575
  return;
446
576
  }
447
577
 
578
+ // ---- 预览资源端点(仅由 Vite 代理使用) ----
579
+ if (req.url === '/preview_read') {
580
+ let body = '';
581
+ for await (const chunk of req) body += chunk;
582
+ if (body.length > 64 * 1024) {
583
+ res.writeHead(413);
584
+ res.end(JSON.stringify({ ok: false, message: 'body too large' }));
585
+ return;
586
+ }
587
+ try {
588
+ const payload = JSON.parse(body);
589
+ const base = resolveSessionBase(payload?.session);
590
+ const rel = sanitizeWorkspaceRelativePath(payload?.path);
591
+ if (resolvesOutsideSession(rel, base)) throw new Error('Path is outside the workspace');
592
+ const file = resolve(base, rel);
593
+ if (!existsSync(file) || !lstatSync(file).isFile()) throw new Error('File not found');
594
+ assertInside(base, realpathSync(file));
595
+ const content = await readFilePromise(file);
596
+ if (content.length > 8 * 1024 * 1024) throw new Error('Preview file exceeds the 8MB limit');
597
+ res.end(JSON.stringify({ ok: true, content: content.toString('base64') }));
598
+ } catch (e) {
599
+ res.writeHead(400);
600
+ res.end(JSON.stringify({ ok: false, message: String((e && e.message) || e) }));
601
+ }
602
+ return;
603
+ }
604
+
448
605
  // ---- 真实文件工作区端点(file_read / file_write / file_delete / file_list) ----
449
606
  if (req.url === '/file_read' || req.url === '/file_write' || req.url === '/file_delete' || req.url === '/file_list') {
450
607
  let body = '';
@@ -513,11 +670,20 @@ const server = createServer(async (req, res) => {
513
670
  throw new Error('Path is outside the workspace');
514
671
  }
515
672
  const fp = resolve(base, rel);
516
- if (!existsSync(fp) || !lstatSync(fp).isFile()) throw new Error('File not found');
673
+ if (!existsSync(fp)) throw new Error('File or directory not found');
674
+ const entry = lstatSync(fp);
675
+ if (entry.isSymbolicLink() && rel === 'public') {
676
+ throw new Error('The public workspace link cannot be deleted');
677
+ }
678
+ if (!entry.isFile() && !entry.isDirectory()) throw new Error('Path is not a file or directory');
517
679
  if (!deletingPublicLink) assertInside(base, realpathSync(fp));
518
- await new Promise((resolveDelete, rejectDelete) => {
519
- unlink(fp, (err) => (err ? rejectDelete(err) : resolveDelete()));
520
- });
680
+ if (entry.isDirectory()) {
681
+ rmSync(fp, { recursive: true, force: false });
682
+ } else {
683
+ await new Promise((resolveDelete, rejectDelete) => {
684
+ unlink(fp, (err) => (err ? rejectDelete(err) : resolveDelete()));
685
+ });
686
+ }
521
687
  res.end(JSON.stringify({ ok: true, path: rel }));
522
688
  } else {
523
689
  // file_list:返回会话工作目录内的相对路径(分组目录)
@@ -534,6 +700,30 @@ const server = createServer(async (req, res) => {
534
700
  return;
535
701
  }
536
702
 
703
+ if (req.url === '/workspace_open') {
704
+ let body = '';
705
+ for await (const chunk of req) body += chunk;
706
+ if (body.length > 64 * 1024) {
707
+ res.writeHead(413);
708
+ res.end(JSON.stringify({ ok: false, message: 'body too large' }));
709
+ return;
710
+ }
711
+ try {
712
+ const payload = JSON.parse(body);
713
+ const base = resolveSessionBase(payload?.session);
714
+ ensureSessionBase(base);
715
+ const command = IS_WIN ? 'explorer.exe' : process.platform === 'darwin' ? 'open' : 'xdg-open';
716
+ execFile(command, [base], (error) => {
717
+ if (error) console.warn(`[command-service] failed to open workspace: ${error.message}`);
718
+ });
719
+ res.end(JSON.stringify({ ok: true }));
720
+ } catch (e) {
721
+ res.writeHead(400);
722
+ res.end(JSON.stringify({ ok: false, message: String((e && e.message) || e) }));
723
+ }
724
+ return;
725
+ }
726
+
537
727
  // ---- 会话工作目录生命周期:对话创建时预建目录、删除对话时清理目录 ----
538
728
  // session_create:幂等,目录已存在则无操作;session_delete:递归删除并忽略不存在。
539
729
  // 必须带合法会话工作目录(resolveSessionBase 已拦截 .. / 绝对路径 / 非法字符)。
@@ -624,27 +814,24 @@ const server = createServer(async (req, res) => {
624
814
  return;
625
815
  }
626
816
 
627
- const lines = script.split('\n').filter((l) => l.trim() && !l.trim().startsWith('#'));
628
817
  const outputs = [];
629
- for (const line of lines) {
630
- try {
631
- const { commands, operators } = parseCommandLine(line.trim());
632
- let lastCode = 0;
633
- let lastError = '';
634
- for (let i = 0; i < commands.length; i++) {
635
- const previousOperator = operators[i - 1];
636
- if ((previousOperator === '&&' && lastCode !== 0) || (previousOperator === '||' && lastCode === 0)) continue;
637
- const result = await runOne(commands[i], sessionBase);
638
- lastCode = result.code;
639
- lastError = result.stderr || (lastCode !== 0 ? `Exit code ${lastCode}` : '');
640
- if (result.stdout) outputs.push(result.stdout.trimEnd());
641
- if (result.stderr) outputs.push(result.stderr.trimEnd());
642
- }
643
- if (lastCode !== 0) throw new Error(lastError);
644
- } catch (e) {
645
- res.end(JSON.stringify({ ok: false, message: `${line.trim()}:\n${(e).message}` }));
646
- return;
818
+ try {
819
+ const { commands, operators } = parseCommandLine(script);
820
+ let lastCode = 0;
821
+ let lastError = '';
822
+ for (let i = 0; i < commands.length; i++) {
823
+ const previousOperator = operators[i - 1];
824
+ if ((previousOperator === '&&' && lastCode !== 0) || (previousOperator === '||' && lastCode === 0)) continue;
825
+ const result = await runCommandGroup(commands[i], sessionBase);
826
+ lastCode = result.code;
827
+ lastError = result.stderr || (lastCode !== 0 ? `Exit code ${lastCode}` : '');
828
+ if (result.stdout) outputs.push(result.stdout.trimEnd());
829
+ if (result.stderr) outputs.push(result.stderr.trimEnd());
647
830
  }
831
+ if (lastCode !== 0) throw new Error(lastError);
832
+ } catch (e) {
833
+ res.end(JSON.stringify({ ok: false, message: `${(e).message}` }));
834
+ return;
648
835
  }
649
836
  res.end(JSON.stringify({ ok: true, output: outputs.join('\n').slice(0, MAX_OUTPUT) }));
650
837
  });
@@ -869,29 +1056,29 @@ function shellReadTargets(command, args) {
869
1056
 
870
1057
  function validateScript(script, sessionBase) {
871
1058
  if (script.length > MAX_SCRIPT_CHARS) return 'Script exceeds 8,000 characters';
872
- const lines = script.split('\n').filter((l) => l.trim() && !l.trim().startsWith('#'));
873
- if (lines.length === 0) return 'Script is empty';
1059
+ let groups;
1060
+ try {
1061
+ groups = parseCommandLine(script).commands.map(parseCommandGroup);
1062
+ } catch (e) {
1063
+ return e.message;
1064
+ }
1065
+ if (groups.length === 0) return 'Script is empty';
874
1066
  let commandCount = 0;
875
1067
  let hasNonAllowlistedCommand = false;
876
1068
  let hasExternalWrite = false;
877
1069
  let hasExternalRead = false;
878
1070
  let hasExternalPath = false;
879
- for (const line of lines) {
880
- let commands;
881
- try {
882
- commands = parseCommandLine(line.trim()).commands;
883
- } catch (e) {
884
- return e.message;
885
- }
1071
+ for (const commands of groups) {
886
1072
  commandCount += commands.length;
887
1073
  if (commandCount > MAX_LINES) return 'Script contains more than 20 commands';
888
- for (const command of commands) {
889
- const [cmd, ...args] = tokenize(command);
1074
+ for (const { cmd, args, input, output, errorOutput } of commands) {
890
1075
  if (!SHELL_ALLOWED_REAL.has(cmd)) hasNonAllowlistedCommand = true;
891
- if (shellWriteTargets(cmd, args).some((arg) => resolvesOutsideSession(arg, sessionBase))) {
1076
+ const writeTargets = [...shellWriteTargets(cmd, args), ...(output ? [output.path] : []), ...(errorOutput ? [errorOutput.path] : [])];
1077
+ if (writeTargets.some((arg) => resolvesOutsideSession(arg, sessionBase) || isPublicLinkReadPath(arg, sessionBase))) {
892
1078
  hasExternalWrite = true;
893
1079
  }
894
- if (shellReadTargets(cmd, args).some((arg) => (
1080
+ const readTargets = [...shellReadTargets(cmd, args), ...(input ? [input] : [])];
1081
+ if (readTargets.some((arg) => (
895
1082
  !isPublicLinkReadPath(arg, sessionBase) && resolvesOutsideSession(arg, sessionBase)
896
1083
  ))) {
897
1084
  hasExternalRead = true;
@@ -910,65 +1097,137 @@ function validateScript(script, sessionBase) {
910
1097
  return null;
911
1098
  }
912
1099
 
913
- function runOne(line, sessionBase) {
914
- const tokens = tokenize(line);
915
- let cmd = tokens[0];
916
- const args = tokens.slice(1);
1100
+ function commandPath(cmd) {
917
1101
  const trustedPath = SHELL_ALLOWED_REAL.has(cmd) ? TRUSTED_COMMAND_PATHS.get(cmd) : null;
918
1102
  if (SHELL_ALLOWED_REAL.has(cmd) && !trustedPath) {
919
- return Promise.reject(new Error(`Allowlisted command ${cmd} is unavailable in a trusted system directory`));
1103
+ throw new Error(`Allowlisted command ${cmd} is unavailable in a trusted system directory`);
920
1104
  }
921
- if (trustedPath) {
922
- cmd = trustedPath;
1105
+ return trustedPath ?? cmd;
1106
+ }
1107
+
1108
+ function killChild(child) {
1109
+ try {
1110
+ if (IS_WIN) {
1111
+ const killed = spawnSync('taskkill.exe', ['/PID', String(child.pid), '/T', '/F'], {
1112
+ windowsHide: true,
1113
+ stdio: 'ignore',
1114
+ });
1115
+ if (killed.error || killed.status !== 0) child.kill('SIGKILL');
1116
+ } else {
1117
+ process.kill(-child.pid, 'SIGKILL');
1118
+ }
1119
+ } catch { /* ignore */ }
1120
+ }
1121
+
1122
+ function runCommandGroup(group, sessionBase) {
1123
+ const commands = parseCommandGroup(group);
1124
+ if (commands.some((command, index) => command.input && index !== 0)) {
1125
+ return Promise.reject(new Error('Input redirection is only supported for the first pipeline command'));
923
1126
  }
1127
+ if (commands.some((command, index) => command.output && index !== commands.length - 1)) {
1128
+ return Promise.reject(new Error('Output redirection is only supported for the last pipeline command'));
1129
+ }
1130
+ if (commands.some((command, index) => command.errorOutput && index !== commands.length - 1)) {
1131
+ return Promise.reject(new Error('Standard error file redirection is only supported for the last pipeline command'));
1132
+ }
1133
+
924
1134
  return new Promise((resolve, reject) => {
925
- const cwd = sessionBase;
926
- const child = spawn(cmd, args, {
927
- cwd,
928
- shell: false,
929
- stdio: ['ignore', 'pipe', 'pipe'],
930
- env: SPAWN_ENV,
931
- detached: !IS_WIN,
932
- });
1135
+ const children = [];
1136
+ const input = commands[0].input
1137
+ ? createReadStream(join(sessionBase, commands[0].input))
1138
+ : null;
1139
+ const last = commands.at(-1);
1140
+ const output = last.output
1141
+ ? createWriteStream(join(sessionBase, last.output.path), { flags: last.output.append ? 'a' : 'w' })
1142
+ : null;
1143
+ const errorOutput = last.errorOutput
1144
+ ? createWriteStream(join(sessionBase, last.errorOutput.path), { flags: last.errorOutput.append ? 'a' : 'w' })
1145
+ : null;
933
1146
  let stdout = '';
934
1147
  let stderr = '';
935
1148
  let settled = false;
936
- const timer = setTimeout(() => {
1149
+ let closed = 0;
1150
+ const finish = (error) => {
937
1151
  if (settled) return;
938
1152
  settled = true;
939
- try {
940
- if (IS_WIN) {
941
- const killed = spawnSync('taskkill.exe', ['/PID', String(child.pid), '/T', '/F'], {
942
- windowsHide: true,
943
- stdio: 'ignore',
1153
+ clearTimeout(timer);
1154
+ input?.destroy();
1155
+ output?.destroy();
1156
+ errorOutput?.destroy();
1157
+ if (error) {
1158
+ children.forEach(killChild);
1159
+ reject(error);
1160
+ } else {
1161
+ resolve({
1162
+ code: children.at(-1)?.exitCode ?? 1,
1163
+ stdout,
1164
+ stderr: stderr.trim(),
1165
+ });
1166
+ }
1167
+ };
1168
+ const timer = setTimeout(() => finish(new Error(`Command timed out (${CMD_TIMEOUT_MS}ms)`)), CMD_TIMEOUT_MS);
1169
+
1170
+ input?.on('error', (error) => finish(new Error(`Failed to read input: ${error.message}`)));
1171
+ output?.on('error', (error) => finish(new Error(`Failed to write output: ${error.message}`)));
1172
+ errorOutput?.on('error', (error) => finish(new Error(`Failed to write standard error: ${error.message}`)));
1173
+
1174
+ try {
1175
+ for (const [index, command] of commands.entries()) {
1176
+ const isLast = index === commands.length - 1;
1177
+ const child = spawn(commandPath(command.cmd), command.args, {
1178
+ cwd: sessionBase,
1179
+ shell: false,
1180
+ stdio: ['pipe', 'pipe', 'pipe'],
1181
+ env: SPAWN_ENV,
1182
+ detached: !IS_WIN,
1183
+ });
1184
+ children.push(child);
1185
+ child.on('error', (error) => finish(new Error(`Failed to execute ${command.cmd}: ${error.message}`)));
1186
+ if (isLast && errorOutput) {
1187
+ child.stderr.pipe(errorOutput);
1188
+ } else if (!command.mergeErrorToOutput) {
1189
+ child.stderr.on('data', (data) => {
1190
+ stderr += data;
1191
+ if (stderr.length > MAX_OUTPUT) child.stderr.pause();
944
1192
  });
945
- if (killed.error || killed.status !== 0) child.kill('SIGKILL');
1193
+ }
1194
+ child.on('close', () => {
1195
+ closed += 1;
1196
+ if (closed === children.length && children.length === commands.length && !settled) finish(null);
1197
+ });
1198
+ }
1199
+ for (let index = 0; index < children.length - 1; index++) {
1200
+ const current = children[index];
1201
+ const next = children[index + 1];
1202
+ if (commands[index].mergeErrorToOutput) {
1203
+ const merged = new PassThrough();
1204
+ current.stdout.pipe(merged, { end: false });
1205
+ current.stderr.pipe(merged);
1206
+ merged.pipe(next.stdin);
946
1207
  } else {
947
- process.kill(-child.pid, 'SIGKILL');
1208
+ current.stdout.pipe(next.stdin);
948
1209
  }
949
- } catch { /* ignore */ }
950
- reject(new Error(`Command timed out (${CMD_TIMEOUT_MS}ms)`));
951
- }, CMD_TIMEOUT_MS);
952
- child.stdout.on('data', (d) => {
953
- stdout += d;
954
- if (stdout.length > MAX_OUTPUT) child.stdout.pause();
955
- });
956
- child.stderr.on('data', (d) => {
957
- stderr += d;
958
- if (stderr.length > MAX_OUTPUT) child.stderr.pause();
959
- });
960
- child.on('error', (e) => {
961
- if (settled) return;
962
- settled = true;
963
- clearTimeout(timer);
964
- reject(new Error(`Failed to execute ${cmd}: ${e.message}`));
965
- });
966
- child.on('close', (code) => {
967
- if (settled) return;
968
- settled = true;
969
- clearTimeout(timer);
970
- resolve({ code: code ?? 1, stdout, stderr: stderr.trim() });
971
- });
1210
+ }
1211
+ if (input) input.pipe(children[0].stdin);
1212
+ else children[0].stdin.end();
1213
+ if (output) {
1214
+ children.at(-1).stdout.pipe(output, { end: !last.mergeErrorToOutput });
1215
+ if (last.mergeErrorToOutput) children.at(-1).stderr.pipe(output);
1216
+ } else {
1217
+ children.at(-1).stdout.on('data', (data) => {
1218
+ stdout += data;
1219
+ if (stdout.length > MAX_OUTPUT) children.at(-1).stdout.pause();
1220
+ });
1221
+ if (last.mergeErrorToOutput) {
1222
+ children.at(-1).stderr.on('data', (data) => {
1223
+ stdout += data;
1224
+ if (stdout.length > MAX_OUTPUT) children.at(-1).stderr.pause();
1225
+ });
1226
+ }
1227
+ }
1228
+ } catch (error) {
1229
+ finish(error);
1230
+ }
972
1231
  });
973
1232
  }
974
1233
 
@@ -980,7 +1239,8 @@ function readFileAsync(p) {
980
1239
  }
981
1240
  function readFilePromise(p, enc) {
982
1241
  return new Promise((resolve, reject) => {
983
- readFile(p, enc, (err, data) => (err ? reject(err) : resolve(data)));
1242
+ if (enc === undefined) readFile(p, (err, data) => (err ? reject(err) : resolve(data)));
1243
+ else readFile(p, enc, (err, data) => (err ? reject(err) : resolve(data)));
984
1244
  });
985
1245
  }
986
1246
  function writeFilePromise(p, data, enc) {