dsh-pwsh-plugin 0.0.0-stage → 0.2.13
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/FINDINGS.md +1789 -0
- package/LICENSE +21 -0
- package/README.md +431 -2
- package/README.zh-CN.md +386 -0
- package/cordis.patch.yml +27 -0
- package/docs/llrun.md +199 -0
- package/icon.svg +5 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +7 -0
- package/package.json +57 -4
- package/plugin/auto-update.mjs +210 -0
- package/plugin/index.mjs +580 -0
- package/plugin/session-server.ps1 +225 -0
- package/plugin/session.mjs +223 -0
- package/plugin/update-apply.mjs +274 -0
- package/plugin/update-check.mjs +270 -0
- package/wsl/acceptance.sh +155 -0
- package/wsl/compaction-epoch.mjs +115 -0
- package/wsl/custom-bash.mjs +355 -0
- package/wsl/llrun +0 -0
- package/wsl/llrun.c +337 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# llrun 验收矩阵 —— 长期回归脚本(缺目录的条目自动 SKIP)
|
|
3
|
+
# 用法:bash wsl/acceptance.sh (在 WSL 里跑,需要 llrun 可执行)
|
|
4
|
+
# LLRUN=/usr/local/bin/llrun bash wsl/acceptance.sh
|
|
5
|
+
# 只读检查,全部探针文件用完即删;每个断言末尾都会做磁盘复核。
|
|
6
|
+
|
|
7
|
+
HERE=$(cd "$(dirname "$0")" && pwd)
|
|
8
|
+
WS=$(cd "$HERE/.." && pwd)
|
|
9
|
+
LL=${LLRUN:-$HERE/llrun}
|
|
10
|
+
|
|
11
|
+
# 工作区所在的盘符挂载点,例如 /mnt/d
|
|
12
|
+
DRIVE=$(printf '%s' "$WS" | cut -d/ -f1-3)
|
|
13
|
+
PARENT=$(dirname "$WS")
|
|
14
|
+
|
|
15
|
+
# Windows 用户目录:取 /mnt/<盘符>/Users 下第一个真实用户目录。
|
|
16
|
+
WINHOME=
|
|
17
|
+
for candidate in /mnt/*/Users/*/; do
|
|
18
|
+
case "$candidate" in
|
|
19
|
+
*/All\ Users/*|*/Default/*|*/Default\ User/*|*/Public/*) continue ;;
|
|
20
|
+
esac
|
|
21
|
+
WINHOME=${candidate%/}
|
|
22
|
+
break
|
|
23
|
+
done
|
|
24
|
+
|
|
25
|
+
# 「工作区外」的目标:存在的才算,逐个验证写不进去。
|
|
26
|
+
OUTSIDE=()
|
|
27
|
+
for dir in "$PARENT" "$DRIVE" "$WINHOME" "$(dirname "$WINHOME")/Users"; do
|
|
28
|
+
[ -n "$dir" ] && [ -d "$dir" ] && OUTSIDE+=("$dir")
|
|
29
|
+
done
|
|
30
|
+
|
|
31
|
+
pass=0; fail=0; skip=0
|
|
32
|
+
chk() { if [ "$2" = "$3" ]; then echo " PASS $1"; pass=$((pass+1)); else echo " FAIL $1 (got=$2 want=$3)"; fail=$((fail+1)); fi; }
|
|
33
|
+
|
|
34
|
+
echo "llrun = $LL"
|
|
35
|
+
echo "workspace = $WS"
|
|
36
|
+
echo "drive root = $DRIVE"
|
|
37
|
+
echo "win home = ${WINHOME:-<none>}"
|
|
38
|
+
echo
|
|
39
|
+
|
|
40
|
+
echo '== 写边界:工作区外一律拒绝 =='
|
|
41
|
+
i=0
|
|
42
|
+
for dir in "${OUTSIDE[@]}"; do
|
|
43
|
+
i=$((i+1))
|
|
44
|
+
target="$dir/_lacc_$i.txt"
|
|
45
|
+
chk "写不进 $dir" "$($LL "$WS" /bin/bash -c "touch '$target' 2>/dev/null && echo W || echo D")" 'D'
|
|
46
|
+
done
|
|
47
|
+
chk '写不进 /var/tmp' "$($LL "$WS" /bin/bash -c 'touch /var/tmp/_lacc_v 2>/dev/null && echo W || echo D')" 'D'
|
|
48
|
+
chk '/var/tmp 可读' "$($LL "$WS" /bin/bash -c 'ls /var/tmp >/dev/null && echo R || echo N')" 'R'
|
|
49
|
+
chk '工作区内可写' "$(WSX="$WS" $LL "$WS" /bin/bash -c 'touch "$WSX"/_lacc_ws.txt 2>/dev/null && echo W || echo D; rm -f "$WSX"/_lacc_ws.txt')" 'W'
|
|
50
|
+
chk '/tmp 可写' "$($LL "$WS" /bin/bash -c 'touch /tmp/_lacc_t.txt 2>/dev/null && echo W || echo D; rm -f /tmp/_lacc_t.txt')" 'W'
|
|
51
|
+
chk '/home(本用户)可写' "$($LL "$WS" /bin/bash -c 'touch ~/_lacc_h.txt 2>/dev/null && echo W || echo D; rm -f ~/_lacc_h.txt')" 'W'
|
|
52
|
+
chk '工作区外可读' "$($LL "$WS" /bin/bash -c "ls '$PARENT' >/dev/null && echo R || echo N")" 'R'
|
|
53
|
+
chk '/dev/shm 默认可写' "$($LL "$WS" /bin/bash -c 'touch /dev/shm/_lacc_s 2>/dev/null && echo W || echo D; rm -f /dev/shm/_lacc_s')" 'W'
|
|
54
|
+
|
|
55
|
+
echo '== Windows exe 全封(landlock 白名单不含 /init)=='
|
|
56
|
+
chk 'cmd.exe' "$($LL "$WS" /bin/bash -c '/mnt/c/Windows/System32/cmd.exe /c echo x >/dev/null 2>&1 && echo RAN || echo BLOCKED')" 'BLOCKED'
|
|
57
|
+
chk 'powershell.exe' "$($LL "$WS" /bin/bash -c '/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe -c "echo x" >/dev/null 2>&1 && echo RAN || echo BLOCKED')" 'BLOCKED'
|
|
58
|
+
chk 'notepad.exe' "$($LL "$WS" /bin/bash -c '/mnt/c/Windows/System32/notepad.exe >/dev/null 2>&1 && echo RAN || echo BLOCKED')" 'BLOCKED'
|
|
59
|
+
chk '/init 直调' "$($LL "$WS" /bin/bash -c '/init /mnt/c/Windows/System32/cmd.exe /c echo x >/dev/null 2>&1 && echo RAN || echo BLOCKED')" 'BLOCKED'
|
|
60
|
+
|
|
61
|
+
echo '== docker 逃逸 =='
|
|
62
|
+
chk 'docker ps' "$($LL "$WS" /bin/bash -c 'timeout 6 docker ps >/dev/null 2>&1 && echo CONNECTED || echo BLOCKED')" 'BLOCKED'
|
|
63
|
+
chk '--no-isolate 时 docker(反向对照)' "$($LL --no-isolate "$WS" /bin/bash -c 'timeout 6 docker ps >/dev/null 2>&1 && echo CONNECTED || echo BLOCKED')" 'CONNECTED'
|
|
64
|
+
chk 'socket 直连' "$($LL "$WS" /bin/bash -c 'timeout 5 python3 -c "
|
|
65
|
+
import socket
|
|
66
|
+
try:
|
|
67
|
+
socket.socket(socket.AF_UNIX).connect(chr(47)+chr(118)+chr(97)+chr(114)+chr(47)+chr(114)+chr(117)+chr(110)+chr(47)+chr(100)+chr(111)+chr(99)+chr(107)+chr(101)+chr(114)+chr(46)+chr(115)+chr(111)+chr(99)+chr(107))
|
|
68
|
+
print(\"CONNECTED\")
|
|
69
|
+
except Exception:
|
|
70
|
+
print(\"BLOCKED\")
|
|
71
|
+
"' | tail -1)" 'BLOCKED'
|
|
72
|
+
|
|
73
|
+
echo '== 开发者工具链 =='
|
|
74
|
+
chk 'git' "$($LL "$WS" /bin/bash -c 'git --version >/dev/null && echo OK')" 'OK'
|
|
75
|
+
chk 'node' "$($LL "$WS" /bin/bash -c 'node -e 1 && echo OK')" 'OK'
|
|
76
|
+
chk 'python3' "$($LL "$WS" /bin/bash -c 'python3 -c "print(7*6)"')" '42'
|
|
77
|
+
chk 'gcc' "$($LL "$WS" /bin/bash -c 'echo "int main(){return 0;}" > /tmp/_lacc.c && gcc -o /tmp/_lacc /tmp/_lacc.c && echo OK; rm -f /tmp/_lacc /tmp/_lacc.c')" 'OK'
|
|
78
|
+
HOST_PKG=$(dpkg -l 2>/dev/null | wc -l)
|
|
79
|
+
chk 'dpkg 只读查询' "$($LL "$WS" /bin/bash -c 'dpkg -l 2>/dev/null | wc -l')" "$HOST_PKG"
|
|
80
|
+
chk 'curl 网络' "$($LL "$WS" /bin/bash -c 'curl -s -o /dev/null -w "%{http_code}" https://www.bing.com')" '200'
|
|
81
|
+
chk 'ssh 不报属主错误' "$($LL "$WS" /bin/bash -c 'ssh -G github.com 2>&1 | grep -c "Bad owner"')" '0'
|
|
82
|
+
|
|
83
|
+
echo '== -r 严格只读:写入一律拒绝 =='
|
|
84
|
+
chk '-r 写工作区' "$(WSX="$WS" $LL -r "$WS" /bin/bash -c 'touch "$WSX"/_lacc_r0.txt 2>/dev/null && echo W || echo D')" 'D'
|
|
85
|
+
chk '-r 写 /tmp' "$($LL -r "$WS" /bin/bash -c 'touch /tmp/_lacc_r1 2>/dev/null && echo W || echo D')" 'D'
|
|
86
|
+
chk '-r 写 /home' "$($LL -r "$WS" /bin/bash -c 'touch ~/_lacc_r2 2>/dev/null && echo W || echo D')" 'D'
|
|
87
|
+
chk '-r 写 /dev/shm' "$($LL -r "$WS" /bin/bash -c 'touch /dev/shm/_lacc_r3 2>/dev/null && echo W || echo D')" 'D'
|
|
88
|
+
chk '-r 写 /var/tmp' "$($LL -r "$WS" /bin/bash -c 'touch /var/tmp/_lacc_r4 2>/dev/null && echo W || echo D')" 'D'
|
|
89
|
+
chk '-r 写 /etc' "$($LL -r "$WS" /bin/bash -c 'touch /etc/_lacc_r5 2>/dev/null && echo W || echo D')" 'D'
|
|
90
|
+
chk '-r 写 /var/log' "$($LL -r "$WS" /bin/bash -c 'touch /var/log/_lacc_r6 2>/dev/null && echo W || echo D')" 'D'
|
|
91
|
+
chk '-r mktemp' "$($LL -r "$WS" /bin/bash -c 'mktemp -d >/dev/null 2>&1 && echo OK || echo NO')" 'NO'
|
|
92
|
+
|
|
93
|
+
echo '== -r 严格只读:读取照常 =='
|
|
94
|
+
chk '-r 读工作区' "$(WSX="$WS" $LL -r "$WS" /bin/bash -c 'ls "$WSX" >/dev/null && echo R || echo N')" 'R'
|
|
95
|
+
chk '-r 读 /tmp' "$($LL -r "$WS" /bin/bash -c 'ls /tmp >/dev/null && echo R || echo N')" 'R'
|
|
96
|
+
chk '-r 读 /home' "$($LL -r "$WS" /bin/bash -c 'ls ~ >/dev/null && echo R || echo N')" 'R'
|
|
97
|
+
chk '-r 读 /etc' "$($LL -r "$WS" /bin/bash -c 'cat /etc/hostname >/dev/null && echo R || echo N')" 'R'
|
|
98
|
+
chk '-r 读工作区外' "$($LL -r "$WS" /bin/bash -c "ls '$PARENT' >/dev/null && echo R || echo N")" 'R'
|
|
99
|
+
chk '-r 写 /dev/null' "$($LL -r "$WS" /bin/bash -c 'echo x > /dev/null && echo OK')" 'OK'
|
|
100
|
+
|
|
101
|
+
echo '== -r 严格只读:工具链存活(无 /tmp 可写的代价见 README)=='
|
|
102
|
+
chk '-r python3' "$($LL -r "$WS" /bin/bash -c 'python3 -c "print(7*6)"')" '42'
|
|
103
|
+
chk '-r git' "$($LL -r "$WS" /bin/bash -c 'git --version >/dev/null && echo OK')" 'OK'
|
|
104
|
+
chk '-r curl' "$($LL -r "$WS" /bin/bash -c 'curl -s -o /dev/null -w "%{http_code}" https://www.bing.com')" '200'
|
|
105
|
+
chk '-r dpkg' "$($LL -r "$WS" /bin/bash -c 'dpkg -l 2>/dev/null | wc -l')" "$(dpkg -l 2>/dev/null | wc -l)"
|
|
106
|
+
chk '-r ssh' "$($LL -r "$WS" /bin/bash -c 'ssh -G github.com 2>&1 | grep -c "Bad owner"')" '0'
|
|
107
|
+
chk '-r gcc 不可用(/tmp 只读,符合预期)' "$($LL -r "$WS" /bin/bash -c 'gcc -x c -o /dev/null - <<< "int main(){return 0;}" >/dev/null 2>&1 && echo OK || echo NO')" 'NO'
|
|
108
|
+
|
|
109
|
+
echo '== -r 严格只读:删除/重命名同样拒绝 =='
|
|
110
|
+
# 先在工作区内建好目标文件(-r 档建不出来),再在 -r 档里删它
|
|
111
|
+
printf x > "$WS"/_lacc_rd
|
|
112
|
+
chk '-r 删工作区文件' "$(WSX="$WS" $LL -r "$WS" /bin/bash -c 'rm -f "$WSX"/_lacc_rd 2>/dev/null && echo DONE || echo D')" 'D'
|
|
113
|
+
chk '-r 删除后文件仍在' "$([ -e "$WS"/_lacc_rd ] && echo STILL || echo GONE)" 'STILL'
|
|
114
|
+
rm -f "$WS"/_lacc_rd
|
|
115
|
+
chk '-r 建目录' "$(WSX="$WS" $LL -r "$WS" /bin/bash -c 'mkdir -p "$WSX"/_lacc_md 2>/dev/null && echo MADE || echo D')" 'D'
|
|
116
|
+
|
|
117
|
+
echo '== 开关 =='
|
|
118
|
+
chk '-C /tmp' "$($LL -C /tmp - /bin/bash -c 'pwd')" '/tmp'
|
|
119
|
+
chk '--no-seal 放开 /var/tmp' "$($LL --no-seal "$WS" /bin/bash -c 'touch /var/tmp/_lacc_ns 2>/dev/null && echo W || echo D; rm -f /var/tmp/_lacc_ns')" 'W'
|
|
120
|
+
chk '--no-isolate 同时失去封印' "$($LL --no-isolate "$WS" /bin/bash -c 'touch /var/tmp/_lacc_ni 2>/dev/null && echo W || echo D; rm -f /var/tmp/_lacc_ni')" 'W'
|
|
121
|
+
|
|
122
|
+
echo '== 原样传递 =='
|
|
123
|
+
cat > /tmp/_lacc_sd.sh <<'OUTER'
|
|
124
|
+
cat <<'INNER'
|
|
125
|
+
lit $HOME $(cmd) 'single' "double" \ back
|
|
126
|
+
INNER
|
|
127
|
+
OUTER
|
|
128
|
+
chk 'stdin heredoc 字面量' "$($LL "$WS" /bin/bash -s < /tmp/_lacc_sd.sh)" 'lit $HOME $(cmd) '\''single'\'' "double" \ back'
|
|
129
|
+
rm -f /tmp/_lacc_sd.sh
|
|
130
|
+
chk '退出码 42' "$($LL "$WS" /bin/bash -c 'exit 42'; echo $?)" '42'
|
|
131
|
+
chk '中文' "$($LL "$WS" /bin/bash -c 'echo 你好世界')" '你好世界'
|
|
132
|
+
chk 'workspace=/' "$($LL / /bin/bash -c 'echo x' 2>/dev/null; echo $?)" '126'
|
|
133
|
+
|
|
134
|
+
echo
|
|
135
|
+
echo "TOTAL pass=$pass fail=$fail skip=$skip"
|
|
136
|
+
echo '--- 磁盘复核 ---'
|
|
137
|
+
leaks=0
|
|
138
|
+
for dir in "${OUTSIDE[@]}"; do
|
|
139
|
+
for f in "$dir"/_lacc_*.txt; do
|
|
140
|
+
[ -e "$f" ] || continue
|
|
141
|
+
echo " LEAK $f"; leaks=$((leaks+1))
|
|
142
|
+
done
|
|
143
|
+
done
|
|
144
|
+
for f in /var/tmp/_lacc_v /var/tmp/_lacc_r4 /tmp/_lacc_r1 /dev/shm/_lacc_r3 \
|
|
145
|
+
/etc/_lacc_r5 /var/log/_lacc_r6 "$WS"/_lacc_md "$WS"/_lacc_rd \
|
|
146
|
+
"$WS"/_lacc_r0.txt "$WS"/_lacc_ws.txt "$WS"/_lacc_t.txt; do
|
|
147
|
+
[ -e "$f" ] && { echo " LEAK $f"; leaks=$((leaks+1)); }
|
|
148
|
+
done
|
|
149
|
+
echo " leaks=$leaks"
|
|
150
|
+
|
|
151
|
+
# 已存在的属主/能力快照,便于发现回归
|
|
152
|
+
echo '--- 能力快照 ---'
|
|
153
|
+
$LL "$WS" /bin/bash -c 'printf " sudo: %s\n" "$(sudo -n true 2>&1 | head -1)"; printf " dbus: %s\n" "$(timeout 5 dbus-send --session --dest=org.freedesktop.DBus --type=method_call /org/freedesktop/DBus org.freedesktop.DBus.ListNames 2>&1 | grep -qi "Failed to open connection" && echo UNREACHABLE || echo OK)"'
|
|
154
|
+
|
|
155
|
+
exit $([ "$fail" -eq 0 ] && echo 0 || echo 1)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// 这个文件是 `presets/shared/compaction-epoch.mjs` 的源:部署时复制进 DSH profile,
|
|
2
|
+
// 这里保留一份供 `wsl/custom-bash.mjs` import 与测试。**改这里,再同步到 profile。**
|
|
3
|
+
/**
|
|
4
|
+
* compaction-epoch —— 感知压缩边界的「晋升」状态跟踪器(共享模块,不是插件行)
|
|
5
|
+
*
|
|
6
|
+
* 用途
|
|
7
|
+
* tool-bootstrap / instruction-hint / router-bootstrap 都需要回答同一个问题:
|
|
8
|
+
* 「这个会话现在算不算已经晋升?」本模块给出唯一答案,三者共用,口径一致。
|
|
9
|
+
*
|
|
10
|
+
* 规则
|
|
11
|
+
* - 晋升信号 = 调用方指定的持久事件类型(`tool/call` 和/或 `assistant/message`)。
|
|
12
|
+
* - 压缩会把整段可见上下文重写成一条摘要,压缩后的第一次请求等同于「第二个首请求」,
|
|
13
|
+
* 所以晋升按「纪元」计算:只有落在最近一次 `compaction/end` 之后的晋升信号才算数。
|
|
14
|
+
* - 从未压缩过时边界为 -1,行为与「一次性晋升」完全相同。
|
|
15
|
+
* - 子智能体(delegationDepth > 0)默认视为已晋升,首请求就能用工具;
|
|
16
|
+
* 传 `includeSubagents: true` 可让子智能体也走锚定相位。
|
|
17
|
+
*
|
|
18
|
+
* 实现
|
|
19
|
+
* - 每个会话的状态按 session id 记忆;冷启动 / 恢复会话时扫描一次持久日志重建相位,
|
|
20
|
+
* 之后靠 `observe()` 增量更新,O(1)。
|
|
21
|
+
* - 持久日志只能经 `session.snapshotEvents()` 读取。`Session` 上没有 `events`
|
|
22
|
+
* 属性(0.1.3 起一直如此);读不存在的属性曾让锚定相位悄悄失效(首请求带了 197 个工具)。
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 创建一个跟踪器。
|
|
27
|
+
* @param {string[]} promoteEvents 计为晋升信号的持久事件类型
|
|
28
|
+
* @param {{ includeSubagents?: boolean }} [options]
|
|
29
|
+
*/
|
|
30
|
+
export function createEpochPromotion(promoteEvents, options = {}) {
|
|
31
|
+
const includeSubagents = options.includeSubagents === true
|
|
32
|
+
const promote = new Set(promoteEvents)
|
|
33
|
+
/** session id -> { boundary, promoted } */
|
|
34
|
+
const state = new Map()
|
|
35
|
+
|
|
36
|
+
/** 从头扫描一次持久日志(冷启动 / 恢复会话)。 */
|
|
37
|
+
const scan = (session) => {
|
|
38
|
+
let boundary = -1
|
|
39
|
+
let promoted = false
|
|
40
|
+
const events = typeof session.snapshotEvents === 'function' ? session.snapshotEvents() : []
|
|
41
|
+
for (const event of events) {
|
|
42
|
+
// 没有 seq 的事件按「边界之后」处理。
|
|
43
|
+
const seq = event.seq ?? 0
|
|
44
|
+
if (event.type === 'compaction/end') {
|
|
45
|
+
boundary = seq
|
|
46
|
+
promoted = false
|
|
47
|
+
continue
|
|
48
|
+
}
|
|
49
|
+
if (promote.has(event.type) && seq > boundary) promoted = true
|
|
50
|
+
}
|
|
51
|
+
const entry = { boundary, promoted }
|
|
52
|
+
state.set(session.id, entry)
|
|
53
|
+
return entry
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
/**
|
|
58
|
+
* 查询某个 agent 所在会话的当前相位。
|
|
59
|
+
* @returns {{ boundary: number, promoted: boolean }}
|
|
60
|
+
* boundary:最近一次 compaction/end 的 seq(未压缩过为 -1);
|
|
61
|
+
* promoted:边界之后是否已有晋升信号。
|
|
62
|
+
* 没有 agent / session 时一律视为已晋升(宁可多给工具,也不卡死请求)。
|
|
63
|
+
*/
|
|
64
|
+
status(agent) {
|
|
65
|
+
if (agent === undefined) return { boundary: -1, promoted: true }
|
|
66
|
+
const session = agent.session
|
|
67
|
+
if (session === undefined) return { boundary: -1, promoted: true }
|
|
68
|
+
if (!includeSubagents && (session.header?.delegationDepth ?? 0) > 0) return { boundary: -1, promoted: true }
|
|
69
|
+
return state.get(session.id) ?? scan(session)
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
/** 增量喂入:在每个 `session/event` 上调用。尚未被查询过的会话直接忽略,等首次查询时再扫描。 */
|
|
73
|
+
observe(session, event) {
|
|
74
|
+
const entry = state.get(session.id)
|
|
75
|
+
if (entry === undefined) return
|
|
76
|
+
const seq = event.seq ?? 0
|
|
77
|
+
if (event.type === 'compaction/end') {
|
|
78
|
+
state.set(session.id, { boundary: seq, promoted: false })
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
if (promote.has(event.type) && seq > entry.boundary && !entry.promoted) {
|
|
82
|
+
state.set(session.id, { ...entry, promoted: true })
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 读取 `user/message` 持久事件里的消息来源。
|
|
90
|
+
* V4 会话格式把 source 放在 `event.data.source`;早期本地代码误读成
|
|
91
|
+
* `event.data.message.source`,导致「是否已注入过」的去重永远判否。两种形状都兼容。
|
|
92
|
+
*/
|
|
93
|
+
export function eventSource(event) {
|
|
94
|
+
return event?.data?.source ?? event?.data?.message?.source
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** 读取 `user/message` 持久事件里的消息 id(两种形状都兼容,理由同上)。 */
|
|
98
|
+
export function eventMessageId(event) {
|
|
99
|
+
return event?.data?.id ?? event?.data?.message?.id
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 解析会话当前工作目录。
|
|
104
|
+
* 上游已把工作目录交给 `workingDirectory` 服务管理(会话中途 cd 之后 header.cwd 不会更新),
|
|
105
|
+
* 所以优先问服务;服务不存在(旧宿主)时退回 header.cwd,再退回进程 cwd。
|
|
106
|
+
*/
|
|
107
|
+
export async function resolveCwd(ctx, agent, signal) {
|
|
108
|
+
try {
|
|
109
|
+
const service = ctx.get('workingDirectory')
|
|
110
|
+
if (service !== undefined && agent !== undefined) return await service.ensure(agent, signal)
|
|
111
|
+
} catch {
|
|
112
|
+
// 服务异常时退回旧路径,不让一次目录查询拖垮工具调用。
|
|
113
|
+
}
|
|
114
|
+
return agent?.session?.header?.cwd ?? process.cwd()
|
|
115
|
+
}
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* custom-bash —— Windows 上的 `bash` 工具(插件行,可在多个 agent 预设里各挂一份)
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要它
|
|
5
|
+
* 官方 persistent bash 依赖 PTY,而 DSH 的 PTY 后端只支持 linux/darwin,
|
|
6
|
+
* 在 win32 上会直接报 "terminal inspection is unsupported"。本工具注册同名 `bash`、
|
|
7
|
+
* 使用与 Minimal 相近的描述,但通过跨平台的 `ctx.subprocess.spawn` 起进程,
|
|
8
|
+
* 从而在 Windows 上保留「首请求只有 bash」这一锚定 schema(issue #11)。
|
|
9
|
+
*
|
|
10
|
+
* Windows 适配
|
|
11
|
+
* Windows 上没有 Git Bash 时,PATH 上的 `bash` 通常是 WSL 垫片;
|
|
12
|
+
* 组合里因此把 `bashPath` 指向 `C:\Windows\System32\bash.exe`,描述写明「WSL Debian」。
|
|
13
|
+
*
|
|
14
|
+
* 配置
|
|
15
|
+
* bashPath 可执行文件:绝对路径,或 `bash`(经 PATH 解析)。默认 `bash`,
|
|
16
|
+
* 解不到时再试 win32 上的 C:\Windows\System32\bash.exe(同一个 WSL 垫片),
|
|
17
|
+
* 免得 PATH 少一项整个工具就废掉。显式给绝对路径则只认那一个。
|
|
18
|
+
* timeoutMs 单次命令超时,默认 120000。超时会终止进程并返回已收集的输出。
|
|
19
|
+
* maxOutputBytes stdout / stderr 各自的收集上限,默认 64000。
|
|
20
|
+
* llrunPath WSL 侧的 llrun 二进制(POSIX 路径)。默认按顺序探测 $LLRUN、
|
|
21
|
+
* <DSH_HOME>/bin/llrun、/usr/local/bin/llrun、~/.local/bin/llrun,
|
|
22
|
+
* 最后才用包内自带的 wsl/llrun —— 显式安装的优先,包里那份只负责开箱可用。
|
|
23
|
+
* 取第一个可执行的;探测结果缓存,装好即生效,不必重启。
|
|
24
|
+
* fence 是否按 sandbox 权限档挂围栏,默认 true。
|
|
25
|
+
*
|
|
26
|
+
* 沙箱(2026-10-23)
|
|
27
|
+
* workspace-write / read-only 下经 `wsl/llrun` 起 bash:userns socket 掩蔽(docker 等)
|
|
28
|
+
* + landlock ABI 7 + /var/tmp 封印;read-only 走 `-r` 严格只读档(写入全拒、读取全留)。
|
|
29
|
+
* danger-full-access 走裸 bash —— 这是 DSH 既定语义(该档 bypasses confinement),不是缺口。
|
|
30
|
+
* llrun 缺失时 fail-closed 抛错,不会静默降级成无围栏。
|
|
31
|
+
*
|
|
32
|
+
* 语义
|
|
33
|
+
* 每次调用都是全新进程,状态不跨调用保留。命令经 **stdin 送入 `bash -s`**,
|
|
34
|
+
* 不再拼进 `bash -c` 字符串 —— 避开 Windows bash.exe 的 argv 引号怪癖(事故根因之一)。
|
|
35
|
+
* 非零退出码不抛异常,而是在输出末尾注明退出码(与官方 bash 工具一致);
|
|
36
|
+
* 只有进程根本起不来时才抛错。
|
|
37
|
+
*
|
|
38
|
+
* 几处容易踩反的细节
|
|
39
|
+
* - `timeoutMs` 必须真落成 timer:只解析不使用会让长命令无限挂住整个工具调用。
|
|
40
|
+
* - POSIX 路径的 `workdir` 不能当 Windows 进程 cwd(spawn 直接失败);
|
|
41
|
+
* 有围栏时交给 llrun 的 `-C`(在 landlock 之后 chdir),无围栏时退化为命令内 `cd`。
|
|
42
|
+
* - 默认工作目录取自 workingDirectory 服务而非 header.cwd:会话中途切目录后 header 不更新。
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import { fileURLToPath } from 'node:url'
|
|
46
|
+
|
|
47
|
+
import { resolveCwd } from './compaction-epoch.mjs'
|
|
48
|
+
|
|
49
|
+
/** 加载器诊断里显示的插件名。 */
|
|
50
|
+
export const name = 'custom-bash'
|
|
51
|
+
|
|
52
|
+
/** 注册前必须已有的服务。 */
|
|
53
|
+
export const inject = ['subprocess', 'tools']
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* `bash` 解不出来时的后备可执行文件。
|
|
57
|
+
* PATH 上缺 System32 是真实会发生的(进程环境块可能停在旧快照上),
|
|
58
|
+
* 而 WSL 垫片的位置是固定的,写死它比让整个工具报 "not found on PATH" 划算。
|
|
59
|
+
* 只在配置给的是裸名时启用;给了绝对路径就只认那一个。
|
|
60
|
+
*/
|
|
61
|
+
const WINDOWS_BASH_FALLBACKS = ['C:\\Windows\\System32\\bash.exe']
|
|
62
|
+
|
|
63
|
+
const DEFAULT_TIMEOUT_MS = 120000
|
|
64
|
+
const DEFAULT_MAX_OUTPUT_BYTES = 64000
|
|
65
|
+
const PROBE_TIMEOUT_MS = 8000
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 本文件同目录的 llrun,也就是随插件包一起发布的那份。
|
|
69
|
+
* 从包加载时它是真的存在(bundle 把这一行指进 node_modules),
|
|
70
|
+
* 所以「装完就能用」不依赖用户往系统目录里放东西。
|
|
71
|
+
* 非 file: 协议加载(测试里的 data: URL 之类)时拿不到路径,返回 undefined。
|
|
72
|
+
*/
|
|
73
|
+
function packagedLlrunPath() {
|
|
74
|
+
try {
|
|
75
|
+
// 目录 URL 带尾分隔符,去掉再转,免得拼出双斜杠。
|
|
76
|
+
const dir = fileURLToPath(new URL('.', import.meta.url)).replace(/[\\/]$/, '')
|
|
77
|
+
// 包本身就在 POSIX 文件系统上时(非 Windows 宿主,或从 WSL 侧加载),
|
|
78
|
+
// 它已经是可直接用的路径,不必也无法转成 /mnt/<drive>。
|
|
79
|
+
return dir.startsWith('/') ? dir : toWslPath(dir)
|
|
80
|
+
} catch {
|
|
81
|
+
return undefined
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* llrun 的候选位置。显式配置 llrunPath 时只用那一个;否则按顺序探测第一个可执行的。
|
|
87
|
+
* 用户自己装的排在包内那份前面:系统级的 llrun 可能更新,包内的只保证可用。
|
|
88
|
+
* /usr/local/bin 在多数发行版属于 root,普通用户装不进去,所以用户可写的位置也要列上。
|
|
89
|
+
*/
|
|
90
|
+
function defaultLlrunCandidates() {
|
|
91
|
+
const candidates = []
|
|
92
|
+
const fromEnv = process.env.LLRUN ?? process.env.DSH_LLRUN
|
|
93
|
+
if (typeof fromEnv === 'string' && fromEnv.length > 0) candidates.push(fromEnv)
|
|
94
|
+
// DSH_HOME 是 Windows 路径,转成 WSL 视角后才能 test -x。
|
|
95
|
+
const posixHome = toWslPath(process.env.DSH_HOME)
|
|
96
|
+
if (posixHome !== undefined) candidates.push(posixHome + '/bin/llrun')
|
|
97
|
+
candidates.push('/usr/local/bin/llrun', '~/.local/bin/llrun')
|
|
98
|
+
const packaged = packagedLlrunPath()
|
|
99
|
+
if (packaged !== undefined) candidates.push(packaged + '/llrun')
|
|
100
|
+
return [...new Set(candidates)]
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 构造探测脚本,经 stdin 送给 `bash -s`。
|
|
105
|
+
* 不能走 `bash -c`:Windows 上的 WSL 垫片 bash.exe 会把 `-c` 的正文里
|
|
106
|
+
* 单字母变量赋值 `x=abc` 整个吃掉(实测 `x=abc; echo "x=$x"` 输出 `x=`),
|
|
107
|
+
* 探测会因此算出错误的候选序号。走 stdin 完全避开这层改写。
|
|
108
|
+
* 结果只走退出码:0 = 一个都没有,N = 第 N 个候选可执行;不读 stdout,
|
|
109
|
+
* 因为收集缓冲区只在 stdio 声明为 collect 时才存在,不该把可用性押在它上面。
|
|
110
|
+
* 候选里的 ~ 在这里展开:用引号包起来的路径不会被 bash 做 tilde 展开。
|
|
111
|
+
*/
|
|
112
|
+
export function llrunProbeScript(candidates) {
|
|
113
|
+
// ~ 在生成时展开成 $HOME,留给 bash 自己解析,不写死探测时的家目录。
|
|
114
|
+
const posix = candidates.map((candidate) => candidate.startsWith('~/') ? '$HOME/' + candidate.slice(2) : candidate)
|
|
115
|
+
const lines = []
|
|
116
|
+
// 第一遍只看能不能跑:任何一个可用就定下来,顺序即优先级。
|
|
117
|
+
const first = ['index=0']
|
|
118
|
+
posix.forEach((path) => {
|
|
119
|
+
first.push('index=$((index + 1))')
|
|
120
|
+
first.push('if [ -x "' + path + '" ]; then exit "$index"; fi')
|
|
121
|
+
// npm 打包在 Windows 上会把文件权限写成 644(那里没有执行位概念),
|
|
122
|
+
// 装到真实 Linux 后这份 llrun 就不可执行。它是我们发的、属主是装包的人,
|
|
123
|
+
// 所以可以就地补上执行位;别人的文件(-O 为假)不碰。
|
|
124
|
+
first.push('if [ -f "' + path + '" ] && [ -O "' + path + '" ] && chmod +x "' + path + '" 2>/dev/null && [ -x "' + path + '" ]; then exit "$index"; fi')
|
|
125
|
+
})
|
|
126
|
+
lines.push(...first)
|
|
127
|
+
// 第二遍才看「在但不是可执行」:要等所有候选都否定过,否则一个无关的文件
|
|
128
|
+
// 会让后面本来能用的那几份没机会。退出码 100+N 让上层能说清是哪一份。
|
|
129
|
+
const second = ['index=0']
|
|
130
|
+
posix.forEach((path) => {
|
|
131
|
+
second.push('index=$((index + 1))')
|
|
132
|
+
second.push('if [ -e "' + path + '" ]; then exit "$((100 + index))"; fi')
|
|
133
|
+
})
|
|
134
|
+
lines.push(...second)
|
|
135
|
+
lines.push('exit 0')
|
|
136
|
+
return lines.join('\n')
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* 把命中路径变成可以安全跟在 `exec` 后面的词。
|
|
141
|
+
* `~/` 开头的候选由 bash 自己的 $HOME 展开,所以这一支不能加单引号。
|
|
142
|
+
*/
|
|
143
|
+
function llrunExecWord(path) {
|
|
144
|
+
if (path.startsWith('~/')) return '"$HOME/' + path.slice(2) + '"'
|
|
145
|
+
return shellQuote(path)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** 面向模型的参数 schema。 */
|
|
149
|
+
const commandSchema = {
|
|
150
|
+
type: 'object',
|
|
151
|
+
properties: {
|
|
152
|
+
command: {
|
|
153
|
+
type: 'string',
|
|
154
|
+
description: 'The bash command to execute (`bash` script domain, fed through stdin — no argument quoting involved).',
|
|
155
|
+
},
|
|
156
|
+
workdir: {
|
|
157
|
+
type: 'string',
|
|
158
|
+
description: 'Optional working directory (Windows path or WSL path such as /mnt/d/...); defaults to the session working directory.',
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
required: ['command'],
|
|
162
|
+
additionalProperties: false,
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** 单引号转义,用于把 POSIX 路径安全拼进 shell 片段。 */
|
|
166
|
+
const shellQuote = (value) => `'${String(value).replaceAll("'", "'\\''")}'`
|
|
167
|
+
|
|
168
|
+
/** 合并调用方的取消信号与本工具的超时信号。 */
|
|
169
|
+
function withTimeout(signal, timeoutMs) {
|
|
170
|
+
const timeout = AbortSignal.timeout(timeoutMs)
|
|
171
|
+
return signal === undefined ? timeout : AbortSignal.any([signal, timeout])
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Windows 绝对路径 → WSL 视角的 /mnt/<drive>/...;无法映射时返回 undefined。 */
|
|
175
|
+
function toWslPath(path) {
|
|
176
|
+
if (typeof path !== 'string') return undefined
|
|
177
|
+
const matched = /^([A-Za-z]):[\\/](.*)$/.exec(path)
|
|
178
|
+
if (matched === null) return undefined
|
|
179
|
+
return `/mnt/${matched[1].toLowerCase()}/${matched[2].replaceAll('\\', '/')}`
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function apply(ctx, config) {
|
|
183
|
+
const bashPath = typeof config?.bashPath === 'string' && config.bashPath.length > 0 ? config.bashPath : 'bash'
|
|
184
|
+
// 裸名才允许走后备:显式绝对路径是用户的明确选择,替他改路径反而掩盖配置错误。
|
|
185
|
+
const bashCandidates = bashPath === 'bash' && process.platform === 'win32'
|
|
186
|
+
? ['bash', ...WINDOWS_BASH_FALLBACKS]
|
|
187
|
+
: [bashPath]
|
|
188
|
+
const timeoutMs = Number.isSafeInteger(config?.timeoutMs) && config.timeoutMs > 0 ? config.timeoutMs : DEFAULT_TIMEOUT_MS
|
|
189
|
+
const maxOutputBytes = Number.isSafeInteger(config?.maxOutputBytes) && config.maxOutputBytes > 0 ? config.maxOutputBytes : DEFAULT_MAX_OUTPUT_BYTES
|
|
190
|
+
// 显式配置时只认那一个;否则探测候选。解析结果跨调用缓存,失败不缓存。
|
|
191
|
+
const configuredLlrun = typeof config?.llrunPath === 'string' && config.llrunPath.length > 0 ? config.llrunPath : undefined
|
|
192
|
+
const llrunCandidates = configuredLlrun === undefined ? defaultLlrunCandidates() : [configuredLlrun]
|
|
193
|
+
const fenceEnabled = config?.fence !== false
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* bash 解析:按 bashCandidates 顺序问 subprocess 服务,第一个解出来的胜出。
|
|
197
|
+
* 缓存成功结果、不缓存失败 —— PATH 修好之后下一次调用立刻能用,不必重启 DSH。
|
|
198
|
+
* 全都解不出来时把最后一个错误抛出去:它带的是 "was not found on PATH",
|
|
199
|
+
* 只有一次 resolve 时也就是原来的行为,文案不变。
|
|
200
|
+
*/
|
|
201
|
+
let resolvedShell
|
|
202
|
+
async function resolveShell(signal) {
|
|
203
|
+
if (resolvedShell !== undefined) return resolvedShell
|
|
204
|
+
let lastError
|
|
205
|
+
for (const candidate of bashCandidates) {
|
|
206
|
+
try {
|
|
207
|
+
resolvedShell = await ctx.subprocess.resolveExecutable(candidate, undefined, signal)
|
|
208
|
+
return resolvedShell
|
|
209
|
+
} catch (error) {
|
|
210
|
+
lastError = error
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
throw lastError
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* llrun 解析:返回真正可执行的那个路径,探测不到返回 undefined。
|
|
218
|
+
* 成功结果跨调用缓存(同一进程里 llrun 不会凭空消失),失败不缓存,
|
|
219
|
+
* 这样用户装好之后下一次调用就能直接用,不必重启 DSH。
|
|
220
|
+
*/
|
|
221
|
+
let resolvedLlrun
|
|
222
|
+
// 探测到「文件存在但用不了」的候选,供错误文案区分两种情况。
|
|
223
|
+
let unusableLlrun
|
|
224
|
+
async function resolveLlrun(shell, cwd) {
|
|
225
|
+
if (resolvedLlrun !== undefined) return resolvedLlrun
|
|
226
|
+
const handle = ctx.subprocess.spawn({
|
|
227
|
+
argv: [shell, '-s'],
|
|
228
|
+
...cwd !== undefined ? { cwd } : {},
|
|
229
|
+
stdio: { stdin: { data: llrunProbeScript(llrunCandidates) }, stdout: { maxBytes: 4096 }, stderr: { maxBytes: 4096 } },
|
|
230
|
+
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
|
|
231
|
+
graceMs: 3000,
|
|
232
|
+
})
|
|
233
|
+
const outcome = await handle.done.catch(() => undefined)
|
|
234
|
+
const index = outcome?.exitCode
|
|
235
|
+
if (!Number.isInteger(index)) return undefined
|
|
236
|
+
if (index > 100 && index <= 100 + llrunCandidates.length) {
|
|
237
|
+
unusableLlrun = llrunCandidates[index - 101]
|
|
238
|
+
return undefined
|
|
239
|
+
}
|
|
240
|
+
if (index < 1 || index > llrunCandidates.length) return undefined
|
|
241
|
+
resolvedLlrun = llrunCandidates[index - 1]
|
|
242
|
+
return resolvedLlrun
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
ctx.tools.register({
|
|
246
|
+
name: 'bash',
|
|
247
|
+
description: [
|
|
248
|
+
'Run commands in a bash shell (WSL Debian on Windows)',
|
|
249
|
+
'* When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped.',
|
|
250
|
+
'* Network access depends on the task environment. Prefer configured mirrors/proxies when they are available.',
|
|
251
|
+
'* State does NOT persist across command calls: each call runs in a fresh shell.',
|
|
252
|
+
"* To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'.",
|
|
253
|
+
'* Please avoid commands that may produce a very large amount of output.',
|
|
254
|
+
`* Each call times out after ${Math.round(timeoutMs / 1000)}s; detach long-lived processes (e.g. 'nohup cmd >log 2>&1 &').`,
|
|
255
|
+
'* OS sandbox: in workspace-write and read-only sessions this shell runs under llrun (landlock + unix-socket masking + /var/tmp sealed; read-only additionally refuses every write). Writes outside the session workspace are denied in those modes. In danger-full-access no fence is applied.',
|
|
256
|
+
'* Do not operate on Windows files from here — that is a workaround, not the intended path. Use `write` / `edit` / `pwsh_run` for anything on the Windows side.',
|
|
257
|
+
'* Deleting files: send them to the Recycle Bin instead of a permanent delete. WSL-internal paths (/home/...) use `gio trash <path>`; /mnt/c and /mnt/d Windows files must go through `pwsh_run` with Microsoft.VisualBasic.FileIO.FileSystem SendToRecycleBin — gio trash there only creates a freedesktop dot-Trash-1000 folder per drive, which the Windows Explorer Recycle Bin cannot see or restore. Never `rm -rf` a path you have not verified.',
|
|
258
|
+
].join('\n'),
|
|
259
|
+
parameters: commandSchema,
|
|
260
|
+
output: {
|
|
261
|
+
schema: {
|
|
262
|
+
type: 'object',
|
|
263
|
+
additionalProperties: false,
|
|
264
|
+
properties: { text: { type: 'string' } },
|
|
265
|
+
required: ['text'],
|
|
266
|
+
},
|
|
267
|
+
render: (_args, value) => [{ type: 'text', text: value.text }],
|
|
268
|
+
},
|
|
269
|
+
async execute(args, exec) {
|
|
270
|
+
const signal = withTimeout(exec?.signal, timeoutMs)
|
|
271
|
+
const shell = await resolveShell(exec?.signal)
|
|
272
|
+
|
|
273
|
+
// POSIX 路径(WSL 视角)不能作为 Windows 进程 cwd;
|
|
274
|
+
// 有围栏时交给 llrun 的 `-C`(在 landlock 之后 chdir),无围栏时退化为命令内 cd。
|
|
275
|
+
const requested = typeof args.workdir === 'string' && args.workdir.length > 0 ? args.workdir : undefined
|
|
276
|
+
const posixDir = requested !== undefined && requested.startsWith('/') ? requested : undefined
|
|
277
|
+
const baseCwd = requested !== undefined && posixDir === undefined
|
|
278
|
+
? requested
|
|
279
|
+
: await resolveCwd(ctx, exec?.agent, exec?.signal)
|
|
280
|
+
|
|
281
|
+
// 权限档。resolve 必须带 session —— 缺了它会静默退回部署默认值(workspaceRoot 指向 profile 目录)。
|
|
282
|
+
const session = exec?.agent?.session
|
|
283
|
+
const policy = ctx.get('sandboxPolicy')?.resolve(session === undefined ? {} : { session })
|
|
284
|
+
if (fenceEnabled && policy === undefined) {
|
|
285
|
+
throw new Error('bash fence requested but ctx.sandboxPolicy is absent — refusing to run unsandboxed;'
|
|
286
|
+
+ ' set the `fence: false` config to accept an unbounded shell.')
|
|
287
|
+
}
|
|
288
|
+
const mode = typeof policy?.mode === 'string' ? policy.mode : undefined
|
|
289
|
+
const needFence = fenceEnabled && mode !== 'danger-full-access'
|
|
290
|
+
|
|
291
|
+
let argv
|
|
292
|
+
let script = args.command
|
|
293
|
+
if (needFence) {
|
|
294
|
+
const llrunPath = await resolveLlrun(shell, baseCwd)
|
|
295
|
+
if (llrunPath === undefined) {
|
|
296
|
+
const detail = unusableLlrun === undefined
|
|
297
|
+
? `looked for: ${llrunCandidates.join(', ')}`
|
|
298
|
+
: `${unusableLlrun} exists but is not executable`
|
|
299
|
+
throw new Error(`bash fence is required in ${mode} mode but llrun was not found; ${detail}`
|
|
300
|
+
+ ' — deploy wsl/llrun to one of them (see README), set the `llrunPath` config, or disable the fence'
|
|
301
|
+
+ ' explicitly with the `fence: false` config.')
|
|
302
|
+
}
|
|
303
|
+
const workspace = toWslPath(policy?.workspaceRoot) ?? toWslPath(baseCwd) ?? '-'
|
|
304
|
+
const fenceArgs = []
|
|
305
|
+
if (mode === 'read-only') fenceArgs.push('-r')
|
|
306
|
+
if (posixDir !== undefined) fenceArgs.push('-C', shellQuote(posixDir))
|
|
307
|
+
fenceArgs.push(shellQuote(workspace), '/bin/bash', '-s')
|
|
308
|
+
argv = [shell, '-c', `exec ${llrunExecWord(llrunPath)} ${fenceArgs.join(' ')}`]
|
|
309
|
+
} else {
|
|
310
|
+
if (posixDir !== undefined) script = `cd ${shellQuote(posixDir)} && ${args.command}`
|
|
311
|
+
argv = [shell, '-s']
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const handle = ctx.subprocess.spawn({
|
|
315
|
+
argv,
|
|
316
|
+
cwd: baseCwd,
|
|
317
|
+
stdio: {
|
|
318
|
+
stdin: { data: script },
|
|
319
|
+
stdout: { maxBytes: maxOutputBytes },
|
|
320
|
+
stderr: { maxBytes: maxOutputBytes },
|
|
321
|
+
},
|
|
322
|
+
signal,
|
|
323
|
+
graceMs: 3000,
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
let outcome
|
|
327
|
+
try {
|
|
328
|
+
outcome = await handle.done
|
|
329
|
+
} catch (error) {
|
|
330
|
+
if (signal.aborted && exec?.signal?.aborted !== true) {
|
|
331
|
+
outcome = { exitCode: null, timedOut: true }
|
|
332
|
+
} else {
|
|
333
|
+
throw new Error(`bash spawn failed: ${String(error)}`)
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
let stdout = ''
|
|
338
|
+
let stderr = ''
|
|
339
|
+
try {
|
|
340
|
+
stdout = handle.collected.stdout.readFrom(0).text
|
|
341
|
+
stderr = handle.collected.stderr.readFrom(0).text
|
|
342
|
+
} catch {
|
|
343
|
+
// 收集缓冲区读取失败时按「无输出」处理。
|
|
344
|
+
}
|
|
345
|
+
const body = [stdout, stderr].filter((part) => part.length > 0).join('\n')
|
|
346
|
+
const notes = []
|
|
347
|
+
if (outcome?.timedOut === true || (signal.aborted && exec?.signal?.aborted !== true)) {
|
|
348
|
+
notes.push(`[timed out after ${Math.round(timeoutMs / 1000)}s]`)
|
|
349
|
+
}
|
|
350
|
+
if (outcome?.exitCode !== undefined && outcome.exitCode !== 0) notes.push(`exit code: ${outcome.exitCode}`)
|
|
351
|
+
const text = [body, ...notes].filter((part) => part.length > 0).join('\n')
|
|
352
|
+
return { text: text.length > 0 ? text : 'exit code: 0 (no output)' }
|
|
353
|
+
},
|
|
354
|
+
})
|
|
355
|
+
}
|
package/wsl/llrun
ADDED
|
Binary file
|