@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.
- package/dist/host/runtime.js +2 -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 +54 -15
- package/dist/local-executor.d.ts +22 -0
- package/dist/local-executor.js +118 -1
- 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/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.js +4 -0
- package/dist/tool-router.js +21 -6
- package/package.json +5 -5
package/dist/host/runtime.js
CHANGED
|
@@ -16,6 +16,7 @@ import { LocalScriptRegistry } from '../local-script-registry.js';
|
|
|
16
16
|
import { TerminalManager } from '../terminal-manager.js';
|
|
17
17
|
import { ToolRouter } from '../tool-router.js';
|
|
18
18
|
import { McpServerRegistry, } from '../mcp-server-registry.js';
|
|
19
|
+
import { mcpExecutor } from '../mcp-executor.js';
|
|
19
20
|
import { ProjectRegistry } from '../project-registry.js';
|
|
20
21
|
import { getProductConfig } from '../product-config.js';
|
|
21
22
|
import { LocalBackendRouter } from '../local-backend/router.js';
|
|
@@ -264,6 +265,7 @@ export async function createHostRuntime(options) {
|
|
|
264
265
|
for (const handle of [...packHandles.values()].reverse()) {
|
|
265
266
|
await handle.dispose?.();
|
|
266
267
|
}
|
|
268
|
+
await mcpExecutor.shutdownAll();
|
|
267
269
|
await shutdownHostSidecar();
|
|
268
270
|
await closeStorage();
|
|
269
271
|
},
|
|
@@ -69,9 +69,14 @@ export interface PortableWebSearchSection {
|
|
|
69
69
|
}
|
|
70
70
|
export interface PortableMcpSection {
|
|
71
71
|
name: string;
|
|
72
|
-
|
|
73
|
-
|
|
72
|
+
transport: 'stdio' | 'streamable-http';
|
|
73
|
+
command?: string;
|
|
74
|
+
args?: string[];
|
|
74
75
|
cwd?: string;
|
|
76
|
+
url?: string;
|
|
77
|
+
headers?: Record<string, string>;
|
|
78
|
+
headersFromEnv?: Record<string, string>;
|
|
79
|
+
bearerTokenEnvVar?: string;
|
|
75
80
|
enabled: boolean;
|
|
76
81
|
envIncluded: boolean;
|
|
77
82
|
env?: Record<string, string>;
|
|
@@ -178,11 +183,20 @@ export declare function webSearchSectionFromSettings(settings: {
|
|
|
178
183
|
}, includeSecrets: boolean): PortableWebSearchSection;
|
|
179
184
|
export declare function mcpSectionFromServer(server: {
|
|
180
185
|
name: string;
|
|
186
|
+
transport: 'stdio';
|
|
181
187
|
command: string;
|
|
182
188
|
args: string[];
|
|
183
189
|
cwd?: string;
|
|
184
190
|
enabled: boolean;
|
|
185
191
|
env: Record<string, string>;
|
|
192
|
+
} | {
|
|
193
|
+
name: string;
|
|
194
|
+
transport: 'streamable-http';
|
|
195
|
+
url: string;
|
|
196
|
+
headers: Record<string, string>;
|
|
197
|
+
headersFromEnv: Record<string, string>;
|
|
198
|
+
bearerTokenEnvVar?: string;
|
|
199
|
+
enabled: boolean;
|
|
186
200
|
}, includeSecrets: boolean): PortableMcpSection;
|
|
187
201
|
/**
|
|
188
202
|
* 把消息正文和 metadata 里的旧附件绝对路径换成新会话里的路径。
|
|
@@ -106,15 +106,27 @@ export function webSearchSectionFromSettings(settings, includeSecrets) {
|
|
|
106
106
|
};
|
|
107
107
|
}
|
|
108
108
|
export function mcpSectionFromServer(server, includeSecrets) {
|
|
109
|
-
return
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
109
|
+
return server.transport === 'stdio'
|
|
110
|
+
? {
|
|
111
|
+
name: server.name,
|
|
112
|
+
transport: 'stdio',
|
|
113
|
+
command: server.command,
|
|
114
|
+
args: [...server.args],
|
|
115
|
+
cwd: server.cwd,
|
|
116
|
+
enabled: server.enabled,
|
|
117
|
+
envIncluded: includeSecrets,
|
|
118
|
+
...(includeSecrets ? { env: { ...server.env } } : {}),
|
|
119
|
+
}
|
|
120
|
+
: {
|
|
121
|
+
name: server.name,
|
|
122
|
+
transport: 'streamable-http',
|
|
123
|
+
url: server.url,
|
|
124
|
+
headers: { ...server.headers },
|
|
125
|
+
headersFromEnv: { ...server.headersFromEnv },
|
|
126
|
+
bearerTokenEnvVar: server.bearerTokenEnvVar,
|
|
127
|
+
enabled: server.enabled,
|
|
128
|
+
envIncluded: false,
|
|
129
|
+
};
|
|
118
130
|
}
|
|
119
131
|
/**
|
|
120
132
|
* 把消息正文和 metadata 里的旧附件绝对路径换成新会话里的路径。
|
|
@@ -180,22 +180,36 @@ async function applyMcp(mcp, servers) {
|
|
|
180
180
|
return 'MCP 注册表不可用';
|
|
181
181
|
for (const server of servers) {
|
|
182
182
|
const name = server.name?.trim();
|
|
183
|
-
|
|
184
|
-
if (!name || !command)
|
|
183
|
+
if (!name)
|
|
185
184
|
continue;
|
|
186
|
-
const input =
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
185
|
+
const input = server.transport === 'streamable-http'
|
|
186
|
+
? {
|
|
187
|
+
name,
|
|
188
|
+
transport: 'streamable-http',
|
|
189
|
+
url: server.url?.trim() ?? '',
|
|
190
|
+
headers: server.headers ?? {},
|
|
191
|
+
headersFromEnv: server.headersFromEnv ?? {},
|
|
192
|
+
bearerTokenEnvVar: server.bearerTokenEnvVar,
|
|
193
|
+
enabled: server.enabled !== false,
|
|
194
|
+
}
|
|
195
|
+
: {
|
|
196
|
+
name,
|
|
197
|
+
transport: 'stdio',
|
|
198
|
+
command: server.command?.trim() ?? '',
|
|
199
|
+
args: Array.isArray(server.args) ? server.args.map((arg) => String(arg)) : [],
|
|
200
|
+
cwd: server.cwd,
|
|
201
|
+
enabled: server.enabled !== false,
|
|
202
|
+
...(server.envIncluded && server.env ? { env: server.env } : {}),
|
|
203
|
+
};
|
|
204
|
+
if ((input.transport === 'stdio' && !input.command) ||
|
|
205
|
+
(input.transport === 'streamable-http' && !input.url)) {
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
194
208
|
const existing = mcp.list().find((item) => item.name.toLowerCase() === name.toLowerCase());
|
|
195
209
|
if (existing)
|
|
196
210
|
mcp.update(existing.id, input);
|
|
197
211
|
else
|
|
198
|
-
mcp.create(
|
|
212
|
+
mcp.create(input);
|
|
199
213
|
}
|
|
200
214
|
return null;
|
|
201
215
|
}
|
|
@@ -51,6 +51,14 @@ import { matchPackBackendRoute } from './pack-backend-routes.js';
|
|
|
51
51
|
import { collectTurnFiles } from './turn-files.js';
|
|
52
52
|
import { resolveMentionedPaths } from './mentioned-paths.js';
|
|
53
53
|
import { getAuthProvider } from '../auth/index.js';
|
|
54
|
+
function stringRecord(value) {
|
|
55
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
56
|
+
return {};
|
|
57
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [
|
|
58
|
+
key,
|
|
59
|
+
String(item),
|
|
60
|
+
]));
|
|
61
|
+
}
|
|
54
62
|
function parseSourceFolders(value) {
|
|
55
63
|
if (!Array.isArray(value))
|
|
56
64
|
return [];
|
|
@@ -1140,7 +1148,7 @@ export class LocalBackendRouter {
|
|
|
1140
1148
|
const servers = registry.list().map((s) => {
|
|
1141
1149
|
const cached = registry.getCachedTools(s.id);
|
|
1142
1150
|
return {
|
|
1143
|
-
...s,
|
|
1151
|
+
...registry.toPublicEntry(s),
|
|
1144
1152
|
serverKey: registry.serverKey(s),
|
|
1145
1153
|
toolCount: cached?.tools.length ?? 0,
|
|
1146
1154
|
toolsPreview: cached?.tools.slice(0, 8).map((t) => t.name) ?? [],
|
|
@@ -1156,19 +1164,35 @@ export class LocalBackendRouter {
|
|
|
1156
1164
|
return { status: 503, data: { error: 'MCP 注册表不可用' } };
|
|
1157
1165
|
try {
|
|
1158
1166
|
const payload = this.toRecord(request.body);
|
|
1159
|
-
const entry =
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
:
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1167
|
+
const entry = payload.transport === 'streamable-http'
|
|
1168
|
+
? registry.create({
|
|
1169
|
+
name: String(payload.name ?? ''),
|
|
1170
|
+
transport: 'streamable-http',
|
|
1171
|
+
url: String(payload.url ?? ''),
|
|
1172
|
+
headers: stringRecord(payload.headers),
|
|
1173
|
+
headersFromEnv: stringRecord(payload.headersFromEnv),
|
|
1174
|
+
bearerTokenEnvVar: typeof payload.bearerTokenEnvVar === 'string'
|
|
1175
|
+
? payload.bearerTokenEnvVar
|
|
1176
|
+
: undefined,
|
|
1177
|
+
reconnect: payload.reconnect &&
|
|
1178
|
+
typeof payload.reconnect === 'object' &&
|
|
1179
|
+
!Array.isArray(payload.reconnect)
|
|
1180
|
+
? payload.reconnect
|
|
1181
|
+
: undefined,
|
|
1182
|
+
enabled: payload.enabled !== false,
|
|
1183
|
+
})
|
|
1184
|
+
: registry.create({
|
|
1185
|
+
name: String(payload.name ?? ''),
|
|
1186
|
+
transport: 'stdio',
|
|
1187
|
+
command: String(payload.command ?? ''),
|
|
1188
|
+
args: Array.isArray(payload.args) ? payload.args.map((a) => String(a)) : [],
|
|
1189
|
+
env: stringRecord(payload.env),
|
|
1190
|
+
cwd: typeof payload.cwd === 'string' ? payload.cwd : undefined,
|
|
1191
|
+
enabled: payload.enabled !== false,
|
|
1192
|
+
});
|
|
1169
1193
|
// 后台拉一次工具列表,下一轮对话即可用;失败不阻塞创建
|
|
1170
1194
|
void registry.refreshTools(entry.id).catch(() => { });
|
|
1171
|
-
return { status: 200, data: { server: entry } };
|
|
1195
|
+
return { status: 200, data: { server: registry.toPublicEntry(entry) } };
|
|
1172
1196
|
}
|
|
1173
1197
|
catch (err) {
|
|
1174
1198
|
return { status: 400, data: { error: err instanceof Error ? err.message : String(err) } };
|
|
@@ -1188,7 +1212,7 @@ export class LocalBackendRouter {
|
|
|
1188
1212
|
return {
|
|
1189
1213
|
status: 200,
|
|
1190
1214
|
data: {
|
|
1191
|
-
added: result.added,
|
|
1215
|
+
added: result.added.map((entry) => registry.toPublicEntry(entry)),
|
|
1192
1216
|
skipped: result.skipped,
|
|
1193
1217
|
},
|
|
1194
1218
|
};
|
|
@@ -1211,17 +1235,32 @@ export class LocalBackendRouter {
|
|
|
1211
1235
|
const payload = this.toRecord(request.body);
|
|
1212
1236
|
const updated = registry.update(serverId, {
|
|
1213
1237
|
name: typeof payload.name === 'string' ? payload.name : undefined,
|
|
1238
|
+
transport: payload.transport === 'stdio' || payload.transport === 'streamable-http'
|
|
1239
|
+
? payload.transport
|
|
1240
|
+
: undefined,
|
|
1214
1241
|
command: typeof payload.command === 'string' ? payload.command : undefined,
|
|
1215
1242
|
args: Array.isArray(payload.args) ? payload.args.map((a) => String(a)) : undefined,
|
|
1216
1243
|
env: payload.env && typeof payload.env === 'object' && !Array.isArray(payload.env)
|
|
1217
|
-
?
|
|
1244
|
+
? stringRecord(payload.env)
|
|
1218
1245
|
: undefined,
|
|
1219
1246
|
cwd: typeof payload.cwd === 'string' ? payload.cwd : undefined,
|
|
1247
|
+
url: typeof payload.url === 'string' ? payload.url : undefined,
|
|
1248
|
+
headers: payload.headers && typeof payload.headers === 'object' && !Array.isArray(payload.headers)
|
|
1249
|
+
? stringRecord(payload.headers)
|
|
1250
|
+
: undefined,
|
|
1251
|
+
headersFromEnv: payload.headersFromEnv &&
|
|
1252
|
+
typeof payload.headersFromEnv === 'object' &&
|
|
1253
|
+
!Array.isArray(payload.headersFromEnv)
|
|
1254
|
+
? stringRecord(payload.headersFromEnv)
|
|
1255
|
+
: undefined,
|
|
1256
|
+
bearerTokenEnvVar: typeof payload.bearerTokenEnvVar === 'string'
|
|
1257
|
+
? payload.bearerTokenEnvVar
|
|
1258
|
+
: undefined,
|
|
1220
1259
|
enabled: typeof payload.enabled === 'boolean' ? payload.enabled : undefined,
|
|
1221
1260
|
});
|
|
1222
1261
|
if (updated.enabled)
|
|
1223
1262
|
void registry.refreshTools(updated.id).catch(() => { });
|
|
1224
|
-
return { status: 200, data: { server: updated } };
|
|
1263
|
+
return { status: 200, data: { server: registry.toPublicEntry(updated) } };
|
|
1225
1264
|
}
|
|
1226
1265
|
catch (err) {
|
|
1227
1266
|
return { status: 400, data: { error: err instanceof Error ? err.message : String(err) } };
|
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;
|
|
@@ -208,7 +221,14 @@ export declare class LocalExecutor {
|
|
|
208
221
|
* 重灌此集合(与框架一致:resume 只重建 version 证据,门退化为 CAS)。
|
|
209
222
|
*/
|
|
210
223
|
private readonly partialReadPaths;
|
|
224
|
+
private execPolicy;
|
|
225
|
+
private shellEndpoint;
|
|
226
|
+
private shellBackend;
|
|
211
227
|
constructor(maxOutputBytes?: number, applyEditsFn?: ApplyEditsFn);
|
|
228
|
+
setExecPolicy(policy: ExecPolicy): void;
|
|
229
|
+
setShellEndpoint(endpoint: ShellEndpoint): void;
|
|
230
|
+
/** 测试和嵌入方替换真正的进程启动。缺省用本机管道或 PTY。 */
|
|
231
|
+
setShellBackend(backend: ShellProcessBackend | null): void;
|
|
212
232
|
updateSafetyConfig(config: CommandSafetyConfigPayload): void;
|
|
213
233
|
/**
|
|
214
234
|
* P2b:会话恢复时重灌 read-before-write 证据。sidecar 扫描持久记录里的
|
|
@@ -227,6 +247,8 @@ export declare class LocalExecutor {
|
|
|
227
247
|
osArch: string;
|
|
228
248
|
};
|
|
229
249
|
executeShell(request: LocalExecRequest): Promise<LocalExecResult>;
|
|
250
|
+
private holdForExecPolicy;
|
|
251
|
+
private runPlannedShell;
|
|
230
252
|
readLocalFile(request: LocalFileReadRequest, projectRoot?: string | null, additionalReadRoots?: string[] | null): Promise<LocalFileReadResult>;
|
|
231
253
|
writeLocalFile(request: LocalFileWriteRequest, projectRoot?: string | null, additionalWriteRoots?: string[] | null): Promise<LocalFileWriteResult>;
|
|
232
254
|
/**
|
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.
|
|
@@ -241,6 +243,16 @@ const BUILTIN_DANGEROUS = [
|
|
|
241
243
|
{ id: 'win_del_force', pattern: '\\bdel\\s+\\/f\\s+\\/s\\s+\\/q\\s+[a-z]:\\\\', platform: 'windows' },
|
|
242
244
|
{ id: 'win_rd_force', pattern: '\\brd\\s+\\/s\\s+\\/q\\s+[a-z]:\\\\', platform: 'windows' },
|
|
243
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
|
+
}
|
|
244
256
|
function toWslPath(input) {
|
|
245
257
|
const normalized = input.replace(/\\/g, '/');
|
|
246
258
|
const winDriveMatch = normalized.match(/^([A-Za-z]):\/(.*)$/);
|
|
@@ -273,11 +285,24 @@ export class LocalExecutor {
|
|
|
273
285
|
* 重灌此集合(与框架一致:resume 只重建 version 证据,门退化为 CAS)。
|
|
274
286
|
*/
|
|
275
287
|
partialReadPaths = new Set();
|
|
288
|
+
execPolicy = { rules: [] };
|
|
289
|
+
shellEndpoint = { kind: 'local' };
|
|
290
|
+
shellBackend = null;
|
|
276
291
|
constructor(maxOutputBytes = DEFAULT_MAX_OUTPUT_BYTES, applyEditsFn = applyEdits) {
|
|
277
292
|
this.maxOutputBytes = maxOutputBytes;
|
|
278
293
|
this.applyEditsFn = applyEditsFn;
|
|
279
294
|
this.defaultShell = this.computeDefaultShell();
|
|
280
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
|
+
}
|
|
281
306
|
updateSafetyConfig(config) {
|
|
282
307
|
const disabled = new Set(config.disabledPatternIds);
|
|
283
308
|
const isWin = process.platform === 'win32';
|
|
@@ -355,8 +380,12 @@ export class LocalExecutor {
|
|
|
355
380
|
if (dangerousReason) {
|
|
356
381
|
return { success: false, error: `Blocked dangerous command: ${dangerousReason}` };
|
|
357
382
|
}
|
|
383
|
+
const held = this.holdForExecPolicy(command, request);
|
|
384
|
+
if (held)
|
|
385
|
+
return held;
|
|
386
|
+
const useSsh = this.shellEndpoint.kind === 'ssh';
|
|
358
387
|
const resolvedShell = this.resolveShell(request.shell);
|
|
359
|
-
if (resolvedShell.type === 'powershell') {
|
|
388
|
+
if (!useSsh && resolvedShell.type === 'powershell') {
|
|
360
389
|
// LLM 经常混写 cmd / bash 方言(&&、||、%VAR%);Windows PowerShell 5.1
|
|
361
390
|
// 不支持这些语法,执行前做确定性转换,避免整条命令直接报解析错误。
|
|
362
391
|
const adapted = adaptCommandForPowerShell(command);
|
|
@@ -382,6 +411,17 @@ export class LocalExecutor {
|
|
|
382
411
|
const timeout = Math.max(1000, rawTimeout);
|
|
383
412
|
const cwd = this.resolveCwd(request.cwd, resolvedShell.type);
|
|
384
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
|
+
}
|
|
385
425
|
const stdoutChunks = [];
|
|
386
426
|
const stderrChunks = [];
|
|
387
427
|
let stdoutBytes = 0;
|
|
@@ -486,6 +526,83 @@ export class LocalExecutor {
|
|
|
486
526
|
return { success: false, error: message, platform: process.platform };
|
|
487
527
|
}
|
|
488
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
|
+
}
|
|
489
606
|
async readLocalFile(request, projectRoot, additionalReadRoots) {
|
|
490
607
|
try {
|
|
491
608
|
const filePath = this.resolvePath(request.path);
|
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 {};
|