@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.
- package/dist/api/handlers/invoke.handler.js +9 -5
- package/dist/api/handlers/stream.handler.js +15 -4
- package/dist/common/index.d.ts +1 -0
- package/dist/common/index.js +1 -0
- package/dist/common/user-root.d.ts +8 -0
- package/dist/common/user-root.js +19 -0
- package/dist/graph/adapters/langgraph.adapter.js +57 -10
- package/dist/graph/adapters/skill-entry-span.d.ts +4 -2
- package/dist/graph/adapters/skill-entry-span.js +10 -3
- package/dist/graph/adapters/skill-read.d.ts +18 -2
- package/dist/graph/adapters/skill-read.js +66 -31
- package/dist/graph/contracts/executable-graph.d.ts +7 -1
- package/dist/graph/nodes/tool-node.d.ts +6 -1
- package/dist/graph/nodes/tool-node.js +18 -3
- package/dist/registry/deployment-snapshot.d.ts +1 -1
- package/dist/registry/deployment-snapshot.js +6 -3
- package/dist/registry/skill-tree.d.ts +1 -1
- package/dist/registry/skill-tree.js +11 -6
- package/dist/runtime/agent-runtime.js +36 -5
- package/dist/session/persist-turn-messages.d.ts +13 -6
- package/dist/session/persist-turn-messages.js +4 -0
- package/dist/sse/events/tool-events.d.ts +29 -0
- package/dist/tool/harness-command.d.ts +1 -1
- package/dist/tool/harness-command.js +8 -7
- package/dist/tool/harness-primitives.d.ts +4 -10
- package/dist/tool/harness-primitives.js +189 -130
- package/dist/tool/index.d.ts +2 -0
- package/dist/tool/index.js +2 -0
- package/dist/tool/inject-public-artifact-links.d.ts +32 -0
- package/dist/tool/inject-public-artifact-links.js +142 -0
- package/dist/tool/skill-relative-read.d.ts +14 -0
- package/dist/tool/skill-relative-read.js +98 -0
- package/dist/tool/static-artifact-url.d.ts +66 -0
- package/dist/tool/static-artifact-url.js +561 -0
- package/dist/tool/system-prompt-builder.js +8 -4
- package/dist/tool/tool-event-emitter.d.ts +2 -1
- package/dist/tool/tool-event-emitter.js +30 -18
- package/dist/tool/tool-executor.interface.d.ts +13 -1
- package/dist/tool/tool-result-injector.d.ts +3 -3
- package/dist/tool/tool-result-injector.js +7 -3
- 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('`
|
|
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`
|
|
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
|
|
195
|
+
'- `execute_command` 的 cwd 必须落在 `files/` 内;命令语法跟当前 shell;子进程不继承 API Key 等密钥环境变量。命令本身不是操作系统级文件系统沙箱,禁止尝试读取 `/etc`、家目录或 jail 外路径。',
|
|
192
196
|
'- 禁止用 `..` 逃逸;禁止把 jail 外的绝对路径当参数。',
|
|
193
197
|
'- 不要把内部绝对路径、offload 目录原样展示给用户。',
|
|
194
198
|
'- 技能正文按需读取,不要把技能目录整包读进回复。',
|
|
195
|
-
'-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
}
|