dsh-plugin-bash 0.0.0-stage → 1.0.0
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/README.md +30 -2
- package/lib/index.js +511 -0
- package/package.json +34 -4
package/README.md
CHANGED
|
@@ -1,3 +1,31 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-plugin-bash
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Windows 上的 `bash` 工具(走 Git for Windows),并对子 agent 隐藏 `pwsh`。
|
|
4
|
+
|
|
5
|
+
## 为什么需要它
|
|
6
|
+
|
|
7
|
+
官方 `@deepseek-ai/dsh-tool-bash` 在 **win32 上被设计性禁用**(bundle patch 里 `disabled: !!js process.platform === 'win32'`),
|
|
8
|
+
执行器侧 `bash-sandbox` 同样禁用;而 Windows 上做文本处理(sed/awk/sort/xargs)又离不开 bash。
|
|
9
|
+
|
|
10
|
+
## 特性
|
|
11
|
+
|
|
12
|
+
- 注册 `bash` 工具(自动探测 Git for Windows 安装路径)
|
|
13
|
+
- **对子 agent 隐藏 `pwsh`**(`agent.ctx.tools.restrict`),主 agent 保留全能力
|
|
14
|
+
- 输出超限 **spill 落盘**(完整内容给路径,不丢)
|
|
15
|
+
- `run_in_background`:接 DSH `ctx.jobs`;前台命令**超时自动转后台**
|
|
16
|
+
|
|
17
|
+
## 配置(均可选)
|
|
18
|
+
|
|
19
|
+
| 环境变量 | 默认 | 说明 |
|
|
20
|
+
|---|---|---|
|
|
21
|
+
| `DSH_BASH_LOG` | `~/.dsh/logs/bash-tool.log` | 插件日志 |
|
|
22
|
+
| `DSH_BASH_SPILL` | `~/.dsh/logs/bash-spill` | 超长输出落盘目录 |
|
|
23
|
+
|
|
24
|
+
## 依赖
|
|
25
|
+
|
|
26
|
+
- **Git for Windows**(默认探测 `C:\Program Files\Gitinash.exe`)
|
|
27
|
+
- DSH **0.2.2-rc.1+** · 零运行时依赖 · host-only
|
|
28
|
+
|
|
29
|
+
## License
|
|
30
|
+
|
|
31
|
+
MIT
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
// dsh-plugin-bash -- host 侧
|
|
2
|
+
//
|
|
3
|
+
// ★ 为什么需要它(2026-10-14 调研结论,依据都可核):
|
|
4
|
+
// DSH 官方【有】bash 工具(@deepseek-ai/dsh-tool-bash,本机 app.asar 里已装),
|
|
5
|
+
// 但在 Windows 上被【设计性禁用】:
|
|
6
|
+
// packages/bundle/base/cordis.patch.yml:276
|
|
7
|
+
// - id: tool-bash
|
|
8
|
+
// disabled: !!js process.platform === 'win32'
|
|
9
|
+
// 执行器侧同理(bash-sandbox 也在 win32 禁用),而 bash-local 是直接 "bash -c"
|
|
10
|
+
// ⇒ 要求 bash 在 PATH 上,本机的 Git bash 并不在 PATH。
|
|
11
|
+
// ⇒ 结果:Windows 上只剩 pwsh,而我们的真实痛点正是"文本处理被 PowerShell 的
|
|
12
|
+
// 转义/解析坑反复咬"(见 ~/.dsh/AGENTS.md「PowerShell 坑」三条根规律)。
|
|
13
|
+
//
|
|
14
|
+
// 本插件三件事:
|
|
15
|
+
// A. 注册 bash 工具(走本机 Git for Windows)
|
|
16
|
+
// B. 对【子 agent】隐藏 pwsh —— 子 agent 的活多是文本/批量/摘要,几乎不用 Windows 原生;
|
|
17
|
+
// 主 agent 保留全能力(注册表/服务/证书/防火墙这些只有主 agent 干)
|
|
18
|
+
// C. 路由写进工具 description:文本/行处理优先 bash;Windows 原生与 .cmd 链用 pwsh
|
|
19
|
+
//
|
|
20
|
+
// ★ 第一版的设计取舍(都记在这,免得下一个人重推):
|
|
21
|
+
// · 不做 run_in_background —— 需要接 jobs 服务;要后台时继续用 pwsh 的 run_in_background
|
|
22
|
+
// · 不注入 systemPrompt 段落 —— 路由放 description,零 API 风险
|
|
23
|
+
// · 不用 DSH 的 subprocess 服务 —— 裸 node:child_process + taskkill(Windows 进程树)
|
|
24
|
+
// · ★ 参数必须【自校验】:官方原话 —— 走裸 ctx.tools.register() 注册的工具
|
|
25
|
+
// "is not argument-checked",拼错的键会静默丢失,必须自己严格拒绝
|
|
26
|
+
//
|
|
27
|
+
// ★ 关于沙箱:本工具 spawn 的子进程与 DSH 原生工具处在同一进程/文件系统策略下
|
|
28
|
+
// (实测:workspace-write 下写工作区外会被拒)⇒ 不开后门。
|
|
29
|
+
// 差异:官方 pwsh 工具带 sandbox_permissions 提权申请路径,本工具没有 —— 遇到
|
|
30
|
+
// 沙箱拒绝只能报错,不能升级。这是【功能差异】,不是安全差异。
|
|
31
|
+
|
|
32
|
+
import { spawn } from 'node:child_process';
|
|
33
|
+
import { existsSync, appendFileSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
34
|
+
import { resolve as resolvePath, join as joinPath } from 'node:path';
|
|
35
|
+
import os from 'node:os';
|
|
36
|
+
|
|
37
|
+
export const name = 'bash-tool';
|
|
38
|
+
export const inject = ['tools'];
|
|
39
|
+
|
|
40
|
+
// 默认写 ~/.dsh/logs,可用环境变量覆盖(开源版去本机路径化)
|
|
41
|
+
const LOG = process.env.DSH_BASH_LOG || joinPath(os.homedir(), '.dsh', 'logs', 'bash-tool.log');
|
|
42
|
+
const BASH_CANDIDATES = [
|
|
43
|
+
'C:\\Program Files\\Git\\bin\\bash.exe',
|
|
44
|
+
'C:\\Program Files\\Git\\usr\\bin\\bash.exe',
|
|
45
|
+
'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
|
|
46
|
+
];
|
|
47
|
+
const MAX_TIMEOUT = 600000;
|
|
48
|
+
const DEFAULT_TIMEOUT = 120000;
|
|
49
|
+
const MAX_OUTPUT = 64000; // 显示上限:超了就截断(保留尾部,与官方 pwsh 工具一致)
|
|
50
|
+
// ★ E(2026-10-14 用户要求):截断【不能把内容丢掉】—— 学官方 pwsh 的 spill:
|
|
51
|
+
// 超限时把完整输出落盘,并在截断提示里给出路径(官方 render.ts: "[output truncated; full output: <path>]")。
|
|
52
|
+
const SPILL_DIR = process.env.DSH_BASH_SPILL || joinPath(os.homedir(), '.dsh', 'logs', 'bash-spill');
|
|
53
|
+
const SPILL_CAP = 2 * 1024 * 1024; // 内存里最多留 2MB(再多就真丢,并注明)
|
|
54
|
+
|
|
55
|
+
/** 把完整输出落盘,返回路径(失败返回 null,绝不因 spill 失败影响命令结果)。 */
|
|
56
|
+
export function spill(text, tag) {
|
|
57
|
+
try {
|
|
58
|
+
mkdirSync(SPILL_DIR, { recursive: true });
|
|
59
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
60
|
+
const p = SPILL_DIR + '\\' + stamp + '-' + (tag || 'out') + '.log';
|
|
61
|
+
writeFileSync(p, text, 'utf8');
|
|
62
|
+
return p;
|
|
63
|
+
} catch (e) { return null; }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function fileLog(msg) {
|
|
67
|
+
try { appendFileSync(LOG, '[' + new Date().toISOString() + '] ' + msg + '\n'); } catch (e) { /* 静默 */ }
|
|
68
|
+
}
|
|
69
|
+
function logger(ctx) { return (ctx && ctx.logger) ? ctx.logger : console; }
|
|
70
|
+
function say(ctx, msg, level) {
|
|
71
|
+
try { logger(ctx)[level || 'info']?.('[bash-tool] ' + msg); } catch (e) { /* 静默 */ }
|
|
72
|
+
fileLog(msg);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 找 bash:按候选路径探测,返回第一个存在的。 */
|
|
76
|
+
export function resolveBash(candidates) {
|
|
77
|
+
const list = candidates || BASH_CANDIDATES;
|
|
78
|
+
for (const p of list) { try { if (existsSync(p)) return p; } catch (e) { /* 忽略 */ } }
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 参数严格校验(裸 register 不校验 ⇒ 这里必须自己来)。
|
|
84
|
+
* 未知键直接拒绝 —— 静默丢弃会让调用方以为生效了。
|
|
85
|
+
*/
|
|
86
|
+
export function validateArgs(args) {
|
|
87
|
+
if (!args || typeof args !== 'object') throw new Error('invalid args: expected an object');
|
|
88
|
+
const known = ['command', 'description', 'workdir', 'timeoutMs', 'run_in_background'];
|
|
89
|
+
for (const k of Object.keys(args)) {
|
|
90
|
+
if (known.indexOf(k) < 0) throw new Error('invalid arg "' + k + '": known args are ' + known.join(', '));
|
|
91
|
+
}
|
|
92
|
+
const command = args.command;
|
|
93
|
+
if (typeof command !== 'string' || command.trim() === '') {
|
|
94
|
+
throw new Error('invalid command: expected a non-empty string');
|
|
95
|
+
}
|
|
96
|
+
if (args.description !== undefined && typeof args.description !== 'string') {
|
|
97
|
+
throw new Error('invalid description: expected a string');
|
|
98
|
+
}
|
|
99
|
+
if (args.workdir !== undefined && (typeof args.workdir !== 'string' || args.workdir.trim() === '')) {
|
|
100
|
+
throw new Error('invalid workdir: expected a non-empty string');
|
|
101
|
+
}
|
|
102
|
+
let timeoutMs = DEFAULT_TIMEOUT;
|
|
103
|
+
if (args.timeoutMs !== undefined) {
|
|
104
|
+
if (!Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0) {
|
|
105
|
+
throw new Error('invalid timeoutMs: expected a positive number');
|
|
106
|
+
}
|
|
107
|
+
timeoutMs = Math.min(args.timeoutMs, MAX_TIMEOUT);
|
|
108
|
+
}
|
|
109
|
+
if (args.run_in_background !== undefined && typeof args.run_in_background !== 'boolean') {
|
|
110
|
+
throw new Error('invalid run_in_background: expected a boolean');
|
|
111
|
+
}
|
|
112
|
+
return { command: command, workdir: args.workdir, timeoutMs: timeoutMs, runInBackground: args.run_in_background === true };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** 截断:保留尾部(错误信息通常在尾巴上),并说明省略了多少。 */
|
|
116
|
+
export function truncate(text, limit) {
|
|
117
|
+
const max = limit || MAX_OUTPUT;
|
|
118
|
+
if (typeof text !== 'string' || text.length <= max) return { text: text || '', truncated: false, omitted: 0 };
|
|
119
|
+
const omitted = text.length - max;
|
|
120
|
+
return { text: text.slice(omitted), truncated: true, omitted: omitted };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 跑一条命令。返回 { code, signal, stdout, stderr, timedOut }。
|
|
125
|
+
* Windows 上杀进程树要 taskkill /T(子进程会活下来)。
|
|
126
|
+
*/
|
|
127
|
+
export function runCommand(bashPath, command, opts) {
|
|
128
|
+
const o = opts || {};
|
|
129
|
+
return new Promise((resolve) => {
|
|
130
|
+
let child;
|
|
131
|
+
try {
|
|
132
|
+
child = spawn(bashPath, ['-c', command], {
|
|
133
|
+
cwd: o.workdir || undefined,
|
|
134
|
+
env: process.env,
|
|
135
|
+
windowsHide: true,
|
|
136
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
137
|
+
});
|
|
138
|
+
} catch (e) {
|
|
139
|
+
resolve({ code: null, signal: null, stdout: '', stderr: String((e && e.message) || e), timedOut: false, spawnError: true });
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
try { o.onChild?.(child); } catch (e) { /* 忽略 */ }
|
|
143
|
+
if (o.signal) {
|
|
144
|
+
try { o.signal.addEventListener('abort', () => { try { child.kill('SIGKILL'); } catch (e) { /* 忽略 */ } }, { once: true }); } catch (e) { /* 忽略 */ }
|
|
145
|
+
}
|
|
146
|
+
let stdout = '';
|
|
147
|
+
let stderr = '';
|
|
148
|
+
let timedOut = false;
|
|
149
|
+
let settled = false;
|
|
150
|
+
const cap = (s, chunk) => (s.length < SPILL_CAP ? s + chunk : s);
|
|
151
|
+
const emit = (chan, text) => { try { o.onChunk?.(chan, text); } catch (e) { /* 忽略 */ } };
|
|
152
|
+
child.stdout?.on('data', (c) => { const t = String(c); stdout = cap(stdout, t); emit('stdout', t); });
|
|
153
|
+
child.stderr?.on('data', (c) => { const t = String(c); stderr = cap(stderr, t); emit('stderr', t); });
|
|
154
|
+
// ★ timeoutMs <= 0 = 【不自己超时】。job 路径必须这样:
|
|
155
|
+
// 超时判断交给 registry.wait;若这里也设一个同样的超时,runCommand 会抢先把进程杀掉,
|
|
156
|
+
// "前台超时 ⇒ 转后台"就永远不可能发生(2026-10-15 实测就是这么坏的)。
|
|
157
|
+
const timer = (o.timeoutMs && o.timeoutMs > 0) ? setTimeout(() => {
|
|
158
|
+
timedOut = true;
|
|
159
|
+
try {
|
|
160
|
+
// ★ Windows:必须杀进程树,否则 bash 的子进程会活下来
|
|
161
|
+
if (process.platform === 'win32' && child.pid) {
|
|
162
|
+
spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' });
|
|
163
|
+
} else {
|
|
164
|
+
child.kill('SIGKILL');
|
|
165
|
+
}
|
|
166
|
+
} catch (e) { /* 忽略 */ }
|
|
167
|
+
// ★ 不等 close 事件:taskkill 之后 close 可能还要几秒(实测 800ms 超时 → 5s 才 close)
|
|
168
|
+
// ⇒ 超时就立刻把已经抓到的输出交出去;随后的 close 会被 settled 拦住。
|
|
169
|
+
done(null, null);
|
|
170
|
+
}, o.timeoutMs) : null;
|
|
171
|
+
const done = (code, signal) => {
|
|
172
|
+
if (settled) return;
|
|
173
|
+
settled = true;
|
|
174
|
+
clearTimeout(timer);
|
|
175
|
+
resolve({ code: code, signal: signal, stdout: stdout, stderr: stderr, timedOut: timedOut });
|
|
176
|
+
};
|
|
177
|
+
child.on('error', (e) => { stderr = stderr + '\n' + String((e && e.message) || e); done(null, null); });
|
|
178
|
+
child.on('close', (code, signal) => done(code, signal));
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* ★ F(2026-10-14):把 bash 子进程接进 DSH 的 jobs 注册表 ——
|
|
184
|
+
* 这样现成的 job_output / job_kill 工具就能收割它,用户不必记两套用法。
|
|
185
|
+
*
|
|
186
|
+
* 契约来自官方 @deepseek-ai/dsh-tool-pwsh(同目录 src/background.ts 与 src/index.ts):
|
|
187
|
+
* registry.start({ kind, label, owner?, output, run }) -> id
|
|
188
|
+
* output: [{ channel: 'stdout'|'stderr', read(fromByte) -> { text, nextOffset, lossy } }]
|
|
189
|
+
* run(): { done: Promise<{status:'completed'|'killed'|'failed', detail:string}>, cancel(reason) }
|
|
190
|
+
* registry.wait(id, timeoutMs, owner) / kill(id, owner, reason) / remove(id, owner)
|
|
191
|
+
* 这里用 JS 字符串游标实现 read(自己 push / 自己 read,计数自洽即可)。
|
|
192
|
+
*/
|
|
193
|
+
export function makeOutputSource(channel) {
|
|
194
|
+
let text = '';
|
|
195
|
+
let lossy = false;
|
|
196
|
+
return {
|
|
197
|
+
channel: channel,
|
|
198
|
+
push: (chunk) => {
|
|
199
|
+
text += chunk;
|
|
200
|
+
if (text.length > SPILL_CAP) { text = text.slice(text.length - SPILL_CAP); lossy = true; }
|
|
201
|
+
},
|
|
202
|
+
read: (fromByte) => {
|
|
203
|
+
const from = Number.isFinite(fromByte) && fromByte > 0 ? fromByte : 0;
|
|
204
|
+
const body = text.length > from ? text.slice(from) : '';
|
|
205
|
+
return { text: body, nextOffset: text.length, lossy: lossy };
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** 起一个已注册的 job。返回 { id, outcome }。 */
|
|
211
|
+
export function startBashJob(registry, args, exec, deps) {
|
|
212
|
+
const outSrc = makeOutputSource('stdout');
|
|
213
|
+
const errSrc = makeOutputSource('stderr');
|
|
214
|
+
const controller = new AbortController();
|
|
215
|
+
let childRef = null;
|
|
216
|
+
const done = (async () => {
|
|
217
|
+
try {
|
|
218
|
+
const r = await runCommand(deps.bash, args.command, {
|
|
219
|
+
workdir: args.workdir, timeoutMs: 0, // ★ 不自己超时(见 runCommand 里的说明)
|
|
220
|
+
onChunk: (chan, text) => { (chan === 'stderr' ? errSrc : outSrc).push(text); },
|
|
221
|
+
onChild: (c) => { childRef = c; },
|
|
222
|
+
signal: controller.signal,
|
|
223
|
+
});
|
|
224
|
+
holder.result = r;
|
|
225
|
+
return { status: r.timedOut ? 'killed' : 'completed', detail: 'exit code: ' + (r.code === null ? '?' : r.code) };
|
|
226
|
+
} catch (e) {
|
|
227
|
+
return { status: 'failed', detail: String((e && e.message) || e) };
|
|
228
|
+
}
|
|
229
|
+
})();
|
|
230
|
+
const holder = { id: null, sources: { stdout: outSrc, stderr: errSrc }, result: null, done: done };
|
|
231
|
+
const id = registry.start({
|
|
232
|
+
kind: 'bash',
|
|
233
|
+
label: args.command,
|
|
234
|
+
...(exec && exec.agent ? { owner: exec.agent.id } : {}),
|
|
235
|
+
output: [outSrc, errSrc],
|
|
236
|
+
run: () => ({
|
|
237
|
+
done: done,
|
|
238
|
+
cancel: (reason) => {
|
|
239
|
+
try { controller.abort(reason); } catch (e) { /* 忽略 */ }
|
|
240
|
+
try {
|
|
241
|
+
if (childRef && childRef.pid && process.platform === 'win32') {
|
|
242
|
+
spawn('taskkill', ['/pid', String(childRef.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' });
|
|
243
|
+
} else if (childRef) { childRef.kill('SIGKILL'); }
|
|
244
|
+
} catch (e) { /* 忽略 */ }
|
|
245
|
+
},
|
|
246
|
+
}),
|
|
247
|
+
});
|
|
248
|
+
holder.id = id;
|
|
249
|
+
return holder;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** 把执行结果渲染成给模型看的文本。 */
|
|
253
|
+
export function renderResult(r, timeoutMs) {
|
|
254
|
+
const out = truncate(r.stdout);
|
|
255
|
+
const err = truncate(r.stderr);
|
|
256
|
+
const lines = [];
|
|
257
|
+
if (r.timedOut) lines.push('[超时 ' + Math.round(timeoutMs / 1000) + 's,已杀进程树]');
|
|
258
|
+
lines.push('exit=' + (r.code === null ? '(no code)' : r.code) + (r.signal ? (' signal=' + r.signal) : ''));
|
|
259
|
+
if (out.text.trim()) lines.push('--- stdout ---\n' + out.text.replace(/\s+$/, ''));
|
|
260
|
+
if (err.text.trim()) lines.push('--- stderr ---\n' + err.text.replace(/\s+$/, ''));
|
|
261
|
+
// ★ 截断时把【完整】输出落盘并给路径(否则超出的部分就永久丢了)
|
|
262
|
+
if (out.truncated) {
|
|
263
|
+
const p = spill(r.stdout, 'stdout');
|
|
264
|
+
lines.push('[stdout 截断:省略前面 ' + out.omitted + ' 字符;完整输出: ' + (p || '(落盘失败)') + ']');
|
|
265
|
+
}
|
|
266
|
+
if (err.truncated) {
|
|
267
|
+
const p = spill(r.stderr, 'stderr');
|
|
268
|
+
lines.push('[stderr 截断:省略前面 ' + err.omitted + ' 字符;完整输出: ' + (p || '(落盘失败)') + ']');
|
|
269
|
+
}
|
|
270
|
+
if (!out.text.trim() && !err.text.trim()) lines.push('(无输出)');
|
|
271
|
+
return lines.join('\n');
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export const BASH_DESCRIPTION = [
|
|
275
|
+
'★ 这是【默认的命令执行工具】—— 敲命令前先想它,而不是 pwsh。',
|
|
276
|
+
'文本/行处理(sed awk sort uniq cut tr xargs 与管道)、批量改名、正则替换、跨文件统计、查文件、看字节、下载、查进程端口 ——',
|
|
277
|
+
'一律用它:一条管道搞定,比 PowerShell 的 Get-ChildItem/Where-Object/ForEach-Object 又短又快(启动也快 5 倍:43ms vs 241ms)。',
|
|
278
|
+
'★ 只有这三种情况才改用 pwsh:',
|
|
279
|
+
' ① 要 Windows 对象 / 原生能力:注册表 · 服务 · WMI-CIM · 证书 · 防火墙 · 事件日志 · .NET 静态调用([System.IO.*]::);',
|
|
280
|
+
' ② 要调 .cmd / .ps1 脚本链(llm\\*.cmd 这类老脚本按 5.1 语法写);',
|
|
281
|
+
' ③ PowerShell 特有 cmdlet 而 bash 没有等价物。',
|
|
282
|
+
'★ 判断法:这条命令里有【Windows 对象】吗?没有 ⇒ bash。',
|
|
283
|
+
'执行方式:给 command(必填)与 description(必填);用 workdir 指定工作目录,不要用 cd。独立进程、每次全新 shell。',
|
|
284
|
+
'★ 这些场景也优先用它(省一次工具往返,也避免 PowerShell 的转义坑):',
|
|
285
|
+
' · 多模式找文件:rg --files -g "*.{ts,tsx}" -g "!node_modules" / ls **/*.css',
|
|
286
|
+
' · 先看分布再决定读哪里(代替逐行 grep):rg -c 模式 或 rg -l 模式',
|
|
287
|
+
' · 看字节/编码/不可见字符:xxd 文件 | head、od -c 文件、head -c 16 文件 | xxd',
|
|
288
|
+
' · 批量文件操作:先用 find/printf 干跑一遍看清清单,确认后再 mv/rm(先看后做)',
|
|
289
|
+
'★ 后台:run_in_background=true 时立即返回 job id,用现成的 job_output / job_kill 收割(不必换回 pwsh);',
|
|
290
|
+
'前台命令若超时,会【自动转为后台】继续跑并给出 job id(进度不丢)。',
|
|
291
|
+
'输出超限截断到尾部,并【把完整输出落盘 + 给出路径】。',
|
|
292
|
+
].join(' ');
|
|
293
|
+
|
|
294
|
+
export const BASH_PARAMETERS = {
|
|
295
|
+
type: 'object',
|
|
296
|
+
properties: {
|
|
297
|
+
command: { type: 'string', description: '要执行的 bash 命令(会以 bash -c 运行)。' },
|
|
298
|
+
description: { type: 'string', description: '一句话说明这条命令在做什么(显示用)。' },
|
|
299
|
+
workdir: { type: 'string', description: '工作目录(绝对或相对会话 cwd);不要用 cd。' },
|
|
300
|
+
timeoutMs: { type: 'number', description: '超时毫秒数(默认 120000,上限 600000)。前台命令超时后【自动转为后台】继续跑,不会被杀。' },
|
|
301
|
+
run_in_background: { type: 'boolean', description: '放后台跑:立即返回 job id,之后用 job_output 看输出、job_kill 终止(默认 false)。' },
|
|
302
|
+
},
|
|
303
|
+
required: ['command', 'description'],
|
|
304
|
+
additionalProperties: false,
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* ★ 工具返回值(2026-10-14 实测教训):裸 ctx.tools.register() 注册的工具
|
|
309
|
+
* 【必须】声明 output: { schema, render },否则 register 直接抛
|
|
310
|
+
* 「tool "x" must declare output { schema, render, presentationMeta? }」——
|
|
311
|
+
* 第一版漏了它,两个工具都没注册上(幸好 apply() 里写了 fileLog,日志一眼看到)。
|
|
312
|
+
* execute 返回的是【结构化 value】,由 output.render 转成模型看到的 content。
|
|
313
|
+
* 这里 value 同时带 text 与 content:render 用 text,老测试/兼容路径用 content。
|
|
314
|
+
*/
|
|
315
|
+
export function toolValue(text, extra) {
|
|
316
|
+
const base = { text: text, content: [{ type: 'text', text: text }] };
|
|
317
|
+
// ⚠️ 必须剔除 undefined:DSH 会校验返回的 value 是【lossless JSON】,
|
|
318
|
+
// 带一个 undefined 属性就会报 'tool "bash" returned invalid output: value is not lossless JSON'
|
|
319
|
+
// (2026-10-15 实测:前台路径给 cwd: undefined 时整个调用失败,后台路径因为只传了字符串 jobId 反而正常)
|
|
320
|
+
if (extra) {
|
|
321
|
+
for (const k of Object.keys(extra)) {
|
|
322
|
+
if (extra[k] !== undefined) base[k] = extra[k];
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return base;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** 构造工具定义(导出来是为了能在离线测试里断言形状)。 */
|
|
329
|
+
export function bashToolDefinition(deps) {
|
|
330
|
+
const d = deps || {};
|
|
331
|
+
const jobs = d.jobs; // 可能为 undefined ⇒ 退化为纯前台
|
|
332
|
+
return {
|
|
333
|
+
name: 'bash',
|
|
334
|
+
description: BASH_DESCRIPTION,
|
|
335
|
+
parameters: BASH_PARAMETERS,
|
|
336
|
+
isConcurrencySafe: () => true,
|
|
337
|
+
execute: async (args, exec) => {
|
|
338
|
+
const v = validateArgs(args);
|
|
339
|
+
const bash = d.bash || resolveBash();
|
|
340
|
+
if (!bash) throw new Error('找不到 bash:候选路径都不存在(' + BASH_CANDIDATES.join(' / ') + ')');
|
|
341
|
+
const workdir = v.workdir ? resolvePath(v.workdir) : undefined;
|
|
342
|
+
if (workdir && !existsSync(workdir)) throw new Error('workdir 不存在:' + workdir);
|
|
343
|
+
const spec = { command: v.command, workdir: workdir, timeoutMs: v.timeoutMs };
|
|
344
|
+
const owner = exec && exec.agent ? exec.agent.id : undefined;
|
|
345
|
+
|
|
346
|
+
// ① 显式后台 ⇒ 注册成 job,立即把 id 交出去
|
|
347
|
+
if (jobs && v.runInBackground) {
|
|
348
|
+
const h = startBashJob(jobs, spec, exec, { bash: bash });
|
|
349
|
+
return toolValue('已在后台启动(job id: ' + h.id + ')\n用 job_output 看输出、job_kill 终止。', { kind: 'background', jobId: h.id });
|
|
350
|
+
}
|
|
351
|
+
// ② 前台但接了 jobs ⇒ 也注册成 job:超时就能【转后台】而不是把进度杀掉
|
|
352
|
+
if (jobs) {
|
|
353
|
+
let h = null;
|
|
354
|
+
try {
|
|
355
|
+
h = startBashJob(jobs, spec, exec, { bash: bash });
|
|
356
|
+
const view = await jobs.wait(h.id, v.timeoutMs, owner);
|
|
357
|
+
const st = view && view.status;
|
|
358
|
+
if (st === 'running' || st === 'stopping') {
|
|
359
|
+
return toolValue('前台等待超时(' + Math.round(v.timeoutMs / 1000) + 's),命令已【转为后台】继续跑(job id: ' + h.id + ')\n用 job_output 收割输出、job_kill 终止。', { kind: 'promoted', jobId: h.id });
|
|
360
|
+
}
|
|
361
|
+
try { jobs.remove(h.id, owner); } catch (e) { /* 忽略 */ }
|
|
362
|
+
// ★ 先等 done 落定,再取结果:wait 返回"已落定"不等于我们自己的 runCommand 回调已跑完
|
|
363
|
+
// (否则会渲染出空输出 —— 测试里就是这条先失败)
|
|
364
|
+
try { await h.done; } catch (e) { /* 忽略 */ }
|
|
365
|
+
const r = h.result || { code: null, stdout: h.sources.stdout.read(0).text, stderr: h.sources.stderr.read(0).text, timedOut: false };
|
|
366
|
+
return toolValue(renderResult(r, v.timeoutMs), { kind: 'foreground', exitCode: r.code, cwd: workdir });
|
|
367
|
+
} catch (e) {
|
|
368
|
+
// ★ 等待失败(例如调用方中止):绝不能回退去【重跑一遍】命令 —— 那会执行两次。
|
|
369
|
+
// 如实报告,并把 job id 交出去(命令可能仍在后台跑)。
|
|
370
|
+
return toolValue('前台等待失败: ' + ((e && e.message) || e)
|
|
371
|
+
+ (h ? ('\n命令可能仍在后台跑(job id: ' + h.id + ')—— 用 job_output 收割、job_kill 终止。') : ''), { kind: 'error' });
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
// ③ 纯前台(没有 jobs 服务时的退化路径)
|
|
375
|
+
const r = await runCommand(bash, spec.command, { workdir: workdir, timeoutMs: v.timeoutMs });
|
|
376
|
+
return toolValue(renderResult(r, v.timeoutMs), { kind: 'foreground', exitCode: r.code, cwd: workdir });
|
|
377
|
+
},
|
|
378
|
+
// ★ 必须声明(漏了 register 直接抛错,见 toolValue 上方注释)
|
|
379
|
+
// ⚠️ 再记一个坑(2026-10-15 实测):DSH 的 schema 校验器【不支持类型数组】——
|
|
380
|
+
// 写 type: ['number','null'] 会被拒:"type arrays are not supported"。
|
|
381
|
+
// ⇒ 只声明必要字段(text),其余靠 additionalProperties: true 放行。
|
|
382
|
+
output: {
|
|
383
|
+
schema: {
|
|
384
|
+
type: 'object',
|
|
385
|
+
additionalProperties: true,
|
|
386
|
+
properties: {
|
|
387
|
+
text: { type: 'string' },
|
|
388
|
+
},
|
|
389
|
+
required: ['text'],
|
|
390
|
+
},
|
|
391
|
+
render: (_args, value) => [{ type: 'text', text: String((value && value.text) || '') }],
|
|
392
|
+
},
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** 判断一个 agent 是不是子 agent(源码原句见宿主API速查 §2.2)。 */
|
|
397
|
+
export function isSubagent(agent) {
|
|
398
|
+
try {
|
|
399
|
+
const header = agent?.session?.header;
|
|
400
|
+
return header?.origin === 'subagent' || (header?.delegationDepth ?? 0) > 0;
|
|
401
|
+
} catch (e) { return false; }
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* B:对子 agent 隐藏 pwsh。
|
|
406
|
+
* ★ restrict 必须挂在【该 agent 自己的 ctx】上(scoped context),并把返回的 lift 存好,
|
|
407
|
+
* agent 销毁时调用 —— 否则限制会跟着泄漏。
|
|
408
|
+
*/
|
|
409
|
+
export function installSubagentPwshDenial(ctx, hooks) {
|
|
410
|
+
const lifts = new Map();
|
|
411
|
+
const onLog = (hooks && hooks.log) || (() => {});
|
|
412
|
+
const visible = (agent) => {
|
|
413
|
+
try { return (ctx.get('tools')?.schemas?.(agent) ?? []).map((s) => s && s.name); }
|
|
414
|
+
catch (e) { return []; }
|
|
415
|
+
};
|
|
416
|
+
const apply = (agent) => {
|
|
417
|
+
try {
|
|
418
|
+
if (!agent || lifts.has(agent)) return;
|
|
419
|
+
if (!isSubagent(agent)) return; // 主 agent 一律不动
|
|
420
|
+
if (visible(agent).indexOf('pwsh') < 0) return; // 本来就没有 pwsh 就别碰
|
|
421
|
+
const scoped = agent.ctx && agent.ctx.tools;
|
|
422
|
+
if (!scoped || typeof scoped.restrict !== 'function') return;
|
|
423
|
+
lifts.set(agent, scoped.restrict({ deny: ['pwsh'] }));
|
|
424
|
+
onLog('已对子 agent 隐藏 pwsh');
|
|
425
|
+
} catch (e) {
|
|
426
|
+
onLog('隐藏 pwsh 失败(忽略): ' + ((e && e.message) || e));
|
|
427
|
+
}
|
|
428
|
+
};
|
|
429
|
+
const release = (agent) => {
|
|
430
|
+
try { lifts.get(agent)?.(); } catch (e) { /* 忽略 */ }
|
|
431
|
+
lifts.delete(agent);
|
|
432
|
+
};
|
|
433
|
+
ctx.on('agent/created', (payload) => apply(payload && payload.agent));
|
|
434
|
+
ctx.on('agent/disposed', (payload) => release(payload && payload.agent));
|
|
435
|
+
for (const a of (ctx.get('agents')?.list?.() ?? [])) apply(a);
|
|
436
|
+
ctx.effect(() => () => { for (const a of [...lifts.keys()]) release(a); });
|
|
437
|
+
return { apply: apply, release: release, size: () => lifts.size };
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export function apply(ctx) {
|
|
441
|
+
const log = (m, l) => say(ctx, m, l);
|
|
442
|
+
log('host apply() 开始');
|
|
443
|
+
if (!ctx || typeof ctx.on !== 'function') { log('ctx.on 不可用,未挂载', 'warn'); return; }
|
|
444
|
+
|
|
445
|
+
// A:注册 bash 工具
|
|
446
|
+
try {
|
|
447
|
+
const bash = resolveBash();
|
|
448
|
+
if (!bash) { log('找不到 bash,跳过工具注册(候选都不存在)', 'warn'); }
|
|
449
|
+
else {
|
|
450
|
+
// ★ 时序坑(2026-10-14 实测):本插件只 inject ['tools'],apply 时 jobs 服务【可能还没注册】
|
|
451
|
+
// ⇒ ctx.get('jobs') 拿到 undefined ⇒ 永久退化成"仅前台"(日志里就是 job 服务: 不可用)。
|
|
452
|
+
// 正解:先注册一版(此刻可能没 jobs),再用 ctx.inject(['jobs'], …) 等服务就绪后【带 jobs 重新注册】。
|
|
453
|
+
let jobsRef = null;
|
|
454
|
+
let disposeTool = null;
|
|
455
|
+
const registerTool = () => {
|
|
456
|
+
// ⚠️ 这里只能用 apply 作用域里的 bash(d 是 bashToolDefinition 内部的概念,
|
|
457
|
+
// 上一版误写成 d 导致 "d is not defined" —— 被自测当场抓到)
|
|
458
|
+
try { if (disposeTool) disposeTool(); } catch (e) { /* 忽略 */ }
|
|
459
|
+
disposeTool = ctx.tools.register(bashToolDefinition({ bash: bash, jobs: jobsRef }));
|
|
460
|
+
};
|
|
461
|
+
try { jobsRef = ctx.get('jobs') || null; } catch (e) { jobsRef = null; }
|
|
462
|
+
registerTool();
|
|
463
|
+
ctx.effect(() => () => { try { if (disposeTool) disposeTool(); } catch (e) { /* 忽略 */ } });
|
|
464
|
+
log('已注册 bash 工具 → ' + bash + '(jobs 服务: ' + (jobsRef ? '立即可用 ⇒ 支持 run_in_background 与超时转后台' : '暂未就绪,等它就绪后自动升级') + ')');
|
|
465
|
+
if (!jobsRef) {
|
|
466
|
+
try {
|
|
467
|
+
ctx.inject(['jobs'], (jobsCtx) => {
|
|
468
|
+
try {
|
|
469
|
+
jobsRef = jobsCtx.get('jobs') || null;
|
|
470
|
+
if (!jobsRef) return;
|
|
471
|
+
registerTool(); // 幂等:先注销旧定义再注册新的
|
|
472
|
+
log('jobs 服务就绪 ⇒ 已重新注册 bash 工具(支持 run_in_background 与超时转后台)');
|
|
473
|
+
} catch (e) {
|
|
474
|
+
log('jobs 注入回调失败: ' + ((e && e.message) || e), 'warn');
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
} catch (e) {
|
|
478
|
+
log('ctx.inject([jobs]) 不可用,后台能力不可用: ' + ((e && e.message) || e), 'warn');
|
|
479
|
+
}
|
|
480
|
+
// ★ 兜底:万一 inject 不回调(或 jobs 出现得更晚),轮询再试 30 秒。
|
|
481
|
+
// 两条机制互补 —— 已经让用户重启三次了,这里不能再赌。
|
|
482
|
+
let tries = 0;
|
|
483
|
+
const timer = setInterval(() => {
|
|
484
|
+
if (jobsRef || ++tries > 60) { clearInterval(timer); return; }
|
|
485
|
+
try {
|
|
486
|
+
const j = ctx.get('jobs');
|
|
487
|
+
if (j) {
|
|
488
|
+
jobsRef = j;
|
|
489
|
+
registerTool();
|
|
490
|
+
log('jobs 服务就绪(轮询兜底)⇒ 已重新注册 bash 工具(支持后台)');
|
|
491
|
+
clearInterval(timer);
|
|
492
|
+
}
|
|
493
|
+
} catch (e) { /* 忽略 */ }
|
|
494
|
+
}, 500);
|
|
495
|
+
if (timer && typeof timer.unref === 'function') timer.unref(); // 别拖住进程退出
|
|
496
|
+
ctx.effect(() => () => { try { clearInterval(timer); } catch (e) { /* 忽略 */ } });
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
} catch (e) {
|
|
500
|
+
log('注册 bash 工具失败: ' + ((e && e.message) || e), 'error');
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// B:子 agent 隐藏 pwsh
|
|
504
|
+
try {
|
|
505
|
+
// (清理由 installSubagentPwshDenial 内部自己的 ctx.effect 负责,这里不再包一层)
|
|
506
|
+
const guard = installSubagentPwshDenial(ctx, { log: (m) => log(m) });
|
|
507
|
+
log('子 agent 的 pwsh 隐藏已挂载(当前命中 ' + guard.size() + ' 个)');
|
|
508
|
+
} catch (e) {
|
|
509
|
+
log('子 agent 隐藏 pwsh 挂载失败: ' + ((e && e.message) || e), 'error');
|
|
510
|
+
}
|
|
511
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,36 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-bash",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Windows 上的 bash 工具(走 Git for Windows)+ 子 agent 隐藏 pwsh + 输出 spill + 后台 job",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"default": "./lib/index.js"
|
|
10
|
+
},
|
|
11
|
+
"./package.json": "./package.json"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"lib/index.js"
|
|
15
|
+
],
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/Fanlr1/dsh-plugins.git",
|
|
20
|
+
"directory": "packages/bash"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://github.com/Fanlr1/dsh-plugins/tree/main/packages/bash#readme",
|
|
23
|
+
"bugs": {
|
|
24
|
+
"url": "https://github.com/Fanlr1/dsh-plugins/issues"
|
|
25
|
+
},
|
|
26
|
+
"keywords": [
|
|
27
|
+
"dsh",
|
|
28
|
+
"dsh-plugin",
|
|
29
|
+
"tools",
|
|
30
|
+
"bash",
|
|
31
|
+
"windows"
|
|
32
|
+
],
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=20"
|
|
35
|
+
}
|
|
36
|
+
}
|