@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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
|
215
|
-
|
|
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
|
-
: {
|
|
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
|
}
|
package/dist/product-config.d.ts
CHANGED
|
@@ -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)。
|
package/dist/project-home.d.ts
CHANGED
package/dist/project-home.js
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* 项目 = 名字 + 托管家目录 + 可选源文件夹。家目录默认建在
|
|
5
5
|
* `Documents/<应用名>/<项目名>/`(见 project-home.ts)。chat 通过
|
|
6
6
|
* `chat_sessions.project_id` 绑定项目;绑定后文件读写与命令执行被硬限制
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* 在家目录及其子目录内(见 tool-router.ts 的 ToolExecContext.projectRoot);
|
|
8
|
+
* 源文件夹及其子目录同样可写。
|
|
9
9
|
*
|
|
10
10
|
* 持久化在 userData/agent-projects.json。存储通过 {@link ProjectKvStore}
|
|
11
11
|
* 接口注入:main.ts 用 electron-store 实现,单测用内存实现——本模块不
|
|
@@ -18,8 +18,8 @@ export interface ProjectRecord {
|
|
|
18
18
|
/** 托管家目录(绝对路径)。默认 `Documents/<应用名>/<项目名>/`。 */
|
|
19
19
|
folderPath: string;
|
|
20
20
|
/**
|
|
21
|
-
*
|
|
22
|
-
*
|
|
21
|
+
* 附加源文件夹(已有代码目录)。与家目录一样可读写,范围含各自子目录。
|
|
22
|
+
* 不替代家目录。旧记录没有此字段,读取时按空列表处理。
|
|
23
23
|
*/
|
|
24
24
|
sourceFolders?: string[];
|
|
25
25
|
/**
|
package/dist/project-registry.js
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* 项目 = 名字 + 托管家目录 + 可选源文件夹。家目录默认建在
|
|
5
5
|
* `Documents/<应用名>/<项目名>/`(见 project-home.ts)。chat 通过
|
|
6
6
|
* `chat_sessions.project_id` 绑定项目;绑定后文件读写与命令执行被硬限制
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* 在家目录及其子目录内(见 tool-router.ts 的 ToolExecContext.projectRoot);
|
|
8
|
+
* 源文件夹及其子目录同样可写。
|
|
9
9
|
*
|
|
10
10
|
* 持久化在 userData/agent-projects.json。存储通过 {@link ProjectKvStore}
|
|
11
11
|
* 接口注入:main.ts 用 electron-store 实现,单测用内存实现——本模块不
|
|
@@ -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
|
+
}
|