@steerable/agent-shell 0.6.52 → 0.6.53

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.
@@ -9,7 +9,7 @@
9
9
  * 实现,单测用内存实现——本模块不 import electron,保持纯 Node 可测。
10
10
  */
11
11
  import { randomUUID } from 'node:crypto';
12
- import { mcpExecutor } from './mcp-executor.js';
12
+ import { MAX_MCP_SERVER_TOOLS, mcpExecutor, } from './mcp-executor.js';
13
13
  const STORE_KEY = 'mcpServers';
14
14
  /** 工具名只允许 [a-z0-9-];中文名会被剥光,退化为 srv-<id前6位>。 */
15
15
  export function sanitizeServerKey(name, id) {
@@ -19,15 +19,90 @@ export function sanitizeServerKey(name, id) {
19
19
  .replace(/^-+|-+$/g, '');
20
20
  return cleaned || `srv-${id.slice(0, 6)}`;
21
21
  }
22
+ function normalizeStoredEntry(entry) {
23
+ if ('transport' in entry)
24
+ return entry;
25
+ const legacy = entry;
26
+ return { ...legacy, transport: 'stdio' };
27
+ }
28
+ function normalizeTransportInput(input) {
29
+ if (input.transport === 'streamable-http') {
30
+ const raw = input;
31
+ if (typeof raw.command === 'string' && raw.command.trim()) {
32
+ throw new Error('Streamable HTTP 配置不能包含 command');
33
+ }
34
+ const url = parseMcpHttpUrl(input.url);
35
+ const headers = { ...(input.headers ?? {}) };
36
+ if (Object.keys(headers).some((name) => name.toLowerCase() === 'authorization')) {
37
+ throw new Error('Authorization 不允许明文保存;请使用 bearerTokenEnvVar 或 headersFromEnv');
38
+ }
39
+ const bearerTokenEnvVar = input.bearerTokenEnvVar?.trim() || undefined;
40
+ return {
41
+ transport: 'streamable-http',
42
+ url,
43
+ headers,
44
+ headersFromEnv: { ...(input.headersFromEnv ?? {}) },
45
+ bearerTokenEnvVar,
46
+ reconnect: input.reconnect ? { ...input.reconnect } : undefined,
47
+ };
48
+ }
49
+ if ('url' in input && typeof input.url === 'string' && input.url.trim()) {
50
+ throw new Error('stdio 配置不能包含 url');
51
+ }
52
+ const command = input.command.trim();
53
+ if (!command)
54
+ throw new Error('启动命令不能为空');
55
+ return {
56
+ transport: 'stdio',
57
+ command,
58
+ args: (input.args ?? []).map(String),
59
+ env: { ...(input.env ?? {}) },
60
+ cwd: input.cwd?.trim() || undefined,
61
+ };
62
+ }
63
+ function parseMcpHttpUrl(raw) {
64
+ let url;
65
+ try {
66
+ url = new URL(raw.trim());
67
+ }
68
+ catch {
69
+ throw new Error('MCP HTTP URL 无效');
70
+ }
71
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
72
+ throw new Error('MCP HTTP URL 必须使用 http 或 https');
73
+ }
74
+ if (url.username || url.password) {
75
+ throw new Error('MCP HTTP URL 不能包含凭据');
76
+ }
77
+ return url.toString();
78
+ }
79
+ function stringRecord(value) {
80
+ if (!value || typeof value !== 'object' || Array.isArray(value))
81
+ return {};
82
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [
83
+ key,
84
+ String(item),
85
+ ]));
86
+ }
87
+ function stringValue(value) {
88
+ return typeof value === 'string' ? value.trim() : '';
89
+ }
22
90
  export class McpServerRegistry {
23
91
  store;
92
+ executor;
24
93
  /** serverId → 最近一次 listTools 的结果(内存缓存,重启后由后台刷新重建)。 */
25
94
  toolCache = new Map();
26
- constructor(store) {
95
+ constructor(store, executor = mcpExecutor) {
27
96
  this.store = store;
97
+ this.executor = executor;
28
98
  }
29
99
  list() {
30
- return this.store.get(STORE_KEY) ?? [];
100
+ const stored = this.store.get(STORE_KEY) ?? [];
101
+ const migrated = stored.map(normalizeStoredEntry);
102
+ if (migrated.some((entry, index) => entry !== stored[index])) {
103
+ this.store.set(STORE_KEY, migrated);
104
+ }
105
+ return migrated;
31
106
  }
32
107
  get(idOrName) {
33
108
  const needle = idOrName.trim().toLowerCase();
@@ -43,24 +118,18 @@ export class McpServerRegistry {
43
118
  }
44
119
  create(input) {
45
120
  const name = input.name.trim();
46
- const command = input.command.trim();
47
121
  if (!name)
48
122
  throw new Error('服务名称不能为空');
49
- if (!command)
50
- throw new Error('启动命令不能为空');
51
123
  if (this.get(name))
52
124
  throw new Error(`已存在同名服务「${name}」`);
53
125
  const now = new Date().toISOString();
54
126
  const entry = {
55
127
  id: randomUUID(),
56
128
  name,
57
- command,
58
- args: (input.args ?? []).map((a) => String(a)),
59
- env: { ...(input.env ?? {}) },
60
- cwd: input.cwd?.trim() || undefined,
61
129
  enabled: input.enabled ?? true,
62
130
  createdAt: now,
63
131
  updatedAt: now,
132
+ ...normalizeTransportInput(input),
64
133
  };
65
134
  this.store.set(STORE_KEY, [...this.list(), entry]);
66
135
  return entry;
@@ -77,18 +146,39 @@ export class McpServerRegistry {
77
146
  const nameClash = servers.some((s) => s.id !== id && s.name.toLowerCase() === nextName.toLowerCase());
78
147
  if (nameClash)
79
148
  throw new Error(`已存在同名服务「${nextName}」`);
149
+ const transport = updates.transport ?? current.transport;
150
+ const transportInput = transport === 'stdio'
151
+ ? {
152
+ name: nextName,
153
+ transport: 'stdio',
154
+ command: updates.command ?? (current.transport === 'stdio' ? current.command : ''),
155
+ args: updates.args ?? (current.transport === 'stdio' ? current.args : []),
156
+ env: updates.env ?? (current.transport === 'stdio' ? current.env : {}),
157
+ cwd: updates.cwd ?? (current.transport === 'stdio' ? current.cwd : undefined),
158
+ }
159
+ : {
160
+ name: nextName,
161
+ transport: 'streamable-http',
162
+ url: updates.url ?? (current.transport === 'streamable-http' ? current.url : ''),
163
+ headers: updates.headers ??
164
+ (current.transport === 'streamable-http' ? current.headers : {}),
165
+ headersFromEnv: updates.headersFromEnv ??
166
+ (current.transport === 'streamable-http' ? current.headersFromEnv : {}),
167
+ bearerTokenEnvVar: updates.bearerTokenEnvVar ??
168
+ (current.transport === 'streamable-http'
169
+ ? current.bearerTokenEnvVar
170
+ : undefined),
171
+ reconnect: updates.reconnect ??
172
+ (current.transport === 'streamable-http' ? current.reconnect : undefined),
173
+ };
80
174
  const next = {
81
- ...current,
175
+ id: current.id,
82
176
  name: nextName,
83
- command: updates.command !== undefined ? updates.command.trim() : current.command,
84
- args: updates.args !== undefined ? updates.args.map((a) => String(a)) : current.args,
85
- env: updates.env !== undefined ? { ...updates.env } : current.env,
86
- cwd: updates.cwd !== undefined ? updates.cwd.trim() || undefined : current.cwd,
87
177
  enabled: updates.enabled !== undefined ? updates.enabled : current.enabled,
178
+ createdAt: current.createdAt,
88
179
  updatedAt: new Date().toISOString(),
180
+ ...normalizeTransportInput(transportInput),
89
181
  };
90
- if (!next.command)
91
- throw new Error('启动命令不能为空');
92
182
  servers[idx] = next;
93
183
  this.store.set(STORE_KEY, servers);
94
184
  // 配置变了,旧工具缓存不可信
@@ -131,10 +221,27 @@ export class McpServerRegistry {
131
221
  for (const [name, cfgRaw] of Object.entries(container)) {
132
222
  const cfg = (cfgRaw ?? {});
133
223
  const command = typeof cfg.command === 'string' ? cfg.command.trim() : '';
134
- if (!command || this.get(name)) {
224
+ const url = typeof cfg.url === 'string' ? cfg.url.trim() : '';
225
+ if ((!command && !url) || (command && url) || this.get(name)) {
135
226
  result.skipped.push(name);
136
227
  continue;
137
228
  }
229
+ if (url) {
230
+ const headers = stringRecord(cfg.headers);
231
+ const headersFromEnv = stringRecord(cfg.envHttpHeaders ?? cfg.env_http_headers ?? cfg.headersFromEnv);
232
+ const bearerTokenEnvVar = stringValue(cfg.bearerTokenEnvVar ?? cfg.bearer_token_env_var) || undefined;
233
+ const entry = this.create({
234
+ name: name.trim(),
235
+ transport: 'streamable-http',
236
+ url,
237
+ headers,
238
+ headersFromEnv,
239
+ bearerTokenEnvVar,
240
+ enabled: true,
241
+ });
242
+ result.added.push(entry);
243
+ continue;
244
+ }
138
245
  const env = {};
139
246
  if (cfg.env && typeof cfg.env === 'object' && !Array.isArray(cfg.env)) {
140
247
  for (const [k, v] of Object.entries(cfg.env)) {
@@ -160,12 +267,48 @@ export class McpServerRegistry {
160
267
  return clash ? `${base}-${entry.id.slice(0, 4)}` : base;
161
268
  }
162
269
  toExecutorConfig(entry) {
163
- return {
164
- command: entry.command,
165
- args: entry.args,
166
- env: entry.env,
167
- cwd: entry.cwd,
270
+ return entry.transport === 'stdio'
271
+ ? {
272
+ transport: 'stdio',
273
+ command: entry.command,
274
+ args: entry.args,
275
+ env: entry.env,
276
+ cwd: entry.cwd,
277
+ }
278
+ : {
279
+ transport: 'streamable-http',
280
+ url: entry.url,
281
+ headers: entry.headers,
282
+ headersFromEnv: entry.headersFromEnv,
283
+ bearerTokenEnvVar: entry.bearerTokenEnvVar,
284
+ reconnect: entry.reconnect,
285
+ };
286
+ }
287
+ toPublicEntry(entry) {
288
+ const common = {
289
+ id: entry.id,
290
+ name: entry.name,
291
+ transport: entry.transport,
292
+ enabled: entry.enabled,
293
+ createdAt: entry.createdAt,
294
+ updatedAt: entry.updatedAt,
168
295
  };
296
+ return entry.transport === 'stdio'
297
+ ? {
298
+ ...common,
299
+ command: entry.command,
300
+ args: [...entry.args],
301
+ cwd: entry.cwd,
302
+ envKeys: Object.keys(entry.env).sort(),
303
+ }
304
+ : {
305
+ ...common,
306
+ url: entry.url,
307
+ headerNames: Object.keys(entry.headers).sort(),
308
+ headersFromEnv: { ...entry.headersFromEnv },
309
+ bearerTokenEnvVar: entry.bearerTokenEnvVar,
310
+ reconnect: entry.reconnect,
311
+ };
169
312
  }
170
313
  getCachedTools(serverId) {
171
314
  return this.toolCache.get(serverId) ?? null;
@@ -211,8 +354,24 @@ export class McpServerRegistry {
211
354
  const entry = this.list().find((s) => s.id === serverId);
212
355
  if (!entry)
213
356
  throw new Error('服务不存在');
214
- const result = await mcpExecutor.listTools(this.toExecutorConfig(entry));
215
- const cached = result.success
357
+ const applyChanged = (tools) => {
358
+ if (tools.length > MAX_MCP_SERVER_TOOLS)
359
+ return;
360
+ this.toolCache.set(serverId, {
361
+ tools: tools.map((tool) => ({ ...tool })),
362
+ fetchedAt: new Date().toISOString(),
363
+ error: null,
364
+ });
365
+ };
366
+ const result = await this.executor.listTools(this.toExecutorConfig(entry), applyChanged, (error) => {
367
+ this.toolCache.set(serverId, {
368
+ tools: [],
369
+ fetchedAt: new Date().toISOString(),
370
+ error,
371
+ });
372
+ });
373
+ const overCap = (result.tools?.length ?? 0) > MAX_MCP_SERVER_TOOLS;
374
+ const cached = result.success && !overCap
216
375
  ? {
217
376
  tools: (result.tools ?? []).map((t) => ({
218
377
  name: t.name,
@@ -222,7 +381,13 @@ export class McpServerRegistry {
222
381
  fetchedAt: new Date().toISOString(),
223
382
  error: null,
224
383
  }
225
- : { tools: [], fetchedAt: new Date().toISOString(), error: result.error ?? '未知错误' };
384
+ : {
385
+ tools: [],
386
+ fetchedAt: new Date().toISOString(),
387
+ error: overCap
388
+ ? `服务工具数超过每服务 ${MAX_MCP_SERVER_TOOLS} 个的上限`
389
+ : result.error ?? '未知错误',
390
+ };
226
391
  this.toolCache.set(serverId, cached);
227
392
  return cached;
228
393
  }
@@ -58,6 +58,11 @@ export interface ProductConfig {
58
58
  * STEERABLE_APPROVAL=0 仍是调试逃生口。
59
59
  */
60
60
  approval?: 'host' | 'off';
61
+ /**
62
+ * 白名单外出网询问:host = 弹宿主审批;off = 自动加入当前代理会话。
63
+ * 缺省 host。自动放行仅存活到代理进程退出,不修改持久白名单。
64
+ */
65
+ egressApproval?: 'host' | 'off';
61
66
  /**
62
67
  * 对话模式。缺省 `['agent','plan']`。只留一种时渲染层不显示切换。
63
68
  */
@@ -100,11 +105,20 @@ export interface ProductConfig {
100
105
  */
101
106
  builtinMcp?: Array<{
102
107
  name: string;
108
+ transport?: 'stdio';
103
109
  command: string;
104
110
  args?: string[];
105
111
  env?: Record<string, string>;
106
112
  cwd?: string;
107
113
  enabled?: boolean;
114
+ } | {
115
+ name: string;
116
+ transport: 'streamable-http';
117
+ url: string;
118
+ headers?: Record<string, string>;
119
+ headersFromEnv?: Record<string, string>;
120
+ bearerTokenEnvVar?: string;
121
+ enabled?: boolean;
108
122
  }>;
109
123
  /**
110
124
  * P3.1 多智能体编排六件套 (agent_spawn / agent_wait / agent_send / agent_interrupt / agent_close / agent_list)。
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Shell 执行规划:前缀策略,以及本地 / SSH 的启动参数。
3
+ * 这里不启动进程。多条前缀同时命中时,取更严的决定:
4
+ * allow < prompt < forbidden。
5
+ */
6
+ export type ExecDecisionName = 'allow' | 'prompt' | 'forbidden';
7
+ export interface PrefixRule {
8
+ argv: string[];
9
+ decision: ExecDecisionName;
10
+ justification?: string;
11
+ }
12
+ export interface ExecPolicy {
13
+ rules: PrefixRule[];
14
+ }
15
+ export interface ExecEvaluation {
16
+ decision: ExecDecisionName;
17
+ command: string[];
18
+ rule?: PrefixRule;
19
+ }
20
+ export interface SshEndpoint {
21
+ kind: 'ssh';
22
+ destination: string;
23
+ remoteCwd?: string;
24
+ }
25
+ export type ShellEndpoint = {
26
+ kind: 'local';
27
+ } | SshEndpoint;
28
+ export interface ShellRunSpec {
29
+ file: string;
30
+ args: string[];
31
+ cwd?: string;
32
+ env?: Record<string, string>;
33
+ pty: boolean;
34
+ transport: 'local' | 'ssh';
35
+ }
36
+ export interface ShellRunLimits {
37
+ timeoutMs: number;
38
+ maxOutputBytes: number;
39
+ gui: boolean;
40
+ }
41
+ export interface ShellRunOutcome {
42
+ exitCode: number;
43
+ stdout: string;
44
+ stderr: string;
45
+ truncated?: boolean;
46
+ timedOut?: boolean;
47
+ stillRunning?: boolean;
48
+ error?: string;
49
+ }
50
+ export interface ShellProcessBackend {
51
+ run(spec: ShellRunSpec, limits: ShellRunLimits): Promise<ShellRunOutcome>;
52
+ }
53
+ export declare function splitShellScript(script: string): string[][];
54
+ export declare function evaluateExecPolicy(script: string, policy: ExecPolicy): ExecEvaluation;
55
+ export declare function planSshArgv(input: {
56
+ command: string;
57
+ cwd?: string;
58
+ pty: boolean;
59
+ shell?: 'powershell';
60
+ endpoint: SshEndpoint;
61
+ }): ShellRunSpec;
62
+ export declare function shellQuote(value: string): string;
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Shell 执行规划:前缀策略,以及本地 / SSH 的启动参数。
3
+ * 这里不启动进程。多条前缀同时命中时,取更严的决定:
4
+ * allow < prompt < forbidden。
5
+ */
6
+ const DECISION_RANK = {
7
+ allow: 0,
8
+ prompt: 1,
9
+ forbidden: 2,
10
+ };
11
+ export function splitShellScript(script) {
12
+ return splitSegments(script).map(tokenize).filter((argv) => argv.length > 0);
13
+ }
14
+ export function evaluateExecPolicy(script, policy) {
15
+ const commands = splitShellScript(script);
16
+ let best = { decision: 'allow', command: commands[0] ?? [] };
17
+ for (const command of commands) {
18
+ for (const rule of policy.rules) {
19
+ if (rule.argv.length === 0 || !isPrefix(rule.argv, command))
20
+ continue;
21
+ const stricter = DECISION_RANK[rule.decision] > DECISION_RANK[best.decision];
22
+ const sameDecisionLonger = rule.decision === best.decision && rule.argv.length > (best.rule?.argv.length ?? 0);
23
+ if (stricter || sameDecisionLonger) {
24
+ best = { decision: rule.decision, command, rule };
25
+ }
26
+ }
27
+ }
28
+ return best;
29
+ }
30
+ export function planSshArgv(input) {
31
+ const destination = input.endpoint.destination.trim();
32
+ if (!destination) {
33
+ throw new Error('ssh destination is required');
34
+ }
35
+ const cwd = input.cwd ?? input.endpoint.remoteCwd;
36
+ const script = remoteScript(input.command, cwd, input.shell);
37
+ const args = ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=15'];
38
+ if (input.pty)
39
+ args.push('-tt');
40
+ args.push(destination, '--');
41
+ if (input.shell === 'powershell') {
42
+ args.push('pwsh', '-NoProfile', '-NonInteractive', '-Command', script);
43
+ }
44
+ else {
45
+ args.push('bash', '-lc', script);
46
+ }
47
+ return { file: 'ssh', args, pty: input.pty, transport: 'ssh' };
48
+ }
49
+ export function shellQuote(value) {
50
+ return `'${value.replace(/'/g, `'\\''`)}'`;
51
+ }
52
+ function remoteScript(command, cwd, shell) {
53
+ if (!cwd)
54
+ return command;
55
+ if (shell === 'powershell')
56
+ return `Set-Location ${shellQuote(cwd)}; ${command}`;
57
+ return `cd ${shellQuote(cwd)} && ${command}`;
58
+ }
59
+ function isPrefix(prefix, argv) {
60
+ if (prefix.length > argv.length)
61
+ return false;
62
+ return prefix.every((token, index) => token === argv[index]);
63
+ }
64
+ function splitSegments(script) {
65
+ const segments = [];
66
+ let current = '';
67
+ let quote = null;
68
+ for (let i = 0; i < script.length; i += 1) {
69
+ const ch = script[i];
70
+ if (quote) {
71
+ current += ch;
72
+ if (ch === '\\' && quote === '"' && i + 1 < script.length) {
73
+ current += script[i + 1];
74
+ i += 1;
75
+ continue;
76
+ }
77
+ if (ch === quote)
78
+ quote = null;
79
+ continue;
80
+ }
81
+ if (ch === '\\' && i + 1 < script.length) {
82
+ current += ch + script[i + 1];
83
+ i += 1;
84
+ continue;
85
+ }
86
+ if (ch === '"' || ch === "'") {
87
+ quote = ch;
88
+ current += ch;
89
+ continue;
90
+ }
91
+ const two = script.slice(i, i + 2);
92
+ if (two === '&&' || two === '||' || two === '|&') {
93
+ segments.push(current);
94
+ current = '';
95
+ i += 1;
96
+ continue;
97
+ }
98
+ if (ch === ';' || ch === '|' || ch === '\n') {
99
+ segments.push(current);
100
+ current = '';
101
+ continue;
102
+ }
103
+ current += ch;
104
+ }
105
+ segments.push(current);
106
+ return segments.map((segment) => segment.trim()).filter((segment) => segment.length > 0);
107
+ }
108
+ function tokenize(segment) {
109
+ const tokens = [];
110
+ let current = '';
111
+ let quote = null;
112
+ for (let i = 0; i < segment.length; i += 1) {
113
+ const ch = segment[i];
114
+ if (quote) {
115
+ if (ch === '\\' && quote === '"' && i + 1 < segment.length) {
116
+ current += segment[i + 1];
117
+ i += 1;
118
+ continue;
119
+ }
120
+ if (ch === quote) {
121
+ quote = null;
122
+ continue;
123
+ }
124
+ current += ch;
125
+ continue;
126
+ }
127
+ if (ch === '\\' && i + 1 < segment.length) {
128
+ current += segment[i + 1];
129
+ i += 1;
130
+ continue;
131
+ }
132
+ if (ch === '"' || ch === "'") {
133
+ quote = ch;
134
+ continue;
135
+ }
136
+ if (ch === ' ' || ch === '\t') {
137
+ if (current)
138
+ tokens.push(current);
139
+ current = '';
140
+ continue;
141
+ }
142
+ current += ch;
143
+ }
144
+ if (current)
145
+ tokens.push(current);
146
+ return tokens;
147
+ }
@@ -0,0 +1,3 @@
1
+ import type { ShellProcessBackend } from './shell-exec.js';
2
+ export declare function stripAnsi(input: string): string;
3
+ export declare const nodeShellBackend: ShellProcessBackend;
@@ -0,0 +1,137 @@
1
+ /**
2
+ * 本地进程启动:管道用 child_process,PTY 用 node-pty。
3
+ * SSH 也走这里,argv 由 shell-exec 规划好,本文件不再解释命令文本。
4
+ */
5
+ import { spawn } from 'child_process';
6
+ import * as pty from 'node-pty';
7
+ // eslint-disable-next-line no-control-regex
8
+ const ANSI_RE = /[\u001B\u009B][[\]()#;?]*(?:(?:(?:[a-zA-Z\d]*(?:;[a-zA-Z\d]*)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-ntqry=><~]))/g;
9
+ export function stripAnsi(input) {
10
+ return input.replace(ANSI_RE, '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
11
+ }
12
+ export const nodeShellBackend = {
13
+ run(spec, limits) {
14
+ return spec.pty ? runPty(spec, limits) : runPipe(spec, limits);
15
+ },
16
+ };
17
+ function runPipe(spec, limits) {
18
+ return new Promise((resolve) => {
19
+ const stdoutChunks = [];
20
+ const stderrChunks = [];
21
+ let stdoutBytes = 0;
22
+ let stderrBytes = 0;
23
+ let truncated = false;
24
+ let settled = false;
25
+ const child = spawn(spec.file, spec.args, {
26
+ cwd: spec.cwd,
27
+ env: spec.env,
28
+ windowsHide: true,
29
+ });
30
+ const finish = (outcome) => {
31
+ if (settled)
32
+ return;
33
+ settled = true;
34
+ clearTimeout(timer);
35
+ resolve(outcome);
36
+ };
37
+ const take = (chunks, bytes, chunk) => {
38
+ if (bytes >= limits.maxOutputBytes) {
39
+ truncated = true;
40
+ return bytes;
41
+ }
42
+ const remain = limits.maxOutputBytes - bytes;
43
+ const safe = chunk.length > remain ? chunk.subarray(0, remain) : chunk;
44
+ chunks.push(safe);
45
+ if (safe.length < chunk.length)
46
+ truncated = true;
47
+ return bytes + safe.length;
48
+ };
49
+ child.stdout?.on('data', (chunk) => {
50
+ stdoutBytes = take(stdoutChunks, stdoutBytes, chunk);
51
+ });
52
+ child.stderr?.on('data', (chunk) => {
53
+ stderrBytes = take(stderrChunks, stderrBytes, chunk);
54
+ });
55
+ const stdout = () => Buffer.concat(stdoutChunks).toString('utf-8');
56
+ const stderr = () => Buffer.concat(stderrChunks).toString('utf-8');
57
+ const timer = setTimeout(() => {
58
+ if (!limits.gui)
59
+ child.kill();
60
+ finish({
61
+ exitCode: -1,
62
+ stdout: stdout(),
63
+ stderr: stderr(),
64
+ truncated,
65
+ timedOut: true,
66
+ stillRunning: limits.gui,
67
+ error: limits.gui ? undefined : `Command timed out after ${limits.timeoutMs}ms`,
68
+ });
69
+ }, limits.timeoutMs);
70
+ child.on('error', (err) => {
71
+ finish({ exitCode: -1, stdout: stdout(), stderr: stderr(), truncated, error: err.message });
72
+ });
73
+ child.on('close', (exitCode) => {
74
+ finish({
75
+ exitCode: exitCode ?? -1,
76
+ stdout: stdout(),
77
+ stderr: stderr(),
78
+ truncated,
79
+ });
80
+ });
81
+ });
82
+ }
83
+ function runPty(spec, limits) {
84
+ return new Promise((resolve) => {
85
+ let output = '';
86
+ let truncated = false;
87
+ let settled = false;
88
+ const proc = pty.spawn(spec.file, spec.args, {
89
+ name: 'xterm-256color',
90
+ cols: 120,
91
+ rows: 30,
92
+ cwd: spec.cwd,
93
+ env: { ...spec.env, TERM: 'xterm-256color' },
94
+ });
95
+ const finish = (outcome) => {
96
+ if (settled)
97
+ return;
98
+ settled = true;
99
+ clearTimeout(timer);
100
+ resolve(outcome);
101
+ };
102
+ proc.onData((chunk) => {
103
+ const next = output + chunk;
104
+ if (Buffer.byteLength(next) > limits.maxOutputBytes) {
105
+ truncated = true;
106
+ output = trimToBytes(next, limits.maxOutputBytes);
107
+ return;
108
+ }
109
+ output = next;
110
+ });
111
+ const timer = setTimeout(() => {
112
+ if (!limits.gui)
113
+ proc.kill();
114
+ finish({
115
+ exitCode: -1,
116
+ stdout: stripAnsi(output),
117
+ stderr: '',
118
+ truncated,
119
+ timedOut: true,
120
+ stillRunning: limits.gui,
121
+ error: limits.gui ? undefined : `Command timed out after ${limits.timeoutMs}ms`,
122
+ });
123
+ }, limits.timeoutMs);
124
+ proc.onExit(({ exitCode }) => {
125
+ finish({
126
+ exitCode: exitCode ?? -1,
127
+ stdout: stripAnsi(output),
128
+ stderr: '',
129
+ truncated,
130
+ });
131
+ });
132
+ });
133
+ }
134
+ function trimToBytes(value, maxBytes) {
135
+ const buffer = Buffer.from(value);
136
+ return buffer.subarray(0, maxBytes).toString('utf-8');
137
+ }