@gt-fe/eap-runtime 0.0.10 → 0.0.12

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 +3 -3
  2. package/dist/api/handlers/invoke.handler.js +4 -0
  3. package/dist/api/handlers/stream.handler.js +4 -0
  4. package/dist/contracts/trace-collector.d.ts +1 -1
  5. package/dist/governance/governance-client.js +40 -16
  6. package/dist/governance/index.d.ts +1 -1
  7. package/dist/governance/index.js +1 -1
  8. package/dist/governance/permission-guard.d.ts +1 -0
  9. package/dist/governance/permission-guard.js +13 -4
  10. package/dist/graph/adapters/langgraph.adapter.js +11 -6
  11. package/dist/graph/contracts/executable-graph.d.ts +2 -0
  12. package/dist/graph/hitl/hitl-handler.d.ts +3 -0
  13. package/dist/graph/hitl/hitl-handler.js +4 -1
  14. package/dist/graph/nodes/tool-node.js +27 -2
  15. package/dist/index.d.ts +1 -1
  16. package/dist/index.js +1 -1
  17. package/dist/providers/echo-llm-provider.js +2 -2
  18. package/dist/registry/deployment-snapshot.d.ts +14 -2
  19. package/dist/registry/deployment-snapshot.js +56 -30
  20. package/dist/registry/graph-loader.js +4 -4
  21. package/dist/registry/index.d.ts +1 -1
  22. package/dist/registry/index.js +1 -1
  23. package/dist/runtime/agent-runtime.js +18 -4
  24. package/dist/runtime/dev-runtime.d.ts +1 -1
  25. package/dist/runtime/dev-runtime.js +6 -10
  26. package/dist/tool/dev-http-executor.js +9 -6
  27. package/dist/tool/dev-mcp-executor.js +5 -3
  28. package/dist/tool/dev-tool-executor.d.ts +1 -1
  29. package/dist/tool/dev-tool-executor.js +2 -2
  30. package/dist/tool/harness-command.d.ts +32 -0
  31. package/dist/tool/harness-command.js +276 -0
  32. package/dist/tool/harness-primitives.d.ts +25 -3
  33. package/dist/tool/harness-primitives.js +156 -49
  34. package/dist/tool/http-tool-executor.js +15 -20
  35. package/dist/tool/index.d.ts +3 -2
  36. package/dist/tool/index.js +3 -2
  37. package/dist/tool/local-mcp-client.js +54 -23
  38. package/dist/tool/local-tool-executor.js +13 -9
  39. package/dist/tool/mcp-tool-executor.js +14 -6
  40. package/dist/tool/skill-script-refs.d.ts +20 -0
  41. package/dist/tool/skill-script-refs.js +62 -0
  42. package/dist/tool/system-prompt-builder.d.ts +2 -2
  43. package/dist/tool/system-prompt-builder.js +7 -4
  44. package/dist/tool/timeout.d.ts +14 -0
  45. package/dist/tool/timeout.js +36 -0
  46. package/dist/tool/tool-confirmation.js +2 -1
  47. package/dist/tool/tool-executor.interface.d.ts +13 -3
  48. package/dist/tool/tool-orchestrator.d.ts +1 -1
  49. package/dist/tool/tool-orchestrator.js +6 -3
  50. package/package.json +3 -3
  51. package/dist/common/concurrency.d.ts +0 -79
  52. package/dist/common/concurrency.js +0 -121
  53. package/dist/common/queue-consumer.d.ts +0 -125
  54. package/dist/common/queue-consumer.js +0 -159
  55. package/dist/tool/mock-tool-executor.d.ts +0 -28
  56. package/dist/tool/mock-tool-executor.js +0 -65
@@ -2,15 +2,18 @@
2
2
  * 平台默认 Harness 原语:read_file / write_file / execute_command。
3
3
  *
4
4
  * 不写进 agent.manifest.toolsets。同名业务工具优先(问数 file-io 等)。
5
- * 读:工作目录 + 技能包 + EAP_LOCAL_SKILLS_DIR + FILE_IO_ROOT。
6
- * 写:仅工作目录,且不跟随符号链接。
7
- * 命令:cwd 落在 jail 内;环境变量白名单,不继承 process.env。
5
+ * 写:`users/<userId>/`(含用户自己的 skills)。
6
+ * 读:写根 + 已装配 `preinstall/tools`、`preinstall/skills`。
7
+ * 命令:cwd 仅写根。技能包只读,EAP_SKILL_DIR 为整包绝对根(不限 scripts/)。不把预装拷进用户装配槽。
8
+ * 开发态可选 overlay:`EAP_LOCAL_SKILLS_DIR` / `EAP_LOCAL_TOOLS_DIR`(只读)。
8
9
  */
9
10
  import { spawn } from 'node:child_process';
10
11
  import { constants as fsConstants, realpathSync } from 'node:fs';
11
12
  import { mkdir, open, readFile } from 'node:fs/promises';
12
13
  import path from 'node:path';
13
14
  import { BUILTIN_TOOL_SOURCE, DEFAULT_BUILTIN_TIMEOUT_MS, MAX_BUILTIN_TIMEOUT_MS, clampBuiltinTimeout, isBuiltinToolExecution, resolveBuiltinToolsConfig, } from '@gt-fe/eap-contracts';
15
+ import { ensureInterpreterShims, formatSpawnError, killProcessTree, resolveShellSpawn, sandboxEnv, spawnOptionsFor, substituteSkillDir, } from './harness-command.js';
16
+ import { expandSkillScriptRefs, isSkillEntryFile } from './skill-script-refs.js';
14
17
  function builtinConfig() {
15
18
  return resolveBuiltinToolsConfig(process.env);
16
19
  }
@@ -29,7 +32,7 @@ function harnessDefinitions() {
29
32
  manifest: {
30
33
  id: 'builtin:read_file',
31
34
  name: 'read_file',
32
- description: '读取工作目录或技能目录内的文本。要用某个技能时读该技能文件夹下的 SKILL.md。超限会截断并注明。',
35
+ description: '读取当前用户目录或预装 tools/skills 内的文本。要用某个技能时读该技能文件夹下的 SKILL.md。超限会截断并注明。',
33
36
  ...common,
34
37
  },
35
38
  inputSchema: {
@@ -48,7 +51,7 @@ function harnessDefinitions() {
48
51
  manifest: {
49
52
  id: 'builtin:write_file',
50
53
  name: 'write_file',
51
- description: '写入工作目录内的文本文件。技能目录只读,禁止写出 jail。',
54
+ description: '写入当前用户目录(含用户自己的 skills)。预装 tools / skills 只读。',
52
55
  ...common,
53
56
  },
54
57
  inputSchema: {
@@ -66,7 +69,7 @@ function harnessDefinitions() {
66
69
  manifest: {
67
70
  id: 'builtin:execute_command',
68
71
  name: 'execute_command',
69
- description: `在 jail 内执行命令(跑技能包 scripts、pip 等)。默认超时 ${DEFAULT_BUILTIN_TIMEOUT_MS}ms,可用 timeoutMs 延长,上限 ${MAX_BUILTIN_TIMEOUT_MS}ms。不继承密钥环境变量。`,
72
+ 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。不继承密钥。`,
70
73
  ...common,
71
74
  },
72
75
  inputSchema: {
@@ -74,7 +77,7 @@ function harnessDefinitions() {
74
77
  required: ['command'],
75
78
  properties: {
76
79
  command: { type: 'string' },
77
- cwd: { type: 'string', description: '相对工作目录的子目录,可选' },
80
+ cwd: { type: 'string', description: '用户目录内的子目录,可选;省略则用户目录。不能是预装技能/工具包' },
78
81
  timeoutMs: {
79
82
  type: 'integer',
80
83
  description: `超时毫秒。省略则 ${DEFAULT_BUILTIN_TIMEOUT_MS}。上限 ${MAX_BUILTIN_TIMEOUT_MS}。`,
@@ -99,9 +102,63 @@ export function mergeHarnessPrimitives(loadedTools) {
99
102
  export function assembleLoadedTools(loadedTools) {
100
103
  return mergeHarnessPrimitives(loadedTools);
101
104
  }
102
- function defaultExtraRoots() {
103
- const extra = [path.resolve(process.cwd(), 'skills')];
104
- for (const key of ['EAP_LOCAL_SKILLS_DIR', 'FILE_IO_ROOT']) {
105
+ const MISSING_USER_DIR = '缺少用户目录,拒绝该操作';
106
+ const PROTECTED_WRITE_NAMES = new Set(['.assembly.json', 'assembly.json', '.ready']);
107
+ /** `{agentDir}/users/<userId>`(写根;含用户 skills) */
108
+ export function resolveUserDir(agentDir, userId) {
109
+ const dir = agentDir.trim();
110
+ const uid = userId.trim();
111
+ if (!dir || !uid) {
112
+ throw new Error(MISSING_USER_DIR);
113
+ }
114
+ if (uid === '.' || uid === '..' || /[\\/]/.test(uid)) {
115
+ throw new Error('非法用户标识');
116
+ }
117
+ return path.resolve(dir, 'users', uid);
118
+ }
119
+ export function collectToolBundlePaths(loadedTools) {
120
+ const roots = [];
121
+ for (const def of Object.values(loadedTools ?? {})) {
122
+ const execution = def.manifest.execution;
123
+ if (!execution || typeof execution !== 'object' || Array.isArray(execution))
124
+ continue;
125
+ const bundlePath = execution.bundlePath;
126
+ if (typeof bundlePath !== 'string' || !bundlePath.trim())
127
+ continue;
128
+ if (!roots.includes(bundlePath))
129
+ roots.push(bundlePath);
130
+ }
131
+ return roots;
132
+ }
133
+ export function resolveJailFromState(state) {
134
+ const execUser = state.executionContext?.userId;
135
+ const userId = (typeof execUser === 'string' && execUser.trim()) ||
136
+ (typeof state.userId === 'string' && state.userId.trim()) ||
137
+ '';
138
+ const agentDir = typeof state.agentDir === 'string' ? state.agentDir.trim() : '';
139
+ const extraRoots = [];
140
+ for (const skill of Object.values(state.skillDefinitions ?? {})) {
141
+ if (skill.bundlePath)
142
+ extraRoots.push(skill.bundlePath);
143
+ }
144
+ extraRoots.push(...collectToolBundlePaths(state.loadedTools));
145
+ const scoped = agentDir
146
+ ? extraRoots.filter((root) => isPreinstallExtraRoot(agentDir, root))
147
+ : [];
148
+ return {
149
+ workdir: agentDir && userId ? resolveUserDir(agentDir, userId) : undefined,
150
+ extraRoots: scoped,
151
+ };
152
+ }
153
+ function deploymentAllowsLocalOverlay() {
154
+ const ctx = process.env.EAP_DEPLOYMENT_CONTEXT?.trim();
155
+ return ctx === 'dev' || ctx === 'desktop';
156
+ }
157
+ function devOverlayRoots() {
158
+ if (!deploymentAllowsLocalOverlay())
159
+ return [];
160
+ const extra = [];
161
+ for (const key of ['EAP_LOCAL_SKILLS_DIR', 'EAP_LOCAL_TOOLS_DIR']) {
105
162
  const value = process.env[key]?.trim();
106
163
  if (value)
107
164
  extra.push(value);
@@ -117,8 +174,12 @@ function tryRealpath(p) {
117
174
  }
118
175
  }
119
176
  export function resolveJailRoots(options) {
120
- const workdir = path.resolve(options?.workdir ?? process.cwd());
121
- const candidates = [workdir, ...defaultExtraRoots(), ...(options?.extraRoots ?? [])];
177
+ const workdir = options?.workdir?.trim() ? path.resolve(options.workdir) : undefined;
178
+ const candidates = [
179
+ ...(workdir ? [workdir] : []),
180
+ ...devOverlayRoots(),
181
+ ...(options?.extraRoots ?? []),
182
+ ];
122
183
  const roots = [];
123
184
  for (const raw of candidates) {
124
185
  const resolved = path.resolve(raw);
@@ -128,27 +189,6 @@ export function resolveJailRoots(options) {
128
189
  }
129
190
  return roots;
130
191
  }
131
- function sandboxEnv(workdir, cwd, allowlist) {
132
- const env = {
133
- PATH: process.env.PATH ?? '/usr/bin:/bin:/usr/local/bin',
134
- LANG: process.env.LANG ?? 'C.UTF-8',
135
- LC_ALL: process.env.LC_ALL ?? process.env.LANG ?? 'C.UTF-8',
136
- HOME: workdir,
137
- TMPDIR: workdir,
138
- TMP: workdir,
139
- TEMP: workdir,
140
- PWD: cwd,
141
- };
142
- const pinned = new Set(['HOME', 'TMPDIR', 'TMP', 'TEMP', 'PWD']);
143
- for (const key of allowlist) {
144
- if (pinned.has(key))
145
- continue;
146
- const value = process.env[key];
147
- if (value !== undefined)
148
- env[key] = value;
149
- }
150
- return env;
151
- }
152
192
  async function writeFileNoFollow(target, content) {
153
193
  const flags = fsConstants.O_WRONLY |
154
194
  fsConstants.O_CREAT |
@@ -176,6 +216,28 @@ function isInsideRoot(candidate, root) {
176
216
  const rel = path.relative(root, candidate);
177
217
  return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
178
218
  }
219
+ function realOrResolve(p) {
220
+ return tryRealpath(p) ?? path.resolve(p);
221
+ }
222
+ /** 路径落在写根外的 extraRoot 时,返回该 extraRoot。 */
223
+ export function extraRootContaining(abs, extraRoots, workdir) {
224
+ const target = realOrResolve(abs);
225
+ const work = realOrResolve(workdir);
226
+ if (isInsideRoot(target, work))
227
+ return undefined;
228
+ for (const raw of extraRoots) {
229
+ const root = realOrResolve(raw);
230
+ if (isInsideRoot(target, root))
231
+ return root;
232
+ }
233
+ return undefined;
234
+ }
235
+ function isPreinstallExtraRoot(agentDir, root) {
236
+ const resolved = path.resolve(root);
237
+ const tools = path.resolve(agentDir, 'preinstall', 'tools');
238
+ const skills = path.resolve(agentDir, 'preinstall', 'skills');
239
+ return isInsideRoot(resolved, tools) || isInsideRoot(resolved, skills);
240
+ }
179
241
  function resolveViaExistingAncestor(abs) {
180
242
  const suffix = [];
181
243
  let cur = abs;
@@ -195,8 +257,15 @@ export function resolveJailedPath(requested, roots, opts) {
195
257
  if (!trimmed || trimmed.includes('\0')) {
196
258
  throw new Error('非法路径');
197
259
  }
198
- const workdir = roots[0] ?? path.resolve(process.cwd());
199
- const abs = path.isAbsolute(trimmed) ? path.normalize(trimmed) : path.resolve(workdir, trimmed);
260
+ const relativeBase = opts?.relativeBase?.trim()
261
+ ? path.resolve(opts.relativeBase)
262
+ : undefined;
263
+ if (!path.isAbsolute(trimmed) && !relativeBase) {
264
+ throw new Error(MISSING_USER_DIR);
265
+ }
266
+ const abs = path.isAbsolute(trimmed)
267
+ ? path.normalize(trimmed)
268
+ : path.resolve(relativeBase, trimmed);
200
269
  const existing = tryRealpath(abs);
201
270
  if (opts?.mustExist && !existing) {
202
271
  throw new Error(`文件不存在:${trimmed}`);
@@ -233,12 +302,28 @@ export class HarnessPrimitiveExecutor {
233
302
  this.workdir = options?.workdir;
234
303
  this.extraRoots = options?.extraRoots;
235
304
  }
305
+ workdirFor(request) {
306
+ const fromRequest = request.jailWorkdir?.trim();
307
+ if (fromRequest)
308
+ return path.resolve(fromRequest);
309
+ return this.workdir?.trim() ? path.resolve(this.workdir) : undefined;
310
+ }
236
311
  rootsFor(request) {
237
312
  return resolveJailRoots({
238
- workdir: this.workdir,
239
- extraRoots: [...(this.extraRoots ?? []), ...(request.jailExtraRoots ?? [])],
313
+ workdir: this.workdirFor(request),
314
+ extraRoots: this.extraRootsFor(request),
240
315
  });
241
316
  }
317
+ extraRootsFor(request) {
318
+ return [...(this.extraRoots ?? []), ...(request.jailExtraRoots ?? [])];
319
+ }
320
+ requireWorkdir(request) {
321
+ const workdir = this.workdirFor(request);
322
+ if (!workdir) {
323
+ throw new Error(MISSING_USER_DIR);
324
+ }
325
+ return tryRealpath(workdir) ?? workdir;
326
+ }
242
327
  async execute(request) {
243
328
  try {
244
329
  switch (request.toolName) {
@@ -258,7 +343,12 @@ export class HarnessPrimitiveExecutor {
258
343
  }
259
344
  async readFile(request) {
260
345
  const filePath = String(request.arguments.path ?? request.arguments.file ?? '');
261
- const target = resolveJailedPath(filePath, this.rootsFor(request), { mustExist: true });
346
+ const workdir = this.workdirFor(request);
347
+ const relativeBase = workdir ? (tryRealpath(workdir) ?? path.resolve(workdir)) : undefined;
348
+ const target = resolveJailedPath(filePath, this.rootsFor(request), {
349
+ mustExist: true,
350
+ relativeBase,
351
+ });
262
352
  const buf = await readFile(target);
263
353
  const config = builtinConfig();
264
354
  const lines = buf.toString('utf8').split('\n');
@@ -286,42 +376,59 @@ export class HarnessPrimitiveExecutor {
286
376
  if (notes.length > 0) {
287
377
  text = `${text}\n[${notes.join('; ')}]`;
288
378
  }
379
+ if (isSkillEntryFile(target)) {
380
+ text = expandSkillScriptRefs(text, path.dirname(target));
381
+ }
289
382
  return ok(request, text);
290
383
  }
291
384
  async writeFile(request) {
292
385
  const filePath = String(request.arguments.path ?? '');
293
386
  const content = String(request.arguments.content ?? '');
294
- const workdir = this.rootsFor(request)[0];
387
+ const workdir = this.requireWorkdir(request);
295
388
  const writeRoots = [workdir];
296
- const target = resolveJailedPath(filePath, writeRoots);
389
+ const jailed = { relativeBase: workdir };
390
+ const target = resolveJailedPath(filePath, writeRoots, jailed);
391
+ if (PROTECTED_WRITE_NAMES.has(path.basename(target))) {
392
+ throw new Error(`禁止写入平台文件:${path.basename(target)}`);
393
+ }
297
394
  await mkdir(path.dirname(target), { recursive: true });
298
- const recast = resolveJailedPath(filePath, writeRoots);
395
+ const recast = resolveJailedPath(filePath, writeRoots, jailed);
396
+ if (PROTECTED_WRITE_NAMES.has(path.basename(recast))) {
397
+ throw new Error(`禁止写入平台文件:${path.basename(recast)}`);
398
+ }
299
399
  await writeFileNoFollow(recast, content);
300
400
  return ok(request, JSON.stringify({ ok: true, path: path.relative(workdir, recast) }));
301
401
  }
302
402
  async executeCommand(request) {
303
- const command = String(request.arguments.command ?? '').trim();
403
+ const command = substituteSkillDir(String(request.arguments.command ?? '').trim(), request.skillDir);
304
404
  if (!command)
305
405
  return fail(request, 'command 不能为空');
306
- const roots = this.rootsFor(request);
307
- const workdir = roots[0];
406
+ const workdir = this.requireWorkdir(request);
407
+ await mkdir(workdir, { recursive: true });
308
408
  const cwdArg = request.arguments.cwd != null ? String(request.arguments.cwd) : workdir;
309
- const cwd = resolveJailedPath(cwdArg, roots, { mustExist: true });
409
+ const cwd = resolveJailedPath(cwdArg, [workdir], {
410
+ mustExist: true,
411
+ relativeBase: workdir,
412
+ });
310
413
  const config = builtinConfig();
311
414
  const timeoutMs = clampBuiltinTimeout(request.arguments.timeoutMs, config);
312
415
  const maxOutput = config.maxOutputBytes;
416
+ const shell = resolveShellSpawn(command);
417
+ const shimDir = ensureInterpreterShims(workdir);
418
+ const env = sandboxEnv(workdir, cwd, config.envAllowlist, shimDir, request.skillDir);
313
419
  const output = await new Promise((resolve, reject) => {
314
- const child = spawn('/bin/sh', ['-c', command], {
420
+ const child = spawn(shell.file, shell.args, {
315
421
  cwd,
316
- env: sandboxEnv(workdir, cwd, config.envAllowlist),
422
+ env,
317
423
  stdio: ['ignore', 'pipe', 'pipe'],
424
+ ...spawnOptionsFor(shell),
318
425
  });
319
426
  let stdout = Buffer.alloc(0);
320
427
  let stderr = Buffer.alloc(0);
321
428
  let stdoutTruncated = false;
322
429
  let stderrTruncated = false;
323
430
  const timer = setTimeout(() => {
324
- child.kill('SIGKILL');
431
+ killProcessTree(child);
325
432
  reject(new Error(`命令超时(${timeoutMs}ms)`));
326
433
  }, timeoutMs);
327
434
  child.stdout?.on('data', (chunk) => {
@@ -344,7 +451,7 @@ export class HarnessPrimitiveExecutor {
344
451
  });
345
452
  child.on('error', (err) => {
346
453
  clearTimeout(timer);
347
- reject(err);
454
+ reject(formatSpawnError(err, shell.file));
348
455
  });
349
456
  child.on('close', (code) => {
350
457
  clearTimeout(timer);
@@ -4,40 +4,45 @@
4
4
  * @see docs/contracts/tool-execution.md §2
5
5
  * @see docs/contracts/cross-service-api.md §4.6
6
6
  */
7
+ import { ErrorCodes } from '@gt-fe/eap-contracts';
8
+ import { abortOrTimeoutMessage, isAbortError } from './timeout.js';
7
9
  function mapGatewayHttpError(status, body, statusText) {
8
10
  const code = body?.error?.code;
9
- const message = body?.error?.message ?? body?.message ?? statusText;
11
+ const bodyMessage = [body?.error?.message, body?.message].find((s) => typeof s === 'string' && s.trim()) ?? '';
12
+ const message = bodyMessage ||
13
+ (typeof statusText === 'string' && statusText.trim()) ||
14
+ '';
10
15
  if (status === 403) {
11
16
  return {
12
- error: message,
17
+ error: bodyMessage || '用户无该 Tool 执行权限',
13
18
  errorCode: code ?? 'NO_PERMISSION',
14
19
  retryable: false,
15
20
  };
16
21
  }
17
22
  if (status === 429) {
18
23
  return {
19
- error: message,
24
+ error: message || `HTTP ${status}`,
20
25
  errorCode: code ?? 'RATE_LIMITED',
21
26
  retryable: body?.error?.retryable ?? true,
22
27
  };
23
28
  }
24
29
  if (status === 503) {
25
30
  return {
26
- error: message,
31
+ error: message || `HTTP ${status}`,
27
32
  errorCode: code ?? 'CIRCUIT_OPEN',
28
33
  retryable: body?.error?.retryable ?? true,
29
34
  };
30
35
  }
31
36
  if (status === 404) {
32
37
  return {
33
- error: message,
38
+ error: message || `HTTP ${status}`,
34
39
  errorCode: code ?? 'TOOL_NOT_FOUND',
35
40
  retryable: false,
36
41
  };
37
42
  }
38
43
  if (status === 400) {
39
44
  return {
40
- error: message,
45
+ error: message || `HTTP ${status}`,
41
46
  errorCode: code ?? 'INVALID_ARGUMENTS',
42
47
  retryable: false,
43
48
  };
@@ -132,25 +137,15 @@ export class HttpToolExecutor {
132
137
  }
133
138
  catch (error) {
134
139
  clearTimeout(timeoutId);
135
- if (error instanceof Error && error.name === 'AbortError') {
136
- return {
137
- toolCallId: request.toolCallId,
138
- toolName: request.toolName,
139
- content: '',
140
- success: false,
141
- error: `Request timeout after ${this.timeout}ms`,
142
- errorCode: 'EXECUTION_FAILED',
143
- runtime: 'server',
144
- };
145
- }
146
- const message = error instanceof Error ? error.message : 'Unknown network error';
140
+ const timeoutSec = Math.max(1, Math.round(this.timeout / 1000));
141
+ const aborted = isAbortError(error);
147
142
  return {
148
143
  toolCallId: request.toolCallId,
149
144
  toolName: request.toolName,
150
145
  content: '',
151
146
  success: false,
152
- error: message,
153
- errorCode: 'EXECUTION_FAILED',
147
+ error: abortOrTimeoutMessage(error, timeoutSec),
148
+ errorCode: aborted ? ErrorCodes.TOOL_TIMEOUT : 'EXECUTION_FAILED',
154
149
  runtime: 'server',
155
150
  };
156
151
  }
@@ -7,12 +7,13 @@ export { LocalToolExecutor, isLocalPreferredExecution, resolveCodeExecutionFromR
7
7
  export { DevMcpExecutor, type DevMcpExecutorOptions, } from './dev-mcp-executor.js';
8
8
  export { DevHttpExecutor, buildHttpToolUrl, resolveHttpExecutionFromRequest, type DevHttpExecution, } from './dev-http-executor.js';
9
9
  export { DevToolExecutor, type DevToolExecutorOptions } from './dev-tool-executor.js';
10
- export { MockToolExecutor, type MockToolExecutorOptions, } from './mock-tool-executor.js';
11
10
  export { LocalMcpClient, type ILocalMcpClient, type LocalMcpInvokeContext, type LocalMcpInvokeResult, } from './local-mcp-client.js';
12
11
  export { fileArtifactFromToolOutput, extractFileReference, resolveFileArtifact, type FileArtifact, } from './file-artifact.js';
13
12
  export { ToolExecutionRouter, type DeploymentContext, type ToolExecutionRouterOptions, } from './tool-execution-router.js';
14
13
  export { createToolExecutor, EmptyInstallStore, type CreateToolExecutorOptions, } from './create-tool-executor.js';
15
- export { assembleLoadedTools, mergeHarnessPrimitives as mergeBuiltinTools, withHarnessPrimitives as withBuiltinTools, HarnessPrimitiveExecutor as BuiltinToolExecutor, type HarnessJailOptions as BuiltinJailOptions, } from './harness-primitives.js';
14
+ export { assembleLoadedTools, mergeHarnessPrimitives as mergeBuiltinTools, withHarnessPrimitives as withBuiltinTools, HarnessPrimitiveExecutor as BuiltinToolExecutor, resolveUserDir, resolveJailFromState, collectToolBundlePaths, type HarnessJailOptions as BuiltinJailOptions, } from './harness-primitives.js';
15
+ export { resolveShellSpawn, shellNameFor, commandDialectLines, findPythonInterpreter, findNodeInterpreter, ensureInterpreterShims, substituteSkillDir, } from './harness-command.js';
16
+ export { expandSkillScriptRefs, isSkillEntryFile, EAP_SKILL_DIR_ENV, } from './skill-script-refs.js';
16
17
  export { ToolResultInjector, type ToolMessage, } from './tool-result-injector.js';
17
18
  export { ToolEventEmitter } from './tool-event-emitter.js';
18
19
  export { ToolConfirmationInterceptor } from './tool-confirmation.js';
@@ -6,12 +6,13 @@ export { LocalToolExecutor, isLocalPreferredExecution, resolveCodeExecutionFromR
6
6
  export { DevMcpExecutor, } from './dev-mcp-executor.js';
7
7
  export { DevHttpExecutor, buildHttpToolUrl, resolveHttpExecutionFromRequest, } from './dev-http-executor.js';
8
8
  export { DevToolExecutor } from './dev-tool-executor.js';
9
- export { MockToolExecutor, } from './mock-tool-executor.js';
10
9
  export { LocalMcpClient, } from './local-mcp-client.js';
11
10
  export { fileArtifactFromToolOutput, extractFileReference, resolveFileArtifact, } from './file-artifact.js';
12
11
  export { ToolExecutionRouter, } from './tool-execution-router.js';
13
12
  export { createToolExecutor, EmptyInstallStore, } from './create-tool-executor.js';
14
- export { assembleLoadedTools, mergeHarnessPrimitives as mergeBuiltinTools, withHarnessPrimitives as withBuiltinTools, HarnessPrimitiveExecutor as BuiltinToolExecutor, } from './harness-primitives.js';
13
+ export { assembleLoadedTools, mergeHarnessPrimitives as mergeBuiltinTools, withHarnessPrimitives as withBuiltinTools, HarnessPrimitiveExecutor as BuiltinToolExecutor, resolveUserDir, resolveJailFromState, collectToolBundlePaths, } from './harness-primitives.js';
14
+ export { resolveShellSpawn, shellNameFor, commandDialectLines, findPythonInterpreter, findNodeInterpreter, ensureInterpreterShims, substituteSkillDir, } from './harness-command.js';
15
+ export { expandSkillScriptRefs, isSkillEntryFile, EAP_SKILL_DIR_ENV, } from './skill-script-refs.js';
15
16
  export { ToolResultInjector, } from './tool-result-injector.js';
16
17
  export { ToolEventEmitter } from './tool-event-emitter.js';
17
18
  export { ToolConfirmationInterceptor } from './tool-confirmation.js';
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { spawn } from 'node:child_process';
8
8
  import readline from 'node:readline';
9
+ import { isAbortError, resolveToolTimeoutSec, toToolTimeoutMs, toolTimeoutErrorMessage, } from './timeout.js';
9
10
  import { fileArtifactFromToolOutput, fileBasesFromEnv } from './file-artifact.js';
10
11
  export function formatMcpOutput(result) {
11
12
  const r = result;
@@ -70,10 +71,13 @@ function withUserMeta(params, userId) {
70
71
  function rpcStdio(session, method, params) {
71
72
  const id = session.nextId++;
72
73
  const timeoutMs = session.timeoutMs;
74
+ const timeoutSec = Math.max(1, Math.round(timeoutMs / 1000));
73
75
  return new Promise((resolve, reject) => {
74
76
  const timer = setTimeout(() => {
75
77
  session.pending.delete(id);
76
- reject(new Error(`MCP RPC timeout: ${method}`));
78
+ const err = new Error(toolTimeoutErrorMessage(timeoutSec));
79
+ err.name = 'TimeoutError';
80
+ reject(err);
77
81
  }, timeoutMs);
78
82
  session.pending.set(id, {
79
83
  resolve: (v) => { clearTimeout(timer); resolve(v); },
@@ -86,7 +90,7 @@ async function invokeStdio(execution, args, context) {
86
90
  const { server } = execution;
87
91
  const command = server.command ?? 'node';
88
92
  const spawnArgs = server.args ?? [];
89
- const timeoutMs = execution.timeout ?? 30_000;
93
+ const timeoutMs = toToolTimeoutMs(execution.timeout);
90
94
  const userId = resolveUserId(execution, context);
91
95
  const child = spawn(command, spawnArgs, {
92
96
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -138,12 +142,32 @@ async function invokeStdio(execution, args, context) {
138
142
  rl.close();
139
143
  }
140
144
  }
145
+ async function postMcp(url, headers, payload, signal) {
146
+ const response = await fetch(url, {
147
+ method: 'POST',
148
+ headers,
149
+ body: JSON.stringify(payload),
150
+ signal,
151
+ });
152
+ const sessionId = response.headers.get('mcp-session-id') ?? undefined;
153
+ const raw = await response.text();
154
+ if (!response.ok) {
155
+ const snippet = raw.replace(/\s+/g, ' ').trim().slice(0, 240);
156
+ throw new Error(`MCP HTTP ${response.status}: ${response.statusText}${snippet ? ` ${snippet}` : ''}`);
157
+ }
158
+ const body = parseMcpHttpBody(raw);
159
+ if (body.error) {
160
+ throw new Error(body.error.message ?? 'MCP HTTP error');
161
+ }
162
+ return { body, sessionId };
163
+ }
141
164
  async function invokeStreamableHttp(execution, args, context) {
142
165
  const url = expandEnvRefs(execution.server.url);
143
166
  if (!url) {
144
167
  throw new Error('streamable_http MCP requires server.url');
145
168
  }
146
- const timeoutMs = execution.timeout ?? 30_000;
169
+ const timeoutSec = resolveToolTimeoutSec(execution.timeout);
170
+ const timeoutMs = toToolTimeoutMs(timeoutSec);
147
171
  const controller = new AbortController();
148
172
  const timer = setTimeout(() => controller.abort(), timeoutMs);
149
173
  const userId = resolveUserId(execution, context);
@@ -161,28 +185,35 @@ async function invokeStreamableHttp(execution, args, context) {
161
185
  if (userId) {
162
186
  headers.userId = userId;
163
187
  }
164
- const body = {
165
- jsonrpc: '2.0',
166
- id: 1,
167
- method: 'tools/call',
168
- params: withUserMeta({ name: execution.toolName, arguments: args }, userId),
169
- };
170
188
  try {
171
- const response = await fetch(url, {
172
- method: 'POST',
173
- headers,
174
- body: JSON.stringify(body),
175
- signal: controller.signal,
176
- });
177
- if (!response.ok) {
178
- throw new Error(`MCP HTTP ${response.status}: ${response.statusText}`);
179
- }
180
- const raw = await response.text();
181
- const payload = parseMcpHttpBody(raw);
182
- if (payload.error) {
183
- throw new Error(payload.error.message ?? 'MCP HTTP error');
189
+ const init = await postMcp(url, headers, {
190
+ jsonrpc: '2.0',
191
+ id: 1,
192
+ method: 'initialize',
193
+ params: {
194
+ protocolVersion: '2024-11-05',
195
+ capabilities: {},
196
+ clientInfo: { name: 'eap-runtime-local', version: '0.1.0' },
197
+ },
198
+ }, controller.signal);
199
+ const callHeaders = { ...headers };
200
+ if (init.sessionId)
201
+ callHeaders['mcp-session-id'] = init.sessionId;
202
+ const call = await postMcp(url, callHeaders, {
203
+ jsonrpc: '2.0',
204
+ id: 2,
205
+ method: 'tools/call',
206
+ params: withUserMeta({ name: execution.toolName, arguments: args }, userId),
207
+ }, controller.signal);
208
+ return toInvokeResult(call.body.result, execution.server.env);
209
+ }
210
+ catch (err) {
211
+ if (isAbortError(err)) {
212
+ const timeoutErr = new Error(toolTimeoutErrorMessage(timeoutSec));
213
+ timeoutErr.name = 'TimeoutError';
214
+ throw timeoutErr;
184
215
  }
185
- return toInvokeResult(payload.result, execution.server.env);
216
+ throw err instanceof Error ? err : new Error(String(err));
186
217
  }
187
218
  finally {
188
219
  clearTimeout(timer);
@@ -7,7 +7,8 @@
7
7
  import { spawn } from 'node:child_process';
8
8
  import { existsSync } from 'node:fs';
9
9
  import path from 'node:path';
10
- import { resolveReadyInstallByToolName, resolveMcpExecutionFromInstall, resolveCodeHandlerFromInstall, } from '@gt-fe/eap-contracts';
10
+ import { ErrorCodes, resolveReadyInstallByToolName, resolveMcpExecutionFromInstall, resolveCodeHandlerFromInstall, } from '@gt-fe/eap-contracts';
11
+ import { isAbortError, toToolTimeoutMs, toolTimeoutErrorMessage } from './timeout.js';
11
12
  import { fileArtifactFromToolOutput, fileBasesFromEnv } from './file-artifact.js';
12
13
  import { LocalMcpClient } from './local-mcp-client.js';
13
14
  /**
@@ -66,7 +67,9 @@ function runHandler(handlerPath, input, timeoutMs, execution) {
66
67
  };
67
68
  const timer = setTimeout(() => {
68
69
  child.kill('SIGKILL');
69
- finish(() => reject(new Error(`Local tool timeout after ${timeoutMs}ms`)));
70
+ const timeoutErr = new Error(toolTimeoutErrorMessage(Math.max(1, Math.round(timeoutMs / 1000))));
71
+ timeoutErr.name = 'TimeoutError';
72
+ finish(() => reject(timeoutErr));
70
73
  }, timeoutMs);
71
74
  child.stdout.on('data', (d) => { stdout += d.toString(); });
72
75
  child.stderr.on('data', (d) => { stderr += d.toString(); });
@@ -112,7 +115,7 @@ export function resolveCodeExecutionFromRequest(request) {
112
115
  const handler = typeof e.handler === 'string' ? e.handler : '';
113
116
  const command = typeof e.command === 'string' ? e.command : undefined;
114
117
  const args = Array.isArray(e.args) ? e.args.map(String) : undefined;
115
- const timeoutMs = typeof e.timeout === 'number' && e.timeout > 0 ? e.timeout : undefined;
118
+ const timeoutMs = typeof e.timeout === 'number' && e.timeout > 0 ? toToolTimeoutMs(e.timeout) : undefined;
116
119
  if (runtime === 'executable') {
117
120
  const handlerPath = handler
118
121
  ? (path.isAbsolute(handler) || !bundlePath
@@ -181,14 +184,14 @@ export class LocalToolExecutor {
181
184
  };
182
185
  }
183
186
  catch (error) {
184
- const message = error instanceof Error ? error.message : String(error);
187
+ const aborted = isAbortError(error);
185
188
  return {
186
189
  toolCallId: request.toolCallId,
187
190
  toolName: request.toolName,
188
191
  content: '',
189
192
  success: false,
190
- error: message,
191
- errorCode: 'EXECUTION_FAILED',
193
+ error: error instanceof Error ? error.message : String(error),
194
+ errorCode: aborted ? ErrorCodes.TOOL_TIMEOUT : 'EXECUTION_FAILED',
192
195
  runtime: 'local',
193
196
  };
194
197
  }
@@ -203,6 +206,7 @@ export class LocalToolExecutor {
203
206
  handlerPath,
204
207
  runtime: execution?.runtime,
205
208
  args: execution?.args,
209
+ timeoutMs: typeof execution?.timeout === 'number' ? toToolTimeoutMs(execution.timeout) : undefined,
206
210
  });
207
211
  }
208
212
  async executeMcp(request, install) {
@@ -232,14 +236,14 @@ export class LocalToolExecutor {
232
236
  };
233
237
  }
234
238
  catch (error) {
235
- const message = error instanceof Error ? error.message : String(error);
239
+ const aborted = isAbortError(error);
236
240
  return {
237
241
  toolCallId: request.toolCallId,
238
242
  toolName: request.toolName,
239
243
  content: '',
240
244
  success: false,
241
- error: message,
242
- errorCode: 'EXECUTION_FAILED',
245
+ error: error instanceof Error ? error.message : String(error),
246
+ errorCode: aborted ? ErrorCodes.TOOL_TIMEOUT : 'EXECUTION_FAILED',
243
247
  runtime: 'local',
244
248
  };
245
249
  }