@gt-fe/eap-runtime 0.0.30 → 0.0.31

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 (36) 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 +30 -3
  8. package/dist/graph/adapters/skill-read.js +13 -4
  9. package/dist/graph/contracts/executable-graph.d.ts +7 -1
  10. package/dist/graph/nodes/tool-node.d.ts +4 -1
  11. package/dist/graph/nodes/tool-node.js +15 -1
  12. package/dist/registry/deployment-snapshot.d.ts +1 -1
  13. package/dist/registry/deployment-snapshot.js +6 -3
  14. package/dist/registry/skill-tree.d.ts +1 -1
  15. package/dist/registry/skill-tree.js +11 -6
  16. package/dist/runtime/agent-runtime.js +36 -5
  17. package/dist/session/persist-turn-messages.d.ts +13 -6
  18. package/dist/session/persist-turn-messages.js +4 -0
  19. package/dist/sse/events/tool-events.d.ts +29 -0
  20. package/dist/tool/harness-command.d.ts +1 -1
  21. package/dist/tool/harness-command.js +8 -7
  22. package/dist/tool/harness-primitives.d.ts +3 -3
  23. package/dist/tool/harness-primitives.js +185 -60
  24. package/dist/tool/index.d.ts +2 -0
  25. package/dist/tool/index.js +2 -0
  26. package/dist/tool/inject-public-artifact-links.d.ts +32 -0
  27. package/dist/tool/inject-public-artifact-links.js +142 -0
  28. package/dist/tool/static-artifact-url.d.ts +66 -0
  29. package/dist/tool/static-artifact-url.js +561 -0
  30. package/dist/tool/system-prompt-builder.js +8 -4
  31. package/dist/tool/tool-event-emitter.d.ts +2 -1
  32. package/dist/tool/tool-event-emitter.js +30 -18
  33. package/dist/tool/tool-executor.interface.d.ts +8 -1
  34. package/dist/tool/tool-result-injector.d.ts +3 -3
  35. package/dist/tool/tool-result-injector.js +7 -3
  36. package/package.json +2 -2
@@ -30,6 +30,19 @@ export interface ToolResultPayload {
30
30
  content: unknown;
31
31
  /** 是否执行成功 */
32
32
  success: boolean;
33
+ /** 与 ToolCallResult.file 同形;SSE / persist / 注入共用 */
34
+ file?: {
35
+ fileName: string;
36
+ fileType: string;
37
+ size: number;
38
+ url: string;
39
+ };
40
+ files?: Array<{
41
+ fileName: string;
42
+ fileType: string;
43
+ size: number;
44
+ url: string;
45
+ }>;
33
46
  }
34
47
  /** 工具执行进度 */
35
48
  export type ToolProgressStatus = 'started' | 'completed' | 'error' | (string & {});
@@ -52,6 +65,8 @@ export interface ToolProgressPayload {
52
65
  content?: unknown;
53
66
  /** status=error 时可选:错误信息 */
54
67
  error?: string;
68
+ file?: ToolResultPayload['file'];
69
+ files?: ToolResultPayload['files'];
55
70
  /** 执行耗时(毫秒),status=completed|error 时可选 */
56
71
  executionTime?: number;
57
72
  }
@@ -65,4 +80,18 @@ export interface ToolErrorPayload {
65
80
  error: string;
66
81
  /** 是否会自动重试 */
67
82
  willRetry: boolean;
83
+ /** 失败且有交付物时保留原正文(与 ToolCallResult.content 同形) */
84
+ content?: unknown;
85
+ file?: {
86
+ fileName: string;
87
+ fileType: string;
88
+ size: number;
89
+ url: string;
90
+ };
91
+ files?: Array<{
92
+ fileName: string;
93
+ fileType: string;
94
+ size: number;
95
+ url: string;
96
+ }>;
68
97
  }
@@ -27,6 +27,6 @@ export declare function ensurePythonShims(workdir: string): string | undefined;
27
27
  * spawn 前替换成绝对路径,不依赖壳去展开。
28
28
  */
29
29
  export declare function substituteSkillDir(command: string, skillDir?: string): string;
30
- export declare function sandboxEnv(workdir: string, cwd: string, allowlist: string[], shimDir?: string, skillDir?: string): NodeJS.ProcessEnv;
30
+ export declare function sandboxEnv(workdir: string, cwd: string, allowlist: string[], shimDir?: string, skillDir?: string, homeDir?: string): NodeJS.ProcessEnv;
31
31
  export declare function formatSpawnError(err: unknown, shellFile: string): Error;
32
32
  export declare function killProcessTree(child: ChildProcess): void;
@@ -21,7 +21,7 @@ export function commandDialectLines(platform = process.platform) {
21
21
  else {
22
22
  lines.push('基础命令:当前目录 `pwd`;列目录 `ls`;环境变量 `$VAR`;串命令用 `&&`。');
23
23
  }
24
- lines.push('解释器:`python`/`python3`、`node`/`nodejs`、`sh`/`bash`(Windows 上 `sh` 需本机有 Git Bash 等)。', '可执行文件:用绝对路径或相对于 cwd(Windows `.exe`/`.bat`/`.cmd`;Unix 二进制需可执行或用 `sh`)。', '`execute_command` 的 cwd 是用户目录。技能包只读。包内任意文件用 `EAP_SKILL_DIR` 加相对路径(不限 `scripts/`)。读到的 SKILL.md 里本包存在的相对路径已展开为绝对路径。不要把 cwd 设成技能目录。相对写出在用户目录。');
24
+ lines.push('解释器:`python`/`python3`、`node`/`nodejs`、`sh`/`bash`(Windows 上 `sh` 需本机有 Git Bash 等)。', '可执行文件:用绝对路径或相对于 cwd(Windows `.exe`/`.bat`/`.cmd`;Unix 二进制需可执行或用 `sh`)。', '`execute_command` 的 cwd 默认是用户目录下的 `files/`。技能包只读。包内任意文件用 `EAP_SKILL_DIR` 加相对路径(不限 `scripts/`)。读到的 SKILL.md 里本包存在的相对路径已展开为绝对路径。不要把 cwd 设成技能目录、`work/` 或 `public/`。相对写出在 `files/`。');
25
25
  return lines;
26
26
  }
27
27
  export function resolveShellSpawn(command, platform = process.platform) {
@@ -179,22 +179,23 @@ export function substituteSkillDir(command, skillDir) {
179
179
  .replace(/\$EAP_SKILL_DIR\b/g, dir)
180
180
  .replace(/%EAP_SKILL_DIR%/gi, dir);
181
181
  }
182
- export function sandboxEnv(workdir, cwd, allowlist, shimDir, skillDir) {
182
+ export function sandboxEnv(workdir, cwd, allowlist, shimDir, skillDir, homeDir) {
183
183
  const win = process.platform === 'win32';
184
184
  const hostPath = process.env.PATH
185
185
  ?? (win
186
186
  ? (process.env.SystemRoot ? path.join(process.env.SystemRoot, 'System32') : 'C:\\Windows\\System32')
187
187
  : '/usr/bin:/bin:/usr/local/bin');
188
188
  const pathValue = shimDir ? `${shimDir}${path.delimiter}${hostPath}` : hostPath;
189
+ const home = homeDir?.trim() ? path.resolve(homeDir) : workdir;
189
190
  const env = {
190
191
  PATH: pathValue,
191
192
  LANG: process.env.LANG ?? 'C.UTF-8',
192
193
  LC_ALL: process.env.LC_ALL ?? process.env.LANG ?? 'C.UTF-8',
193
- HOME: workdir,
194
- USERPROFILE: workdir,
195
- TMPDIR: workdir,
196
- TMP: workdir,
197
- TEMP: workdir,
194
+ HOME: home,
195
+ USERPROFILE: home,
196
+ TMPDIR: home,
197
+ TMP: home,
198
+ TEMP: home,
198
199
  PWD: cwd,
199
200
  PYTHONUTF8: '1',
200
201
  PYTHONIOENCODING: 'utf-8',
@@ -2,9 +2,9 @@
2
2
  * 平台默认 Harness 原语:read_file / write_file / execute_command。
3
3
  *
4
4
  * 不写进 agent.manifest.toolsets。同名业务工具优先(问数 file-io 等)。
5
- * 写:`users/<userId>/`(含用户自己的 skills)。
5
+ * 写:`dirname(agentDir)/users/<userId>/`(含用户自己的 skills)。产物默认 `files/`,公开副本 `public/`。
6
6
  * 读:写根 + 已装配 `preinstall/tools`、`preinstall/skills`。
7
- * 命令:cwd 仅写根。技能包只读,EAP_SKILL_DIR 为整包绝对根(不限 scripts/)。不把预装拷进用户装配槽。
7
+ * 命令:cwd 默认 `files/`。技能包只读,EAP_SKILL_DIR 为整包绝对根(不限 scripts/)。不把预装拷进用户装配槽。
8
8
  * 开发态 overlay:`EAP_LOCAL_SKILLS_DIR` / `EAP_LOCAL_TOOLS_DIR`(只读)。在 `createDevRuntime` /
9
9
  * `createToolExecutor` 时钉进执行器 extraRoots;SDK `createAgent` 构造完会恢复 process.env,
10
10
  * 请求时不再读这两项。相对 `read_file` 会把 overlay 根下直接子目录里带 SKILL.md 的包纳入唯一命中。
@@ -19,7 +19,7 @@ export interface HarnessJailOptions {
19
19
  workdir?: string;
20
20
  extraRoots?: string[];
21
21
  }
22
- /** `{agentDir}/users/<userId>`(写根;含用户 skills) */
22
+ /** `dirname(agentDir)/users/<userId>`(写根;含用户 skills) */
23
23
  export declare function resolveUserDir(agentDir: string, userId: string): string;
24
24
  export declare function collectToolBundlePaths(loadedTools?: LoadedToolMap): string[];
25
25
  export declare function resolveJailFromState(state: {
@@ -2,20 +2,24 @@
2
2
  * 平台默认 Harness 原语:read_file / write_file / execute_command。
3
3
  *
4
4
  * 不写进 agent.manifest.toolsets。同名业务工具优先(问数 file-io 等)。
5
- * 写:`users/<userId>/`(含用户自己的 skills)。
5
+ * 写:`dirname(agentDir)/users/<userId>/`(含用户自己的 skills)。产物默认 `files/`,公开副本 `public/`。
6
6
  * 读:写根 + 已装配 `preinstall/tools`、`preinstall/skills`。
7
- * 命令:cwd 仅写根。技能包只读,EAP_SKILL_DIR 为整包绝对根(不限 scripts/)。不把预装拷进用户装配槽。
7
+ * 命令:cwd 默认 `files/`。技能包只读,EAP_SKILL_DIR 为整包绝对根(不限 scripts/)。不把预装拷进用户装配槽。
8
8
  * 开发态 overlay:`EAP_LOCAL_SKILLS_DIR` / `EAP_LOCAL_TOOLS_DIR`(只读)。在 `createDevRuntime` /
9
9
  * `createToolExecutor` 时钉进执行器 extraRoots;SDK `createAgent` 构造完会恢复 process.env,
10
10
  * 请求时不再读这两项。相对 `read_file` 会把 overlay 根下直接子目录里带 SKILL.md 的包纳入唯一命中。
11
11
  */
12
12
  import { spawn } from 'node:child_process';
13
13
  import { constants as fsConstants, existsSync, readdirSync, realpathSync } from 'node:fs';
14
- import { mkdir, open, readFile } from 'node:fs/promises';
14
+ import { chmod, mkdir, open, readFile, stat } from 'node:fs/promises';
15
15
  import path from 'node:path';
16
+ import { MISSING_USER_DIR, userRoot } from '../common/user-root.js';
17
+ import { MAX_SNAPSHOT_ENUM, MAX_SNAPSHOT_URLS, PUBLIC_FILE_MODE, ensureUserSlots, maxWriteBytesFromEnv, publishFromFiles, resolveUserProductPath, snapshotCandidates, walkFilesTree, } from './static-artifact-url.js';
16
18
  import { BUILTIN_TOOL_SOURCE, DEFAULT_BUILTIN_TIMEOUT_MS, MAX_BUILTIN_TIMEOUT_MS, clampBuiltinTimeout, isBuiltinToolExecution, resolveBuiltinToolsConfig, } from '@gt-fe/eap-contracts';
17
19
  import { ensureInterpreterShims, formatSpawnError, killProcessTree, resolveShellSpawn, sandboxEnv, spawnOptionsFor, substituteSkillDir, } from './harness-command.js';
18
20
  import { expandSkillScriptRefs, isSkillEntryFile } from './skill-script-refs.js';
21
+ /** 超时杀进程或 spawn 失败后等 `close`;未到则最多再等此时长再快照。 */
22
+ const SPAWN_CLOSE_GRACE_MS = 1000;
19
23
  function builtinConfig() {
20
24
  return resolveBuiltinToolsConfig(process.env);
21
25
  }
@@ -66,7 +70,7 @@ function harnessDefinitions() {
66
70
  manifest: {
67
71
  id: 'builtin:write_file',
68
72
  name: 'write_file',
69
- description: '把文本整份写入当前用户目录(含用户自己的 skills)。没有该文件则创建;已有文件会整份换成 content,不是改某一行。只要在末尾加内容请用 append_to_file。不能写预装 tools/skills,不能写 .assembly.json 等平台文件。成功时工具结果是 JSON:{"ok":true,"path":"相对用户目录的路径","bytesWritten":字节数}。路径越权或禁写文件名会失败。',
73
+ description: '把 UTF-8 文本整份写入当前用户的 files/(装配槽用 skills/ 前缀)。没有该文件则创建;已有文件会整份换成 content,不是改某一行。只要在末尾加内容请用 append_to_file。不能写 public/ 或 work/,不能写预装 tools/skills,不能写 .assembly.json 等平台文件。png/xlsx/docx/zip/pdf 等二进制请用 execute_command 写。成功时工具结果是 JSON:{"ok":true,"path":"工作稿原名","bytesWritten":字节数,"url":"公开副本链接,未配基址则无此字段"}。路径越权或禁写文件名会失败。',
70
74
  ...common,
71
75
  },
72
76
  inputSchema: {
@@ -77,11 +81,11 @@ function harnessDefinitions() {
77
81
  path: {
78
82
  type: 'string',
79
83
  minLength: 1,
80
- description: '相对当前用户目录的路径,例如 notes/todo.md。不能写预装 tools/skills。',
84
+ description: '相对 files/ 的路径,例如 notes/todo.md 或 page/index.html。装配槽用 skills/ 前缀。不能写预装 tools/skills、public/、work/。',
81
85
  },
82
86
  content: {
83
87
  type: 'string',
84
- description: '文件的完整新内容。会覆盖原文件全部内容。',
88
+ description: '文件的完整新内容(UTF-8 文本)。会覆盖原文件全部内容。',
85
89
  },
86
90
  },
87
91
  },
@@ -92,7 +96,7 @@ function harnessDefinitions() {
92
96
  manifest: {
93
97
  id: 'builtin:append_to_file',
94
98
  name: 'append_to_file',
95
- description: '在当前用户目录的文件末尾追加文本,不覆盖已有内容。没有该文件则创建。不要用它整份改写文件,那是 write_file。不能写预装 tools/skills,不能写 .assembly.json 等平台文件。成功时工具结果是 JSON:{"ok":true,"path":"相对用户目录的路径","bytesWritten":本次追加字节数}。路径越权或禁写文件名会失败。',
99
+ description: '在当前用户 files/ 的文件末尾追加 UTF-8 文本,不覆盖已有内容。没有该文件则创建。不要用它整份改写文件,那是 write_file。不能写 public/ 或 work/,不能写预装 tools/skills,不能写 .assembly.json 等平台文件。成功时工具结果是 JSON:{"ok":true,"path":"工作稿原名","bytesWritten":本次追加字节数,"url":"公开副本链接,未配基址则无此字段"}。路径越权或禁写文件名会失败。',
96
100
  ...common,
97
101
  },
98
102
  inputSchema: {
@@ -103,11 +107,11 @@ function harnessDefinitions() {
103
107
  path: {
104
108
  type: 'string',
105
109
  minLength: 1,
106
- description: '相对当前用户目录的路径,例如 notes/todo.md。不能写预装 tools/skills。',
110
+ description: '相对 files/ 的路径,例如 notes/todo.md。装配槽用 skills/ 前缀。不能写预装 tools/skills、public/、work/。',
107
111
  },
108
112
  content: {
109
113
  type: 'string',
110
- description: '接到文件末尾的文本。不会删除或替换原有内容。',
114
+ description: '接到文件末尾的 UTF-8 文本。不会删除或替换原有内容。',
111
115
  },
112
116
  },
113
117
  },
@@ -118,7 +122,7 @@ function harnessDefinitions() {
118
122
  manifest: {
119
123
  id: 'builtin:execute_command',
120
124
  name: 'execute_command',
121
- description: `用本机 shell 在用户目录执行命令(Windows cmd.exe,其它 /bin/sh)。技能包只读,用 EAP_SKILL_DIR 加包内相对路径跑文件(不限 scripts/)。python/python3、node/nodejs、sh/bash 经 shim。默认超时 ${DEFAULT_BUILTIN_TIMEOUT_MS}ms,可用 timeoutMs 延长,上限 ${MAX_BUILTIN_TIMEOUT_MS}ms。不继承密钥。`,
125
+ description: `用本机 shell 在用户 files/ 执行命令(Windows cmd.exe,其它 /bin/sh)。技能包只读,用 EAP_SKILL_DIR 加包内相对路径跑文件(不限 scripts/)。python/python3、node/nodejs、sh/bash 经 shim。默认超时 ${DEFAULT_BUILTIN_TIMEOUT_MS}ms,可用 timeoutMs 延长,上限 ${MAX_BUILTIN_TIMEOUT_MS}ms。不继承密钥。HOME/TMPDIR 在 work/。子进程 close 后(含超时杀进程)把 files/ 里新的或有改动的公开类型文件做成 public/ 时间戳副本。`,
122
126
  ...common,
123
127
  },
124
128
  inputSchema: {
@@ -126,7 +130,7 @@ function harnessDefinitions() {
126
130
  required: ['command'],
127
131
  properties: {
128
132
  command: { type: 'string' },
129
- cwd: { type: 'string', description: '用户目录内的子目录,可选;省略则用户目录。不能是预装技能/工具包' },
133
+ cwd: { type: 'string', description: 'files/ 内的子目录,可选;省略则 files/。不能是 work/、public/ 或预装技能/工具包' },
130
134
  timeoutMs: {
131
135
  type: 'integer',
132
136
  description: `超时毫秒。省略则 ${DEFAULT_BUILTIN_TIMEOUT_MS}。上限 ${MAX_BUILTIN_TIMEOUT_MS}。`,
@@ -151,19 +155,10 @@ export function mergeHarnessPrimitives(loadedTools) {
151
155
  export function assembleLoadedTools(loadedTools) {
152
156
  return mergeHarnessPrimitives(loadedTools);
153
157
  }
154
- const MISSING_USER_DIR = '缺少用户目录,拒绝该操作';
155
158
  const PROTECTED_WRITE_NAMES = new Set(['.assembly.json', 'assembly.json', '.ready']);
156
- /** `{agentDir}/users/<userId>`(写根;含用户 skills) */
159
+ /** `dirname(agentDir)/users/<userId>`(写根;含用户 skills) */
157
160
  export function resolveUserDir(agentDir, userId) {
158
- const dir = agentDir.trim();
159
- const uid = userId.trim();
160
- if (!dir || !uid) {
161
- throw new Error(MISSING_USER_DIR);
162
- }
163
- if (uid === '.' || uid === '..' || /[\\/]/.test(uid)) {
164
- throw new Error('非法用户标识');
165
- }
166
- return path.resolve(dir, 'users', uid);
161
+ return userRoot(agentDir, userId);
167
162
  }
168
163
  export function collectToolBundlePaths(loadedTools) {
169
164
  const roots = [];
@@ -270,6 +265,17 @@ async function writeFileNoFollow(target, content, append = false) {
270
265
  await handle.close();
271
266
  }
272
267
  }
268
+ function maxWriteBytes(config) {
269
+ const n = config.maxWriteBytes;
270
+ if (typeof n === 'number' && Number.isFinite(n) && n > 0)
271
+ return Math.floor(n);
272
+ return maxWriteBytesFromEnv();
273
+ }
274
+ function toolFileFromPublished(p) {
275
+ if (!p.url)
276
+ return undefined;
277
+ return { fileName: p.fileName, fileType: p.fileType, size: p.size, url: p.url };
278
+ }
273
279
  function isInsideRoot(candidate, root) {
274
280
  const rel = path.relative(root, candidate);
275
281
  return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
@@ -473,10 +479,27 @@ export class HarnessPrimitiveExecutor {
473
479
  const filePath = String(request.arguments.path ?? '');
474
480
  const workdir = this.workdirFor(request);
475
481
  const located = locateSkillRelativeRead(filePath, workdir, request.skillDir, this.extraRootsFor(request));
476
- const target = resolveJailedPath(located.path, this.rootsFor(request), {
477
- mustExist: true,
478
- relativeBase: located.relativeBase,
479
- });
482
+ const userBase = userReadBase(workdir);
483
+ let target;
484
+ if (path.isAbsolute(filePath.trim())) {
485
+ target = resolveJailedPath(filePath, this.rootsFor(request), { mustExist: true });
486
+ }
487
+ else if (located.relativeBase && located.relativeBase !== userBase) {
488
+ target = resolveJailedPath(located.path, this.rootsFor(request), {
489
+ mustExist: true,
490
+ relativeBase: located.relativeBase,
491
+ });
492
+ }
493
+ else if (workdir) {
494
+ const product = resolveUserProductPath(filePath, workdir, 'read');
495
+ target = resolveJailedPath(product.abs, this.rootsFor(request), { mustExist: true });
496
+ }
497
+ else {
498
+ target = resolveJailedPath(located.path, this.rootsFor(request), {
499
+ mustExist: true,
500
+ relativeBase: located.relativeBase,
501
+ });
502
+ }
480
503
  const buf = await readFile(target);
481
504
  const config = builtinConfig();
482
505
  const lines = buf.toString('utf8').split('\n');
@@ -513,38 +536,75 @@ export class HarnessPrimitiveExecutor {
513
536
  const filePath = String(request.arguments.path ?? '');
514
537
  const content = String(request.arguments.content ?? '');
515
538
  const workdir = this.requireWorkdir(request);
516
- const writeRoots = [workdir];
517
- const jailed = { relativeBase: workdir };
518
- const target = resolveJailedPath(filePath, writeRoots, jailed);
519
- if (PROTECTED_WRITE_NAMES.has(path.basename(target))) {
520
- throw new Error(`禁止写入平台文件:${path.basename(target)}`);
539
+ await ensureUserSlots(workdir);
540
+ const resolved = resolveUserProductPath(filePath, workdir, 'write');
541
+ if (PROTECTED_WRITE_NAMES.has(path.basename(resolved.abs))) {
542
+ throw new Error(`禁止写入平台文件:${path.basename(resolved.abs)}`);
543
+ }
544
+ const incomingBytes = Buffer.byteLength(content, 'utf8');
545
+ const limit = maxWriteBytes(builtinConfig());
546
+ if (append) {
547
+ const existing = await stat(resolved.abs)
548
+ .then((s) => s.size)
549
+ .catch(() => 0);
550
+ if (existing + incomingBytes > limit) {
551
+ throw new Error(`文件超过 ${limit} 字节上限`);
552
+ }
553
+ }
554
+ else if (incomingBytes > limit) {
555
+ throw new Error(`文件超过 ${limit} 字节上限`);
521
556
  }
522
- await mkdir(path.dirname(target), { recursive: true });
523
- const recast = resolveJailedPath(filePath, writeRoots, jailed);
524
- if (PROTECTED_WRITE_NAMES.has(path.basename(recast))) {
525
- throw new Error(`禁止写入平台文件:${path.basename(recast)}`);
557
+ await mkdir(path.dirname(resolved.abs), { recursive: true });
558
+ const recast = resolveUserProductPath(filePath, workdir, 'write');
559
+ if (PROTECTED_WRITE_NAMES.has(path.basename(recast.abs))) {
560
+ throw new Error(`禁止写入平台文件:${path.basename(recast.abs)}`);
526
561
  }
527
- const bytesWritten = await writeFileNoFollow(recast, content, append);
528
- return ok(request, JSON.stringify({ ok: true, path: path.relative(workdir, recast), bytesWritten }));
562
+ const bytesWritten = await writeFileNoFollow(recast.abs, content, append);
563
+ await chmod(recast.abs, PUBLIC_FILE_MODE).catch(() => undefined);
564
+ const payload = {
565
+ ok: true,
566
+ path: recast.jsonPath,
567
+ bytesWritten,
568
+ };
569
+ let file;
570
+ if (recast.slot === 'files' && recast.relFromFiles) {
571
+ const published = await publishFromFiles(workdir, [recast.relFromFiles]);
572
+ const first = published[0];
573
+ if (first?.url) {
574
+ payload.url = first.url;
575
+ file = toolFileFromPublished(first);
576
+ }
577
+ }
578
+ return { ...ok(request, JSON.stringify(payload)), ...(file ? { file } : {}) };
529
579
  }
530
580
  async executeCommand(request) {
531
581
  const command = substituteSkillDir(String(request.arguments.command ?? '').trim(), request.skillDir);
532
582
  if (!command)
533
583
  return fail(request, 'command 不能为空');
534
584
  const workdir = this.requireWorkdir(request);
535
- await mkdir(workdir, { recursive: true });
536
- const cwdArg = request.arguments.cwd != null ? String(request.arguments.cwd) : workdir;
537
- const cwd = resolveJailedPath(cwdArg, [workdir], {
538
- mustExist: true,
539
- relativeBase: workdir,
540
- });
585
+ const slots = await ensureUserSlots(workdir);
586
+ let cwd;
587
+ if (request.arguments.cwd != null && String(request.arguments.cwd).trim() !== '') {
588
+ const resolvedCwd = resolveUserProductPath(String(request.arguments.cwd), workdir, 'cwd');
589
+ if (resolvedCwd.slot !== 'files') {
590
+ throw new Error(`路径超出允许目录:${String(request.arguments.cwd)}`);
591
+ }
592
+ cwd = resolvedCwd.abs;
593
+ }
594
+ else {
595
+ cwd = slots.filesDir;
596
+ }
597
+ await mkdir(cwd, { recursive: true });
541
598
  const config = builtinConfig();
542
599
  const timeoutMs = clampBuiltinTimeout(request.arguments.timeoutMs, config);
543
600
  const maxOutput = config.maxOutputBytes;
601
+ const writeLimit = maxWriteBytes(config);
602
+ const before = await walkFilesTree(slots.filesDir);
544
603
  const shell = resolveShellSpawn(command);
545
- const shimDir = ensureInterpreterShims(workdir);
546
- const env = sandboxEnv(workdir, cwd, config.envAllowlist, shimDir, request.skillDir);
547
- const output = await new Promise((resolve, reject) => {
604
+ const shimDir = ensureInterpreterShims(slots.workDir);
605
+ const env = sandboxEnv(workdir, cwd, config.envAllowlist, shimDir, request.skillDir, slots.workDir);
606
+ // 子进程 close 后再快照 files/(超时先杀进程再等 close;close 未到则最多再等 SPAWN_CLOSE_GRACE_MS)。
607
+ const output = await new Promise((resolve) => {
548
608
  const child = spawn(shell.file, shell.args, {
549
609
  cwd,
550
610
  env,
@@ -555,9 +615,36 @@ export class HarnessPrimitiveExecutor {
555
615
  let stderr = Buffer.alloc(0);
556
616
  let stdoutTruncated = false;
557
617
  let stderrTruncated = false;
618
+ let settled = false;
619
+ let timedOut = false;
620
+ let spawnError;
621
+ let closeFallback;
622
+ const finish = (value) => {
623
+ if (settled)
624
+ return;
625
+ settled = true;
626
+ clearTimeout(timer);
627
+ if (closeFallback !== undefined)
628
+ clearTimeout(closeFallback);
629
+ resolve(value);
630
+ };
631
+ const snapshotStdio = () => ({
632
+ stdout: stdout.toString('utf8'),
633
+ stderr: stderr.toString('utf8'),
634
+ stdoutTruncated,
635
+ stderrTruncated,
636
+ });
637
+ const waitForCloseThenFinish = () => {
638
+ if (closeFallback !== undefined)
639
+ clearTimeout(closeFallback);
640
+ closeFallback = setTimeout(() => {
641
+ finish({ ...snapshotStdio(), code: null, timedOut, spawnError });
642
+ }, SPAWN_CLOSE_GRACE_MS);
643
+ };
558
644
  const timer = setTimeout(() => {
645
+ timedOut = true;
559
646
  killProcessTree(child);
560
- reject(new Error(`命令超时(${timeoutMs}ms)`));
647
+ waitForCloseThenFinish();
561
648
  }, timeoutMs);
562
649
  child.stdout?.on('data', (chunk) => {
563
650
  if (stdout.length >= maxOutput) {
@@ -578,33 +665,71 @@ export class HarnessPrimitiveExecutor {
578
665
  stderrTruncated = true;
579
666
  });
580
667
  child.on('error', (err) => {
668
+ spawnError = formatSpawnError(err, shell.file);
581
669
  clearTimeout(timer);
582
- reject(formatSpawnError(err, shell.file));
670
+ waitForCloseThenFinish();
583
671
  });
584
672
  child.on('close', (code) => {
585
- clearTimeout(timer);
586
- resolve({
587
- stdout: stdout.toString('utf8'),
588
- stderr: stderr.toString('utf8'),
589
- code,
590
- stdoutTruncated,
591
- stderrTruncated,
592
- });
673
+ finish({ ...snapshotStdio(), code, timedOut, spawnError });
593
674
  });
594
675
  });
595
- const body = [
676
+ const after = await walkFilesTree(slots.filesDir);
677
+ const { rels, skippedOversize } = snapshotCandidates(before.entries, after.entries, writeLimit);
678
+ const limited = rels.slice(0, MAX_SNAPSHOT_URLS);
679
+ const published = limited.length > 0 ? await publishFromFiles(workdir, limited) : [];
680
+ const publicFiles = published.map((p) => ({
681
+ path: p.sourceRel ?? p.fileName,
682
+ fileName: p.fileName,
683
+ size: p.size,
684
+ ...(p.url ? { url: p.url } : {}),
685
+ }));
686
+ const notes = [];
687
+ if (before.truncated || after.truncated) {
688
+ notes.push(`快照枚举达到 ${MAX_SNAPSHOT_ENUM} 上限`);
689
+ }
690
+ if (skippedOversize > 0) {
691
+ notes.push(`跳过 ${skippedOversize} 个超过大小上限的文件`);
692
+ }
693
+ if (rels.length > MAX_SNAPSHOT_URLS) {
694
+ notes.push(`快照 url 最多 ${MAX_SNAPSHOT_URLS} 个`);
695
+ }
696
+ const withUrl = published
697
+ .map(toolFileFromPublished)
698
+ .filter((f) => Boolean(f));
699
+ const file = withUrl[0];
700
+ const files = withUrl.slice(1);
701
+ const ended = output.timedOut
702
+ ? `命令超时(${timeoutMs}ms)`
703
+ : output.spawnError
704
+ ? output.spawnError.message
705
+ : `exit ${output.code ?? -1}`;
706
+ const commandBody = [
596
707
  output.stdout.trimEnd(),
597
708
  output.stdoutTruncated ? `[stdout truncated at ${maxOutput} bytes]` : '',
598
709
  output.stderr.trim() ? `stderr:\n${output.stderr.trimEnd()}` : '',
599
710
  output.stderrTruncated ? `[stderr truncated at ${maxOutput} bytes]` : '',
600
- `exit ${output.code ?? -1}`,
711
+ ended,
712
+ ...notes,
601
713
  ]
602
714
  .filter(Boolean)
603
715
  .join('\n');
716
+ const body = publicFiles.length > 0
717
+ ? `${JSON.stringify({ publicFiles })}\n${commandBody}`
718
+ : commandBody;
719
+ const extra = {
720
+ ...(file ? { file } : {}),
721
+ ...(files.length > 0 ? { files } : {}),
722
+ };
723
+ if (output.spawnError) {
724
+ return { ...fail(request, output.spawnError.message), content: body, ...extra };
725
+ }
726
+ if (output.timedOut) {
727
+ return { ...fail(request, `命令超时(${timeoutMs}ms)`), content: body, ...extra };
728
+ }
604
729
  if (output.code !== 0) {
605
- return { ...fail(request, `命令退出码 ${output.code}`), content: body };
730
+ return { ...fail(request, `命令退出码 ${output.code}`), content: body, ...extra };
606
731
  }
607
- return ok(request, body);
732
+ return { ...ok(request, body), ...extra };
608
733
  }
609
734
  }
610
735
  export function withHarnessPrimitives(inner, options) {
@@ -15,6 +15,8 @@ export { formatMcpOutput, toModelVisibleToolContent, MAX_MODEL_RESULT_ROWS, MAX_
15
15
  export { ToolExecutionRouter, type DeploymentContext, type ToolExecutionRouterOptions, } from './tool-execution-router.js';
16
16
  export { createToolExecutor, EmptyInstallStore, type CreateToolExecutorOptions, } from './create-tool-executor.js';
17
17
  export { assembleLoadedTools, mergeHarnessPrimitives as mergeBuiltinTools, withHarnessPrimitives as withBuiltinTools, HarnessPrimitiveExecutor as BuiltinToolExecutor, resolveUserDir, resolveJailFromState, captureOverlayJailOptions, collectToolBundlePaths, type HarnessJailOptions as BuiltinJailOptions, } from './harness-primitives.js';
18
+ export { injectPublicArtifactLinks, collectPublicArtifactLinks, applyInjectedAssistant, } from './inject-public-artifact-links.js';
19
+ export { publishFromFiles, ensureUserSlots, publicArtifactUrl, PUBLIC_EXTENSIONS, } from './static-artifact-url.js';
18
20
  export { resolveShellSpawn, shellNameFor, commandDialectLines, findPythonInterpreter, findNodeInterpreter, ensureInterpreterShims, substituteSkillDir, } from './harness-command.js';
19
21
  export { expandSkillScriptRefs, isSkillEntryFile, EAP_SKILL_DIR_ENV, } from './skill-script-refs.js';
20
22
  export { ToolResultInjector, type ToolMessage, } from './tool-result-injector.js';
@@ -14,6 +14,8 @@ export { formatMcpOutput, toModelVisibleToolContent, MAX_MODEL_RESULT_ROWS, MAX_
14
14
  export { ToolExecutionRouter, } from './tool-execution-router.js';
15
15
  export { createToolExecutor, EmptyInstallStore, } from './create-tool-executor.js';
16
16
  export { assembleLoadedTools, mergeHarnessPrimitives as mergeBuiltinTools, withHarnessPrimitives as withBuiltinTools, HarnessPrimitiveExecutor as BuiltinToolExecutor, resolveUserDir, resolveJailFromState, captureOverlayJailOptions, collectToolBundlePaths, } from './harness-primitives.js';
17
+ export { injectPublicArtifactLinks, collectPublicArtifactLinks, applyInjectedAssistant, } from './inject-public-artifact-links.js';
18
+ export { publishFromFiles, ensureUserSlots, publicArtifactUrl, PUBLIC_EXTENSIONS, } from './static-artifact-url.js';
17
19
  export { resolveShellSpawn, shellNameFor, commandDialectLines, findPythonInterpreter, findNodeInterpreter, ensureInterpreterShims, substituteSkillDir, } from './harness-command.js';
18
20
  export { expandSkillScriptRefs, isSkillEntryFile, EAP_SKILL_DIR_ENV, } from './skill-script-refs.js';
19
21
  export { ToolResultInjector, } from './tool-result-injector.js';
@@ -0,0 +1,32 @@
1
+ /**
2
+ * 本轮带 url 的公开副本 → 助手正文末尾 GFM 链接。
3
+ * stream / streamResumeHitl / stream.handler / invoke.handler 共用。
4
+ */
5
+ export type ArtifactLink = {
6
+ fileName: string;
7
+ url: string;
8
+ };
9
+ export type ToolResultLike = {
10
+ content?: unknown;
11
+ file?: {
12
+ fileName?: string;
13
+ url?: string;
14
+ };
15
+ files?: Array<{
16
+ fileName?: string;
17
+ url?: string;
18
+ }>;
19
+ };
20
+ export declare function mergeArtifactLinks(...groups: ArtifactLink[][]): ArtifactLink[];
21
+ export declare function collectPublicArtifactLinks(results: ToolResultLike[]): ArtifactLink[];
22
+ export declare function collectPublicArtifactLinksFromEvent(eventType: string, payload: unknown, bucket: ArtifactLink[]): void;
23
+ export declare function toolResultHasArtifacts(result: ToolResultLike): boolean;
24
+ export declare function applyInjectedAssistant<T extends {
25
+ role?: string;
26
+ content: string;
27
+ toolCalls?: unknown[];
28
+ }>(messages: T[], injected: string, links: ArtifactLink[]): T[];
29
+ export declare function injectPublicArtifactLinks(visibleText: string, artifacts: ArtifactLink[]): {
30
+ injected: string;
31
+ suffix: string;
32
+ };