@steerable/agent-shell 0.6.51 → 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.
- package/dist/brand.d.ts +5 -0
- package/dist/goal-store.d.ts +43 -0
- package/dist/goal-store.js +155 -0
- package/dist/host/runtime.js +15 -3
- package/dist/host-tools.d.ts +1 -1
- package/dist/host-tools.js +6 -0
- package/dist/local-backend/portable-bundle.d.ts +16 -2
- package/dist/local-backend/portable-bundle.js +21 -9
- package/dist/local-backend/portable-service.js +25 -11
- package/dist/local-backend/router.js +71 -27
- package/dist/local-backend/subagent-profiles.js +4 -0
- package/dist/local-backend/task-service.d.ts +6 -0
- package/dist/local-backend/task-service.js +47 -2
- package/dist/local-executor.d.ts +36 -2
- package/dist/local-executor.js +171 -19
- package/dist/mcp-executor.d.ts +42 -10
- package/dist/mcp-executor.js +248 -41
- package/dist/mcp-server-registry.d.ts +56 -8
- package/dist/mcp-server-registry.js +191 -26
- package/dist/product-config.d.ts +14 -0
- package/dist/project-home.d.ts +1 -1
- package/dist/project-home.js +1 -1
- package/dist/project-registry.d.ts +4 -4
- package/dist/project-registry.js +2 -2
- package/dist/shell-exec.d.ts +62 -0
- package/dist/shell-exec.js +147 -0
- package/dist/shell-process.d.ts +3 -0
- package/dist/shell-process.js +137 -0
- package/dist/sidecar/boot.d.ts +5 -2
- package/dist/sidecar/boot.js +5 -0
- package/dist/sidecar/reverse-approval.d.ts +2 -0
- package/dist/sidecar/reverse-approval.js +2 -0
- package/dist/sidecar/reverse-tools.d.ts +6 -2
- package/dist/sidecar/reverse-tools.js +9 -1
- package/dist/sidecar/types.d.ts +10 -1
- package/dist/storage/write-lease-error.js +4 -1
- package/dist/tool-presentation.d.ts +23 -0
- package/dist/tool-presentation.js +266 -0
- package/dist/tool-router.d.ts +26 -8
- package/dist/tool-router.js +275 -24
- package/dist/workspace-search.d.ts +35 -0
- package/dist/workspace-search.js +186 -0
- package/package.json +5 -5
|
@@ -32,6 +32,7 @@ import { buildExecSandbox } from '../sidecar/exec-sandbox.js';
|
|
|
32
32
|
import { resolveTurnApproval } from '../host-tools-runtime.js';
|
|
33
33
|
import { readSidecarHistoryEntries, timelineFromHistoryEntries, } from './task-process.js';
|
|
34
34
|
import { appendTimelineDelta, syncTimelineTools, } from './turn-timeline.js';
|
|
35
|
+
import { presentToolCall } from '../tool-presentation.js';
|
|
35
36
|
/** 任务回合的系统提示词:自包含、汇报结果、不假装执行。 */
|
|
36
37
|
function buildTaskSystemPrompt(input) {
|
|
37
38
|
const lines = [
|
|
@@ -278,7 +279,11 @@ export class TaskService {
|
|
|
278
279
|
(this.deps.resolveChatWorkspaceRoot
|
|
279
280
|
? await this.deps.resolveChatWorkspaceRoot(chatId)
|
|
280
281
|
: null);
|
|
281
|
-
const
|
|
282
|
+
const writableRoots = [
|
|
283
|
+
...(fenceRoot ? [fenceRoot] : []),
|
|
284
|
+
...(project?.sourceFolders ?? []),
|
|
285
|
+
];
|
|
286
|
+
const execSandbox = buildExecSandbox(writableRoots);
|
|
282
287
|
const approval = resolveTurnApproval();
|
|
283
288
|
// depth-1:任务回合没有 task_run(不能再派生任务),其余工具面与
|
|
284
289
|
// 普通回合一致;task_status / task_result 保留(任务可以自查/互查)。
|
|
@@ -332,7 +337,7 @@ export class TaskService {
|
|
|
332
337
|
},
|
|
333
338
|
}),
|
|
334
339
|
execSandbox,
|
|
335
|
-
approval,
|
|
340
|
+
approval: approval ? { ...approval, writableRoots } : undefined,
|
|
336
341
|
// 与主对话同一套内置画像(explore/research/coder)——任务回合的
|
|
337
342
|
// 子代理编排语义不分叉。
|
|
338
343
|
subagent: builtinSubagentParam(),
|
|
@@ -341,6 +346,8 @@ export class TaskService {
|
|
|
341
346
|
askUser: false,
|
|
342
347
|
onStreamId: (streamId) => {
|
|
343
348
|
entry.streamId = streamId;
|
|
349
|
+
if (entry.killed)
|
|
350
|
+
void supervisor.cancelChat(streamId);
|
|
344
351
|
},
|
|
345
352
|
onText: (delta) => {
|
|
346
353
|
answer += delta;
|
|
@@ -356,6 +363,7 @@ export class TaskService {
|
|
|
356
363
|
id: call.id,
|
|
357
364
|
tool: call.tool,
|
|
358
365
|
arguments: call.arguments,
|
|
366
|
+
view: presentToolCall(call.tool, call.arguments),
|
|
359
367
|
threw: false,
|
|
360
368
|
});
|
|
361
369
|
syncTimelineTools(entry.timeline, entry.actions);
|
|
@@ -369,6 +377,7 @@ export class TaskService {
|
|
|
369
377
|
id: action.id,
|
|
370
378
|
tool: action.tool,
|
|
371
379
|
arguments: action.arguments,
|
|
380
|
+
view: presentToolCall(action.tool, action.arguments, action.result),
|
|
372
381
|
result: action.result,
|
|
373
382
|
success: action.success,
|
|
374
383
|
error: action.error,
|
|
@@ -398,6 +407,10 @@ export class TaskService {
|
|
|
398
407
|
traceId = failureTraceId;
|
|
399
408
|
}
|
|
400
409
|
}
|
|
410
|
+
if (entry.killed) {
|
|
411
|
+
status = 'failed';
|
|
412
|
+
error = entry.killReason || 'killed by job_kill';
|
|
413
|
+
}
|
|
401
414
|
await this.store.updateTask(taskId, {
|
|
402
415
|
status,
|
|
403
416
|
answer: answer.trim() || null,
|
|
@@ -430,6 +443,38 @@ export class TaskService {
|
|
|
430
443
|
tasks: tasks.map((t) => this.toModelView(t)),
|
|
431
444
|
};
|
|
432
445
|
}
|
|
446
|
+
/**
|
|
447
|
+
* job_kill:结束 running 或 blocked 任务。running 先打标记再取消流,
|
|
448
|
+
* 终态回写看到标记后保持 failed,避免流的正常完成把杀死盖掉。
|
|
449
|
+
*/
|
|
450
|
+
async kill(chatId, taskId, reason) {
|
|
451
|
+
const task = await this.store.getTask(taskId);
|
|
452
|
+
if (!task || task.chatId !== chatId) {
|
|
453
|
+
return { success: false, error: `任务不存在:${taskId}` };
|
|
454
|
+
}
|
|
455
|
+
if (task.status === 'completed' || task.status === 'failed') {
|
|
456
|
+
return { success: false, error: `任务已终态(${task.status}),不能再结束。` };
|
|
457
|
+
}
|
|
458
|
+
const note = reason?.trim() || 'killed by job_kill';
|
|
459
|
+
const entry = this.running.get(taskId);
|
|
460
|
+
if (entry) {
|
|
461
|
+
entry.killed = true;
|
|
462
|
+
entry.killReason = note;
|
|
463
|
+
}
|
|
464
|
+
if (!entry || task.status === 'blocked') {
|
|
465
|
+
await this.store.updateTask(taskId, { status: 'failed', error: note });
|
|
466
|
+
this.deps.broadcast?.('task-updated', { chatId, taskId });
|
|
467
|
+
if (task.status === 'blocked')
|
|
468
|
+
await this.maybeUnblock(chatId);
|
|
469
|
+
return { success: true, job_id: taskId, status: 'failed', killed: true };
|
|
470
|
+
}
|
|
471
|
+
const getSupervisor = this.deps.getSupervisor ?? getSidecarSupervisor;
|
|
472
|
+
const supervisor = getSupervisor();
|
|
473
|
+
if (entry.streamId && supervisor) {
|
|
474
|
+
await supervisor.cancelChat(entry.streamId);
|
|
475
|
+
}
|
|
476
|
+
return { success: true, job_id: taskId, status: 'failed', killed: true };
|
|
477
|
+
}
|
|
433
478
|
/** task_result 工具:取终态任务的完整结果。 */
|
|
434
479
|
async result(chatId, taskId) {
|
|
435
480
|
const task = await this.store.getTask(taskId);
|
package/dist/local-executor.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ExecDecisionName, type ExecPolicy, type ShellEndpoint, type ShellProcessBackend } from './shell-exec.js';
|
|
1
2
|
import { type ApplyEditsResult, type EditOp } from './local-edit.js';
|
|
2
3
|
export declare function getWineCommand(): string | null;
|
|
3
4
|
export declare function rewriteExeCommandIfNeeded(command: string): string;
|
|
@@ -8,6 +9,10 @@ export interface LocalExecRequest {
|
|
|
8
9
|
timeout?: number;
|
|
9
10
|
env?: Record<string, string>;
|
|
10
11
|
shell?: ShellType;
|
|
12
|
+
/** 在伪终端里跑。缺省走管道,调用方要终端语义时显式打开。 */
|
|
13
|
+
pty?: boolean;
|
|
14
|
+
/** 用户已经批准一次 prompt 决定。不能放过 forbidden。 */
|
|
15
|
+
execApproval?: 'allow';
|
|
11
16
|
}
|
|
12
17
|
export interface LocalExecResult {
|
|
13
18
|
success: boolean;
|
|
@@ -25,6 +30,14 @@ export interface LocalExecResult {
|
|
|
25
30
|
* "程序已启动并在运行" 对启动类命令就是成功,不能让 LLM 当失败重试。
|
|
26
31
|
*/
|
|
27
32
|
stillRunning?: boolean;
|
|
33
|
+
/** 前缀策略的决定。forbidden 不启动进程;prompt 在批准前也不启动。 */
|
|
34
|
+
execDecision?: ExecDecisionName;
|
|
35
|
+
/** prompt 时为 true,让模型把决定交给用户而不是改写命令重试。 */
|
|
36
|
+
needsFollowup?: boolean;
|
|
37
|
+
/** 这次命令跑在本机还是 SSH 对端。 */
|
|
38
|
+
transport?: 'local' | 'ssh';
|
|
39
|
+
/** 这次启动是否分配了伪终端。 */
|
|
40
|
+
pty?: boolean;
|
|
28
41
|
}
|
|
29
42
|
export interface LocalFileReadRequest {
|
|
30
43
|
path: string;
|
|
@@ -159,8 +172,20 @@ export declare function isGuiLaunchCommand(command: string): boolean;
|
|
|
159
172
|
* 字符串/相对路径判断,不碰 fs,方便 ToolRouter 与 LocalExecutor 共用。
|
|
160
173
|
*/
|
|
161
174
|
export declare function isPathWithinRoot(resolvedPath: string, resolvedRoot: string): boolean;
|
|
175
|
+
/** `~` 展开后 resolve。围栏比较前路径与根都要过这一步。 */
|
|
176
|
+
export declare function expandFencePath(inputPath: string): string;
|
|
177
|
+
/**
|
|
178
|
+
* 可写根:项目家目录(或对话工作区)加上额外可写根(源文件夹)。
|
|
179
|
+
* 每个根都包含它自己和全部子目录。空列表表示不设围栏。
|
|
180
|
+
*/
|
|
181
|
+
export declare function writableFenceRoots(projectRoot?: string | null, additionalWriteRoots?: string[] | null): string[];
|
|
162
182
|
/** 越界错误消息(给模型看,引导它把操作收回项目目录内)。 */
|
|
163
183
|
export declare function buildProjectRootViolation(resolvedPath: string, resolvedRoot: string): string;
|
|
184
|
+
/**
|
|
185
|
+
* 路径不在任一可写根(含其子目录)内时返回给模型的错误;在围栏内或未设围栏时返回 null。
|
|
186
|
+
* `resolvedPath` 与 `roots` 都应已 resolve。
|
|
187
|
+
*/
|
|
188
|
+
export declare function writableFenceViolation(resolvedPath: string, roots: string[]): string | null;
|
|
164
189
|
export interface CommandSafetyConfigPayload {
|
|
165
190
|
disabledPatternIds: string[];
|
|
166
191
|
customPatterns: Array<{
|
|
@@ -196,7 +221,14 @@ export declare class LocalExecutor {
|
|
|
196
221
|
* 重灌此集合(与框架一致:resume 只重建 version 证据,门退化为 CAS)。
|
|
197
222
|
*/
|
|
198
223
|
private readonly partialReadPaths;
|
|
224
|
+
private execPolicy;
|
|
225
|
+
private shellEndpoint;
|
|
226
|
+
private shellBackend;
|
|
199
227
|
constructor(maxOutputBytes?: number, applyEditsFn?: ApplyEditsFn);
|
|
228
|
+
setExecPolicy(policy: ExecPolicy): void;
|
|
229
|
+
setShellEndpoint(endpoint: ShellEndpoint): void;
|
|
230
|
+
/** 测试和嵌入方替换真正的进程启动。缺省用本机管道或 PTY。 */
|
|
231
|
+
setShellBackend(backend: ShellProcessBackend | null): void;
|
|
200
232
|
updateSafetyConfig(config: CommandSafetyConfigPayload): void;
|
|
201
233
|
/**
|
|
202
234
|
* P2b:会话恢复时重灌 read-before-write 证据。sidecar 扫描持久记录里的
|
|
@@ -215,14 +247,16 @@ export declare class LocalExecutor {
|
|
|
215
247
|
osArch: string;
|
|
216
248
|
};
|
|
217
249
|
executeShell(request: LocalExecRequest): Promise<LocalExecResult>;
|
|
250
|
+
private holdForExecPolicy;
|
|
251
|
+
private runPlannedShell;
|
|
218
252
|
readLocalFile(request: LocalFileReadRequest, projectRoot?: string | null, additionalReadRoots?: string[] | null): Promise<LocalFileReadResult>;
|
|
219
|
-
writeLocalFile(request: LocalFileWriteRequest, projectRoot?: string | null): Promise<LocalFileWriteResult>;
|
|
253
|
+
writeLocalFile(request: LocalFileWriteRequest, projectRoot?: string | null, additionalWriteRoots?: string[] | null): Promise<LocalFileWriteResult>;
|
|
220
254
|
/**
|
|
221
255
|
* local_edit_file 的实现(W6-1):结构化批量编辑。先按 expectedVersion 做
|
|
222
256
|
* read-before-write 冲突检测,再在原文上做三级模糊定位 + 逆序替换,最后
|
|
223
257
|
* 原子落盘。返回新版本令牌与统一 diff。
|
|
224
258
|
*/
|
|
225
|
-
editLocalFile(request: LocalFileEditRequest, projectRoot?: string | null): Promise<LocalFileEditResult>;
|
|
259
|
+
editLocalFile(request: LocalFileEditRequest, projectRoot?: string | null, additionalWriteRoots?: string[] | null): Promise<LocalFileEditResult>;
|
|
226
260
|
/**
|
|
227
261
|
* read-before-write 冲突检测:当前内容版本与调用方期望不一致时返回错误
|
|
228
262
|
* 消息(引导重新读取),一致返回 null。文件不存在且期望存在也算冲突,
|
package/dist/local-executor.js
CHANGED
|
@@ -7,6 +7,8 @@ import { spawn, execSync } from 'child_process';
|
|
|
7
7
|
import log from 'electron-log';
|
|
8
8
|
import { constants as fsConstants } from 'fs';
|
|
9
9
|
import { adaptCommandForPowerShell } from './shell-adapt.js';
|
|
10
|
+
import { evaluateExecPolicy, planSshArgv, } from './shell-exec.js';
|
|
11
|
+
import { nodeShellBackend } from './shell-process.js';
|
|
10
12
|
import { applyEdits, EditError } from './local-edit.js';
|
|
11
13
|
// `he` ships as CommonJS and does not expose ESM named exports, so we have to
|
|
12
14
|
// take the default import and destructure at runtime in this ESM module.
|
|
@@ -180,11 +182,53 @@ export function isPathWithinRoot(resolvedPath, resolvedRoot) {
|
|
|
180
182
|
const rel = path.relative(resolvedRoot, resolvedPath);
|
|
181
183
|
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
182
184
|
}
|
|
185
|
+
/** `~` 展开后 resolve。围栏比较前路径与根都要过这一步。 */
|
|
186
|
+
export function expandFencePath(inputPath) {
|
|
187
|
+
const expanded = inputPath.startsWith('~')
|
|
188
|
+
? path.join(os.homedir(), inputPath.slice(1))
|
|
189
|
+
: inputPath;
|
|
190
|
+
return path.resolve(expanded);
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* 可写根:项目家目录(或对话工作区)加上额外可写根(源文件夹)。
|
|
194
|
+
* 每个根都包含它自己和全部子目录。空列表表示不设围栏。
|
|
195
|
+
*/
|
|
196
|
+
export function writableFenceRoots(projectRoot, additionalWriteRoots) {
|
|
197
|
+
const roots = [];
|
|
198
|
+
const push = (raw) => {
|
|
199
|
+
if (!raw)
|
|
200
|
+
return;
|
|
201
|
+
const trimmed = raw.trim();
|
|
202
|
+
if (!trimmed)
|
|
203
|
+
return;
|
|
204
|
+
const resolved = expandFencePath(trimmed);
|
|
205
|
+
if (!roots.includes(resolved))
|
|
206
|
+
roots.push(resolved);
|
|
207
|
+
};
|
|
208
|
+
push(projectRoot);
|
|
209
|
+
for (const extra of additionalWriteRoots ?? [])
|
|
210
|
+
push(extra);
|
|
211
|
+
return roots;
|
|
212
|
+
}
|
|
183
213
|
/** 越界错误消息(给模型看,引导它把操作收回项目目录内)。 */
|
|
184
214
|
export function buildProjectRootViolation(resolvedPath, resolvedRoot) {
|
|
185
215
|
return (`路径越界:${resolvedPath} 不在当前对话绑定的项目目录 ${resolvedRoot} 内。` +
|
|
186
|
-
|
|
187
|
-
|
|
216
|
+
`项目模式下文件操作被限制在项目目录及其子目录中——请改用项目内的相对/绝对路径;` +
|
|
217
|
+
`如确需访问目录外文件,请告知用户该限制并请其把目录附加为源文件夹。`);
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* 路径不在任一可写根(含其子目录)内时返回给模型的错误;在围栏内或未设围栏时返回 null。
|
|
221
|
+
* `resolvedPath` 与 `roots` 都应已 resolve。
|
|
222
|
+
*/
|
|
223
|
+
export function writableFenceViolation(resolvedPath, roots) {
|
|
224
|
+
if (roots.length === 0)
|
|
225
|
+
return null;
|
|
226
|
+
if (roots.some((root) => isPathWithinRoot(resolvedPath, root)))
|
|
227
|
+
return null;
|
|
228
|
+
if (roots.length === 1)
|
|
229
|
+
return buildProjectRootViolation(resolvedPath, roots[0]);
|
|
230
|
+
return (`路径越界:${resolvedPath} 不在可写目录内(${roots.join('、')})。` +
|
|
231
|
+
`可写范围是这些目录及其子目录。请改用其中的路径。`);
|
|
188
232
|
}
|
|
189
233
|
const BUILTIN_DANGEROUS = [
|
|
190
234
|
{ id: 'rm_rf_root', pattern: 'rm\\s+-rf\\s+\\/(?:\\s|$)', platform: 'unix' },
|
|
@@ -199,6 +243,16 @@ const BUILTIN_DANGEROUS = [
|
|
|
199
243
|
{ id: 'win_del_force', pattern: '\\bdel\\s+\\/f\\s+\\/s\\s+\\/q\\s+[a-z]:\\\\', platform: 'windows' },
|
|
200
244
|
{ id: 'win_rd_force', pattern: '\\brd\\s+\\/s\\s+\\/q\\s+[a-z]:\\\\', platform: 'windows' },
|
|
201
245
|
];
|
|
246
|
+
function stringEnv(extra) {
|
|
247
|
+
const env = {};
|
|
248
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
249
|
+
if (typeof value === 'string')
|
|
250
|
+
env[key] = value;
|
|
251
|
+
}
|
|
252
|
+
if (extra)
|
|
253
|
+
Object.assign(env, extra);
|
|
254
|
+
return env;
|
|
255
|
+
}
|
|
202
256
|
function toWslPath(input) {
|
|
203
257
|
const normalized = input.replace(/\\/g, '/');
|
|
204
258
|
const winDriveMatch = normalized.match(/^([A-Za-z]):\/(.*)$/);
|
|
@@ -231,11 +285,24 @@ export class LocalExecutor {
|
|
|
231
285
|
* 重灌此集合(与框架一致:resume 只重建 version 证据,门退化为 CAS)。
|
|
232
286
|
*/
|
|
233
287
|
partialReadPaths = new Set();
|
|
288
|
+
execPolicy = { rules: [] };
|
|
289
|
+
shellEndpoint = { kind: 'local' };
|
|
290
|
+
shellBackend = null;
|
|
234
291
|
constructor(maxOutputBytes = DEFAULT_MAX_OUTPUT_BYTES, applyEditsFn = applyEdits) {
|
|
235
292
|
this.maxOutputBytes = maxOutputBytes;
|
|
236
293
|
this.applyEditsFn = applyEditsFn;
|
|
237
294
|
this.defaultShell = this.computeDefaultShell();
|
|
238
295
|
}
|
|
296
|
+
setExecPolicy(policy) {
|
|
297
|
+
this.execPolicy = policy;
|
|
298
|
+
}
|
|
299
|
+
setShellEndpoint(endpoint) {
|
|
300
|
+
this.shellEndpoint = endpoint;
|
|
301
|
+
}
|
|
302
|
+
/** 测试和嵌入方替换真正的进程启动。缺省用本机管道或 PTY。 */
|
|
303
|
+
setShellBackend(backend) {
|
|
304
|
+
this.shellBackend = backend;
|
|
305
|
+
}
|
|
239
306
|
updateSafetyConfig(config) {
|
|
240
307
|
const disabled = new Set(config.disabledPatternIds);
|
|
241
308
|
const isWin = process.platform === 'win32';
|
|
@@ -313,8 +380,12 @@ export class LocalExecutor {
|
|
|
313
380
|
if (dangerousReason) {
|
|
314
381
|
return { success: false, error: `Blocked dangerous command: ${dangerousReason}` };
|
|
315
382
|
}
|
|
383
|
+
const held = this.holdForExecPolicy(command, request);
|
|
384
|
+
if (held)
|
|
385
|
+
return held;
|
|
386
|
+
const useSsh = this.shellEndpoint.kind === 'ssh';
|
|
316
387
|
const resolvedShell = this.resolveShell(request.shell);
|
|
317
|
-
if (resolvedShell.type === 'powershell') {
|
|
388
|
+
if (!useSsh && resolvedShell.type === 'powershell') {
|
|
318
389
|
// LLM 经常混写 cmd / bash 方言(&&、||、%VAR%);Windows PowerShell 5.1
|
|
319
390
|
// 不支持这些语法,执行前做确定性转换,避免整条命令直接报解析错误。
|
|
320
391
|
const adapted = adaptCommandForPowerShell(command);
|
|
@@ -340,6 +411,17 @@ export class LocalExecutor {
|
|
|
340
411
|
const timeout = Math.max(1000, rawTimeout);
|
|
341
412
|
const cwd = this.resolveCwd(request.cwd, resolvedShell.type);
|
|
342
413
|
const env = { ...process.env, ...(request.env || {}) };
|
|
414
|
+
if (this.shellBackend || useSsh || request.pty) {
|
|
415
|
+
return await this.runPlannedShell({
|
|
416
|
+
command,
|
|
417
|
+
request,
|
|
418
|
+
resolvedShell,
|
|
419
|
+
useSsh,
|
|
420
|
+
guiCommand,
|
|
421
|
+
timeout,
|
|
422
|
+
cwd,
|
|
423
|
+
});
|
|
424
|
+
}
|
|
343
425
|
const stdoutChunks = [];
|
|
344
426
|
const stderrChunks = [];
|
|
345
427
|
let stdoutBytes = 0;
|
|
@@ -444,6 +526,83 @@ export class LocalExecutor {
|
|
|
444
526
|
return { success: false, error: message, platform: process.platform };
|
|
445
527
|
}
|
|
446
528
|
}
|
|
529
|
+
holdForExecPolicy(command, request) {
|
|
530
|
+
const evaluation = evaluateExecPolicy(command, this.execPolicy);
|
|
531
|
+
if (evaluation.decision === 'allow')
|
|
532
|
+
return null;
|
|
533
|
+
if (evaluation.decision === 'prompt' && request.execApproval === 'allow')
|
|
534
|
+
return null;
|
|
535
|
+
const shown = evaluation.command.join(' ');
|
|
536
|
+
const why = evaluation.rule?.justification ? ` (${evaluation.rule.justification})` : '';
|
|
537
|
+
if (evaluation.decision === 'forbidden') {
|
|
538
|
+
return {
|
|
539
|
+
success: false,
|
|
540
|
+
error: `exec policy forbids: ${shown}${why}`,
|
|
541
|
+
execDecision: 'forbidden',
|
|
542
|
+
platform: process.platform,
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
return {
|
|
546
|
+
success: false,
|
|
547
|
+
error: `exec policy requires approval: ${shown}${why}`,
|
|
548
|
+
execDecision: 'prompt',
|
|
549
|
+
needsFollowup: true,
|
|
550
|
+
platform: process.platform,
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
async runPlannedShell(input) {
|
|
554
|
+
const pty = input.request.pty === true;
|
|
555
|
+
const spec = input.useSsh
|
|
556
|
+
? planSshArgv({
|
|
557
|
+
command: input.command,
|
|
558
|
+
cwd: input.request.cwd ? input.cwd : undefined,
|
|
559
|
+
pty,
|
|
560
|
+
shell: input.resolvedShell.type === 'powershell' ? 'powershell' : undefined,
|
|
561
|
+
endpoint: this.shellEndpoint,
|
|
562
|
+
})
|
|
563
|
+
: {
|
|
564
|
+
file: input.resolvedShell.shell,
|
|
565
|
+
args: [...input.resolvedShell.args, input.command],
|
|
566
|
+
cwd: input.cwd,
|
|
567
|
+
env: stringEnv(input.request.env),
|
|
568
|
+
pty,
|
|
569
|
+
transport: 'local',
|
|
570
|
+
};
|
|
571
|
+
if (input.useSsh) {
|
|
572
|
+
spec.cwd = os.homedir();
|
|
573
|
+
spec.env = stringEnv();
|
|
574
|
+
}
|
|
575
|
+
const backend = this.shellBackend ?? nodeShellBackend;
|
|
576
|
+
const outcome = await backend.run(spec, {
|
|
577
|
+
timeoutMs: input.timeout,
|
|
578
|
+
maxOutputBytes: this.maxOutputBytes,
|
|
579
|
+
gui: input.guiCommand,
|
|
580
|
+
});
|
|
581
|
+
const launched = outcome.stillRunning === true;
|
|
582
|
+
return {
|
|
583
|
+
success: launched || (!outcome.error && outcome.exitCode === 0),
|
|
584
|
+
stdout: launched
|
|
585
|
+
? `${outcome.stdout}\n[GUI 程序已启动,仍在运行(等待 ${input.timeout}ms 后未退出,进程未被终止)。请在打开的界面中继续操作;不要重新执行同一条启动命令。]`
|
|
586
|
+
: outcome.stdout,
|
|
587
|
+
stderr: outcome.stderr,
|
|
588
|
+
exitCode: outcome.exitCode,
|
|
589
|
+
error: launched
|
|
590
|
+
? undefined
|
|
591
|
+
: outcome.timedOut
|
|
592
|
+
? `Command timed out after ${input.timeout}ms (shell terminated; a launched GUI app may still be running). ` +
|
|
593
|
+
'DO NOT blindly re-run the same launch command — if this launched a GUI/long-running program, ' +
|
|
594
|
+
'first check whether it is already running, or re-run with a larger `timeout`, ' +
|
|
595
|
+
'or start it detached (e.g. PowerShell Start-Process) so it does not block.'
|
|
596
|
+
: outcome.error,
|
|
597
|
+
truncated: outcome.truncated,
|
|
598
|
+
timedOut: outcome.timedOut,
|
|
599
|
+
stillRunning: outcome.stillRunning,
|
|
600
|
+
shell: input.resolvedShell.type,
|
|
601
|
+
platform: process.platform,
|
|
602
|
+
transport: spec.transport,
|
|
603
|
+
pty: spec.pty,
|
|
604
|
+
};
|
|
605
|
+
}
|
|
447
606
|
async readLocalFile(request, projectRoot, additionalReadRoots) {
|
|
448
607
|
try {
|
|
449
608
|
const filePath = this.resolvePath(request.path);
|
|
@@ -480,14 +639,11 @@ export class LocalExecutor {
|
|
|
480
639
|
return { success: false, error: message };
|
|
481
640
|
}
|
|
482
641
|
}
|
|
483
|
-
async writeLocalFile(request, projectRoot) {
|
|
642
|
+
async writeLocalFile(request, projectRoot, additionalWriteRoots) {
|
|
484
643
|
const filePath = this.resolvePath(request.path);
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
return { success: false, error: buildProjectRootViolation(filePath, root) };
|
|
489
|
-
}
|
|
490
|
-
}
|
|
644
|
+
const violation = writableFenceViolation(filePath, writableFenceRoots(projectRoot, additionalWriteRoots));
|
|
645
|
+
if (violation)
|
|
646
|
+
return { success: false, error: violation };
|
|
491
647
|
// 同文件串行化:读-改-写必须排队,否则并发写会互相覆盖(pi file-mutation-queue)。
|
|
492
648
|
return this.serializeFileOp(filePath, async () => {
|
|
493
649
|
try {
|
|
@@ -544,14 +700,11 @@ export class LocalExecutor {
|
|
|
544
700
|
* read-before-write 冲突检测,再在原文上做三级模糊定位 + 逆序替换,最后
|
|
545
701
|
* 原子落盘。返回新版本令牌与统一 diff。
|
|
546
702
|
*/
|
|
547
|
-
async editLocalFile(request, projectRoot) {
|
|
703
|
+
async editLocalFile(request, projectRoot, additionalWriteRoots) {
|
|
548
704
|
const filePath = this.resolvePath(request.path);
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
return { success: false, error: buildProjectRootViolation(filePath, root) };
|
|
553
|
-
}
|
|
554
|
-
}
|
|
705
|
+
const violation = writableFenceViolation(filePath, writableFenceRoots(projectRoot, additionalWriteRoots));
|
|
706
|
+
if (violation)
|
|
707
|
+
return { success: false, error: violation };
|
|
555
708
|
return this.serializeFileOp(filePath, async () => {
|
|
556
709
|
try {
|
|
557
710
|
const encoding = request.encoding ?? 'utf-8';
|
|
@@ -910,8 +1063,7 @@ export class LocalExecutor {
|
|
|
910
1063
|
});
|
|
911
1064
|
}
|
|
912
1065
|
resolvePath(inputPath) {
|
|
913
|
-
|
|
914
|
-
return path.resolve(expanded);
|
|
1066
|
+
return expandFencePath(inputPath);
|
|
915
1067
|
}
|
|
916
1068
|
resolveCwd(cwd, shellType) {
|
|
917
1069
|
const resolved = this.resolvePath(cwd || os.homedir());
|
package/dist/mcp-executor.d.ts
CHANGED
|
@@ -1,13 +1,35 @@
|
|
|
1
|
+
import { type StreamableHTTPReconnectionOptions } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
|
2
|
+
export declare const MAX_MCP_SERVER_TOOLS = 64;
|
|
1
3
|
/**
|
|
2
4
|
* 给超时类错误追加可操作的排查提示。独立成纯函数便于单测。
|
|
3
5
|
*/
|
|
4
6
|
export declare function withMcpTroubleshootHint(message: string): string;
|
|
5
|
-
export interface
|
|
7
|
+
export interface StdioMcpServerConfig {
|
|
8
|
+
transport: 'stdio';
|
|
6
9
|
command: string;
|
|
7
10
|
args?: string[];
|
|
8
11
|
env?: Record<string, string>;
|
|
9
12
|
cwd?: string;
|
|
10
13
|
}
|
|
14
|
+
export interface StreamableHttpMcpServerConfig {
|
|
15
|
+
transport: 'streamable-http';
|
|
16
|
+
url: string;
|
|
17
|
+
/** Non-sensitive literal headers. Authorization must come from an env reference. */
|
|
18
|
+
headers?: Record<string, string>;
|
|
19
|
+
/** Header name -> environment variable name. */
|
|
20
|
+
headersFromEnv?: Record<string, string>;
|
|
21
|
+
bearerTokenEnvVar?: string;
|
|
22
|
+
reconnect?: Partial<StreamableHTTPReconnectionOptions>;
|
|
23
|
+
}
|
|
24
|
+
export type McpServerConfig = StdioMcpServerConfig | StreamableHttpMcpServerConfig;
|
|
25
|
+
export type LegacyMcpServerConfig = Omit<StdioMcpServerConfig, 'transport'>;
|
|
26
|
+
export interface ListedTool {
|
|
27
|
+
name: string;
|
|
28
|
+
description: string;
|
|
29
|
+
inputSchema?: unknown;
|
|
30
|
+
}
|
|
31
|
+
export declare function resolveHttpHeaders(config: StreamableHttpMcpServerConfig, env?: Record<string, string | undefined>): Record<string, string>;
|
|
32
|
+
export declare function mcpConfigKey(rawConfig: McpServerConfig | LegacyMcpServerConfig, env?: Record<string, string | undefined>): string;
|
|
11
33
|
/**
|
|
12
34
|
* Pure eviction predicate, split out so it's unit-testable without spinning
|
|
13
35
|
* up a real MCP child process (`McpExecutor` isn't otherwise exported —
|
|
@@ -17,29 +39,39 @@ export declare function shouldEvictMcpConnection(entry: {
|
|
|
17
39
|
lastUsed: number;
|
|
18
40
|
inflight: number;
|
|
19
41
|
}, now: number, idleTimeoutMs: number): boolean;
|
|
20
|
-
|
|
42
|
+
export interface McpExecutorLike {
|
|
43
|
+
executeTool(config: McpServerConfig | LegacyMcpServerConfig, toolName: string, toolArgs: Record<string, unknown>): Promise<{
|
|
44
|
+
success: boolean;
|
|
45
|
+
text?: string;
|
|
46
|
+
error?: string;
|
|
47
|
+
}>;
|
|
48
|
+
listTools(config: McpServerConfig | LegacyMcpServerConfig, onChanged?: (tools: ListedTool[]) => void, onUnavailable?: (error: string) => void): Promise<{
|
|
49
|
+
success: boolean;
|
|
50
|
+
tools?: ListedTool[];
|
|
51
|
+
error?: string;
|
|
52
|
+
}>;
|
|
53
|
+
}
|
|
54
|
+
export declare class McpExecutor implements McpExecutorLike {
|
|
21
55
|
private pool;
|
|
22
56
|
private cleanupTimer;
|
|
23
57
|
constructor();
|
|
24
|
-
executeTool(
|
|
58
|
+
executeTool(rawConfig: McpServerConfig | LegacyMcpServerConfig, toolName: string, toolArgs: Record<string, unknown>): Promise<{
|
|
25
59
|
success: boolean;
|
|
26
60
|
text?: string;
|
|
27
61
|
error?: string;
|
|
28
62
|
}>;
|
|
29
|
-
listTools(
|
|
63
|
+
listTools(rawConfig: McpServerConfig | LegacyMcpServerConfig, onChanged?: (tools: ListedTool[]) => void, onUnavailable?: (error: string) => void): Promise<{
|
|
30
64
|
success: boolean;
|
|
31
|
-
tools?:
|
|
32
|
-
name: string;
|
|
33
|
-
description: string;
|
|
34
|
-
inputSchema?: unknown;
|
|
35
|
-
}[];
|
|
65
|
+
tools?: ListedTool[];
|
|
36
66
|
error?: string;
|
|
37
67
|
}>;
|
|
38
68
|
private getOrCreate;
|
|
69
|
+
private createTransport;
|
|
70
|
+
private refreshEntryTools;
|
|
71
|
+
private fetchEntryTools;
|
|
39
72
|
private closeEntry;
|
|
40
73
|
/** 关闭连接池中的所有连接(应用退出 / 测试收尾时调用)。 */
|
|
41
74
|
shutdownAll(): Promise<void>;
|
|
42
75
|
private evictIdle;
|
|
43
76
|
}
|
|
44
77
|
export declare const mcpExecutor: McpExecutor;
|
|
45
|
-
export {};
|