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 +74 -0
- package/package.json +1 -1
- package/sandbox-server.mjs +354 -94
- package/src/components/FileDisplayModal.tsx +89 -101
- package/src/components/WorkspaceFileManagerModal.tsx +58 -29
- package/src/core/i18n.ts +9 -3
- package/src/core/markdown.ts +3 -2
- package/src/core/openai.ts +11 -11
- package/src/core/toolDefinitions.ts +9 -9
- package/src/core/tools/generatedSkillExecutor.ts +53 -33
- package/src/generated/changelog.json +109 -1
- package/src/store/store.ts +34 -12
- package/src/theme/views.css +26 -14
- package/vite.config.ts +121 -3
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
package/sandbox-server.mjs
CHANGED
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
// - 仅监听 127.0.0.1(不回显到局域网)
|
|
10
10
|
// - 白名单命令直接执行,无需确认
|
|
11
11
|
// - 任一非白名单命令均需用户批准并回传服务端签发的一次性确认票据
|
|
12
|
-
// - 支持受控的
|
|
13
|
-
//
|
|
14
|
-
// -
|
|
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)
|
|
176
|
-
|
|
177
|
-
|
|
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)
|
|
184
|
-
|
|
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)
|
|
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
|
-
|
|
519
|
-
|
|
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
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
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
|
-
|
|
873
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1103
|
+
throw new Error(`Allowlisted command ${cmd} is unavailable in a trusted system directory`);
|
|
920
1104
|
}
|
|
921
|
-
|
|
922
|
-
|
|
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
|
|
926
|
-
const
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
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
|
-
|
|
1149
|
+
let closed = 0;
|
|
1150
|
+
const finish = (error) => {
|
|
937
1151
|
if (settled) return;
|
|
938
1152
|
settled = true;
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1208
|
+
current.stdout.pipe(next.stdin);
|
|
948
1209
|
}
|
|
949
|
-
}
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
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,
|
|
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) {
|