@gt-fe/eap-runtime 0.0.30 → 0.0.32

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 (41) hide show
  1. package/dist/api/handlers/invoke.handler.js +9 -5
  2. package/dist/api/handlers/stream.handler.js +15 -4
  3. package/dist/common/index.d.ts +1 -0
  4. package/dist/common/index.js +1 -0
  5. package/dist/common/user-root.d.ts +8 -0
  6. package/dist/common/user-root.js +19 -0
  7. package/dist/graph/adapters/langgraph.adapter.js +57 -10
  8. package/dist/graph/adapters/skill-entry-span.d.ts +4 -2
  9. package/dist/graph/adapters/skill-entry-span.js +10 -3
  10. package/dist/graph/adapters/skill-read.d.ts +18 -2
  11. package/dist/graph/adapters/skill-read.js +66 -31
  12. package/dist/graph/contracts/executable-graph.d.ts +7 -1
  13. package/dist/graph/nodes/tool-node.d.ts +6 -1
  14. package/dist/graph/nodes/tool-node.js +18 -3
  15. package/dist/registry/deployment-snapshot.d.ts +1 -1
  16. package/dist/registry/deployment-snapshot.js +6 -3
  17. package/dist/registry/skill-tree.d.ts +1 -1
  18. package/dist/registry/skill-tree.js +11 -6
  19. package/dist/runtime/agent-runtime.js +36 -5
  20. package/dist/session/persist-turn-messages.d.ts +13 -6
  21. package/dist/session/persist-turn-messages.js +4 -0
  22. package/dist/sse/events/tool-events.d.ts +29 -0
  23. package/dist/tool/harness-command.d.ts +1 -1
  24. package/dist/tool/harness-command.js +8 -7
  25. package/dist/tool/harness-primitives.d.ts +4 -10
  26. package/dist/tool/harness-primitives.js +189 -130
  27. package/dist/tool/index.d.ts +2 -0
  28. package/dist/tool/index.js +2 -0
  29. package/dist/tool/inject-public-artifact-links.d.ts +32 -0
  30. package/dist/tool/inject-public-artifact-links.js +142 -0
  31. package/dist/tool/skill-relative-read.d.ts +14 -0
  32. package/dist/tool/skill-relative-read.js +98 -0
  33. package/dist/tool/static-artifact-url.d.ts +66 -0
  34. package/dist/tool/static-artifact-url.js +561 -0
  35. package/dist/tool/system-prompt-builder.js +8 -4
  36. package/dist/tool/tool-event-emitter.d.ts +2 -1
  37. package/dist/tool/tool-event-emitter.js +30 -18
  38. package/dist/tool/tool-executor.interface.d.ts +13 -1
  39. package/dist/tool/tool-result-injector.d.ts +3 -3
  40. package/dist/tool/tool-result-injector.js +7 -3
  41. package/package.json +2 -2
@@ -0,0 +1,561 @@
1
+ /**
2
+ * 用户产物公开副本:files/ 工作稿 → public/ 时间戳 copy;拼 Hosting /static url。
3
+ * @see docs/design/user-artifact-delivery.md
4
+ */
5
+ import { randomBytes } from 'node:crypto';
6
+ import { realpathSync } from 'node:fs';
7
+ import { chmod, copyFile, cp, link, lstat, mkdir, open, readdir, rename, rm, stat, unlink, } from 'node:fs/promises';
8
+ import path from 'node:path';
9
+ /** 与 contracts `DEFAULT_BUILTIN_MAX_WRITE_BYTES` 同值;本地编译不依赖未发版 contracts */
10
+ export const DEFAULT_MAX_WRITE_BYTES = 50 * 1024 * 1024;
11
+ export const PUBLIC_DIR_MODE = 0o755;
12
+ export const PUBLIC_FILE_MODE = 0o644;
13
+ export const MAX_SNAPSHOT_ENUM = 5000;
14
+ export const MAX_SNAPSHOT_URLS = 20;
15
+ export const MAX_PUBLIC_NAME_ATTEMPTS = 8;
16
+ export const PUBLIC_EXTENSIONS = new Set([
17
+ 'html',
18
+ 'htm',
19
+ 'pdf',
20
+ 'png',
21
+ 'jpg',
22
+ 'jpeg',
23
+ 'gif',
24
+ 'webp',
25
+ 'css',
26
+ 'xlsx',
27
+ 'xls',
28
+ 'csv',
29
+ 'tsv',
30
+ 'docx',
31
+ 'doc',
32
+ 'pptx',
33
+ 'ppt',
34
+ 'zip',
35
+ 'json',
36
+ 'md',
37
+ 'txt',
38
+ 'xml',
39
+ 'js',
40
+ 'svg',
41
+ ]);
42
+ const DENY_SEGMENTS = new Set([
43
+ 'preinstall',
44
+ 'skills',
45
+ 'node_modules',
46
+ 'node-compile-cache',
47
+ 'work',
48
+ 'files',
49
+ ]);
50
+ const SNAPSHOT_JUNK_NAMES = new Set([
51
+ 'package.json',
52
+ 'package-lock.json',
53
+ 'yarn.lock',
54
+ 'pnpm-lock.yaml',
55
+ 'bun.lock',
56
+ 'bun.lockb',
57
+ 'tsconfig.json',
58
+ 'jsconfig.json',
59
+ 'go.mod',
60
+ 'go.sum',
61
+ 'Cargo.toml',
62
+ 'Cargo.lock',
63
+ 'requirements.txt',
64
+ 'Pipfile',
65
+ 'Pipfile.lock',
66
+ 'poetry.lock',
67
+ 'pyproject.toml',
68
+ 'setup.py',
69
+ 'setup.cfg',
70
+ 'README',
71
+ 'README.md',
72
+ 'README.txt',
73
+ 'LICENSE',
74
+ 'LICENSE.md',
75
+ 'Makefile',
76
+ '__init__.py',
77
+ ].map((n) => n.toLowerCase()));
78
+ const SUFFIX_RE = /_(\d{12}|\d{15})$/;
79
+ const publishQueues = new Map();
80
+ export function staticBaseUrlFromEnv(env = process.env) {
81
+ const raw = env.EAP_STATIC_BASE_URL?.trim();
82
+ if (!raw)
83
+ return undefined;
84
+ return raw.replace(/\/+$/, '');
85
+ }
86
+ export function preinstallRootFromEnv(env = process.env) {
87
+ const raw = env.EAP_PREINSTALL_ROOT?.trim();
88
+ if (!raw)
89
+ return undefined;
90
+ return path.resolve(raw);
91
+ }
92
+ export function encodePublicRel(rel) {
93
+ return rel
94
+ .replace(/\\/g, '/')
95
+ .split('/')
96
+ .filter((seg) => seg.length > 0)
97
+ .map((seg) => encodeURIComponent(seg))
98
+ .join('/');
99
+ }
100
+ export function isPublicUserArtifactRel(rel) {
101
+ const posix = rel.replace(/\\/g, '/').replace(/^\/+/, '');
102
+ const m = posix.match(/^agents\/[a-z0-9][a-z0-9-]*\/users\/[A-Za-z0-9][A-Za-z0-9._-]*\/public\/(.+)$/);
103
+ if (!m)
104
+ return false;
105
+ const rest = m[1];
106
+ const segs = rest.split('/').filter(Boolean);
107
+ if (segs.length === 0)
108
+ return false;
109
+ if (segs.some((seg) => DENY_SEGMENTS.has(seg) || seg.startsWith('.')))
110
+ return false;
111
+ const base = segs[segs.length - 1] ?? '';
112
+ if (base === 'assembly.json')
113
+ return false;
114
+ const first = segs[0] ?? '';
115
+ const firstStem = first.includes('.') ? first.slice(0, first.lastIndexOf('.')) : first;
116
+ if (!/_[0-9]{12}([0-9]{3})?$/.test(firstStem))
117
+ return false;
118
+ const ext = path.extname(base).slice(1).toLowerCase();
119
+ return PUBLIC_EXTENSIONS.has(ext);
120
+ }
121
+ export function publicArtifactUrl(absPath, env = process.env) {
122
+ const rootRaw = preinstallRootFromEnv(env);
123
+ const base = staticBaseUrlFromEnv(env);
124
+ if (!rootRaw || !base)
125
+ return undefined;
126
+ const root = tryRealpath(rootRaw) ?? path.resolve(rootRaw);
127
+ const abs = tryRealpath(absPath) ?? path.resolve(absPath);
128
+ const rel = path.relative(root, abs).replace(/\\/g, '/');
129
+ if (rel.startsWith('..') || path.isAbsolute(rel))
130
+ return undefined;
131
+ if (!isPublicUserArtifactRel(rel))
132
+ return undefined;
133
+ return `${base}/${encodePublicRel(rel)}`;
134
+ }
135
+ export function publicTimeSuffix(now = new Date(), withMs = false) {
136
+ const parts = new Intl.DateTimeFormat('en-GB', {
137
+ timeZone: 'Asia/Shanghai',
138
+ year: '2-digit',
139
+ month: '2-digit',
140
+ day: '2-digit',
141
+ hour: '2-digit',
142
+ minute: '2-digit',
143
+ second: '2-digit',
144
+ hourCycle: 'h23',
145
+ }).formatToParts(now);
146
+ const pick = (type) => parts.find((p) => p.type === type)?.value ?? '00';
147
+ const stamp = `${pick('year')}${pick('month')}${pick('day')}${pick('hour')}${pick('minute')}${pick('second')}`;
148
+ if (!withMs)
149
+ return stamp;
150
+ return `${stamp}${String(now.getMilliseconds()).padStart(3, '0')}`;
151
+ }
152
+ export function stripPublicSuffix(name) {
153
+ const ext = path.extname(name);
154
+ const stem = ext ? name.slice(0, -ext.length) : name;
155
+ const stripped = stem.replace(SUFFIX_RE, '');
156
+ return stripped + ext;
157
+ }
158
+ export function extensionOf(name) {
159
+ return path.extname(name).slice(1).toLowerCase();
160
+ }
161
+ export function isPublicExtension(name) {
162
+ return PUBLIC_EXTENSIONS.has(extensionOf(name));
163
+ }
164
+ export function isSnapshotJunkName(name) {
165
+ return SNAPSHOT_JUNK_NAMES.has(stripPublicSuffix(path.basename(name)).toLowerCase());
166
+ }
167
+ export async function mkdirChmod(dir, mode = PUBLIC_DIR_MODE) {
168
+ await mkdir(dir, { recursive: true, mode });
169
+ await chmod(dir, mode);
170
+ }
171
+ /** 四槽 `skills/` `work/` `files/` `public/`,目录 0755(与 Hosting `ensureUserWorkspace` 对齐)。 */
172
+ export async function ensureUserSlots(userDir) {
173
+ await mkdirChmod(userDir, PUBLIC_DIR_MODE);
174
+ const filesDir = path.join(userDir, 'files');
175
+ const workDir = path.join(userDir, 'work');
176
+ const publicDir = path.join(userDir, 'public');
177
+ const skillsDir = path.join(userDir, 'skills');
178
+ await mkdirChmod(filesDir, PUBLIC_DIR_MODE);
179
+ await mkdirChmod(workDir, PUBLIC_DIR_MODE);
180
+ await mkdirChmod(publicDir, PUBLIC_DIR_MODE);
181
+ await mkdirChmod(skillsDir, PUBLIC_DIR_MODE);
182
+ return { filesDir, workDir, publicDir, skillsDir };
183
+ }
184
+ async function chmodTree(root) {
185
+ await chmod(root, PUBLIC_DIR_MODE);
186
+ try {
187
+ const entries = await readdir(root, { withFileTypes: true });
188
+ for (const ent of entries) {
189
+ const child = path.join(root, ent.name);
190
+ if (ent.isDirectory())
191
+ await chmodTree(child);
192
+ else if (ent.isFile())
193
+ await chmod(child, PUBLIC_FILE_MODE);
194
+ }
195
+ }
196
+ catch {
197
+ return;
198
+ }
199
+ }
200
+ function enqueueUserDir(userDir, job) {
201
+ const key = path.resolve(userDir);
202
+ const prev = publishQueues.get(key) ?? Promise.resolve();
203
+ const next = prev.then(job, job);
204
+ publishQueues.set(key, next.then(() => undefined, () => undefined));
205
+ return next;
206
+ }
207
+ async function exclusiveTmpFile(publicDir) {
208
+ for (let i = 0; i < 16; i++) {
209
+ const tmp = path.join(publicDir, `.tmp-${randomBytes(8).toString('hex')}`);
210
+ try {
211
+ const handle = await open(tmp, 'wx', PUBLIC_FILE_MODE);
212
+ await handle.close();
213
+ return tmp;
214
+ }
215
+ catch (err) {
216
+ if (err.code !== 'EEXIST')
217
+ throw err;
218
+ }
219
+ }
220
+ throw new Error('无法创建公开临时文件');
221
+ }
222
+ async function exclusiveTmpDir(publicDir) {
223
+ for (let i = 0; i < 16; i++) {
224
+ const tmp = path.join(publicDir, `.tmp-${randomBytes(8).toString('hex')}`);
225
+ try {
226
+ await mkdir(tmp, { recursive: false, mode: PUBLIC_DIR_MODE });
227
+ await chmod(tmp, PUBLIC_DIR_MODE);
228
+ return tmp;
229
+ }
230
+ catch (err) {
231
+ if (err.code !== 'EEXIST')
232
+ throw err;
233
+ }
234
+ }
235
+ throw new Error('无法创建公开临时目录');
236
+ }
237
+ async function waitNextMs(from) {
238
+ let now = Date.now();
239
+ while (now <= from) {
240
+ await new Promise((r) => setTimeout(r, 1));
241
+ now = Date.now();
242
+ }
243
+ return new Date(now);
244
+ }
245
+ export async function allocPublicBasename(publicDir, srcFile, originalBasename) {
246
+ const stripped = stripPublicSuffix(originalBasename);
247
+ const ext = path.extname(stripped);
248
+ const stem = ext ? stripped.slice(0, -ext.length) : stripped;
249
+ let withMs = false;
250
+ let lastMs = Date.now();
251
+ for (let attempt = 0; attempt < MAX_PUBLIC_NAME_ATTEMPTS; attempt++) {
252
+ const now = attempt === 0 ? new Date() : await waitNextMs(lastMs);
253
+ lastMs = now.getTime();
254
+ const stamp = publicTimeSuffix(now, withMs);
255
+ const destName = `${stem}_${stamp}${ext}`;
256
+ const dest = path.join(publicDir, destName);
257
+ const tmp = await exclusiveTmpFile(publicDir);
258
+ try {
259
+ await copyFile(srcFile, tmp);
260
+ await chmod(tmp, PUBLIC_FILE_MODE);
261
+ try {
262
+ await link(tmp, dest);
263
+ }
264
+ catch (err) {
265
+ if (err.code === 'EEXIST') {
266
+ withMs = true;
267
+ continue;
268
+ }
269
+ throw err;
270
+ }
271
+ await chmod(dest, PUBLIC_FILE_MODE);
272
+ return destName;
273
+ }
274
+ finally {
275
+ await unlink(tmp).catch(() => undefined);
276
+ }
277
+ }
278
+ return undefined;
279
+ }
280
+ export async function allocPublicDir(publicDir, srcDir, originalDirName) {
281
+ const stem = stripPublicSuffix(originalDirName).replace(/\.[^.]+$/, '') || originalDirName;
282
+ let withMs = false;
283
+ let lastMs = Date.now();
284
+ for (let attempt = 0; attempt < MAX_PUBLIC_NAME_ATTEMPTS; attempt++) {
285
+ const now = attempt === 0 ? new Date() : await waitNextMs(lastMs);
286
+ lastMs = now.getTime();
287
+ const stamp = publicTimeSuffix(now, withMs);
288
+ const destName = `${stem}_${stamp}`;
289
+ const dest = path.join(publicDir, destName);
290
+ const tmp = await exclusiveTmpDir(publicDir);
291
+ try {
292
+ await cp(srcDir, tmp, { recursive: true });
293
+ await chmodTree(tmp);
294
+ try {
295
+ await rename(tmp, dest);
296
+ }
297
+ catch (err) {
298
+ const code = err.code;
299
+ if (code === 'EEXIST' || code === 'ENOTEMPTY' || code === 'EPERM') {
300
+ withMs = true;
301
+ await rm(tmp, { recursive: true, force: true }).catch(() => undefined);
302
+ continue;
303
+ }
304
+ throw err;
305
+ }
306
+ await chmodTree(dest);
307
+ return destName;
308
+ }
309
+ catch (err) {
310
+ await rm(tmp, { recursive: true, force: true }).catch(() => undefined);
311
+ throw err;
312
+ }
313
+ }
314
+ return undefined;
315
+ }
316
+ function groupKey(relFromFiles) {
317
+ const posix = relFromFiles.replace(/\\/g, '/').replace(/^\/+/, '');
318
+ const slash = posix.indexOf('/');
319
+ return slash === -1 ? '.' : posix.slice(0, slash);
320
+ }
321
+ async function publishedFromAbs(abs, publicDir, env, sourceRel) {
322
+ const st = await stat(abs).catch(() => undefined);
323
+ if (!st?.isFile())
324
+ return undefined;
325
+ const fileName = path.relative(publicDir, abs).replace(/\\/g, '/');
326
+ return {
327
+ fileName,
328
+ fileType: extensionOf(abs),
329
+ size: st.size,
330
+ url: publicArtifactUrl(abs, env),
331
+ absPath: abs,
332
+ sourceRel,
333
+ };
334
+ }
335
+ async function pickDirUrlFile(dirAbs) {
336
+ const index = path.join(dirAbs, 'index.html');
337
+ if ((await stat(index).catch(() => undefined))?.isFile())
338
+ return index;
339
+ let newest;
340
+ async function walk(dir) {
341
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
342
+ for (const ent of entries) {
343
+ const child = path.join(dir, ent.name);
344
+ if (ent.isDirectory())
345
+ await walk(child);
346
+ else if (ent.isFile() && isPublicExtension(ent.name)) {
347
+ const st = await stat(child);
348
+ if (!newest || st.mtimeMs > newest.mtime)
349
+ newest = { abs: child, mtime: st.mtimeMs };
350
+ }
351
+ }
352
+ }
353
+ await walk(dirAbs);
354
+ return newest?.abs;
355
+ }
356
+ export async function publishFromFiles(userDir, relFromFiles, env = process.env) {
357
+ return enqueueUserDir(userDir, async () => {
358
+ const { filesDir, publicDir } = await ensureUserSlots(userDir);
359
+ const unique = [...new Set(relFromFiles.map((p) => p.replace(/\\/g, '/').replace(/^\/+/, '')))];
360
+ const groups = new Map();
361
+ for (const rel of unique) {
362
+ const key = groupKey(rel);
363
+ const list = groups.get(key) ?? [];
364
+ list.push(rel);
365
+ groups.set(key, list);
366
+ }
367
+ const out = [];
368
+ const rootFiles = groups.get('.') ?? [];
369
+ groups.delete('.');
370
+ for (const rel of rootFiles) {
371
+ const src = path.join(filesDir, rel);
372
+ const st = await stat(src).catch(() => undefined);
373
+ if (!st?.isFile())
374
+ continue;
375
+ if (!isPublicExtension(rel))
376
+ continue;
377
+ const destName = await allocPublicBasename(publicDir, src, path.basename(rel));
378
+ if (!destName)
379
+ continue;
380
+ const published = await publishedFromAbs(path.join(publicDir, destName), publicDir, env, rel);
381
+ if (published)
382
+ out.push(published);
383
+ }
384
+ for (const [dirName, _rels] of groups) {
385
+ const srcDir = path.join(filesDir, dirName);
386
+ const st = await stat(srcDir).catch(() => undefined);
387
+ if (!st?.isDirectory())
388
+ continue;
389
+ const destName = await allocPublicDir(publicDir, srcDir, dirName);
390
+ if (!destName)
391
+ continue;
392
+ const destAbs = path.join(publicDir, destName);
393
+ const urlFile = await pickDirUrlFile(destAbs);
394
+ if (!urlFile)
395
+ continue;
396
+ const inner = path.relative(destAbs, urlFile).replace(/\\/g, '/');
397
+ const sourceRel = inner && inner !== '.' ? `${dirName}/${inner}` : dirName;
398
+ const published = await publishedFromAbs(urlFile, publicDir, env, sourceRel);
399
+ if (published)
400
+ out.push(published);
401
+ }
402
+ return out;
403
+ });
404
+ }
405
+ export async function walkFilesTree(filesDir) {
406
+ const entries = new Map();
407
+ let truncated = false;
408
+ async function walk(dir, relBase) {
409
+ if (entries.size >= MAX_SNAPSHOT_ENUM) {
410
+ truncated = true;
411
+ return;
412
+ }
413
+ const list = await readdir(dir, { withFileTypes: true }).catch(() => []);
414
+ for (const ent of list) {
415
+ if (entries.size >= MAX_SNAPSHOT_ENUM) {
416
+ truncated = true;
417
+ return;
418
+ }
419
+ if (DENY_SEGMENTS.has(ent.name) || ent.name.startsWith('.'))
420
+ continue;
421
+ const rel = relBase ? `${relBase}/${ent.name}` : ent.name;
422
+ const abs = path.join(dir, ent.name);
423
+ if (ent.isDirectory()) {
424
+ await walk(abs, rel);
425
+ continue;
426
+ }
427
+ if (!ent.isFile())
428
+ continue;
429
+ const st = await lstat(abs).catch(() => undefined);
430
+ if (!st?.isFile())
431
+ continue;
432
+ entries.set(rel.replace(/\\/g, '/'), {
433
+ rel: rel.replace(/\\/g, '/'),
434
+ size: st.size,
435
+ mtimeMs: st.mtimeMs,
436
+ ctimeMs: st.ctimeMs,
437
+ });
438
+ }
439
+ }
440
+ await walk(filesDir, '');
441
+ return { entries, truncated };
442
+ }
443
+ export function snapshotCandidates(before, after, maxWriteBytes = DEFAULT_MAX_WRITE_BYTES) {
444
+ const rels = [];
445
+ let skippedOversize = 0;
446
+ for (const [rel, now] of after) {
447
+ if (!isPublicExtension(rel))
448
+ continue;
449
+ if (isSnapshotJunkName(rel))
450
+ continue;
451
+ if (now.size > maxWriteBytes) {
452
+ skippedOversize += 1;
453
+ continue;
454
+ }
455
+ const prev = before.get(rel);
456
+ if (!prev || prev.size !== now.size || prev.mtimeMs !== now.mtimeMs || prev.ctimeMs !== now.ctimeMs) {
457
+ rels.push(rel);
458
+ }
459
+ }
460
+ rels.sort((a, b) => {
461
+ const ma = after.get(a).mtimeMs;
462
+ const mb = after.get(b).mtimeMs;
463
+ if (mb !== ma)
464
+ return mb - ma;
465
+ return a.localeCompare(b);
466
+ });
467
+ return { rels, skippedOversize };
468
+ }
469
+ function tryRealpath(p) {
470
+ try {
471
+ return realpathSync(p);
472
+ }
473
+ catch {
474
+ return undefined;
475
+ }
476
+ }
477
+ function resolveViaExistingAncestor(abs) {
478
+ const suffix = [];
479
+ let cur = abs;
480
+ while (true) {
481
+ const real = tryRealpath(cur);
482
+ if (real)
483
+ return path.resolve(real, ...suffix);
484
+ const parent = path.dirname(cur);
485
+ if (parent === cur)
486
+ return abs;
487
+ suffix.unshift(path.basename(cur));
488
+ cur = parent;
489
+ }
490
+ }
491
+ function canonPath(p) {
492
+ const resolved = path.resolve(p);
493
+ return tryRealpath(resolved) ?? resolveViaExistingAncestor(resolved);
494
+ }
495
+ export function resolveUserProductPath(requested, userDir, kind) {
496
+ const trimmed = requested.trim();
497
+ if (!trimmed || trimmed.includes('\0')) {
498
+ throw new Error('非法路径');
499
+ }
500
+ const posix = trimmed.replace(/\\/g, '/');
501
+ const root = canonPath(userDir);
502
+ const filesDir = canonPath(path.join(root, 'files'));
503
+ const skillsDir = canonPath(path.join(root, 'skills'));
504
+ const workDir = canonPath(path.join(root, 'work'));
505
+ const publicDir = canonPath(path.join(root, 'public'));
506
+ const inside = (candidate, dir) => {
507
+ const rel = path.relative(dir, candidate);
508
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
509
+ };
510
+ let abs;
511
+ let keepPrefix = false;
512
+ if (path.isAbsolute(posix)) {
513
+ abs = canonPath(posix);
514
+ }
515
+ else if (posix === 'skills' || posix.startsWith('skills/')) {
516
+ abs = canonPath(path.join(root, posix));
517
+ keepPrefix = true;
518
+ }
519
+ else if (posix === 'files' || posix.startsWith('files/')) {
520
+ abs = canonPath(path.join(root, posix));
521
+ keepPrefix = true;
522
+ }
523
+ else if (posix === 'work' || posix.startsWith('work/') || posix === 'public' || posix.startsWith('public/')) {
524
+ throw new Error(`路径超出允许目录:${trimmed}`);
525
+ }
526
+ else {
527
+ abs = canonPath(path.join(filesDir, posix));
528
+ }
529
+ if (inside(abs, workDir) || inside(abs, publicDir) || abs === root) {
530
+ throw new Error(`路径超出允许目录:${trimmed}`);
531
+ }
532
+ if (inside(abs, skillsDir)) {
533
+ if (kind === 'cwd')
534
+ throw new Error(`路径超出允许目录:${trimmed}`);
535
+ const rel = path.relative(root, abs).replace(/\\/g, '/');
536
+ return { abs, jsonPath: rel, slot: 'skills' };
537
+ }
538
+ if (inside(abs, filesDir) && abs !== filesDir) {
539
+ const relFromFiles = path.relative(filesDir, abs).replace(/\\/g, '/');
540
+ const relFromUser = path.relative(root, abs).replace(/\\/g, '/');
541
+ return {
542
+ abs,
543
+ jsonPath: keepPrefix || path.isAbsolute(posix) ? relFromUser : relFromFiles,
544
+ slot: 'files',
545
+ relFromFiles,
546
+ };
547
+ }
548
+ if (kind === 'cwd' && abs === filesDir) {
549
+ return { abs: filesDir, jsonPath: 'files', slot: 'files', relFromFiles: '' };
550
+ }
551
+ throw new Error(`路径超出允许目录:${trimmed}`);
552
+ }
553
+ export function maxWriteBytesFromEnv(env = process.env) {
554
+ const raw = env.EAP_BUILTIN_MAX_WRITE_BYTES?.trim();
555
+ if (!raw)
556
+ return DEFAULT_MAX_WRITE_BYTES;
557
+ const n = Number(raw);
558
+ if (!Number.isFinite(n) || n <= 0)
559
+ return DEFAULT_MAX_WRITE_BYTES;
560
+ return Math.floor(n);
561
+ }
@@ -166,7 +166,10 @@ export class SystemPromptBuilder {
166
166
  const lines = ['## 文件与命令权限'];
167
167
  if (workdir) {
168
168
  lines.push(`工作目录:${workdir}`);
169
- lines.push('`execute_command` 的 cwd **必须**是工作目录或其子目录,不能是预装技能/工具包。');
169
+ lines.push('产物写在当前用户的 `files/`。工具 JSON 的 `path` 是工作稿原名,可继续 `read_file` / 追加 / 命令使用。');
170
+ lines.push('一套页面写在 `files/<子目录>/`(如 `page/index.html` 与 `page/style.css`),或把 css/js 内联进单文件 html。');
171
+ lines.push('写路径与 cwd 落在 `files/`(装配槽用 `skills/`)。不要写 `public/` 或把 cwd 设成 `work/` / `public/`。');
172
+ lines.push('`execute_command` 的 cwd **必须**是 `files/` 或其子目录,不能是预装技能/工具包、`work/` 或 `public/`。png、xlsx、docx、zip、pdf 等二进制用 `execute_command` 写。');
170
173
  }
171
174
  lines.push(...commandDialectLines(context.os?.platform ?? process.platform));
172
175
  if (skills.length > 0) {
@@ -183,16 +186,17 @@ export class SystemPromptBuilder {
183
186
  lines.push(`- ${root}`);
184
187
  }
185
188
  if (workdir) {
186
- lines.push('`write_file` **仅**允许写入当前用户目录(含用户 skills);预装 tools / skills 只读。');
189
+ lines.push('`write_file` **仅**允许写入当前用户 `files/`(装配槽 `skills/`);预装 tools / skills 只读。');
190
+ lines.push('面向用户的最终文件若 JSON 含 `url`,把该 `url` 原样发给用户(链接文案用相对 `public/` 的路径)。用户要本场前几轮的链接时,从已有 `url` 原样复述;缺盘上已有 `url` 时只说明文件名,容器绝对路径与 `file://` 不当作交付地址。`execute_command` 正文开头若有 `publicFiles`,把其中的 `url` 原样给出。');
187
191
  }
188
192
  lines.push([
189
193
  '硬限制:',
190
194
  '- `read_file` / `write_file` 的路径必须落在上述允许根内。用户要求越界必须拒绝。',
191
- '- `execute_command` 的 cwd 必须落在工作目录内;命令语法跟当前 shell;子进程不继承 API Key 等密钥环境变量。命令本身不是操作系统级文件系统沙箱,禁止尝试读取 `/etc`、家目录或 jail 外路径。',
195
+ '- `execute_command` 的 cwd 必须落在 `files/` 内;命令语法跟当前 shell;子进程不继承 API Key 等密钥环境变量。命令本身不是操作系统级文件系统沙箱,禁止尝试读取 `/etc`、家目录或 jail 外路径。',
192
196
  '- 禁止用 `..` 逃逸;禁止把 jail 外的绝对路径当参数。',
193
197
  '- 不要把内部绝对路径、offload 目录原样展示给用户。',
194
198
  '- 技能正文按需读取,不要把技能目录整包读进回复。',
195
- '- 技能脚本需要依赖时,可在工作目录内用 pip / npm 安装。',
199
+ '- 技能脚本需要依赖时,可在 `files/` 内用 pip / npm 安装。',
196
200
  ].join('\n'));
197
201
  return lines.join('\n');
198
202
  }
@@ -18,7 +18,8 @@ import type { ToolCallRequest, ToolCallResult } from './tool-executor.interface.
18
18
  * 在 ToolOrchestrator 的基础上为每次 Tool 调用添加事件推送:
19
19
  * 1. 执行前 → emit tool.called
20
20
  * 2. 执行成功 → emit tool.result
21
- * 3. 执行失败 → emit tool.error
21
+ * 3. 执行失败 → emit tool.error(payload 可带 content / file / files)
22
+ * 4. 有 url 时再 emit file.created
22
23
  */
23
24
  export declare class ToolEventEmitter {
24
25
  private readonly orchestrator;
@@ -18,7 +18,8 @@
18
18
  * 在 ToolOrchestrator 的基础上为每次 Tool 调用添加事件推送:
19
19
  * 1. 执行前 → emit tool.called
20
20
  * 2. 执行成功 → emit tool.result
21
- * 3. 执行失败 → emit tool.error
21
+ * 3. 执行失败 → emit tool.error(payload 可带 content / file / files)
22
+ * 4. 有 url 时再 emit file.created
22
23
  */
23
24
  export class ToolEventEmitter {
24
25
  orchestrator;
@@ -73,11 +74,11 @@ export class ToolEventEmitter {
73
74
  // 3. emit tool.result 或 tool.error
74
75
  if (result.success) {
75
76
  this.emitToolResult(result, emitter, taskId, correlationId);
76
- this.emitFileCreated(result, emitter, taskId, correlationId);
77
77
  }
78
78
  else {
79
79
  this.emitToolError(result, emitter, taskId, correlationId);
80
80
  }
81
+ this.emitFileCreated(result, emitter, taskId, correlationId);
81
82
  return result;
82
83
  }
83
84
  /**
@@ -108,6 +109,8 @@ export class ToolEventEmitter {
108
109
  toolName: result.toolName,
109
110
  content: result.content,
110
111
  success: true,
112
+ ...(result.file ? { file: result.file } : {}),
113
+ ...(result.files?.length ? { files: result.files } : {}),
111
114
  },
112
115
  taskId,
113
116
  correlationId,
@@ -126,6 +129,9 @@ export class ToolEventEmitter {
126
129
  toolName: result.toolName,
127
130
  error: result.error ?? 'Unknown error',
128
131
  willRetry: false,
132
+ ...(result.content ? { content: result.content } : {}),
133
+ ...(result.file ? { file: result.file } : {}),
134
+ ...(result.files?.length ? { files: result.files } : {}),
129
135
  },
130
136
  taskId,
131
137
  correlationId,
@@ -134,21 +140,27 @@ export class ToolEventEmitter {
134
140
  emitter.emit(event);
135
141
  }
136
142
  emitFileCreated(result, emitter, taskId, correlationId) {
137
- if (!result.file)
138
- return;
139
- emitter.emit({
140
- type: 'file.created',
141
- data: {
142
- fileName: result.file.fileName,
143
- fileType: result.file.fileType,
144
- size: result.file.size,
145
- url: result.file.url,
146
- toolCallId: result.toolCallId,
147
- toolName: result.toolName,
148
- },
149
- taskId,
150
- correlationId,
151
- timestamp: new Date().toISOString(),
152
- });
143
+ const files = [
144
+ ...(result.file ? [result.file] : []),
145
+ ...(result.files ?? []),
146
+ ];
147
+ for (const file of files) {
148
+ if (!file?.url)
149
+ continue;
150
+ emitter.emit({
151
+ type: 'file.created',
152
+ data: {
153
+ fileName: file.fileName,
154
+ fileType: file.fileType,
155
+ size: file.size,
156
+ url: file.url,
157
+ toolCallId: result.toolCallId,
158
+ toolName: result.toolName,
159
+ },
160
+ taskId,
161
+ correlationId,
162
+ timestamp: new Date().toISOString(),
163
+ });
164
+ }
153
165
  }
154
166
  }