tavern-harness 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +161 -0
- package/README.zh.md +123 -0
- package/bin/cli.js +48 -0
- package/index.html +35 -0
- package/package.json +50 -0
- package/public/favicon.png +0 -0
- package/public/tavern-harness-logo.png +0 -0
- package/sandbox-server.mjs +997 -0
- package/src/App.tsx +268 -0
- package/src/assets/tavern-handbook.md +39 -0
- package/src/components/AchievementModal.tsx +158 -0
- package/src/components/CharactersView.tsx +904 -0
- package/src/components/ChatView.tsx +1639 -0
- package/src/components/ConfirmationDialog.tsx +56 -0
- package/src/components/Dashboard.tsx +180 -0
- package/src/components/DeleteConfirmDialog.tsx +48 -0
- package/src/components/FileDisplayModal.tsx +556 -0
- package/src/components/GameplayDialogs.tsx +191 -0
- package/src/components/NewSessionMenu.tsx +362 -0
- package/src/components/SettingsView.tsx +517 -0
- package/src/components/Sidebar.tsx +392 -0
- package/src/components/StatsView.tsx +189 -0
- package/src/components/Toasts.tsx +20 -0
- package/src/components/WorkspaceFileManagerModal.tsx +216 -0
- package/src/components/shared.tsx +369 -0
- package/src/core/achievements.ts +85 -0
- package/src/core/fileDownload.ts +67 -0
- package/src/core/gameplay.ts +521 -0
- package/src/core/horizontalWheel.ts +93 -0
- package/src/core/i18n.ts +1403 -0
- package/src/core/linkedScroll.ts +236 -0
- package/src/core/markdown.ts +170 -0
- package/src/core/openai.ts +451 -0
- package/src/core/openingGreeting.ts +41 -0
- package/src/core/prompts.ts +254 -0
- package/src/core/proxy.ts +58 -0
- package/src/core/sillyTavernImporter.ts +317 -0
- package/src/core/stats.ts +91 -0
- package/src/core/strings.ts +19 -0
- package/src/core/toolDefinitions.ts +400 -0
- package/src/core/tools/builtinTools.ts +38 -0
- package/src/core/tools/generatedSkillExecutor.ts +706 -0
- package/src/core/tools/generatedWorkspace.ts +13 -0
- package/src/core/tools/toolExecutor.ts +718 -0
- package/src/core/turnLoop.ts +211 -0
- package/src/db/database.ts +647 -0
- package/src/main.tsx +23 -0
- package/src/store/store.ts +2419 -0
- package/src/theme/chat.css +1227 -0
- package/src/theme/global.css +1377 -0
- package/src/theme/theme.ts +143 -0
- package/src/theme/views.css +1430 -0
- package/src/types/models.ts +364 -0
- package/src/vite-env.d.ts +1 -0
- package/tsconfig.json +21 -0
- package/vite.config.ts +273 -0
|
@@ -0,0 +1,997 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ============================================================
|
|
3
|
+
// 生成式技能受控本地命令执行服务(可选,独立进程)
|
|
4
|
+
//
|
|
5
|
+
// 默认不启动。启动后 vite 开发服务器会把 /api-v2/exec 转发到本服务,
|
|
6
|
+
// 前端生成式技能的 shell 类型即可真实执行命令。
|
|
7
|
+
//
|
|
8
|
+
// 执行模型:
|
|
9
|
+
// - 仅监听 127.0.0.1(不回显到局域网)
|
|
10
|
+
// - 白名单命令直接执行,无需确认
|
|
11
|
+
// - 任一非白名单命令均需用户批准并回传服务端签发的一次性确认票据
|
|
12
|
+
// - 支持受控的 &&、||、; 连接符;无 shell 解释器(spawn shell:false),
|
|
13
|
+
// 不支持管道、重定向、变量展开或命令替换
|
|
14
|
+
// - 单条命令 5s 超时,超时即 kill 进程树
|
|
15
|
+
// - 输出截断 64KB,合并 stderr
|
|
16
|
+
//
|
|
17
|
+
// 启动:node sandbox-server.mjs [port] (默认 17891)
|
|
18
|
+
// 锁文件:.sandbox-port(写端口号,供 vite 插件读取)
|
|
19
|
+
// ============================================================
|
|
20
|
+
|
|
21
|
+
import { createServer } from 'node:http';
|
|
22
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
23
|
+
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
24
|
+
import {
|
|
25
|
+
writeFileSync, existsSync, lstatSync, readlinkSync, readFile, writeFile, mkdirSync, mkdtempSync, readdir, statSync, symlinkSync, realpathSync, rmSync, unlink,
|
|
26
|
+
} from 'node:fs';
|
|
27
|
+
import { tmpdir } from 'node:os';
|
|
28
|
+
import { join, dirname, resolve, sep, delimiter } from 'node:path';
|
|
29
|
+
import { fileURLToPath } from 'node:url';
|
|
30
|
+
|
|
31
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
32
|
+
const PORT = Number(process.argv[2] || process.env.SANDBOX_PORT || 17891);
|
|
33
|
+
const LOCK_FILE = join(__dirname, '.sandbox-port');
|
|
34
|
+
const SERVICE_TOKEN = process.env.COMMAND_SERVICE_TOKEN;
|
|
35
|
+
const APPROVAL_TOKEN = process.env.COMMAND_APPROVAL_TOKEN;
|
|
36
|
+
if (!SERVICE_TOKEN || !APPROVAL_TOKEN) {
|
|
37
|
+
console.error('[command-service] missing COMMAND_SERVICE_TOKEN or COMMAND_APPROVAL_TOKEN');
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ---- Windows 适配 ----
|
|
42
|
+
// 该命令集为 Unix 风格。Windows 上尽量使用 Git for Windows 自带的
|
|
43
|
+
// usr/bin 工具(bool 验证过 pwd/ls/date/echo 等均存在)。
|
|
44
|
+
const IS_WIN = process.platform === 'win32';
|
|
45
|
+
function findGitUsrBin() {
|
|
46
|
+
if (!IS_WIN) return null;
|
|
47
|
+
const candidates = [
|
|
48
|
+
// 常见 Git 安装位置(不含 \cmd 的父级为安装根,usr\bin 在根下)
|
|
49
|
+
'C:\\Program Files\\Git\\usr\\bin',
|
|
50
|
+
'C:\\Program Files (x86)\\Git\\usr\\bin',
|
|
51
|
+
];
|
|
52
|
+
try {
|
|
53
|
+
const which = spawnSync('where', ['git'], { encoding: 'utf8' });
|
|
54
|
+
if (which.status === 0 && which.stdout) {
|
|
55
|
+
const gitPath = which.stdout.split(/\r?\n/).find((l) => l.trim());
|
|
56
|
+
if (gitPath) {
|
|
57
|
+
const root = dirname(dirname(gitPath)); // 去掉 \cmd\git.exe
|
|
58
|
+
const p = join(root, 'usr', 'bin');
|
|
59
|
+
if (existsSync(join(p, 'pwd.exe'))) return p;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
} catch { /* ignore */ }
|
|
63
|
+
for (const c of candidates) if (existsSync(join(c, 'pwd.exe'))) return c;
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
const GIT_USR_BIN = findGitUsrBin();
|
|
67
|
+
const SANDBOX_ENV_ROOT = mkdtempSync(join(tmpdir(), 'tavern-harness-env-'));
|
|
68
|
+
const SANDBOX_HOME = join(SANDBOX_ENV_ROOT, 'home');
|
|
69
|
+
const SANDBOX_TMP = join(SANDBOX_ENV_ROOT, 'tmp');
|
|
70
|
+
mkdirSync(SANDBOX_HOME, { recursive: true });
|
|
71
|
+
mkdirSync(SANDBOX_TMP, { recursive: true });
|
|
72
|
+
process.once('exit', () => rmSync(SANDBOX_ENV_ROOT, { recursive: true, force: true }));
|
|
73
|
+
|
|
74
|
+
// 子进程仅继承运行所需变量;Git usr\bin 放在 PATH 最前,保证 Unix 命令优先。
|
|
75
|
+
const SPAWN_ENV = {
|
|
76
|
+
PATH: GIT_USR_BIN ? `${GIT_USR_BIN}${delimiter}${process.env.PATH ?? ''}` : (process.env.PATH ?? ''),
|
|
77
|
+
LANG: process.env.LANG ?? 'C.UTF-8',
|
|
78
|
+
LC_ALL: process.env.LC_ALL ?? '',
|
|
79
|
+
HOME: SANDBOX_HOME,
|
|
80
|
+
TMPDIR: SANDBOX_TMP,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 命令行分词:支持单引号/双引号(去引号)、反斜杠转义、引号内空白保持原样。
|
|
85
|
+
* `& | ; > < $ ( ) \` 等字符在引号外保持字面量(不作为 shell 操作符)——
|
|
86
|
+
* 仍然每行一条命令、经 spawn 直接执行(无 shell 解释器),不构成拼接/注入。
|
|
87
|
+
* 参数以空白开头/结尾(如 printf 的 \n" 结尾)会被 trim 掉,与之前行为一致。
|
|
88
|
+
*/
|
|
89
|
+
function tokenize(line) {
|
|
90
|
+
const tokens = [];
|
|
91
|
+
let cur = null; // 当前 token 缓冲(null 表示不在 token 中)
|
|
92
|
+
let quote = null; // null | "'" | '"'
|
|
93
|
+
let i = 0;
|
|
94
|
+
while (i < line.length) {
|
|
95
|
+
const ch = line[i];
|
|
96
|
+
if (quote) {
|
|
97
|
+
if (ch === '\\' && quote === '"' && i + 1 < line.length) {
|
|
98
|
+
// 双引号内只转义 \" \\;其余反斜杠保留字面量(与 bash 一致)
|
|
99
|
+
const nx = line[i + 1];
|
|
100
|
+
if (nx === '"' || nx === '\\') {
|
|
101
|
+
cur += nx;
|
|
102
|
+
i += 2;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
cur += ch;
|
|
106
|
+
i += 1;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (ch === quote) {
|
|
110
|
+
quote = null;
|
|
111
|
+
i += 1;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
cur += ch;
|
|
115
|
+
i += 1;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (ch === "'" || ch === '"') {
|
|
119
|
+
if (cur === null) cur = '';
|
|
120
|
+
quote = ch;
|
|
121
|
+
i += 1;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (ch === '\\' && i + 1 < line.length) {
|
|
125
|
+
// 引号外:反斜杠转义下一个字符,避免引号被当作语法(如 \" 保留为字面量 ")
|
|
126
|
+
if (cur === null) cur = '';
|
|
127
|
+
cur += line[i + 1];
|
|
128
|
+
i += 2;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (/\s/.test(ch)) {
|
|
132
|
+
if (cur !== null) {
|
|
133
|
+
tokens.push(cur);
|
|
134
|
+
cur = null;
|
|
135
|
+
}
|
|
136
|
+
i += 1;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (cur === null) cur = '';
|
|
140
|
+
cur += ch;
|
|
141
|
+
i += 1;
|
|
142
|
+
}
|
|
143
|
+
if (cur !== null) tokens.push(cur);
|
|
144
|
+
return tokens;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** 在引号与转义之外拆分受控连接符,不解释任何其它 shell 语法。 */
|
|
148
|
+
function parseCommandLine(line) {
|
|
149
|
+
const commands = [];
|
|
150
|
+
const operators = [];
|
|
151
|
+
let start = 0;
|
|
152
|
+
let quote = null;
|
|
153
|
+
let escaped = false;
|
|
154
|
+
for (let i = 0; i < line.length; i++) {
|
|
155
|
+
const ch = line[i];
|
|
156
|
+
if (escaped) {
|
|
157
|
+
escaped = false;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (ch === '\\') {
|
|
161
|
+
escaped = true;
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (quote) {
|
|
165
|
+
if (ch === quote) quote = null;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (ch === "'" || ch === '"') {
|
|
169
|
+
quote = ch;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const operator = line.startsWith('&&', i) ? '&&' : line.startsWith('||', i) ? '||' : ch === ';' ? ';' : null;
|
|
173
|
+
if (!operator) continue;
|
|
174
|
+
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);
|
|
178
|
+
i += operator.length - 1;
|
|
179
|
+
start = i + 1;
|
|
180
|
+
}
|
|
181
|
+
if (quote) throw new Error('Command contains an unclosed quote');
|
|
182
|
+
const command = line.slice(start).trim();
|
|
183
|
+
if (!command) throw new Error('Missing command after operator');
|
|
184
|
+
commands.push(command);
|
|
185
|
+
return { commands, operators };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ---- 白名单:可直接执行;名单外命令统一请求用户许可 ----
|
|
189
|
+
const SHELL_ALLOWLIST_GROUPS = {
|
|
190
|
+
basicInfo: [
|
|
191
|
+
'pwd', 'date', 'whoami', 'uname', 'hostname', 'uptime', 'which',
|
|
192
|
+
],
|
|
193
|
+
fileAndDirectory: [
|
|
194
|
+
'ls', 'touch', 'mkdir', 'cp', 'basename', 'dirname', 'du', 'stat', 'file',
|
|
195
|
+
],
|
|
196
|
+
textProcessing: [
|
|
197
|
+
'echo', 'printf', 'cat', 'head', 'tail', 'wc', 'uniq', 'grep', 'cut', 'tr',
|
|
198
|
+
'diff', 'cmp', 'od', 'xxd', 'hexdump', 'strings', 'tee', 'jq',
|
|
199
|
+
],
|
|
200
|
+
systemQuery: [
|
|
201
|
+
'localectl', 'timedatectl', 'ffprobe',
|
|
202
|
+
],
|
|
203
|
+
logicAndMath: [
|
|
204
|
+
'true', 'false', 'seq', 'factor', 'bc', 'sha256sum', 'md5sum', 'cksum', 'sum',
|
|
205
|
+
],
|
|
206
|
+
};
|
|
207
|
+
const SHELL_ALLOWED_REAL = new Set(Object.values(SHELL_ALLOWLIST_GROUPS).flat());
|
|
208
|
+
|
|
209
|
+
const TRUSTED_COMMAND_DIRS = (IS_WIN
|
|
210
|
+
? [GIT_USR_BIN]
|
|
211
|
+
: ['/bin', '/usr/bin', '/usr/sbin', '/sbin'])
|
|
212
|
+
.filter(Boolean)
|
|
213
|
+
.map((dir) => {
|
|
214
|
+
try {
|
|
215
|
+
return realpathSync(dir);
|
|
216
|
+
} catch {
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
})
|
|
220
|
+
.filter(Boolean);
|
|
221
|
+
|
|
222
|
+
function resolveTrustedCommand(command) {
|
|
223
|
+
const names = IS_WIN ? [`${command}.exe`, command] : [command];
|
|
224
|
+
for (const trustedDir of TRUSTED_COMMAND_DIRS) {
|
|
225
|
+
for (const name of names) {
|
|
226
|
+
const candidate = join(trustedDir, name);
|
|
227
|
+
if (!existsSync(candidate)) continue;
|
|
228
|
+
try {
|
|
229
|
+
const resolved = realpathSync(candidate);
|
|
230
|
+
if (!resolved.startsWith(trustedDir + sep)) return null;
|
|
231
|
+
if (IS_WIN) return resolved; // Windows 无 POSIX 权限位(Git usr/bin 的 exe 显示 666),跳过 uid/writable 校验
|
|
232
|
+
const executable = statSync(resolved);
|
|
233
|
+
const writableByNonOwner = (executable.mode & 0o022) !== 0;
|
|
234
|
+
if (executable.uid === 0 && !writableByNonOwner) return resolved;
|
|
235
|
+
} catch { /* try next candidate */ }
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const TRUSTED_COMMAND_PATHS = new Map(
|
|
242
|
+
[...SHELL_ALLOWED_REAL]
|
|
243
|
+
.map((command) => [command, resolveTrustedCommand(command)])
|
|
244
|
+
.filter((entry) => entry[1])
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
const MAX_LINES = 20;
|
|
248
|
+
const MAX_SCRIPT_CHARS = 8000;
|
|
249
|
+
const MAX_OUTPUT = 64 * 1024;
|
|
250
|
+
const CMD_TIMEOUT_MS = 5000;
|
|
251
|
+
const CONFIRMATION_TTL_MS = 60_000;
|
|
252
|
+
const MAX_PENDING_CONFIRMATIONS = 1000;
|
|
253
|
+
const pendingConfirmations = new Map();
|
|
254
|
+
|
|
255
|
+
function confirmationDigest(operation, sessionBase) {
|
|
256
|
+
return createHash('sha256')
|
|
257
|
+
.update(operation)
|
|
258
|
+
.update('\0')
|
|
259
|
+
.update(sessionBase)
|
|
260
|
+
.digest();
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function issueConfirmationRequest(operation, sessionBase) {
|
|
264
|
+
const now = Date.now();
|
|
265
|
+
for (const [requestId, entry] of pendingConfirmations) {
|
|
266
|
+
if (entry.expiresAt <= now) pendingConfirmations.delete(requestId);
|
|
267
|
+
}
|
|
268
|
+
while (pendingConfirmations.size >= MAX_PENDING_CONFIRMATIONS) {
|
|
269
|
+
pendingConfirmations.delete(pendingConfirmations.keys().next().value);
|
|
270
|
+
}
|
|
271
|
+
const requestId = randomBytes(32).toString('base64url');
|
|
272
|
+
pendingConfirmations.set(requestId, {
|
|
273
|
+
digest: confirmationDigest(operation, sessionBase),
|
|
274
|
+
expiresAt: now + CONFIRMATION_TTL_MS,
|
|
275
|
+
approved: false,
|
|
276
|
+
});
|
|
277
|
+
return requestId;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function approveConfirmationRequest(requestId) {
|
|
281
|
+
if (typeof requestId !== 'string' || !requestId) return false;
|
|
282
|
+
const entry = pendingConfirmations.get(requestId);
|
|
283
|
+
if (!entry) return false;
|
|
284
|
+
if (entry.expiresAt <= Date.now()) {
|
|
285
|
+
pendingConfirmations.delete(requestId);
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
entry.approved = true;
|
|
289
|
+
return true;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function consumeApprovedConfirmation(requestId, operation, sessionBase) {
|
|
293
|
+
if (typeof requestId !== 'string' || !requestId) return false;
|
|
294
|
+
const entry = pendingConfirmations.get(requestId);
|
|
295
|
+
if (!entry || !entry.approved) return false;
|
|
296
|
+
pendingConfirmations.delete(requestId);
|
|
297
|
+
if (entry.expiresAt <= Date.now()) return false;
|
|
298
|
+
return timingSafeEqual(entry.digest, confirmationDigest(operation, sessionBase));
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ---- 真实文件工作区(generate_skill 的 file_read / file_write 落盘区) ----
|
|
302
|
+
// 项目根目录下 sandbox_workspace/,仅允许读写该目录内文件(虚拟磁盘)。
|
|
303
|
+
// 会话隔离:每次请求必须携带会话工作目录(session 字段,如 "session-12"),
|
|
304
|
+
// 所有读写/执行都锁定在该目录内,不允许回退到共享根工作区。
|
|
305
|
+
const WORKSPACE_ROOT = resolve(__dirname, 'sandbox_workspace');
|
|
306
|
+
const PUBLIC_WORKSPACE = join(WORKSPACE_ROOT, 'public');
|
|
307
|
+
const MAX_FILE_READ_CHARS = 100_000; // 单文件读取上限(与前端虚拟工作区一致)
|
|
308
|
+
const MAX_FILE_WRITE_BYTES = 400 * 1024; // 单文件写入上限 400KB
|
|
309
|
+
const MAX_LIST_ENTRIES = 500;
|
|
310
|
+
// 会话工作目录的安全字符集:仅允许小写字母数字、下划线、斜杠、点、连字符,
|
|
311
|
+
// 防止路径穿越/注入(由前端按会话 id 生成,如 "session-12")
|
|
312
|
+
const SESSION_DIR_RE = /^[a-z0-9_.-]+$/;
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* 解析会话工作目录。仅允许 sandbox_workspace 下的单层目录。
|
|
316
|
+
*/
|
|
317
|
+
function resolveSessionBase(session) {
|
|
318
|
+
if (session === undefined || session === null) throw new Error('A valid session workspace is required');
|
|
319
|
+
const raw = String(session).replace(/\\/g, '/').trim();
|
|
320
|
+
if (!raw || raw.startsWith('/') || raw === '.' || raw === '..' || !SESSION_DIR_RE.test(raw)) {
|
|
321
|
+
throw new Error('A valid session workspace is required');
|
|
322
|
+
}
|
|
323
|
+
return join(WORKSPACE_ROOT, raw);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** 创建会话目录,并为普通会话提供固定的只读公共目录入口。 */
|
|
327
|
+
function ensureSessionBase(base) {
|
|
328
|
+
mkdirSync(PUBLIC_WORKSPACE, { recursive: true });
|
|
329
|
+
mkdirSync(base, { recursive: true });
|
|
330
|
+
if (base === PUBLIC_WORKSPACE) return;
|
|
331
|
+
const publicLink = join(base, 'public');
|
|
332
|
+
if (existsSync(publicLink)) {
|
|
333
|
+
if (!lstatSync(publicLink).isSymbolicLink() || realpathSync(publicLink) !== realpathSync(PUBLIC_WORKSPACE)) {
|
|
334
|
+
throw new Error('The session public entry is not a valid link to the public workspace');
|
|
335
|
+
}
|
|
336
|
+
} else {
|
|
337
|
+
symlinkSync(IS_WIN ? PUBLIC_WORKSPACE : '../public', publicLink, IS_WIN ? 'junction' : 'dir');
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** 相对路径校验:禁止绝对路径、.. 等,锁定在会话工作目录内 */
|
|
342
|
+
function sanitizeWorkspaceRelativePath(p) {
|
|
343
|
+
const normalized = String(p).replace(/\\/g, '/').trim();
|
|
344
|
+
if (!normalized || normalized.startsWith('/')) throw new Error('Invalid path');
|
|
345
|
+
const parts = normalized.split('/').filter((s) => s && s !== '.');
|
|
346
|
+
if (parts.length === 0) throw new Error('Invalid path');
|
|
347
|
+
if (parts.some((s) => s === '..')) throw new Error('Path must not contain ..');
|
|
348
|
+
return parts.join('/');
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function workspacePathFor(rel, base) {
|
|
352
|
+
const safe = sanitizeWorkspaceRelativePath(rel);
|
|
353
|
+
return join(base, ...safe.split('/'));
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** 校验最终解析路径仍在会话工作目录内(防符号链接逃逸) */
|
|
357
|
+
function assertInside(root, p) {
|
|
358
|
+
const rp = resolve(p);
|
|
359
|
+
if (rp !== root && !rp.startsWith(root + sep)) throw new Error('Path is outside the workspace');
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function isInside(root, p) {
|
|
363
|
+
return p === root || p.startsWith(root + sep);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function assertWritableParent(base, parent) {
|
|
367
|
+
let existing = parent;
|
|
368
|
+
while (!existsSync(existing) && existing !== base) existing = dirname(existing);
|
|
369
|
+
assertInside(base, realpathSync(existing));
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// ---- HTTP 服务 ----
|
|
373
|
+
const server = createServer(async (req, res) => {
|
|
374
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
375
|
+
const suppliedToken = req.headers['x-command-service-token'];
|
|
376
|
+
const tokenMatches = typeof suppliedToken === 'string'
|
|
377
|
+
&& suppliedToken.length === SERVICE_TOKEN.length
|
|
378
|
+
&& timingSafeEqual(Buffer.from(suppliedToken), Buffer.from(SERVICE_TOKEN));
|
|
379
|
+
if (!tokenMatches) {
|
|
380
|
+
res.writeHead(401);
|
|
381
|
+
res.end(JSON.stringify({ ok: false, message: 'unauthorized' }));
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
if (req.method !== 'POST') {
|
|
385
|
+
res.writeHead(404);
|
|
386
|
+
res.end(JSON.stringify({ ok: false, message: 'not found' }));
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (req.url === '/approve') {
|
|
391
|
+
const suppliedApprovalToken = req.headers['x-command-approval-token'];
|
|
392
|
+
const approvalTokenMatches = typeof suppliedApprovalToken === 'string'
|
|
393
|
+
&& suppliedApprovalToken.length === APPROVAL_TOKEN.length
|
|
394
|
+
&& timingSafeEqual(Buffer.from(suppliedApprovalToken), Buffer.from(APPROVAL_TOKEN));
|
|
395
|
+
if (!approvalTokenMatches) {
|
|
396
|
+
res.writeHead(401);
|
|
397
|
+
res.end(JSON.stringify({ ok: false, message: 'approval unauthorized' }));
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
let body = '';
|
|
401
|
+
for await (const chunk of req) body += chunk;
|
|
402
|
+
if (body.length > 16 * 1024) {
|
|
403
|
+
res.writeHead(413);
|
|
404
|
+
res.end(JSON.stringify({ ok: false, message: 'body too large' }));
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
try {
|
|
408
|
+
const payload = JSON.parse(body);
|
|
409
|
+
const approved = approveConfirmationRequest(payload?.confirmationRequestId);
|
|
410
|
+
res.writeHead(approved ? 200 : 404);
|
|
411
|
+
res.end(JSON.stringify({ ok: approved, message: approved ? undefined : 'confirmation request not found or expired' }));
|
|
412
|
+
} catch {
|
|
413
|
+
res.writeHead(400);
|
|
414
|
+
res.end(JSON.stringify({ ok: false, message: 'invalid json' }));
|
|
415
|
+
}
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ---- 真实文件工作区端点(file_read / file_write / file_delete / file_list) ----
|
|
420
|
+
if (req.url === '/file_read' || req.url === '/file_write' || req.url === '/file_delete' || req.url === '/file_list') {
|
|
421
|
+
let body = '';
|
|
422
|
+
for await (const chunk of req) body += chunk;
|
|
423
|
+
if (body.length > 512 * 1024) {
|
|
424
|
+
res.writeHead(413);
|
|
425
|
+
res.end(JSON.stringify({ ok: false, message: 'body too large' }));
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
let payload;
|
|
429
|
+
try {
|
|
430
|
+
payload = JSON.parse(body);
|
|
431
|
+
} catch {
|
|
432
|
+
res.writeHead(400);
|
|
433
|
+
res.end(JSON.stringify({ ok: false, message: 'invalid json' }));
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
try {
|
|
437
|
+
// 会话隔离:同一端点按会话工作目录读写文件(互不影响)
|
|
438
|
+
const base = resolveSessionBase(payload?.session);
|
|
439
|
+
if (req.url === '/file_read') {
|
|
440
|
+
const rawPath = String(payload?.path ?? '').replace(/\\/g, '/').trim();
|
|
441
|
+
if (!rawPath) throw new Error('Invalid path');
|
|
442
|
+
const rel = sanitizeWorkspaceRelativePath(rawPath);
|
|
443
|
+
const readingPublicLink = base !== PUBLIC_WORKSPACE && (rel === 'public' || rel.startsWith('public/'));
|
|
444
|
+
if (!readingPublicLink && resolvesOutsideSession(rel, base)) {
|
|
445
|
+
throw new Error('Path is outside the workspace');
|
|
446
|
+
}
|
|
447
|
+
const fp = resolve(base, rel);
|
|
448
|
+
const text = await readFileAsync(fp);
|
|
449
|
+
res.end(JSON.stringify({ ok: true, path: rel, content: text.slice(0, MAX_FILE_READ_CHARS) }));
|
|
450
|
+
} else if (req.url === '/file_write') {
|
|
451
|
+
const rawPath = String(payload?.path ?? '').replace(/\\/g, '/').trim();
|
|
452
|
+
if (!rawPath) throw new Error('Invalid path');
|
|
453
|
+
const mode = payload?.mode === 'append' ? 'append' : 'write';
|
|
454
|
+
const content = String(payload?.content ?? '');
|
|
455
|
+
ensureSessionBase(base);
|
|
456
|
+
const rel = sanitizeWorkspaceRelativePath(rawPath);
|
|
457
|
+
if (base !== PUBLIC_WORKSPACE && (rel === 'public' || rel.startsWith('public/'))) {
|
|
458
|
+
throw new Error('The public directory is read-only');
|
|
459
|
+
}
|
|
460
|
+
if (resolvesOutsideSession(rel, base)) {
|
|
461
|
+
throw new Error('Path is outside the workspace');
|
|
462
|
+
}
|
|
463
|
+
const fp = resolve(base, rel);
|
|
464
|
+
assertWritableParent(base, dirname(fp));
|
|
465
|
+
mkdirSync(dirname(fp), { recursive: true });
|
|
466
|
+
assertInside(base, realpathSync(dirname(fp)));
|
|
467
|
+
if (existsSync(fp)) assertInside(base, realpathSync(fp));
|
|
468
|
+
if (Buffer.byteLength(content, 'utf8') > MAX_FILE_WRITE_BYTES) {
|
|
469
|
+
throw new Error(`File exceeds the ${MAX_FILE_WRITE_BYTES / 1024}KB limit`);
|
|
470
|
+
}
|
|
471
|
+
if (mode === 'append') {
|
|
472
|
+
const existing = existsSync(fp) ? await readFilePromise(fp, 'utf8') : '';
|
|
473
|
+
await writeFilePromise(fp, existing + content, 'utf8');
|
|
474
|
+
} else {
|
|
475
|
+
await writeFilePromise(fp, content, 'utf8');
|
|
476
|
+
}
|
|
477
|
+
res.end(JSON.stringify({ ok: true, path: rel, mode, bytes: Buffer.byteLength(content, 'utf8') }));
|
|
478
|
+
} else if (req.url === '/file_delete') {
|
|
479
|
+
const rawPath = String(payload?.path ?? '').replace(/\\/g, '/').trim();
|
|
480
|
+
if (!rawPath) throw new Error('Invalid path');
|
|
481
|
+
const rel = sanitizeWorkspaceRelativePath(rawPath);
|
|
482
|
+
const deletingPublicLink = base !== PUBLIC_WORKSPACE && (rel === 'public' || rel.startsWith('public/'));
|
|
483
|
+
if (!deletingPublicLink && resolvesOutsideSession(rel, base)) {
|
|
484
|
+
throw new Error('Path is outside the workspace');
|
|
485
|
+
}
|
|
486
|
+
const fp = resolve(base, rel);
|
|
487
|
+
if (!existsSync(fp) || !lstatSync(fp).isFile()) throw new Error('File not found');
|
|
488
|
+
if (!deletingPublicLink) assertInside(base, realpathSync(fp));
|
|
489
|
+
await new Promise((resolveDelete, rejectDelete) => {
|
|
490
|
+
unlink(fp, (err) => (err ? rejectDelete(err) : resolveDelete()));
|
|
491
|
+
});
|
|
492
|
+
res.end(JSON.stringify({ ok: true, path: rel }));
|
|
493
|
+
} else {
|
|
494
|
+
// file_list:返回会话工作目录内的相对路径(分组目录)
|
|
495
|
+
const files = [];
|
|
496
|
+
await walkWorkspace(base, '', files);
|
|
497
|
+
files.sort();
|
|
498
|
+
const sliced = files.slice(0, MAX_LIST_ENTRIES);
|
|
499
|
+
res.end(JSON.stringify({ ok: true, files: sliced, truncated: files.length > MAX_LIST_ENTRIES }));
|
|
500
|
+
}
|
|
501
|
+
} catch (e) {
|
|
502
|
+
res.writeHead(400);
|
|
503
|
+
res.end(JSON.stringify({ ok: false, message: String((e && e.message) || e) }));
|
|
504
|
+
}
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// ---- 会话工作目录生命周期:对话创建时预建目录、删除对话时清理目录 ----
|
|
509
|
+
// session_create:幂等,目录已存在则无操作;session_delete:递归删除并忽略不存在。
|
|
510
|
+
// 必须带合法会话工作目录(resolveSessionBase 已拦截 .. / 绝对路径 / 非法字符)。
|
|
511
|
+
if (req.url === '/session_create' || req.url === '/session_delete') {
|
|
512
|
+
let body = '';
|
|
513
|
+
for await (const chunk of req) body += chunk;
|
|
514
|
+
if (body.length > 64 * 1024) {
|
|
515
|
+
res.writeHead(413);
|
|
516
|
+
res.end(JSON.stringify({ ok: false, message: 'body too large' }));
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
let payload;
|
|
520
|
+
try {
|
|
521
|
+
payload = JSON.parse(body);
|
|
522
|
+
} catch {
|
|
523
|
+
res.writeHead(400);
|
|
524
|
+
res.end(JSON.stringify({ ok: false, message: 'invalid json' }));
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
try {
|
|
528
|
+
const base = resolveSessionBase(payload?.session);
|
|
529
|
+
if (req.url === '/session_create') {
|
|
530
|
+
ensureSessionBase(base);
|
|
531
|
+
} else {
|
|
532
|
+
if (base === PUBLIC_WORKSPACE) throw new Error('The public workspace cannot be deleted');
|
|
533
|
+
rmSync(base, { recursive: true, force: true });
|
|
534
|
+
}
|
|
535
|
+
res.end(JSON.stringify({ ok: true }));
|
|
536
|
+
} catch (e) {
|
|
537
|
+
res.writeHead(400);
|
|
538
|
+
res.end(JSON.stringify({ ok: false, message: String((e && e.message) || e) }));
|
|
539
|
+
}
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
if (req.url !== '/exec') {
|
|
544
|
+
res.writeHead(404);
|
|
545
|
+
res.end(JSON.stringify({ ok: false, message: 'not found' }));
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
let body = '';
|
|
550
|
+
for await (const chunk of req) body += chunk;
|
|
551
|
+
if (body.length > 64 * 1024) {
|
|
552
|
+
res.writeHead(413);
|
|
553
|
+
res.end(JSON.stringify({ ok: false, message: 'body too large' }));
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
let payload;
|
|
558
|
+
try {
|
|
559
|
+
payload = JSON.parse(body);
|
|
560
|
+
} catch {
|
|
561
|
+
res.writeHead(400);
|
|
562
|
+
res.end(JSON.stringify({ ok: false, message: 'invalid json' }));
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
const script = String(payload?.script ?? '');
|
|
566
|
+
// 会话隔离:shell 命令在会话工作目录(cwd)下执行;自动创建目录
|
|
567
|
+
let sessionBase;
|
|
568
|
+
try {
|
|
569
|
+
sessionBase = resolveSessionBase(payload?.session);
|
|
570
|
+
} catch (e) {
|
|
571
|
+
res.end(JSON.stringify({ ok: false, message: String((e && e.message) || e) }));
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
ensureSessionBase(sessionBase);
|
|
575
|
+
|
|
576
|
+
// 校验:非白名单命令只能凭服务端签发、绑定脚本与会话的一次性票据执行。
|
|
577
|
+
let check = validateScript(script, sessionBase);
|
|
578
|
+
if (check?.needConfirmReason) {
|
|
579
|
+
if (!consumeApprovedConfirmation(payload?.confirmationRequestId, script, sessionBase)) {
|
|
580
|
+
const confirmationRequestId = issueConfirmationRequest(script, sessionBase);
|
|
581
|
+
res.end(JSON.stringify({
|
|
582
|
+
ok: false,
|
|
583
|
+
needConfirm: true,
|
|
584
|
+
confirmationReason: check.needConfirmReason,
|
|
585
|
+
confirmationRequestId,
|
|
586
|
+
confirmationExpiresInMs: CONFIRMATION_TTL_MS,
|
|
587
|
+
message: 'The script contains a non-allowlisted command and requires user confirmation',
|
|
588
|
+
}));
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
check = null;
|
|
592
|
+
}
|
|
593
|
+
if (check) {
|
|
594
|
+
res.end(JSON.stringify({ ok: false, message: check }));
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const lines = script.split('\n').filter((l) => l.trim() && !l.trim().startsWith('#'));
|
|
599
|
+
const outputs = [];
|
|
600
|
+
for (const line of lines) {
|
|
601
|
+
try {
|
|
602
|
+
const { commands, operators } = parseCommandLine(line.trim());
|
|
603
|
+
let lastCode = 0;
|
|
604
|
+
let lastError = '';
|
|
605
|
+
for (let i = 0; i < commands.length; i++) {
|
|
606
|
+
const previousOperator = operators[i - 1];
|
|
607
|
+
if ((previousOperator === '&&' && lastCode !== 0) || (previousOperator === '||' && lastCode === 0)) continue;
|
|
608
|
+
const result = await runOne(commands[i], sessionBase);
|
|
609
|
+
lastCode = result.code;
|
|
610
|
+
lastError = result.stderr || (lastCode !== 0 ? `Exit code ${lastCode}` : '');
|
|
611
|
+
if (result.stdout) outputs.push(result.stdout.trimEnd());
|
|
612
|
+
if (result.stderr) outputs.push(result.stderr.trimEnd());
|
|
613
|
+
}
|
|
614
|
+
if (lastCode !== 0) throw new Error(lastError);
|
|
615
|
+
} catch (e) {
|
|
616
|
+
res.end(JSON.stringify({ ok: false, message: `${line.trim()}:\n${(e).message}` }));
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
res.end(JSON.stringify({ ok: true, output: outputs.join('\n').slice(0, MAX_OUTPUT) }));
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
// ---- 校验与执行 ----
|
|
624
|
+
/**
|
|
625
|
+
* 返回 null → 全部命令均在白名单;'NEED_CONFIRM' → 至少一个命令不在白名单;字符串 → 拒绝原因
|
|
626
|
+
*/
|
|
627
|
+
function extractPathCandidate(value) {
|
|
628
|
+
if (!value || value === '-') return null;
|
|
629
|
+
let candidate = value;
|
|
630
|
+
if (value.startsWith('-')) {
|
|
631
|
+
const equalsIndex = value.indexOf('=');
|
|
632
|
+
const traversalIndex = value.indexOf('..');
|
|
633
|
+
const absoluteIndex = value.indexOf('/');
|
|
634
|
+
const pathIndex = equalsIndex >= 0
|
|
635
|
+
? equalsIndex + 1
|
|
636
|
+
: traversalIndex >= 0
|
|
637
|
+
? traversalIndex
|
|
638
|
+
: absoluteIndex >= 0
|
|
639
|
+
? absoluteIndex
|
|
640
|
+
: -1;
|
|
641
|
+
if (pathIndex < 0) return null;
|
|
642
|
+
candidate = value.slice(pathIndex);
|
|
643
|
+
}
|
|
644
|
+
return candidate || null;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function isPublicLinkReadPath(value, sessionBase) {
|
|
648
|
+
if (sessionBase === PUBLIC_WORKSPACE) return false;
|
|
649
|
+
const candidate = extractPathCandidate(value);
|
|
650
|
+
if (!candidate) return false;
|
|
651
|
+
const normalized = candidate.replace(/\\/g, '/').trim();
|
|
652
|
+
return normalized === 'public' || normalized.startsWith('public/');
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function resolvesOutsideSession(value, sessionBase) {
|
|
656
|
+
const candidate = extractPathCandidate(value);
|
|
657
|
+
if (!candidate) return false;
|
|
658
|
+
const target = resolve(sessionBase, candidate);
|
|
659
|
+
if (!isInside(sessionBase, target)) return true;
|
|
660
|
+
|
|
661
|
+
try {
|
|
662
|
+
const realBase = realpathSync(sessionBase);
|
|
663
|
+
const relativeTarget = target.slice(sessionBase.length).split(sep).filter(Boolean);
|
|
664
|
+
let resolvedPrefix = realBase;
|
|
665
|
+
let symlinkHops = 0;
|
|
666
|
+
for (const part of relativeTarget) {
|
|
667
|
+
const next = join(resolvedPrefix, part);
|
|
668
|
+
try {
|
|
669
|
+
if (lstatSync(next).isSymbolicLink()) {
|
|
670
|
+
resolvedPrefix = resolve(dirname(next), readlinkSync(next));
|
|
671
|
+
} else {
|
|
672
|
+
resolvedPrefix = next;
|
|
673
|
+
}
|
|
674
|
+
} catch {
|
|
675
|
+
resolvedPrefix = next;
|
|
676
|
+
}
|
|
677
|
+
if (!isInside(realBase, resolvedPrefix)) return true;
|
|
678
|
+
while (true) {
|
|
679
|
+
try {
|
|
680
|
+
if (!lstatSync(resolvedPrefix).isSymbolicLink()) break;
|
|
681
|
+
if (++symlinkHops > 40) return true;
|
|
682
|
+
resolvedPrefix = resolve(dirname(resolvedPrefix), readlinkSync(resolvedPrefix));
|
|
683
|
+
if (!isInside(realBase, resolvedPrefix)) return true;
|
|
684
|
+
} catch {
|
|
685
|
+
break;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
return false;
|
|
690
|
+
} catch {
|
|
691
|
+
return true;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function operandsAfterOptions(args, optionsWithValues = new Set()) {
|
|
696
|
+
const operands = [];
|
|
697
|
+
let optionsEnded = false;
|
|
698
|
+
for (let index = 0; index < args.length; index++) {
|
|
699
|
+
const arg = args[index];
|
|
700
|
+
if (!optionsEnded && arg === '--') {
|
|
701
|
+
optionsEnded = true;
|
|
702
|
+
continue;
|
|
703
|
+
}
|
|
704
|
+
if (!optionsEnded && arg.startsWith('-') && arg !== '-') {
|
|
705
|
+
const option = arg.split('=', 1)[0];
|
|
706
|
+
if (!arg.includes('=') && optionsWithValues.has(option)) index += 1;
|
|
707
|
+
continue;
|
|
708
|
+
}
|
|
709
|
+
operands.push(arg);
|
|
710
|
+
}
|
|
711
|
+
return operands;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/** cp / mv / install / ln 共用的操作数提取:识别 -t/--target-directory 及其嵌入式形态。 */
|
|
715
|
+
function cpLikeOperands(args) {
|
|
716
|
+
let targetDirectory = null;
|
|
717
|
+
const operands = [];
|
|
718
|
+
let optionsEnded = false;
|
|
719
|
+
for (let index = 0; index < args.length; index++) {
|
|
720
|
+
const arg = args[index];
|
|
721
|
+
if (!optionsEnded && arg === '--') {
|
|
722
|
+
optionsEnded = true;
|
|
723
|
+
continue;
|
|
724
|
+
}
|
|
725
|
+
if (!optionsEnded && (arg === '-t' || arg === '--target-directory')) {
|
|
726
|
+
targetDirectory = args[index + 1] ?? null;
|
|
727
|
+
index += 1;
|
|
728
|
+
continue;
|
|
729
|
+
}
|
|
730
|
+
if (!optionsEnded && arg.startsWith('--target-directory=')) {
|
|
731
|
+
targetDirectory = arg.slice('--target-directory='.length);
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
734
|
+
if (!optionsEnded && arg.startsWith('-t') && arg.length > 2) {
|
|
735
|
+
targetDirectory = arg.slice(2);
|
|
736
|
+
continue;
|
|
737
|
+
}
|
|
738
|
+
if (!optionsEnded && arg.startsWith('-') && arg !== '-') continue;
|
|
739
|
+
operands.push(arg);
|
|
740
|
+
}
|
|
741
|
+
return { targetDirectory, operands };
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function shellWriteTargets(command, args) {
|
|
745
|
+
if (command === 'touch') {
|
|
746
|
+
return operandsAfterOptions(args, new Set(['-d', '--date', '-r', '--reference', '-t']));
|
|
747
|
+
}
|
|
748
|
+
if (command === 'mkdir') {
|
|
749
|
+
return operandsAfterOptions(args, new Set(['-m', '--mode', '-Z', '--context']));
|
|
750
|
+
}
|
|
751
|
+
if (command === 'tee') return operandsAfterOptions(args);
|
|
752
|
+
if (command === 'uniq') {
|
|
753
|
+
const operands = operandsAfterOptions(args, new Set([
|
|
754
|
+
'-f', '--skip-fields', '-s', '--skip-chars', '-w', '--check-chars',
|
|
755
|
+
]));
|
|
756
|
+
return operands.length > 1 ? [operands[1]] : [];
|
|
757
|
+
}
|
|
758
|
+
if (command === 'xxd') {
|
|
759
|
+
const operands = operandsAfterOptions(args, new Set([
|
|
760
|
+
'-a', '-c', '-cols', '-g', '-groupsize', '-l', '-len', '-o', '-s', '-seek',
|
|
761
|
+
]));
|
|
762
|
+
return operands.length > 1 ? [operands[1]] : [];
|
|
763
|
+
}
|
|
764
|
+
// cp / install:只写目标位(-t 目录或最后一个操作数)
|
|
765
|
+
if (command === 'cp' || command === 'install') {
|
|
766
|
+
const { targetDirectory, operands } = cpLikeOperands(args);
|
|
767
|
+
if (targetDirectory) return [targetDirectory];
|
|
768
|
+
return operands.length > 1 ? [operands.at(-1)] : [];
|
|
769
|
+
}
|
|
770
|
+
// mv 会删除源文件,因此源与目标都按写目标校验(源越界同样直接拒绝)
|
|
771
|
+
if (command === 'mv') {
|
|
772
|
+
const { targetDirectory, operands } = cpLikeOperands(args);
|
|
773
|
+
return targetDirectory ? [...operands, targetDirectory] : operands;
|
|
774
|
+
}
|
|
775
|
+
if (command === 'ln') {
|
|
776
|
+
const { targetDirectory, operands } = cpLikeOperands(args);
|
|
777
|
+
if (targetDirectory) return [targetDirectory];
|
|
778
|
+
return operands.length > 1 ? [operands.at(-1)] : [];
|
|
779
|
+
}
|
|
780
|
+
// 以下均为非白名单的已知改动型命令:路径校验必须照常生效,越界直接拒绝而非进入确认通道。
|
|
781
|
+
if (command === 'rm' || command === 'rmdir') return operandsAfterOptions(args);
|
|
782
|
+
if (command === 'shred') {
|
|
783
|
+
return operandsAfterOptions(args, new Set(['-s', '--size', '-n', '--iterations']));
|
|
784
|
+
}
|
|
785
|
+
if (command === 'truncate') {
|
|
786
|
+
return operandsAfterOptions(args, new Set(['-s', '--size']));
|
|
787
|
+
}
|
|
788
|
+
if (command === 'chmod' || command === 'chown' || command === 'chgrp') {
|
|
789
|
+
return operandsAfterOptions(args, new Set(['--reference']));
|
|
790
|
+
}
|
|
791
|
+
if (command === 'dd') return args;
|
|
792
|
+
return [];
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
function shellReadTargets(command, args) {
|
|
796
|
+
if (['ls', 'du', 'stat', 'file', 'basename', 'dirname', 'sha256sum', 'md5sum', 'cksum', 'sum', 'ffprobe'].includes(command)) {
|
|
797
|
+
return operandsAfterOptions(args);
|
|
798
|
+
}
|
|
799
|
+
if (['cat', 'head', 'tail', 'wc', 'od', 'hexdump', 'strings', 'cut'].includes(command)) {
|
|
800
|
+
return operandsAfterOptions(args, new Set(['-n', '-c', '-s', '-w', '-f', '--fields']));
|
|
801
|
+
}
|
|
802
|
+
if (command === 'uniq') {
|
|
803
|
+
const operands = operandsAfterOptions(args, new Set([
|
|
804
|
+
'-f', '--skip-fields', '-s', '--skip-chars', '-w', '--check-chars',
|
|
805
|
+
]));
|
|
806
|
+
return operands.length > 0 ? [operands[0]] : [];
|
|
807
|
+
}
|
|
808
|
+
if (command === 'xxd') {
|
|
809
|
+
const operands = operandsAfterOptions(args, new Set([
|
|
810
|
+
'-a', '-c', '-cols', '-g', '-groupsize', '-l', '-len', '-o', '-s', '-seek',
|
|
811
|
+
]));
|
|
812
|
+
return operands.length > 0 ? [operands[0]] : [];
|
|
813
|
+
}
|
|
814
|
+
if (command === 'grep') {
|
|
815
|
+
const operands = operandsAfterOptions(args, new Set([
|
|
816
|
+
'-e', '--regexp', '-f', '--file', '-m', '--max-count', '-A', '-B', '-C',
|
|
817
|
+
]));
|
|
818
|
+
return operands.length > 1 ? operands.slice(1) : [];
|
|
819
|
+
}
|
|
820
|
+
if (command === 'tr') return [];
|
|
821
|
+
if (command === 'diff' || command === 'cmp') {
|
|
822
|
+
const operands = operandsAfterOptions(args);
|
|
823
|
+
return operands.slice(0, 2);
|
|
824
|
+
}
|
|
825
|
+
if (command === 'cp' || command === 'install') {
|
|
826
|
+
const { targetDirectory, operands } = cpLikeOperands(args);
|
|
827
|
+
if (targetDirectory) return operands;
|
|
828
|
+
return operands.length > 1 ? operands.slice(0, -1) : [];
|
|
829
|
+
}
|
|
830
|
+
if (command === 'jq') {
|
|
831
|
+
const operands = operandsAfterOptions(args, new Set([
|
|
832
|
+
'-f', '--from-file', '-L',
|
|
833
|
+
]));
|
|
834
|
+
if (operands.length === 0) return [];
|
|
835
|
+
if (operands.length === 1) return [];
|
|
836
|
+
return operands.slice(1);
|
|
837
|
+
}
|
|
838
|
+
return [];
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function validateScript(script, sessionBase) {
|
|
842
|
+
if (script.length > MAX_SCRIPT_CHARS) return 'Script exceeds 8,000 characters';
|
|
843
|
+
const lines = script.split('\n').filter((l) => l.trim() && !l.trim().startsWith('#'));
|
|
844
|
+
if (lines.length === 0) return 'Script is empty';
|
|
845
|
+
let commandCount = 0;
|
|
846
|
+
let hasNonAllowlistedCommand = false;
|
|
847
|
+
let hasExternalWrite = false;
|
|
848
|
+
let hasExternalRead = false;
|
|
849
|
+
let hasExternalPath = false;
|
|
850
|
+
for (const line of lines) {
|
|
851
|
+
let commands;
|
|
852
|
+
try {
|
|
853
|
+
commands = parseCommandLine(line.trim()).commands;
|
|
854
|
+
} catch (e) {
|
|
855
|
+
return e.message;
|
|
856
|
+
}
|
|
857
|
+
commandCount += commands.length;
|
|
858
|
+
if (commandCount > MAX_LINES) return 'Script contains more than 20 commands';
|
|
859
|
+
for (const command of commands) {
|
|
860
|
+
const [cmd, ...args] = tokenize(command);
|
|
861
|
+
if (!SHELL_ALLOWED_REAL.has(cmd)) hasNonAllowlistedCommand = true;
|
|
862
|
+
if (shellWriteTargets(cmd, args).some((arg) => resolvesOutsideSession(arg, sessionBase))) {
|
|
863
|
+
hasExternalWrite = true;
|
|
864
|
+
}
|
|
865
|
+
if (shellReadTargets(cmd, args).some((arg) => (
|
|
866
|
+
!isPublicLinkReadPath(arg, sessionBase) && resolvesOutsideSession(arg, sessionBase)
|
|
867
|
+
))) {
|
|
868
|
+
hasExternalRead = true;
|
|
869
|
+
}
|
|
870
|
+
if ([cmd, ...args].some((arg) => (
|
|
871
|
+
!isPublicLinkReadPath(arg, sessionBase) && resolvesOutsideSession(arg, sessionBase)
|
|
872
|
+
))) {
|
|
873
|
+
hasExternalPath = true;
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
if (hasExternalRead) return 'Script read paths must stay within the current session workspace; public files may only be read through the public link';
|
|
878
|
+
if (hasExternalWrite) return 'Script write paths must stay within the current session workspace and cannot use the public symlink';
|
|
879
|
+
if (hasExternalPath) return 'Script paths must stay within the current session workspace; public files may only be read through the public link';
|
|
880
|
+
if (hasNonAllowlistedCommand) return { needConfirmReason: 'non_allowlisted' };
|
|
881
|
+
return null;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
function runOne(line, sessionBase) {
|
|
885
|
+
const tokens = tokenize(line);
|
|
886
|
+
let cmd = tokens[0];
|
|
887
|
+
const args = tokens.slice(1);
|
|
888
|
+
const trustedPath = SHELL_ALLOWED_REAL.has(cmd) ? TRUSTED_COMMAND_PATHS.get(cmd) : null;
|
|
889
|
+
if (SHELL_ALLOWED_REAL.has(cmd) && !trustedPath) {
|
|
890
|
+
return Promise.reject(new Error(`Allowlisted command ${cmd} is unavailable in a trusted system directory`));
|
|
891
|
+
}
|
|
892
|
+
if (trustedPath) {
|
|
893
|
+
cmd = trustedPath;
|
|
894
|
+
}
|
|
895
|
+
return new Promise((resolve, reject) => {
|
|
896
|
+
const cwd = sessionBase;
|
|
897
|
+
const child = spawn(cmd, args, {
|
|
898
|
+
cwd,
|
|
899
|
+
shell: false,
|
|
900
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
901
|
+
env: SPAWN_ENV,
|
|
902
|
+
detached: !IS_WIN,
|
|
903
|
+
});
|
|
904
|
+
let stdout = '';
|
|
905
|
+
let stderr = '';
|
|
906
|
+
let settled = false;
|
|
907
|
+
const timer = setTimeout(() => {
|
|
908
|
+
if (settled) return;
|
|
909
|
+
settled = true;
|
|
910
|
+
try {
|
|
911
|
+
if (IS_WIN) {
|
|
912
|
+
const killed = spawnSync('taskkill.exe', ['/PID', String(child.pid), '/T', '/F'], {
|
|
913
|
+
windowsHide: true,
|
|
914
|
+
stdio: 'ignore',
|
|
915
|
+
});
|
|
916
|
+
if (killed.error || killed.status !== 0) child.kill('SIGKILL');
|
|
917
|
+
} else {
|
|
918
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
919
|
+
}
|
|
920
|
+
} catch { /* ignore */ }
|
|
921
|
+
reject(new Error(`Command timed out (${CMD_TIMEOUT_MS}ms)`));
|
|
922
|
+
}, CMD_TIMEOUT_MS);
|
|
923
|
+
child.stdout.on('data', (d) => {
|
|
924
|
+
stdout += d;
|
|
925
|
+
if (stdout.length > MAX_OUTPUT) child.stdout.pause();
|
|
926
|
+
});
|
|
927
|
+
child.stderr.on('data', (d) => {
|
|
928
|
+
stderr += d;
|
|
929
|
+
if (stderr.length > MAX_OUTPUT) child.stderr.pause();
|
|
930
|
+
});
|
|
931
|
+
child.on('error', (e) => {
|
|
932
|
+
if (settled) return;
|
|
933
|
+
settled = true;
|
|
934
|
+
clearTimeout(timer);
|
|
935
|
+
reject(new Error(`Failed to execute ${cmd}: ${e.message}`));
|
|
936
|
+
});
|
|
937
|
+
child.on('close', (code) => {
|
|
938
|
+
if (settled) return;
|
|
939
|
+
settled = true;
|
|
940
|
+
clearTimeout(timer);
|
|
941
|
+
resolve({ code: code ?? 1, stdout, stderr: stderr.trim() });
|
|
942
|
+
});
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// ---- 真实工作区文件工具 ----
|
|
947
|
+
function readFileAsync(p) {
|
|
948
|
+
return new Promise((resolve, reject) => {
|
|
949
|
+
readFile(p, 'utf8', (err, data) => (err ? reject(new Error('File not found')) : resolve(data)));
|
|
950
|
+
});
|
|
951
|
+
}
|
|
952
|
+
function readFilePromise(p, enc) {
|
|
953
|
+
return new Promise((resolve, reject) => {
|
|
954
|
+
readFile(p, enc, (err, data) => (err ? reject(err) : resolve(data)));
|
|
955
|
+
});
|
|
956
|
+
}
|
|
957
|
+
function writeFilePromise(p, data, enc) {
|
|
958
|
+
return new Promise((resolve, reject) => {
|
|
959
|
+
writeFile(p, data, enc, (err) => (err ? reject(err) : resolve()));
|
|
960
|
+
});
|
|
961
|
+
}
|
|
962
|
+
/** 递归收集工作区相对文件路径(仅文件) */
|
|
963
|
+
function readdirPromise(p) {
|
|
964
|
+
return new Promise((resolve, reject) => {
|
|
965
|
+
readdir(p, { withFileTypes: true }, (err, ents) => (err ? reject(err) : resolve(ents)));
|
|
966
|
+
});
|
|
967
|
+
}
|
|
968
|
+
async function walkWorkspace(dir, prefix, out) {
|
|
969
|
+
let entries;
|
|
970
|
+
try {
|
|
971
|
+
entries = await readdirPromise(dir);
|
|
972
|
+
} catch {
|
|
973
|
+
return; // 目录不存在 → 空
|
|
974
|
+
}
|
|
975
|
+
for (const ent of entries) {
|
|
976
|
+
const rel = prefix ? `${prefix}/${ent.name}` : ent.name;
|
|
977
|
+
const full = join(dir, ent.name);
|
|
978
|
+
try {
|
|
979
|
+
if (ent.isDirectory()) {
|
|
980
|
+
await walkWorkspace(full, rel, out);
|
|
981
|
+
} else if (ent.isSymbolicLink() && rel === 'public' && realpathSync(full) === realpathSync(PUBLIC_WORKSPACE)) {
|
|
982
|
+
await walkWorkspace(full, rel, out);
|
|
983
|
+
} else if (ent.isFile()) {
|
|
984
|
+
out.push(rel);
|
|
985
|
+
}
|
|
986
|
+
} catch { /* 跳过无权限项 */ }
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// ---- 锁文件 + 启动 ----
|
|
991
|
+
// 确保工作区根目录存在
|
|
992
|
+
mkdirSync(WORKSPACE_ROOT, { recursive: true });
|
|
993
|
+
writeFileSync(LOCK_FILE, String(PORT));
|
|
994
|
+
server.listen(PORT, '127.0.0.1', () => {
|
|
995
|
+
console.log(`[sandbox-server] listening on http://127.0.0.1:${PORT} (lock: ${LOCK_FILE})`);
|
|
996
|
+
console.log(`[sandbox-server] workspace: ${WORKSPACE_ROOT}`);
|
|
997
|
+
});
|