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.
Files changed (56) hide show
  1. package/README.md +161 -0
  2. package/README.zh.md +123 -0
  3. package/bin/cli.js +48 -0
  4. package/index.html +35 -0
  5. package/package.json +50 -0
  6. package/public/favicon.png +0 -0
  7. package/public/tavern-harness-logo.png +0 -0
  8. package/sandbox-server.mjs +997 -0
  9. package/src/App.tsx +268 -0
  10. package/src/assets/tavern-handbook.md +39 -0
  11. package/src/components/AchievementModal.tsx +158 -0
  12. package/src/components/CharactersView.tsx +904 -0
  13. package/src/components/ChatView.tsx +1639 -0
  14. package/src/components/ConfirmationDialog.tsx +56 -0
  15. package/src/components/Dashboard.tsx +180 -0
  16. package/src/components/DeleteConfirmDialog.tsx +48 -0
  17. package/src/components/FileDisplayModal.tsx +556 -0
  18. package/src/components/GameplayDialogs.tsx +191 -0
  19. package/src/components/NewSessionMenu.tsx +362 -0
  20. package/src/components/SettingsView.tsx +517 -0
  21. package/src/components/Sidebar.tsx +392 -0
  22. package/src/components/StatsView.tsx +189 -0
  23. package/src/components/Toasts.tsx +20 -0
  24. package/src/components/WorkspaceFileManagerModal.tsx +216 -0
  25. package/src/components/shared.tsx +369 -0
  26. package/src/core/achievements.ts +85 -0
  27. package/src/core/fileDownload.ts +67 -0
  28. package/src/core/gameplay.ts +521 -0
  29. package/src/core/horizontalWheel.ts +93 -0
  30. package/src/core/i18n.ts +1403 -0
  31. package/src/core/linkedScroll.ts +236 -0
  32. package/src/core/markdown.ts +170 -0
  33. package/src/core/openai.ts +451 -0
  34. package/src/core/openingGreeting.ts +41 -0
  35. package/src/core/prompts.ts +254 -0
  36. package/src/core/proxy.ts +58 -0
  37. package/src/core/sillyTavernImporter.ts +317 -0
  38. package/src/core/stats.ts +91 -0
  39. package/src/core/strings.ts +19 -0
  40. package/src/core/toolDefinitions.ts +400 -0
  41. package/src/core/tools/builtinTools.ts +38 -0
  42. package/src/core/tools/generatedSkillExecutor.ts +706 -0
  43. package/src/core/tools/generatedWorkspace.ts +13 -0
  44. package/src/core/tools/toolExecutor.ts +718 -0
  45. package/src/core/turnLoop.ts +211 -0
  46. package/src/db/database.ts +647 -0
  47. package/src/main.tsx +23 -0
  48. package/src/store/store.ts +2419 -0
  49. package/src/theme/chat.css +1227 -0
  50. package/src/theme/global.css +1377 -0
  51. package/src/theme/theme.ts +143 -0
  52. package/src/theme/views.css +1430 -0
  53. package/src/types/models.ts +364 -0
  54. package/src/vite-env.d.ts +1 -0
  55. package/tsconfig.json +21 -0
  56. package/vite.config.ts +273 -0
@@ -0,0 +1,706 @@
1
+ import type { GeneratedSkillExecution, ToolConfirmationRequest } from '../../types/models';
2
+ import { sanitizeRelativePath } from './generatedWorkspace';
3
+ import { translate } from '../i18n';
4
+
5
+ // ============================================================
6
+ // 生成式技能执行引擎(JavaScript 沙箱为 Web Worker + CSP)
7
+ // ============================================================
8
+
9
+ const MAX_OUTPUT_CHARS = 20_000;
10
+ /** 单文件读写字符上限(与沙箱服务 file_read/file_write 对齐) */
11
+ const MAX_READ_CHARS = 100_000;
12
+ const WORKSPACE_SCOPE_KEY = 'th-workspace-scope';
13
+ let cachedWorkspaceScope: string | undefined;
14
+
15
+ function workspaceScope(): string {
16
+ if (cachedWorkspaceScope) return cachedWorkspaceScope;
17
+ try {
18
+ const existing = localStorage.getItem(WORKSPACE_SCOPE_KEY);
19
+ if (existing && /^[a-z0-9-]+$/i.test(existing)) return (cachedWorkspaceScope = existing);
20
+ const scope = crypto.randomUUID();
21
+ localStorage.setItem(WORKSPACE_SCOPE_KEY, scope);
22
+ return (cachedWorkspaceScope = scope);
23
+ } catch {
24
+ return (cachedWorkspaceScope = crypto.randomUUID());
25
+ }
26
+ }
27
+
28
+ /** 为当前浏览器酒馆实例生成独立的会话工作目录名。 */
29
+ export function createSessionWorkspaceDir(sessionId: number): string {
30
+ return `instance-${workspaceScope()}-session-${sessionId}`;
31
+ }
32
+
33
+ /** 工作目录是否属于当前浏览器酒馆实例。 */
34
+ export function isCurrentInstanceWorkspaceDir(dir: string | null | undefined): boolean {
35
+ return typeof dir === 'string' && dir.startsWith(`instance-${workspaceScope()}-session-`);
36
+ }
37
+
38
+ /** 会话工作目录名是否合规(仅允许相对目录、不允许 .. / 绝对路径 / 危险字符) */
39
+ function isValidWorkspaceDirName(dir: string): boolean {
40
+ const s = dir.replace(/\\/g, '/').trim();
41
+ return Boolean(s && s !== '.' && s !== '..' && /^[a-z0-9_.-]+$/i.test(s));
42
+ }
43
+
44
+ /**
45
+ * 创建会话时预建其专属工作目录(磁盘沙箱可用时)。
46
+ * 幂等、尽量不抛错:沙箱未启动 / 请求失败时静默跳过,目录会在首次工具调用时再建。
47
+ */
48
+ export async function ensureSessionWorkspaceDir(dir: string | null | undefined): Promise<void> {
49
+ if (!dir || typeof dir !== 'string' || !isValidWorkspaceDirName(dir)) return;
50
+ try {
51
+ await fetch('/api-v2/session_create', {
52
+ method: 'POST',
53
+ headers: { 'Content-Type': 'application/json' },
54
+ body: JSON.stringify({ session: dir }),
55
+ });
56
+ } catch {
57
+ /* 沙箱未启动等:静默忽略 */
58
+ }
59
+ }
60
+
61
+ /**
62
+ * 删除会话时清理其专属磁盘工作目录。
63
+ * 沙箱不可用时不阻塞会话删除。
64
+ */
65
+ export async function deleteSessionWorkspace(dir: string | null | undefined): Promise<void> {
66
+ if (dir && typeof dir === 'string' && isValidWorkspaceDirName(dir)) {
67
+ try {
68
+ await fetch('/api-v2/session_delete', {
69
+ method: 'POST',
70
+ headers: { 'Content-Type': 'application/json' },
71
+ body: JSON.stringify({ session: dir }),
72
+ });
73
+ } catch {
74
+ /* 沙箱不可用:磁盘目录无法删除,忽略 */
75
+ }
76
+ }
77
+ }
78
+
79
+ /** 规范化会话工作目录名;不合规时返回 null 并禁止访问。 */
80
+ export function normalizeWorkspaceDir(dir: string | null | undefined): string | null {
81
+ if (!dir || typeof dir !== 'string') return null;
82
+ const s = dir.replace(/\\/g, '/').trim();
83
+ return isValidWorkspaceDirName(s) ? s : null;
84
+ }
85
+
86
+ /** shell 确认请求回调(由调用方注入,走统一确认弹窗链路) */
87
+ type SkillConfirmFn = (req: ToolConfirmationRequest) => Promise<boolean>;
88
+
89
+ /** 填充 {{param}} 占位符 */
90
+ function interpolate(template: string, args: Record<string, unknown>): string {
91
+ return template.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_, key: string) => {
92
+ const val = key.split('.').reduce<unknown>((acc, k) => (acc as Record<string, unknown>)?.[k], args);
93
+ if (val == null) return '';
94
+ return String(val);
95
+ });
96
+ }
97
+
98
+ export async function executeGeneratedSkill(
99
+ execution: GeneratedSkillExecution,
100
+ args: Record<string, unknown>,
101
+ confirm?: SkillConfirmFn | null,
102
+ workspaceDir?: string | null,
103
+ ): Promise<string> {
104
+ switch (execution.type) {
105
+ case 'template':
106
+ return truncateToolOutput(interpolate(execution.template ?? '', args));
107
+ case 'http_get':
108
+ return execHttpGet(execution, args);
109
+ case 'javascript':
110
+ return execJavaScript(execution, args, workspaceDir);
111
+ case 'file_read':
112
+ return execFileRead(execution, args, workspaceDir);
113
+ case 'file_write':
114
+ return execFileWrite(execution, args, workspaceDir);
115
+ case 'shell':
116
+ return execShell(execution, args, confirm, workspaceDir);
117
+ default:
118
+ return `ERROR: Unknown execution type ${(execution as GeneratedSkillExecution).type}`;
119
+ }
120
+ }
121
+
122
+ export function truncateToolOutput(s: string): string {
123
+ return s.length > MAX_OUTPUT_CHARS ? s.slice(0, MAX_OUTPUT_CHARS) + translate('tool.truncated') : s;
124
+ }
125
+
126
+ function normalizePath(path: string): string {
127
+ return sanitizeRelativePath(path);
128
+ }
129
+
130
+ // ---------- http_get ----------
131
+ async function execHttpGet(execution: GeneratedSkillExecution, args: Record<string, unknown>): Promise<string> {
132
+ const raw = interpolate(execution.url ?? '', args);
133
+ let url: URL;
134
+ try {
135
+ url = new URL(raw);
136
+ } catch {
137
+ return 'ERROR: Invalid URL';
138
+ }
139
+ if (url.protocol !== 'https:') return 'ERROR: Only public HTTPS URLs are allowed';
140
+ // 屏蔽内网地址
141
+ const host = url.hostname.toLowerCase();
142
+ if (
143
+ host === 'localhost' ||
144
+ host.endsWith('.local') ||
145
+ host === '127.0.0.1' ||
146
+ /^10\.|^192\.168\.|^169\.254\.|^172\.(1[6-9]|2\d|3[01])\./.test(host) ||
147
+ host === '[::1]'
148
+ ) {
149
+ return 'ERROR: Private network addresses are not allowed';
150
+ }
151
+ try {
152
+ const resp = await fetch(url.toString(), {
153
+ signal: AbortSignal.timeout(10_000),
154
+ redirect: 'manual',
155
+ });
156
+ const text = await resp.text();
157
+ return truncateToolOutput(text);
158
+ } catch (e) {
159
+ return `ERROR: Request failed: ${(e as Error).message}`;
160
+ }
161
+ }
162
+
163
+ // ---------- javascript(Web Worker 沙箱,async/await + 受限桥接 API)----------
164
+ /**
165
+ * Worker 内注入的受限桥接函数($read / $write / $append / $list)。
166
+ * 实现通过 postMessage 与主线程通信,实际能力全部复用主线程中已验证的
167
+ * 文件读写沙箱(路径校验、大小上限、磁盘/虚拟双模式),技能代码本身
168
+ * 拿不到任意文件系统或网络能力。
169
+ */
170
+ const JS_INJECT_BOOT = `
171
+ self.__pending = new Map();
172
+ self.__seq = 0;
173
+ self.__bridge = (method, payload) => new Promise((resolve, reject) => {
174
+ const id = ++self.__seq;
175
+ self.__pending.set(id, { resolve, reject });
176
+ self.postMessage({ __bridge__: true, id, method, payload });
177
+ });
178
+ self.__req = async (name, payload) => {
179
+ const r = await self.__bridge(name, payload);
180
+ if (r && r.ok === false) throw new Error(r.error || 'Operation failed');
181
+ return r;
182
+ };
183
+ self.__run = async (ev) => {
184
+ const input = ev.input;
185
+ const $read = async (p) => (await self.__req('read', { path: p })).content;
186
+ const $write = async (p, c) => (await self.__req('write', { path: p, content: c })).result;
187
+ const $append = async (p, c) => (await self.__req('append', { path: p, content: c })).result;
188
+ const $list = async () => (await self.__req('list', {})).files;
189
+ try {
190
+ const fn = new Function('input', '$read', '$write', '$append', '$list', CODE_FN);
191
+ const result = await fn(input, $read, $write, $append, $list);
192
+ let safe;
193
+ try {
194
+ safe = JSON.parse(JSON.stringify(result ?? null));
195
+ } catch (e) {
196
+ self.postMessage({ __error__: 'Result is not serializable: ' + String(e) });
197
+ return;
198
+ }
199
+ self.postMessage({ __result__: safe });
200
+ } catch (e) {
201
+ self.postMessage({ __error__: String((e && e.message) || e) });
202
+ }
203
+ };
204
+ self.onmessage = (ev) => {
205
+ const d = ev.data;
206
+ if (d && d.__bridge_resp__) {
207
+ const p = self.__pending.get(d.id);
208
+ if (!p) return;
209
+ self.__pending.delete(d.id);
210
+ if (d.ok) p.resolve(d.result); else p.reject(new Error(d.error));
211
+ return;
212
+ }
213
+ self.__run(d);
214
+ };
215
+ `;
216
+
217
+ function createSandboxWorker(code: string): Worker {
218
+ // 函数体内声明 let result,兼容旧式 `result = {...}` 用户代码;
219
+ // 函数体为 async 包装,用户代码可用 await + $read/$write/$append/$list
220
+ const codeFn = `return (async () => { "use strict";\nlet result;\n${code}\nreturn result; })();`;
221
+ const src = JS_INJECT_BOOT.replace('CODE_FN', JSON.stringify(codeFn));
222
+ const blob = new Blob([src], { type: 'application/javascript' });
223
+ return new Worker(URL.createObjectURL(blob));
224
+ }
225
+
226
+ async function execJavaScript(
227
+ execution: GeneratedSkillExecution,
228
+ args: Record<string, unknown>,
229
+ workspaceDir?: string | null,
230
+ ): Promise<string> {
231
+ const code = execution.code ?? '';
232
+ if (code.length > 20_000) return 'ERROR: Code exceeds 20,000 characters';
233
+ const input = JSON.parse(JSON.stringify(args ?? {}));
234
+
235
+ return new Promise((resolve) => {
236
+ let worker: Worker;
237
+ try {
238
+ worker = createSandboxWorker(code);
239
+ } catch (e) {
240
+ resolve(`ERROR: Failed to create sandbox: ${(e as Error).message}`);
241
+ return;
242
+ }
243
+ // 单次执行总超时(含所有桥接等待),到点即 terminate
244
+ const timer = setTimeout(() => {
245
+ worker.terminate();
246
+ resolve('ERROR: Script execution timed out (5000ms)');
247
+ }, 5000);
248
+ // 桥接请求:复用主线程已验证的文件沙箱能力
249
+ worker.onmessage = (ev: MessageEvent) => {
250
+ const d = ev.data;
251
+ if (!d) return;
252
+ if (d.__bridge__) {
253
+ void handleBridgeCall(d, workspaceDir).then(
254
+ (result) => worker.postMessage({ __bridge_resp__: true, id: d.id, ok: true, result }),
255
+ (err) => worker.postMessage({ __bridge_resp__: true, id: d.id, ok: false, error: String((err && err.message) || err) })
256
+ );
257
+ return;
258
+ }
259
+ if (d.__error__) {
260
+ clearTimeout(timer);
261
+ worker.terminate();
262
+ resolve(`ERROR: ${d.__error__}`);
263
+ return;
264
+ }
265
+ if ('__result__' in d) {
266
+ clearTimeout(timer);
267
+ worker.terminate();
268
+ resolve(truncateToolOutput(JSON.stringify(d.__result__ ?? null)));
269
+ return;
270
+ }
271
+ };
272
+ worker.onerror = (e) => {
273
+ clearTimeout(timer);
274
+ worker.terminate();
275
+ resolve(`ERROR: ${e.message}`);
276
+ };
277
+ worker.postMessage({ input });
278
+ });
279
+ }
280
+
281
+ /**
282
+ * 主线程侧桥接实现:全部复用已验证的文件沙箱逻辑。
283
+ * 统一返回 { ok, content?/result?/files?, error? },Worker 内 $read 等
284
+ * 在失败时 reject 成 Error,技能代码用 try/catch 接住。
285
+ */
286
+ async function handleBridgeCall(
287
+ req: { method: string; payload: Record<string, unknown> },
288
+ workspaceDir?: string | null,
289
+ ): Promise<unknown> {
290
+ const { method, payload } = req;
291
+ switch (method) {
292
+ case 'read': {
293
+ const path = sanitizeRelativePath(String(payload.path ?? ''));
294
+ await requireFileServer(workspaceDir);
295
+ const text = await diskFileRead(path, workspaceDir!);
296
+ if (text === null) return { ok: false, error: `File not found: ${path}` };
297
+ return { ok: true, content: text.slice(0, MAX_READ_CHARS) };
298
+ }
299
+ case 'write': {
300
+ const path = sanitizeRelativePath(String(payload.path ?? ''));
301
+ const content = String(payload.content ?? '');
302
+ await requireFileServer(workspaceDir);
303
+ return { ok: true, result: await diskFileWrite(path, content, false, workspaceDir!) };
304
+ }
305
+ case 'append': {
306
+ const path = sanitizeRelativePath(String(payload.path ?? ''));
307
+ const content = String(payload.content ?? '');
308
+ await requireFileServer(workspaceDir);
309
+ return { ok: true, result: await diskFileWrite(path, content, true, workspaceDir!) };
310
+ }
311
+ case 'list': {
312
+ await requireFileServer(workspaceDir);
313
+ const files = await diskFileList(workspaceDir!);
314
+ return { ok: true, files: files.slice(0, 500) };
315
+ }
316
+ default:
317
+ return { ok: false, error: `Unknown bridge method ${method}` };
318
+ }
319
+ }
320
+
321
+ // ---------- file_read / file_write(仅使用项目 sandbox_workspace/)----------
322
+ interface ServiceProbeState {
323
+ available: boolean;
324
+ retryAt: number;
325
+ }
326
+
327
+ async function probeLocalService(
328
+ state: ServiceProbeState,
329
+ url: string,
330
+ body: Record<string, unknown>,
331
+ workspaceDir?: string | null,
332
+ ): Promise<boolean> {
333
+ if (!normalizeWorkspaceDir(workspaceDir)) return false;
334
+ if (state.available) return true;
335
+ if (Date.now() < state.retryAt) return false;
336
+ if (typeof window === 'undefined' || !/^https?:\/\//.test(window.location.origin)) {
337
+ state.retryAt = Date.now() + 60_000;
338
+ return false;
339
+ }
340
+ try {
341
+ const resp = await fetch(url, {
342
+ method: 'POST',
343
+ headers: { 'Content-Type': 'application/json' },
344
+ body: JSON.stringify(body),
345
+ });
346
+ state.available = resp.ok;
347
+ } catch {
348
+ state.available = false;
349
+ }
350
+ if (!state.available) state.retryAt = Date.now() + 5000;
351
+ return state.available;
352
+ }
353
+
354
+ /** 本地文件服务可用性(与本地命令服务共用端点,成功则缓存) */
355
+ const fileServerProbe: ServiceProbeState = { available: false, retryAt: 0 };
356
+ async function detectFileServer(workspaceDir?: string | null): Promise<boolean> {
357
+ return probeLocalService(fileServerProbe, '/api-v2/file_list', { session: workspaceDir }, workspaceDir);
358
+ }
359
+
360
+ async function requireFileServer(workspaceDir?: string | null): Promise<void> {
361
+ if (!(await detectFileServer(workspaceDir))) {
362
+ throw new Error('Local workspace service is unavailable; files cannot be read or written');
363
+ }
364
+ }
365
+
366
+ async function diskFileRead(path: string, workspaceDir: string): Promise<string | null> {
367
+ try {
368
+ const resp = await fetch('/api-v2/file_read', {
369
+ method: 'POST',
370
+ headers: { 'Content-Type': 'application/json' },
371
+ body: JSON.stringify({ path, session: workspaceDir }),
372
+ });
373
+ const data = (await resp.json()) as { ok?: boolean; message?: string; content?: string };
374
+ if (resp.ok && data.ok) return data.content ?? '';
375
+ if (data.message === 'File not found') return null;
376
+ throw new Error(data.message || `HTTP ${resp.status}`);
377
+ } catch (e) {
378
+ throw new Error(`Failed to read from local workspace: ${(e as Error).message}`);
379
+ }
380
+ }
381
+
382
+ async function diskFileWrite(
383
+ path: string,
384
+ content: string,
385
+ append: boolean,
386
+ workspaceDir: string,
387
+ ): Promise<string> {
388
+ try {
389
+ const resp = await fetch('/api-v2/file_write', {
390
+ method: 'POST',
391
+ headers: { 'Content-Type': 'application/json' },
392
+ body: JSON.stringify({
393
+ path,
394
+ content,
395
+ mode: append ? 'append' : 'write',
396
+ session: workspaceDir,
397
+ }),
398
+ });
399
+ const data = (await resp.json()) as { ok?: boolean; message?: string };
400
+ if (!resp.ok || !data.ok) throw new Error(data?.message || `HTTP ${resp.status}`);
401
+ return translate('tool.fileWriteSuccess', { path, count: content.length });
402
+ } catch (e) {
403
+ throw new Error(`Failed to write to disk: ${(e as Error).message}`);
404
+ }
405
+ }
406
+
407
+ async function diskFileDelete(path: string, workspaceDir: string): Promise<void> {
408
+ try {
409
+ const resp = await fetch('/api-v2/file_delete', {
410
+ method: 'POST',
411
+ headers: { 'Content-Type': 'application/json' },
412
+ body: JSON.stringify({ path, session: workspaceDir }),
413
+ });
414
+ const data = (await resp.json()) as { ok?: boolean; message?: string };
415
+ if (!resp.ok || !data.ok) throw new Error(data.message || `HTTP ${resp.status}`);
416
+ } catch (e) {
417
+ throw new Error(`Failed to delete from local workspace: ${(e as Error).message}`);
418
+ }
419
+ }
420
+
421
+ async function diskFileList(workspaceDir: string): Promise<string[]> {
422
+ try {
423
+ const resp = await fetch('/api-v2/file_list', {
424
+ method: 'POST',
425
+ headers: { 'Content-Type': 'application/json' },
426
+ body: JSON.stringify({ session: workspaceDir }),
427
+ });
428
+ const data = (await resp.json()) as { ok?: boolean; files?: string[]; message?: string };
429
+ if (resp.ok && data.ok) return data.files ?? [];
430
+ throw new Error(data.message || `HTTP ${resp.status}`);
431
+ } catch (e) {
432
+ throw new Error(`Failed to list local workspace: ${(e as Error).message}`);
433
+ }
434
+ }
435
+
436
+ // ---------- file_read ----------
437
+ /**
438
+ * 读取工作区文件文本。
439
+ * 从 sandbox_workspace/ 读取;文件不存在返回 null,服务不可用时抛出明确错误。
440
+ * 供 file_read 技能、file_display 展示以及弹窗回看共用。
441
+ */
442
+ export async function readWorkspaceFileText(path: string, workspaceDir: string): Promise<string | null> {
443
+ const readablePath = normalizePath(path);
444
+ if (!readablePath) throw new Error('Invalid path');
445
+ await requireFileServer(workspaceDir);
446
+ return diskFileRead(readablePath, workspaceDir);
447
+ }
448
+
449
+ /**
450
+ * 写入本地沙箱工作区。
451
+ * 与 file_write 技能一致:仅允许工作区相对路径;普通会话的 public 软链只读。
452
+ * 供 file_edit 复用同一写入链路。
453
+ */
454
+ export async function writeWorkspaceFileTextFor(
455
+ path: string,
456
+ content: string,
457
+ workspaceDir: string,
458
+ ): Promise<string> {
459
+ const normalizedPath = normalizePath(path);
460
+ if (!normalizedPath) throw new Error('Invalid path');
461
+ await requireFileServer(workspaceDir);
462
+ return diskFileWrite(normalizedPath, content, false, workspaceDir);
463
+ }
464
+
465
+ /** 删除工作区中的单个文件。 */
466
+ export async function deleteWorkspaceFile(path: string, workspaceDir: string): Promise<void> {
467
+ const normalizedPath = normalizePath(path);
468
+ if (!normalizedPath) throw new Error('Invalid path');
469
+ await requireFileServer(workspaceDir);
470
+ await diskFileDelete(normalizedPath, workspaceDir);
471
+ }
472
+
473
+ // ---------- 会话工作区枚举(文件管理器浏览共用) ----------
474
+ /**
475
+ * 列出当前会话专属工作区内的全部文件相对路径。
476
+ * 仅通过本地沙箱服务枚举;服务不可用时抛出明确错误。
477
+ */
478
+ export async function listSessionWorkspaceFiles(workspaceDir: string): Promise<string[]> {
479
+ await requireFileServer(workspaceDir);
480
+ return (await diskFileList(workspaceDir)).slice(0, 500);
481
+ }
482
+
483
+ async function execFileRead(
484
+ execution: GeneratedSkillExecution,
485
+ args: Record<string, unknown>,
486
+ workspaceDir?: string | null,
487
+ ): Promise<string> {
488
+ try {
489
+ const path = normalizePath(interpolate(execution.path ?? '', args));
490
+ if (!path) throw new Error('Invalid path');
491
+ const text = await readWorkspaceFileText(path, normalizeWorkspaceDir(workspaceDir) ?? '');
492
+ if (text === null) return `ERROR: File not found: ${path}`;
493
+ return truncateToolOutput(text);
494
+ } catch (e) {
495
+ return `ERROR: ${(e as Error).message}`;
496
+ }
497
+ }
498
+
499
+ // ---------- file_write ----------
500
+ async function execFileWrite(
501
+ execution: GeneratedSkillExecution,
502
+ args: Record<string, unknown>,
503
+ workspaceDir?: string | null,
504
+ ): Promise<string> {
505
+ try {
506
+ const path = normalizePath(interpolate(execution.path ?? '', args));
507
+ if (!path) throw new Error('Invalid path');
508
+ let content: string;
509
+ if (execution.json_content != null) {
510
+ content = JSON.stringify(interpolateDeep(execution.json_content, args), null, 2);
511
+ } else {
512
+ content = interpolate(execution.content ?? '', args);
513
+ }
514
+ const safeWorkspaceDir = normalizeWorkspaceDir(workspaceDir);
515
+ await requireFileServer(safeWorkspaceDir);
516
+ const diskContent = execution.append && execution.append_newline ? `${content}\n` : content;
517
+ return await diskFileWrite(path, diskContent, execution.append ?? false, safeWorkspaceDir!);
518
+ } catch (e) {
519
+ return `ERROR: ${(e as Error).message}`;
520
+ }
521
+ }
522
+
523
+ function interpolateDeep(value: unknown, args: Record<string, unknown>): unknown {
524
+ if (typeof value === 'string') return interpolate(value, args);
525
+ if (Array.isArray(value)) return value.map((v) => interpolateDeep(v, args));
526
+ if (value && typeof value === 'object') {
527
+ const out: Record<string, unknown> = {};
528
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) out[k] = interpolateDeep(v, args);
529
+ return out;
530
+ }
531
+ return value;
532
+ }
533
+
534
+ // ---------- shell(真实执行:白名单直执 + 其它命令确认)----------
535
+
536
+ /** 按服务端规则统计换行及 && / || / ; 分隔的命令,忽略引号和转义内的连接符。 */
537
+ function countShellCommands(script: string): number {
538
+ const lines = script.split('\n').filter((line) => line.trim() && !line.trim().startsWith('#'));
539
+ let count = 0;
540
+ for (const rawLine of lines) {
541
+ const line = rawLine.trim();
542
+ let commandStart = 0;
543
+ let quote: "'" | '"' | null = null;
544
+ let escaped = false;
545
+ for (let index = 0; index < line.length; index++) {
546
+ const char = line[index];
547
+ if (escaped) {
548
+ escaped = false;
549
+ continue;
550
+ }
551
+ if (char === '\\') {
552
+ escaped = true;
553
+ continue;
554
+ }
555
+ if (quote) {
556
+ if (char === quote) quote = null;
557
+ continue;
558
+ }
559
+ if (char === "'" || char === '"') {
560
+ quote = char;
561
+ continue;
562
+ }
563
+ const operator = line.startsWith('&&', index) ? '&&' : line.startsWith('||', index) ? '||' : char === ';' ? ';' : null;
564
+ if (!operator) continue;
565
+ if (!line.slice(commandStart, index).trim()) throw new Error(`Missing command before operator ${operator}`);
566
+ count += 1;
567
+ index += operator.length - 1;
568
+ commandStart = index + 1;
569
+ }
570
+ if (quote) throw new Error('Command contains an unclosed quote');
571
+ if (!line.slice(commandStart).trim()) throw new Error('Missing command after operator');
572
+ count += 1;
573
+ }
574
+ return count;
575
+ }
576
+
577
+ /** 本地命令服务是否可用(成功则永久缓存;失败后短暂重试,避免探测结果永久失效) */
578
+ const sandboxProbe: ServiceProbeState = { available: false, retryAt: 0 };
579
+ async function detectSandbox(workspaceDir?: string | null): Promise<boolean> {
580
+ return probeLocalService(
581
+ sandboxProbe,
582
+ '/api-v2/exec',
583
+ { script: 'echo 1', session: workspaceDir },
584
+ workspaceDir,
585
+ );
586
+ }
587
+
588
+ /** 把整段脚本送去本地命令服务执行(白名单/确认票据/超时均由服务端校验) */
589
+ async function execShell(
590
+ execution: GeneratedSkillExecution,
591
+ args: Record<string, unknown>,
592
+ confirm?: SkillConfirmFn | null,
593
+ workspaceDir?: string | null,
594
+ ): Promise<string> {
595
+ const script = interpolate(execution.script ?? '', args);
596
+ if (script.length > 8000) return 'ERROR: Script exceeds 8,000 characters';
597
+ let commandCount: number;
598
+ try {
599
+ commandCount = countShellCommands(script);
600
+ } catch (error) {
601
+ return `ERROR: ${(error as Error).message}`;
602
+ }
603
+ if (commandCount === 0) return 'ERROR: Script is empty';
604
+ if (commandCount > 20) return 'ERROR: Script contains more than 20 commands';
605
+
606
+ // 先尝试让本地命令服务执行(服务端决定是否需要确认)
607
+ const safeWorkspaceDir = normalizeWorkspaceDir(workspaceDir);
608
+ const result = await sendToSandbox(script, safeWorkspaceDir);
609
+ if (typeof result === 'string') return result; // ERROR: ...
610
+
611
+ // 服务端返回需要确认:脚本包含非白名单命令
612
+ if (result.needConfirm) {
613
+ const confirmationRequestId = result.confirmationRequestId;
614
+ let approved = false;
615
+ if (confirm && confirmationRequestId) {
616
+ try {
617
+ approved = await confirm({
618
+ sessionId: -1,
619
+ toolName: 'run_shell_script',
620
+ title: translate('tool.gateShellNonAllowlistedTitle'),
621
+ message: translate('tool.gateShellNonAllowlistedMsg'),
622
+ argsJson: JSON.stringify({ script }),
623
+ });
624
+ } catch {
625
+ approved = false;
626
+ }
627
+ }
628
+ if (!approved) return translate('tool.shellDenied', { name: 'shell' });
629
+ const approvalError = await approveSandboxScript(confirmationRequestId!);
630
+ if (approvalError) return approvalError;
631
+ return await execSandboxScript(script, confirmationRequestId!, safeWorkspaceDir!);
632
+ }
633
+
634
+ return truncateToolOutput(result.output ?? '');
635
+ }
636
+
637
+ interface SandboxResult {
638
+ needConfirm: boolean;
639
+ output?: string;
640
+ confirmationRequestId?: string;
641
+ confirmationReason?: 'non_allowlisted';
642
+ }
643
+
644
+ /** 发送脚本到本地沙箱;返回 needConfirm=true 表示需用户批准后重发 */
645
+ async function sendToSandbox(script: string, workspaceDir: string | null): Promise<SandboxResult | string> {
646
+ const available = await detectSandbox(workspaceDir);
647
+ if (!available) return 'ERROR: Local command service is not running (start it with node sandbox-server.mjs)';
648
+ try {
649
+ const resp = await fetch('/api-v2/exec', {
650
+ method: 'POST',
651
+ headers: { 'Content-Type': 'application/json' },
652
+ body: JSON.stringify({ script: script.slice(0, 8000), session: workspaceDir }),
653
+ });
654
+ const data = (await resp.json()) as {
655
+ ok?: boolean;
656
+ message?: string;
657
+ output?: string;
658
+ needConfirm?: boolean;
659
+ confirmationRequestId?: string;
660
+ confirmationReason?: 'non_allowlisted';
661
+ };
662
+ if (resp.ok && data.ok) return { needConfirm: false, output: data.output ?? '' };
663
+ if (data.needConfirm) {
664
+ return {
665
+ needConfirm: true,
666
+ confirmationRequestId: data.confirmationRequestId,
667
+ confirmationReason: data.confirmationReason,
668
+ };
669
+ }
670
+ return `ERROR: ${data?.message ?? `HTTP ${resp.status}`}`;
671
+ } catch (e) {
672
+ return `ERROR: Shell execution endpoint failed: ${(e as Error).message}`;
673
+ }
674
+ }
675
+
676
+ /** 用户确认后,通过 Vite 的受信任端点批准待执行请求。 */
677
+ async function approveSandboxScript(confirmationRequestId: string): Promise<string | null> {
678
+ try {
679
+ const resp = await fetch('/api-v2/approve', {
680
+ method: 'POST',
681
+ headers: { 'Content-Type': 'application/json' },
682
+ body: JSON.stringify({ confirmationRequestId }),
683
+ });
684
+ const data = (await resp.json()) as { ok?: boolean; message?: string };
685
+ if (resp.ok && data.ok) return null;
686
+ return `ERROR: ${data?.message ?? `HTTP ${resp.status}`}`;
687
+ } catch (error) {
688
+ return `ERROR: Shell approval endpoint failed: ${(error as Error).message}`;
689
+ }
690
+ }
691
+
692
+ /** 消费已由受信任批准端点授权的一次性请求。 */
693
+ async function execSandboxScript(script: string, confirmationRequestId: string, workspaceDir: string): Promise<string> {
694
+ const resp = await fetch('/api-v2/exec', {
695
+ method: 'POST',
696
+ headers: { 'Content-Type': 'application/json' },
697
+ body: JSON.stringify({
698
+ script: script.slice(0, 8000),
699
+ confirmationRequestId,
700
+ session: workspaceDir,
701
+ }),
702
+ });
703
+ const data = (await resp.json()) as { ok?: boolean; message?: string; output?: string };
704
+ if (resp.ok && data.ok) return truncateToolOutput(data.output ?? '');
705
+ return `ERROR: ${data?.message ?? `HTTP ${resp.status}`}`;
706
+ }