terminal-bridge-setup 2.8.0 → 2.9.1
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/files/proxy/client-example.mjs +22 -10
- package/files/proxy/server.js +166 -27
- package/files/skill/SKILL.md +6 -0
- package/files/skill/references/protocol.md +17 -3
- package/package.json +1 -1
|
@@ -24,7 +24,7 @@ const BRIDGE = process.env.BRIDGE || "ws://127.0.0.1:8787/ssh";
|
|
|
24
24
|
* @param {number} timeoutMs - 超时(默认 10s)
|
|
25
25
|
* @returns {Promise<{ok: boolean, output?: string, error?: string, suggest?: string, message?: string}>}
|
|
26
26
|
*/
|
|
27
|
-
function run(cmd, timeoutMs = 10000) {
|
|
27
|
+
function run(cmd, timeoutMs = 10000, opts = {}) {
|
|
28
28
|
return new Promise((resolve) => {
|
|
29
29
|
const ws = new WebSocket(BRIDGE);
|
|
30
30
|
const reqId = randomBytes(4).toString("hex");
|
|
@@ -47,7 +47,12 @@ function run(cmd, timeoutMs = 10000) {
|
|
|
47
47
|
};
|
|
48
48
|
|
|
49
49
|
ws.on("open", () => {
|
|
50
|
-
|
|
50
|
+
// base64 通道:命令 base64 编码后由代理包装成 `echo <b64> | base64 -d | sh`。
|
|
51
|
+
// 用于多层引号场景(Bash→mjs→SSH→kubectl exec→sh -c),彻底规避引号剥离。
|
|
52
|
+
const payload = { type: "run", reqId, timeoutMs };
|
|
53
|
+
if (opts.b64) payload.cmdB64 = Buffer.from(cmd, "utf8").toString("base64");
|
|
54
|
+
else payload.cmd = cmd;
|
|
55
|
+
ws.send(JSON.stringify(payload));
|
|
51
56
|
});
|
|
52
57
|
|
|
53
58
|
ws.on("message", (raw) => {
|
|
@@ -85,10 +90,11 @@ function run(cmd, timeoutMs = 10000) {
|
|
|
85
90
|
* @param {function} ask - 可选的自定义询问函数,默认用 readline 控制台提问。
|
|
86
91
|
* Agent 接入时应传入自己的 AskUserQuestion 逻辑。
|
|
87
92
|
* 签名: async (message) => boolean
|
|
93
|
+
* @param {object} opts - 可选,透传给 run():{ b64: true } 走 base64 通道
|
|
88
94
|
* @returns {Promise<{ok: boolean, output?: string, error?: string}>}
|
|
89
95
|
*/
|
|
90
|
-
async function runWithSudoRetry(cmd, timeoutMs = 10000, ask = defaultAsk) {
|
|
91
|
-
let result = await run(cmd, timeoutMs);
|
|
96
|
+
async function runWithSudoRetry(cmd, timeoutMs = 10000, ask = defaultAsk, opts = {}) {
|
|
97
|
+
let result = await run(cmd, timeoutMs, opts);
|
|
92
98
|
|
|
93
99
|
if (result.error === "sudo-required") {
|
|
94
100
|
const approved = await ask(result.message || "检测到需要 sudo 权限,是否切换到 root 后重试?");
|
|
@@ -103,7 +109,7 @@ async function runWithSudoRetry(cmd, timeoutMs = 10000, ask = defaultAsk) {
|
|
|
103
109
|
}
|
|
104
110
|
// 切成功后重发原命令
|
|
105
111
|
console.log("→ 重新执行原命令");
|
|
106
|
-
result = await run(cmd, timeoutMs);
|
|
112
|
+
result = await run(cmd, timeoutMs, opts);
|
|
107
113
|
}
|
|
108
114
|
|
|
109
115
|
return result;
|
|
@@ -178,20 +184,26 @@ async function runWithArthasGuard(cmd, timeoutMs = 10000, ask = defaultAsk) {
|
|
|
178
184
|
}
|
|
179
185
|
|
|
180
186
|
// --- CLI ---
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
187
|
+
// 用法:node client-example.mjs [--b64] "<cmd>" [timeoutMs]
|
|
188
|
+
// --b64 命令经 base64 通道下发(多层引号场景防剥离,kubectl exec 刚需)
|
|
189
|
+
const cliArgs = process.argv.slice(2).filter(a => a !== "--b64");
|
|
190
|
+
const useB64 = process.argv.slice(2).includes("--b64");
|
|
191
|
+
const cmd = cliArgs[0] || "uname -a";
|
|
192
|
+
const timeoutMs = Number(cliArgs[1] || 10000);
|
|
193
|
+
const runOpts = { b64: useB64 };
|
|
194
|
+
|
|
195
|
+
console.log(`→ run${useB64 ? " [b64]" : ""}: ${cmd}`);
|
|
185
196
|
// 自动判断用哪种 wrapper:Arthas 命令走 guard,其他走 sudo 重试
|
|
186
197
|
const { isArthasCommand } = await import("./arthas-guard.js");
|
|
187
198
|
const result = isArthasCommand(cmd)
|
|
188
199
|
? await runWithArthasGuard(cmd, timeoutMs)
|
|
189
|
-
: await runWithSudoRetry(cmd, timeoutMs);
|
|
200
|
+
: await runWithSudoRetry(cmd, timeoutMs, undefined, runOpts);
|
|
190
201
|
if (result.ok) {
|
|
191
202
|
console.log(`✓ ok (${result.elapsedMs}ms)`);
|
|
192
203
|
console.log(result.output);
|
|
193
204
|
} else {
|
|
194
205
|
console.error(`✗ failed: ${result.error || "unknown"}`);
|
|
206
|
+
if (result.message) console.error(result.message);
|
|
195
207
|
if (result.output) console.log("--- partial output ---\n" + result.output);
|
|
196
208
|
process.exit(1);
|
|
197
209
|
}
|
package/files/proxy/server.js
CHANGED
|
@@ -70,11 +70,42 @@ function genReqId() {
|
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
// Agent 发来的 run 请求
|
|
73
|
+
// 支持两种命令下发方式:
|
|
74
|
+
// { cmd: "..." } —— 明文命令(原有)
|
|
75
|
+
// { cmdB64: "<base64>" } —— base64 编码命令(多层引号场景的安全传参通道)
|
|
76
|
+
// 代理解码后包装成 `echo <b64> | base64 -d | sh` 下发:
|
|
77
|
+
// base64 字符集(A-Za-z0-9+/=)不含引号/空格/元字符,四层引号嵌套
|
|
78
|
+
// (Bash→mjs→SSH→kubectl exec→sh -c)下也不会被任何一层剥离篡改。
|
|
73
79
|
function handleRun(ws, msg) {
|
|
74
80
|
const reqId = msg.reqId || genReqId();
|
|
75
|
-
const cmd = (msg.cmd || "").toString();
|
|
76
81
|
const timeoutMs = Number(msg.timeoutMs || DEFAULT_TIMEOUT_MS);
|
|
77
82
|
|
|
83
|
+
let cmd;
|
|
84
|
+
let wrapped = null; // 实际注入终端的完整文本(base64 通道时为 wrapper 命令)
|
|
85
|
+
|
|
86
|
+
if (msg.cmdB64) {
|
|
87
|
+
// base64 通道:解码 → 校验 → 包装
|
|
88
|
+
let decoded;
|
|
89
|
+
try {
|
|
90
|
+
decoded = Buffer.from(String(msg.cmdB64), "base64").toString("utf8");
|
|
91
|
+
} catch {
|
|
92
|
+
ws.send(JSON.stringify({ type: "result", reqId, ok: false, error: "invalid cmdB64" }));
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (!decoded.trim()) {
|
|
96
|
+
ws.send(JSON.stringify({ type: "result", reqId, ok: false, error: "empty cmd" }));
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (decoded.length > 16 * 1024) {
|
|
100
|
+
ws.send(JSON.stringify({ type: "result", reqId, ok: false, error: "cmd too long (max 16KB after decode)" }));
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
cmd = decoded;
|
|
104
|
+
wrapped = `echo ${Buffer.from(cmd, "utf8").toString("base64")} | base64 -d | sh`;
|
|
105
|
+
} else {
|
|
106
|
+
cmd = (msg.cmd || "").toString();
|
|
107
|
+
}
|
|
108
|
+
|
|
78
109
|
if (!cmd) {
|
|
79
110
|
ws.send(JSON.stringify({ type: "result", reqId, ok: false, error: "empty cmd" }));
|
|
80
111
|
return;
|
|
@@ -109,7 +140,7 @@ function handleRun(ws, msg) {
|
|
|
109
140
|
}
|
|
110
141
|
}
|
|
111
142
|
|
|
112
|
-
const job = { ws, reqId, cmd: finalCmd, timeoutMs, sentAt: Date.now() };
|
|
143
|
+
const job = { ws, reqId, cmd: finalCmd, wrapped, timeoutMs, sentAt: Date.now() };
|
|
113
144
|
queue.push(job);
|
|
114
145
|
maybeRunNext();
|
|
115
146
|
}
|
|
@@ -129,12 +160,14 @@ function maybeRunNext() {
|
|
|
129
160
|
// 关键:prompt 是服务端 shell 在命令完成后输出的,不受 kitty 输入重绘影响。
|
|
130
161
|
//
|
|
131
162
|
// 流程:
|
|
132
|
-
// 1. 发 cmd + \r
|
|
163
|
+
// 1. 发 cmd + \r(base64 通道时发 `echo <b64> | base64 -d | sh` + \r)
|
|
133
164
|
// 2. 在 ws-recv 流里累积,找 prompt 正则匹配(]\s*[#$]\s*$ 在行尾)
|
|
134
165
|
// 3. 第一次匹配到 prompt:说明之前的 buffer 含"上一条命令的尾部 prompt + cmd 回显",
|
|
135
166
|
// 从这个 prompt 之后开始才是本命令的输出区域
|
|
136
167
|
// 4. 第二次匹配到 prompt:命令执行完毕,两个 prompt 之间就是输出
|
|
137
|
-
|
|
168
|
+
// 注:wrapped 优先用 handleRun 传入的 base64 wrapper(此时终端回显的是 wrapper
|
|
169
|
+
// 命令而非原始 cmd,回显清理必须按 wrapper 比对才能删掉)。
|
|
170
|
+
const wrapped = `${job.wrapped != null ? job.wrapped : job.cmd}\r`;
|
|
138
171
|
|
|
139
172
|
// 状态机:
|
|
140
173
|
// phase 0 (wait_prompt_1) : 等 prompt 第 1 次出现(命令回显前的 prompt)
|
|
@@ -144,6 +177,7 @@ function maybeRunNext() {
|
|
|
144
177
|
if (pending.has(job.reqId)) {
|
|
145
178
|
clearTimeout(entry.timer);
|
|
146
179
|
if (entry.weakTimer != null) { clearTimeout(entry.weakTimer); entry.weakTimer = null; }
|
|
180
|
+
if (entry.ps2Timer != null) { clearTimeout(entry.ps2Timer); entry.ps2Timer = null; }
|
|
147
181
|
pending.delete(job.reqId);
|
|
148
182
|
try {
|
|
149
183
|
job.ws.send(JSON.stringify({ type: "result", reqId: job.reqId, ...result }));
|
|
@@ -156,6 +190,8 @@ function maybeRunNext() {
|
|
|
156
190
|
phase: 0,
|
|
157
191
|
buffer: "",
|
|
158
192
|
cmd: job.cmd,
|
|
193
|
+
// 回显清理用的比对串:base64 通道下终端实际回显的是 wrapper 命令
|
|
194
|
+
echoCmd: job.wrapped != null ? job.wrapped : job.cmd,
|
|
159
195
|
promptCount: 0
|
|
160
196
|
};
|
|
161
197
|
pending.set(job.reqId, entry);
|
|
@@ -168,7 +204,7 @@ function maybeRunNext() {
|
|
|
168
204
|
entry.resolve({
|
|
169
205
|
ok: false,
|
|
170
206
|
error: "timeout",
|
|
171
|
-
output:
|
|
207
|
+
output: finalizeOutput(entry),
|
|
172
208
|
elapsedMs: Date.now() - job.sentAt
|
|
173
209
|
});
|
|
174
210
|
}, job.timeoutMs);
|
|
@@ -362,6 +398,40 @@ function handleWsRecv(payload) {
|
|
|
362
398
|
continue; // 已 resolve,跳过后续 prompt 检测
|
|
363
399
|
}
|
|
364
400
|
|
|
401
|
+
// ====== PS2 续行快速失败(问题 3,必须在 prompt 匹配之前)======
|
|
402
|
+
// 多层引号在某层被剥离/篡改后,远端 shell 因引号未闭合进入续行等待,
|
|
403
|
+
// 终端显示行首孤立的 >(PS2 提示符)。此时 prompt 锚点永远不会命中 →
|
|
404
|
+
// 死等超时。检测到孤立 > 且 400ms 无新数据时立即失败并 Ctrl+C 退出续行
|
|
405
|
+
// (终端回到正常 prompt,可继续执行后续命令),返回明确的错误类型。
|
|
406
|
+
// 顺序关键:unknown 类型的宽松兜底正则含裸 >,PS2 的 > 若先落到那里
|
|
407
|
+
// 会被误判成"命令完成"(实测:ok + 空输出),所以 PS2 必须先检查。
|
|
408
|
+
if (entry.phase === 0 && /^\s*>$/.test(lastNonEmptyLine(entry.buffer))) {
|
|
409
|
+
if (entry.ps2Timer == null) {
|
|
410
|
+
const ps2SnapLen = entry.buffer.length;
|
|
411
|
+
entry.ps2Timer = setTimeout(() => {
|
|
412
|
+
entry.ps2Timer = null;
|
|
413
|
+
if (!pending.has(reqId) || entry.buffer.length !== ps2SnapLen) return;
|
|
414
|
+
if (!/^\s*>$/.test(lastNonEmptyLine(entry.buffer))) return;
|
|
415
|
+
console.warn(TAG, `[${reqId}] PS2 续行命中(未闭合引号),快速失败 + Ctrl+C 退出续行`);
|
|
416
|
+
sendCtrlC();
|
|
417
|
+
entry.resolve({
|
|
418
|
+
ok: false,
|
|
419
|
+
error: "unterminated-quote",
|
|
420
|
+
message: "命令疑似包含未闭合的引号/括号,远端 shell 进入续行等待(PS2 >)。已自动 Ctrl+C 退出续行。多层引号场景请改用 base64 通道下发命令。",
|
|
421
|
+
suggest: "用 client 的 --b64 参数(或 run 帧的 cmdB64 字段)下发,规避多层引号剥离",
|
|
422
|
+
output: finalizeOutput(entry),
|
|
423
|
+
elapsedMs: Date.now() - entry.sentAt
|
|
424
|
+
});
|
|
425
|
+
}, 400);
|
|
426
|
+
}
|
|
427
|
+
// PS2 等待期间不做 prompt 匹配(下一帧数据到达会重新进入本循环)
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
if (entry.ps2Timer != null) {
|
|
431
|
+
clearTimeout(entry.ps2Timer);
|
|
432
|
+
entry.ps2Timer = null;
|
|
433
|
+
}
|
|
434
|
+
|
|
365
435
|
// 检查清理后 buffer 的尾部是否以 prompt 结尾
|
|
366
436
|
// 用按终端类型选出的正则(截断根治:jumpserver 不再被裸 > 误触发)
|
|
367
437
|
const tail = cleanFull.slice(-200);
|
|
@@ -375,8 +445,8 @@ function handleWsRecv(payload) {
|
|
|
375
445
|
// 早已流过(attach 之前)。我们注入 cmd 后,第一个出现的 prompt 就是
|
|
376
446
|
// 命令完成后的 prompt。所以只需等 1 个 prompt。
|
|
377
447
|
// buffer 里 = kitty 重绘碎片 + cmd 回显 + 真实输出 + 最终 prompt
|
|
378
|
-
// 切掉末尾 prompt
|
|
379
|
-
const output =
|
|
448
|
+
// 切掉末尾 prompt,前面的内容交给清理器(finalizeOutput 含空兜底)
|
|
449
|
+
const output = finalizeOutput(entry);
|
|
380
450
|
entry.resolve({
|
|
381
451
|
ok: true,
|
|
382
452
|
output,
|
|
@@ -403,7 +473,7 @@ function handleWsRecv(payload) {
|
|
|
403
473
|
entry.lastScanLen = entry.buffer.length;
|
|
404
474
|
entry.resolve({
|
|
405
475
|
ok: true,
|
|
406
|
-
output:
|
|
476
|
+
output: finalizeOutput(entry),
|
|
407
477
|
elapsedMs: Date.now() - entry.sentAt
|
|
408
478
|
});
|
|
409
479
|
}
|
|
@@ -446,11 +516,13 @@ function probeLog(data, opcode) {
|
|
|
446
516
|
|
|
447
517
|
// ===================== 输出清理 =====================
|
|
448
518
|
// prompt 锚点方案:buffer = kitty 重绘碎片 + cmd 回显 + 真实输出 + 最终 prompt
|
|
449
|
-
//
|
|
519
|
+
// 清理策略(收紧:只删确定属于终端噪音的内容,其余原样保留):
|
|
450
520
|
// 1. 去 ANSI(颜色、OSC 标题)
|
|
451
|
-
// 2.
|
|
452
|
-
//
|
|
453
|
-
//
|
|
521
|
+
// 2. 控制字符替换为空格(NUL/x00 等,/proc/*/cmdline 场景;绝不因含控制字符丢整段)
|
|
522
|
+
// 3. 删 prompt 行(要求 prompt 出现在行首附近,且 #$ 紧跟 prompt——
|
|
523
|
+
// 旧行为 "]/…任意内容…/#$" 会把含 ] 和 $ 的长 JSON 输出整行误删,即问题 1 根因)
|
|
524
|
+
// 4. 删 koko 控制消息行
|
|
525
|
+
// 5. 删命令回显行(折行感知:终端宽度折行会把长命令拆成多行,先做软换行 join 再比对)
|
|
454
526
|
function cleanOutput(text, cmd) {
|
|
455
527
|
if (!text) return "";
|
|
456
528
|
let out = text
|
|
@@ -458,23 +530,39 @@ function cleanOutput(text, cmd) {
|
|
|
458
530
|
.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "")
|
|
459
531
|
// ANSI OSC 序列:ESC ] ... BEL 或 ESC \(标题设置等)
|
|
460
532
|
.replace(/\x1b\][^\x07\x1b]*(\x07|\x1b\\)/g, "")
|
|
533
|
+
// 控制字符 → 空格:NUL 等 C0 控制符(保留 \t\n\r,它们在下一步处理)。
|
|
534
|
+
// /proc/*/cmdline、environ 都是 NUL 分隔,替换而非丢弃(问题 2)
|
|
535
|
+
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, " ")
|
|
461
536
|
// \r\n 和孤立 \r → \n
|
|
462
537
|
.replace(/\r\n?/g, "\n");
|
|
463
538
|
|
|
464
|
-
// 删除 prompt
|
|
465
|
-
// -
|
|
466
|
-
//
|
|
467
|
-
//
|
|
468
|
-
//
|
|
539
|
+
// 删除 prompt 行(命令回显行 = prompt + 命令文本,可能粘有 kitty 重绘碎片):
|
|
540
|
+
// - 红帽系:[user@host /cwd]# —— user/host 段各限长 64(真实 prompt 远短于此)。
|
|
541
|
+
// 不限长时的实测反例(Case A):head -c 600 截断的 JSON 无闭合 ],
|
|
542
|
+
// "data":[ 的 [ 落在前缀窗口内,[^\]]+ 跨过整段 JSON 匹配到 prompt 的 @…]
|
|
543
|
+
// → 647 字符合并行整行被误删。长度约束让这种跨吞匹配失败。
|
|
544
|
+
// - Ubuntu/Debian:user@host:~/path$ —— 同样限长
|
|
545
|
+
// - Arthas:行尾 > 且行短(≤60 字符)
|
|
546
|
+
// 前缀窗口 40 字符:正常 prompt 行 prompt 就在行首;碎片通常几到几十字符。
|
|
469
547
|
out = out.split("\n").filter(line => {
|
|
470
|
-
if (
|
|
471
|
-
|
|
472
|
-
if (/[\w.-]+@[\w.-]+:\S*[$#]\s*$/.test(line)) return false;
|
|
548
|
+
if (/^.{0,40}\[[^\]\n]{1,64}@[^\]\n]{1,64}\]\s*[#$]/.test(line)) return false;
|
|
549
|
+
if (/^.{0,40}[\w.-]{1,64}@[\w.-]{1,64}:[^\s]{0,128}[$#]/.test(line)) return false;
|
|
473
550
|
// Arthas / 通用 REPL prompt:行尾 > (允许前面有 arthas@xxx 等前缀)
|
|
474
551
|
if (/>\s*$/.test(line) && line.trim().length <= 60) return false;
|
|
475
552
|
return true;
|
|
476
553
|
}).join("\n");
|
|
477
554
|
|
|
555
|
+
// 行尾 prompt 后缀剥离:无尾换行的输出(head -c N 截断)会与后续 prompt
|
|
556
|
+
// 合并成同一物理行。上面的整行过滤(带前缀窗口)会放过这种合并行——内容
|
|
557
|
+
// 保住了,但 prompt 碎片粘在输出尾巴上(如 JSON 尾 + [root@host dir]#),
|
|
558
|
+
// 污染 Agent 的后续解析。这里只剥离行尾的 prompt 形态后缀,内容原样保留。
|
|
559
|
+
out = out.split("\n").map(line => {
|
|
560
|
+
if (line.length <= 100) return line; // 短行已由整行过滤处理,避免误伤
|
|
561
|
+
return line
|
|
562
|
+
.replace(/\[[^\]\n]{1,64}@[^\]\n]{1,64}\]\s*[#$]\s*$/, "") // 红帽系后缀
|
|
563
|
+
.replace(/[\w.-]{1,64}@[\w.-]{1,64}:[^\s]{0,128}[$#]\s*$/, ""); // Ubuntu 后缀
|
|
564
|
+
}).join("\n");
|
|
565
|
+
|
|
478
566
|
// 删除 koko 控制消息:koko 偶尔在终端流里发送 JSON 控制消息
|
|
479
567
|
// (TERMINAL_RESIZE、PING 等),特征是以 {"id": 开头的 JSON 行(可能前面带 # )
|
|
480
568
|
out = out.split("\n").filter(line => {
|
|
@@ -483,14 +571,33 @@ function cleanOutput(text, cmd) {
|
|
|
483
571
|
return true;
|
|
484
572
|
}).join("\n");
|
|
485
573
|
|
|
486
|
-
// 删除命令回显行:整行(去空白后)等于 cmd
|
|
487
|
-
//
|
|
488
|
-
|
|
574
|
+
// 删除命令回显行:整行(去空白后)等于 cmd 的行。
|
|
575
|
+
// 折行感知(问题 4):长命令在终端宽度处折行会拆开 token(--com\nmand=),
|
|
576
|
+
// 单行比对失败 → 残留回显碎片。这里允许把连续 1-4 行 join 后再与 cmd 比对,
|
|
577
|
+
// 匹配则整组删除。只做"合并后完全相等"的精确匹配,不做子串匹配(防误删真实输出)。
|
|
578
|
+
if (cmd && cmd.trim()) {
|
|
489
579
|
const cmdNoWs = cmd.replace(/\s+/g, "");
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
580
|
+
const lines = out.split("\n");
|
|
581
|
+
const kept = [];
|
|
582
|
+
let i = 0;
|
|
583
|
+
while (i < lines.length) {
|
|
584
|
+
let matched = false;
|
|
585
|
+
let joinedNoWs = "";
|
|
586
|
+
for (let k = 0; i + k < lines.length; k++) {
|
|
587
|
+
joinedNoWs += lines[i + k].replace(/\s+/g, "");
|
|
588
|
+
if (joinedNoWs.length > cmdNoWs.length) break;
|
|
589
|
+
if (joinedNoWs === cmdNoWs) {
|
|
590
|
+
i += k + 1;
|
|
591
|
+
matched = true;
|
|
592
|
+
break;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
if (!matched) {
|
|
596
|
+
kept.push(lines[i]);
|
|
597
|
+
i++;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
out = kept.join("\n");
|
|
494
601
|
}
|
|
495
602
|
|
|
496
603
|
return out
|
|
@@ -498,6 +605,38 @@ function cleanOutput(text, cmd) {
|
|
|
498
605
|
.trim();
|
|
499
606
|
}
|
|
500
607
|
|
|
608
|
+
// ===================== 输出兜底(问题 1 的安全网)=====================
|
|
609
|
+
// 两层防御:
|
|
610
|
+
// 1. 清理后为空 + 原始内容 ≥512 字符 → 返回截断原始内容 + warning
|
|
611
|
+
// 2. 清理损失率 >80%(原始 ≥512 字符但清理后 <20%)→ 保留清理结果,
|
|
612
|
+
// 但附加 warning + 原始内容截断——任何未来的 filter 误杀都不再静默
|
|
613
|
+
// (Case A 教训:REDHAT 正则曾跨吞 647 字节合并行,靠这层兜底可暴露)
|
|
614
|
+
const RAW_FALLBACK_THRESHOLD = 512; // 原始内容低于此值视为真无输出
|
|
615
|
+
const RAW_FALLBACK_LIMIT = 4000; // 兜底输出截断长度
|
|
616
|
+
|
|
617
|
+
function finalizeOutput(entry) {
|
|
618
|
+
const echoCmd = entry.echoCmd != null ? entry.echoCmd : entry.cmd;
|
|
619
|
+
const cleaned = cleanOutput(entry.buffer, echoCmd);
|
|
620
|
+
|
|
621
|
+
const raw = stripAnsi(entry.buffer)
|
|
622
|
+
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, " ")
|
|
623
|
+
.replace(/\r\n?/g, "\n")
|
|
624
|
+
.trim();
|
|
625
|
+
if (raw.length < RAW_FALLBACK_THRESHOLD) return cleaned;
|
|
626
|
+
|
|
627
|
+
const truncated = raw.length > RAW_FALLBACK_LIMIT
|
|
628
|
+
? raw.slice(0, RAW_FALLBACK_LIMIT) + "\n...[truncated]"
|
|
629
|
+
: raw;
|
|
630
|
+
|
|
631
|
+
if (cleaned.length === 0) {
|
|
632
|
+
return truncated + `\n[warning] output filtered to empty by cleaner; raw ${raw.length} chars shown]`;
|
|
633
|
+
}
|
|
634
|
+
if (cleaned.length < raw.length * 0.2) {
|
|
635
|
+
return cleaned + `\n[warning] cleaner dropped ${(100 - Math.round(cleaned.length / raw.length * 100))}% of output; raw tail:\n${truncated}`;
|
|
636
|
+
}
|
|
637
|
+
return cleaned;
|
|
638
|
+
}
|
|
639
|
+
|
|
501
640
|
// ===================== WebSocket 服务 =====================
|
|
502
641
|
const wss = new WebSocketServer({ host: HOST, port: PORT });
|
|
503
642
|
|
package/files/skill/SKILL.md
CHANGED
|
@@ -120,6 +120,10 @@ node client-example.mjs "uname -a"
|
|
|
120
120
|
node client-example.mjs "df -h" 15000 # 第二参数是超时 ms
|
|
121
121
|
node client-example.mjs "ps aux | grep java" 20000
|
|
122
122
|
|
|
123
|
+
# 多层引号场景(kubectl exec / 嵌套引号):加 --b64 走 base64 通道,
|
|
124
|
+
# 命令经 base64 编码下发(echo <b64> | base64 -d | sh),任何一层都不会剥离引号
|
|
125
|
+
node client-example.mjs --b64 "kubectl exec -n ns pod -- sh -c 'ps aux | grep java'"
|
|
126
|
+
|
|
123
127
|
# Arthas 场景
|
|
124
128
|
node client-example.mjs "help"
|
|
125
129
|
node client-example.mjs "thread" # 查看线程概况
|
|
@@ -259,6 +263,8 @@ Agent → 代理:
|
|
|
259
263
|
| `error: arthas-forbidden` | Arthas 高风险命令(retransform/profiler/stop/reset)被禁用 | 告知用户去浏览器 Arthas 终端手动执行,不要重试或绕过 |
|
|
260
264
|
| `error: arthas-needs-limit` | 中风险命令(trace/watch/stack/monitor)缺 -n/#cost(严格模式) | 按 suggest 补参数重发,或加 `-n 1` |
|
|
261
265
|
| `error: arthas-quota-exceeded` | 中风险命令会话内超限(默认 20 次) | 告知用户已达上限,不要重试;如需继续重启代理 |
|
|
266
|
+
| `error: unterminated-quote` | 命令含未闭合引号,远端 shell 卡在 PS2 续行(已自动 Ctrl+C 退出) | 检查引号配对;多层引号场景改用 `--b64` 通道重发 |
|
|
267
|
+
| 输出末尾带 `[warning] output filtered to empty...` | 清理器把输出过滤为空,兜底返回了原始内容(截断) | 输出可用但含终端噪音;原命令输出被判定为 prompt 噪音时可调整命令(如拆行) |
|
|
262
268
|
| `error: timeout` + output 含 `[sudo] password` | 旧版代理未实现 sudo 检测 | 升级 proxy/server.js;临时用绝对路径 `/bin/cat` 绕过 |
|
|
263
269
|
| `error: timeout` + output 为空 | 命令是交互式/持续刷新(vim/dashboard/monitor) | 改用非交互等价命令,或 Arthas 用一次性命令(thread/jad) |
|
|
264
270
|
| 命令发出去但 Arthas/JumpServer 没反应 | content script 没识别到 xterm | 让用户 F5 刷新终端页,或在 popup 点"捕捉 xterm" |
|
|
@@ -27,9 +27,14 @@
|
|
|
27
27
|
}
|
|
28
28
|
```
|
|
29
29
|
- `reqId`:可选。不传则代理生成(8 位 hex)。用于配对 result。
|
|
30
|
-
- `cmd
|
|
30
|
+
- `cmd`:必填(与 `cmdB64` 二选一)。命令本身,**不要自己加哨兵/换行**——代理会自动包末尾 `\r`。
|
|
31
31
|
- JumpServer:linux 命令,如 `ps aux | grep java`
|
|
32
32
|
- Arthas:Arthas 命令,如 `jad com.foo.Bar`
|
|
33
|
+
- `cmdB64`:可选,与 `cmd` 二选一。命令的 base64 编码(UTF-8)。
|
|
34
|
+
代理解码后包装成 `echo <b64> | base64 -d | sh` 下发——base64 字符集不含
|
|
35
|
+
引号/空格/元字符,Bash→mjs→SSH→kubectl exec→sh -c 多层引号嵌套下也不会
|
|
36
|
+
被任何一层剥离篡改。kubectl exec / 嵌套引号场景**必须**用这个通道。
|
|
37
|
+
限制:解码后 ≤16KB。client 侧对应 `--b64` 参数。
|
|
33
38
|
- `timeoutMs`:可选,默认 10000。超时则返回 `ok:false, error:timeout`。
|
|
34
39
|
|
|
35
40
|
### 代理 → Agent
|
|
@@ -50,14 +55,23 @@
|
|
|
50
55
|
"type": "result",
|
|
51
56
|
"reqId": "abc123",
|
|
52
57
|
"ok": false,
|
|
53
|
-
"error": "timeout | inject failed | extension disconnected | sudo-required | arthas-forbidden | arthas-needs-limit | arthas-quota-exceeded",
|
|
58
|
+
"error": "timeout | inject failed | extension disconnected | sudo-required | arthas-forbidden | arthas-needs-limit | arthas-quota-exceeded | unterminated-quote",
|
|
54
59
|
"output": "部分输出(可能为空)",
|
|
55
60
|
"suggest": "更安全的替代命令(部分错误才有)",
|
|
56
61
|
"message": "给用户看的说明文字(部分错误才有)",
|
|
57
62
|
"elapsedMs": 10000
|
|
58
63
|
}
|
|
59
64
|
```
|
|
60
|
-
- `output`:prompt 锚点出现前的所有 recv 帧拼接,已去 ANSI、`\r\n`→`\n
|
|
65
|
+
- `output`:prompt 锚点出现前的所有 recv 帧拼接,已去 ANSI、`\r\n`→`\n`、
|
|
66
|
+
控制字符(NUL 等)替换为空格、删 prompt 行和命令回显行(折行感知)、
|
|
67
|
+
剥离行尾 prompt 后缀(无尾换行输出与 prompt 合并形态)、首尾 trim。
|
|
68
|
+
prompt 段识别带长度约束(user/host 各 ≤64 字符),防止截断 JSON 的 `[`
|
|
69
|
+
被当作 prompt 起点跨吞整行(Case A 根因)。
|
|
70
|
+
**双层兜底**:清理后为空或清理损失率 >80%(原始 ≥512 字符)时,附加
|
|
71
|
+
`[warning]` + 截断原始内容——绝不静默丢弃大段输出。
|
|
72
|
+
- `unterminated-quote`:命令含未闭合引号,远端 shell 卡在 PS2 续行(`>` 提示)。
|
|
73
|
+
代理检测到后 ~400ms 快速失败并自动 Ctrl+C 退出续行(终端可继续用),
|
|
74
|
+
`suggest` 会指向 base64 通道。
|
|
61
75
|
- `error` 枚举:
|
|
62
76
|
- `timeout` — 命令超时(交互式/持续命令会触发)
|
|
63
77
|
- `inject failed` — content script 注入失败(xterm 没捕捉到)
|
package/package.json
CHANGED