terminal-bridge-setup 2.7.0 → 2.9.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/files/proxy/client-example.mjs +22 -10
- package/files/proxy/server.js +212 -28
- package/files/skill/SKILL.md +8 -2
- package/files/skill/references/protocol.md +19 -5
- 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)
|
|
@@ -143,6 +176,8 @@ function maybeRunNext() {
|
|
|
143
176
|
resolve: (result) => {
|
|
144
177
|
if (pending.has(job.reqId)) {
|
|
145
178
|
clearTimeout(entry.timer);
|
|
179
|
+
if (entry.weakTimer != null) { clearTimeout(entry.weakTimer); entry.weakTimer = null; }
|
|
180
|
+
if (entry.ps2Timer != null) { clearTimeout(entry.ps2Timer); entry.ps2Timer = null; }
|
|
146
181
|
pending.delete(job.reqId);
|
|
147
182
|
try {
|
|
148
183
|
job.ws.send(JSON.stringify({ type: "result", reqId: job.reqId, ...result }));
|
|
@@ -155,6 +190,8 @@ function maybeRunNext() {
|
|
|
155
190
|
phase: 0,
|
|
156
191
|
buffer: "",
|
|
157
192
|
cmd: job.cmd,
|
|
193
|
+
// 回显清理用的比对串:base64 通道下终端实际回显的是 wrapper 命令
|
|
194
|
+
echoCmd: job.wrapped != null ? job.wrapped : job.cmd,
|
|
158
195
|
promptCount: 0
|
|
159
196
|
};
|
|
160
197
|
pending.set(job.reqId, entry);
|
|
@@ -167,7 +204,7 @@ function maybeRunNext() {
|
|
|
167
204
|
entry.resolve({
|
|
168
205
|
ok: false,
|
|
169
206
|
error: "timeout",
|
|
170
|
-
output:
|
|
207
|
+
output: finalizeOutput(entry),
|
|
171
208
|
elapsedMs: Date.now() - job.sentAt
|
|
172
209
|
});
|
|
173
210
|
}, job.timeoutMs);
|
|
@@ -203,7 +240,7 @@ let terminalType = "unknown"; // "unknown" | "jumpserver" | "arthas"
|
|
|
203
240
|
|
|
204
241
|
// 各类型的 prompt 正则
|
|
205
242
|
const PROMPT_RE_BY_TYPE = {
|
|
206
|
-
// JumpServer shell prompt
|
|
243
|
+
// JumpServer shell prompt(强匹配):[user@host /dir]# 或 ]$
|
|
207
244
|
// 注意:不匹配裸 >,避免输出内容里的 > 行误触发
|
|
208
245
|
jumpserver: /\]\s*[#$]\s*$/,
|
|
209
246
|
// Arthas prompt:arthas@pid>(带 arthas@ 前缀,不匹配裸 >)
|
|
@@ -212,16 +249,38 @@ const PROMPT_RE_BY_TYPE = {
|
|
|
212
249
|
unknown: /\]\s*[#$]\s*$|>\s*$/,
|
|
213
250
|
};
|
|
214
251
|
|
|
252
|
+
// 弱 prompt(无方括号的 bash 默认 PS1):user@host:~/path$ 或 root@host:~#
|
|
253
|
+
// Ubuntu/Debian 系资产的 PS1 没有方括号,强正则永远不匹配 → 全部超时。
|
|
254
|
+
// 弱匹配特征:行尾是 $ 或 #,且行内含 user@host: 或 ~/ 特征。
|
|
255
|
+
// 防误判(输出行恰好以 #/$ 结尾):不立即判定,等 WEAK_QUIET_MS 无新数据才确认。
|
|
256
|
+
const WEAK_PROMPT_LINE_RE = /[$#]$/;
|
|
257
|
+
const WEAK_PROMPT_CONTEXT_RE = /@[\w.-]+:|~\//;
|
|
258
|
+
const WEAK_QUIET_MS = 350;
|
|
259
|
+
|
|
215
260
|
// 旧名保留(cleanOutput 等处仍引用),指向宽松兜底,仅用于"删 prompt 行"的清理逻辑
|
|
216
261
|
const PROMPT_RE = PROMPT_RE_BY_TYPE.unknown;
|
|
217
262
|
|
|
263
|
+
// ANSI 清理(CSI + OSC),prompt 匹配统一在清理后的文本上做
|
|
264
|
+
function stripAnsi(s) {
|
|
265
|
+
return s
|
|
266
|
+
.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "")
|
|
267
|
+
.replace(/\x1b\][^\x07\x1b]*(\x07|\x1b\\)/g, "");
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// 取 ANSI 清理后文本的最后一个非空行(去尾部空白),prompt 判定用
|
|
271
|
+
function lastNonEmptyLine(s) {
|
|
272
|
+
const lines = stripAnsi(s).split("\n").map(l => l.replace(/\s+$/, ""));
|
|
273
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
274
|
+
if (lines[i].length > 0) return lines[i];
|
|
275
|
+
}
|
|
276
|
+
return "";
|
|
277
|
+
}
|
|
278
|
+
|
|
218
279
|
// 探测终端类型:扫描 ANSI 清理后的文本,按 prompt 特征判定
|
|
219
280
|
// 支持切换:如果已锁定类型 A,但检测到明确的类型 B 特征,则切换到 B
|
|
220
281
|
// (用户在 popup 切 tab 从 Arthas 切到 JumpServer 时,代理靠这条路径纠正)
|
|
221
282
|
function detectTerminalType(text) {
|
|
222
|
-
const clean = text
|
|
223
|
-
.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "")
|
|
224
|
-
.replace(/\x1b\][^\x07\x1b]*(\x07|\x1b\\)/g, "");
|
|
283
|
+
const clean = stripAnsi(text);
|
|
225
284
|
|
|
226
285
|
// Arthas prompt 特征:arthas@<pid>> (出现在任意行尾)
|
|
227
286
|
if (/arthas@\S+>\s*$/m.test(clean)) {
|
|
@@ -232,7 +291,7 @@ function detectTerminalType(text) {
|
|
|
232
291
|
return;
|
|
233
292
|
}
|
|
234
293
|
|
|
235
|
-
// JumpServer shell prompt
|
|
294
|
+
// JumpServer shell prompt 特征(红帽系):[user@host /dir]# 或 ]$
|
|
236
295
|
if (/\[[^\]\n]+@[^\]\n]+\][^\n]*[#$]\s*$/m.test(clean)) {
|
|
237
296
|
if (terminalType !== "jumpserver") {
|
|
238
297
|
terminalType = "jumpserver";
|
|
@@ -240,6 +299,16 @@ function detectTerminalType(text) {
|
|
|
240
299
|
}
|
|
241
300
|
return;
|
|
242
301
|
}
|
|
302
|
+
|
|
303
|
+
// JumpServer shell prompt 特征(Ubuntu/Debian 默认 PS1,无方括号):
|
|
304
|
+
// user@host:~/path$ 或 root@host:~#
|
|
305
|
+
if (/[\w.-]+@[\w.-]+:\S*[$#]\s*$/m.test(clean)) {
|
|
306
|
+
if (terminalType !== "jumpserver") {
|
|
307
|
+
terminalType = "jumpserver";
|
|
308
|
+
console.log(TAG, "[probe] 终端类型切换 → jumpserver(检测到 user@host:path$ prompt,无方括号)");
|
|
309
|
+
}
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
243
312
|
// 未命中任一特征,保持当前类型不变
|
|
244
313
|
}
|
|
245
314
|
|
|
@@ -329,6 +398,40 @@ function handleWsRecv(payload) {
|
|
|
329
398
|
continue; // 已 resolve,跳过后续 prompt 检测
|
|
330
399
|
}
|
|
331
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
|
+
|
|
332
435
|
// 检查清理后 buffer 的尾部是否以 prompt 结尾
|
|
333
436
|
// 用按终端类型选出的正则(截断根治:jumpserver 不再被裸 > 误触发)
|
|
334
437
|
const tail = cleanFull.slice(-200);
|
|
@@ -342,14 +445,45 @@ function handleWsRecv(payload) {
|
|
|
342
445
|
// 早已流过(attach 之前)。我们注入 cmd 后,第一个出现的 prompt 就是
|
|
343
446
|
// 命令完成后的 prompt。所以只需等 1 个 prompt。
|
|
344
447
|
// buffer 里 = kitty 重绘碎片 + cmd 回显 + 真实输出 + 最终 prompt
|
|
345
|
-
// 切掉末尾 prompt
|
|
346
|
-
const output =
|
|
448
|
+
// 切掉末尾 prompt,前面的内容交给清理器(finalizeOutput 含空兜底)
|
|
449
|
+
const output = finalizeOutput(entry);
|
|
347
450
|
entry.resolve({
|
|
348
451
|
ok: true,
|
|
349
452
|
output,
|
|
350
453
|
elapsedMs: Date.now() - entry.sentAt
|
|
351
454
|
});
|
|
352
455
|
}
|
|
456
|
+
} else if (entry.phase === 0 && (terminalType === "jumpserver" || terminalType === "unknown")) {
|
|
457
|
+
// ====== 弱 prompt 兜底(Ubuntu/Debian 默认 PS1 无方括号)======
|
|
458
|
+
// 强正则要求 ] 前缀;user@host:~/path$ 这种 prompt 永远匹配不上 → 全部超时。
|
|
459
|
+
// 弱匹配:最后一个非空行以 $/# 结尾且行内含 @ 或 : 特征。
|
|
460
|
+
// 防误判:不立即判定,等 WEAK_QUIET_MS 无新数据再确认
|
|
461
|
+
// (输出行恰好以 #/$ 结尾时,后续输出到达会取消定时器)。
|
|
462
|
+
const lastLine = lastNonEmptyLine(entry.buffer);
|
|
463
|
+
const weakHit = WEAK_PROMPT_LINE_RE.test(lastLine) && WEAK_PROMPT_CONTEXT_RE.test(lastLine);
|
|
464
|
+
if (weakHit) {
|
|
465
|
+
if (entry.weakTimer == null) {
|
|
466
|
+
const snapLen = entry.buffer.length;
|
|
467
|
+
entry.weakTimer = setTimeout(() => {
|
|
468
|
+
entry.weakTimer = null;
|
|
469
|
+
if (!pending.has(reqId) || entry.buffer.length !== snapLen) return;
|
|
470
|
+
const lineNow = lastNonEmptyLine(entry.buffer);
|
|
471
|
+
if (WEAK_PROMPT_LINE_RE.test(lineNow) && WEAK_PROMPT_CONTEXT_RE.test(lineNow)) {
|
|
472
|
+
console.log(TAG, `[${reqId}] 弱 prompt 确认完成(${WEAK_QUIET_MS}ms 静默): ${lineNow.slice(-60)}`);
|
|
473
|
+
entry.lastScanLen = entry.buffer.length;
|
|
474
|
+
entry.resolve({
|
|
475
|
+
ok: true,
|
|
476
|
+
output: finalizeOutput(entry),
|
|
477
|
+
elapsedMs: Date.now() - entry.sentAt
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
}, WEAK_QUIET_MS);
|
|
481
|
+
}
|
|
482
|
+
} else if (entry.weakTimer != null) {
|
|
483
|
+
// 新数据不再是弱 prompt 形态,取消待确认的判定
|
|
484
|
+
clearTimeout(entry.weakTimer);
|
|
485
|
+
entry.weakTimer = null;
|
|
486
|
+
}
|
|
353
487
|
}
|
|
354
488
|
}
|
|
355
489
|
}
|
|
@@ -382,11 +516,13 @@ function probeLog(data, opcode) {
|
|
|
382
516
|
|
|
383
517
|
// ===================== 输出清理 =====================
|
|
384
518
|
// prompt 锚点方案:buffer = kitty 重绘碎片 + cmd 回显 + 真实输出 + 最终 prompt
|
|
385
|
-
//
|
|
519
|
+
// 清理策略(收紧:只删确定属于终端噪音的内容,其余原样保留):
|
|
386
520
|
// 1. 去 ANSI(颜色、OSC 标题)
|
|
387
|
-
// 2.
|
|
388
|
-
//
|
|
389
|
-
//
|
|
521
|
+
// 2. 控制字符替换为空格(NUL/x00 等,/proc/*/cmdline 场景;绝不因含控制字符丢整段)
|
|
522
|
+
// 3. 删 prompt 行(要求 prompt 出现在行首附近,且 #$ 紧跟 prompt——
|
|
523
|
+
// 旧行为 "]/…任意内容…/#$" 会把含 ] 和 $ 的长 JSON 输出整行误删,即问题 1 根因)
|
|
524
|
+
// 4. 删 koko 控制消息行
|
|
525
|
+
// 5. 删命令回显行(折行感知:终端宽度折行会把长命令拆成多行,先做软换行 join 再比对)
|
|
390
526
|
function cleanOutput(text, cmd) {
|
|
391
527
|
if (!text) return "";
|
|
392
528
|
let out = text
|
|
@@ -394,15 +530,21 @@ function cleanOutput(text, cmd) {
|
|
|
394
530
|
.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "")
|
|
395
531
|
// ANSI OSC 序列:ESC ] ... BEL 或 ESC \(标题设置等)
|
|
396
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, " ")
|
|
397
536
|
// \r\n 和孤立 \r → \n
|
|
398
537
|
.replace(/\r\n?/g, "\n");
|
|
399
538
|
|
|
400
|
-
// 删除 prompt
|
|
401
|
-
// -
|
|
402
|
-
//
|
|
403
|
-
//
|
|
539
|
+
// 删除 prompt 行(命令回显行 = prompt + 命令文本,可能粘有 kitty 重绘碎片):
|
|
540
|
+
// - 红帽系:[user@host /cwd]# —— 要求 ] 后紧跟 #$(不再允许中间隔任意内容)
|
|
541
|
+
// - Ubuntu/Debian:user@host:~/path$ —— 要求出现在行首附近(≤40 字符前缀内,
|
|
542
|
+
// 覆盖 kitty 碎片),且 prompt 段内无空格
|
|
543
|
+
// - Arthas:行尾 > 且行短(≤60 字符)
|
|
544
|
+
// 前缀窗口 40 字符:正常 prompt 行 prompt 就在行首;碎片通常几到几十字符。
|
|
404
545
|
out = out.split("\n").filter(line => {
|
|
405
|
-
if (
|
|
546
|
+
if (/^.{0,40}\[[^\]\n]+@[^\]\n]+\]\s*[#$]/.test(line)) return false;
|
|
547
|
+
if (/^.{0,40}[\w.-]+@[\w.-]+:[^\s]*[$#]/.test(line)) return false;
|
|
406
548
|
// Arthas / 通用 REPL prompt:行尾 > (允许前面有 arthas@xxx 等前缀)
|
|
407
549
|
if (/>\s*$/.test(line) && line.trim().length <= 60) return false;
|
|
408
550
|
return true;
|
|
@@ -416,14 +558,33 @@ function cleanOutput(text, cmd) {
|
|
|
416
558
|
return true;
|
|
417
559
|
}).join("\n");
|
|
418
560
|
|
|
419
|
-
// 删除命令回显行:整行(去空白后)等于 cmd
|
|
420
|
-
//
|
|
421
|
-
|
|
561
|
+
// 删除命令回显行:整行(去空白后)等于 cmd 的行。
|
|
562
|
+
// 折行感知(问题 4):长命令在终端宽度处折行会拆开 token(--com\nmand=),
|
|
563
|
+
// 单行比对失败 → 残留回显碎片。这里允许把连续 1-4 行 join 后再与 cmd 比对,
|
|
564
|
+
// 匹配则整组删除。只做"合并后完全相等"的精确匹配,不做子串匹配(防误删真实输出)。
|
|
565
|
+
if (cmd && cmd.trim()) {
|
|
422
566
|
const cmdNoWs = cmd.replace(/\s+/g, "");
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
567
|
+
const lines = out.split("\n");
|
|
568
|
+
const kept = [];
|
|
569
|
+
let i = 0;
|
|
570
|
+
while (i < lines.length) {
|
|
571
|
+
let matched = false;
|
|
572
|
+
let joinedNoWs = "";
|
|
573
|
+
for (let k = 0; i + k < lines.length; k++) {
|
|
574
|
+
joinedNoWs += lines[i + k].replace(/\s+/g, "");
|
|
575
|
+
if (joinedNoWs.length > cmdNoWs.length) break;
|
|
576
|
+
if (joinedNoWs === cmdNoWs) {
|
|
577
|
+
i += k + 1;
|
|
578
|
+
matched = true;
|
|
579
|
+
break;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
if (!matched) {
|
|
583
|
+
kept.push(lines[i]);
|
|
584
|
+
i++;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
out = kept.join("\n");
|
|
427
588
|
}
|
|
428
589
|
|
|
429
590
|
return out
|
|
@@ -431,6 +592,29 @@ function cleanOutput(text, cmd) {
|
|
|
431
592
|
.trim();
|
|
432
593
|
}
|
|
433
594
|
|
|
595
|
+
// ===================== 输出兜底(问题 1 的安全网)=====================
|
|
596
|
+
// 清理后为空、但原始 WS 帧里有实际内容时,绝不能返回 ok:true + 空输出——
|
|
597
|
+
// 那会让 Agent 误判"命令无输出/文件为空"。这里返回去 ANSI 的原始内容
|
|
598
|
+
// (截断到 4KB)+ raw 字节数提示,把"被过滤"的事实显式暴露给调用方。
|
|
599
|
+
const RAW_FALLBACK_THRESHOLD = 512; // 原始内容低于此值视为真无输出
|
|
600
|
+
const RAW_FALLBACK_LIMIT = 4000; // 兜底输出截断长度
|
|
601
|
+
|
|
602
|
+
function finalizeOutput(entry) {
|
|
603
|
+
const cleaned = cleanOutput(entry.buffer, entry.echoCmd != null ? entry.echoCmd : entry.cmd);
|
|
604
|
+
if (cleaned.length > 0) return cleaned;
|
|
605
|
+
|
|
606
|
+
const raw = stripAnsi(entry.buffer)
|
|
607
|
+
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, " ")
|
|
608
|
+
.replace(/\r\n?/g, "\n")
|
|
609
|
+
.trim();
|
|
610
|
+
if (raw.length < RAW_FALLBACK_THRESHOLD) return "";
|
|
611
|
+
|
|
612
|
+
const note = `[warning] output filtered to empty by cleaner; raw ${raw.length} chars shown (truncated)]`;
|
|
613
|
+
return raw.length > RAW_FALLBACK_LIMIT
|
|
614
|
+
? raw.slice(0, RAW_FALLBACK_LIMIT) + "\n" + note
|
|
615
|
+
: raw + "\n" + note;
|
|
616
|
+
}
|
|
617
|
+
|
|
434
618
|
// ===================== WebSocket 服务 =====================
|
|
435
619
|
const wss = new WebSocketServer({ host: HOST, port: PORT });
|
|
436
620
|
|
package/files/skill/SKILL.md
CHANGED
|
@@ -78,8 +78,8 @@ Arthas 的能力不止于读,以下命令/用法一律禁止通过桥接执行
|
|
|
78
78
|
| 页面形态 | koko connect **iframe**(嵌在 luna 父页里) | **顶层文档**(无 iframe) |
|
|
79
79
|
| WebSocket URL | `wss://.../koko/ws/...` | `wss://.../ws?method=connectArthas...` |
|
|
80
80
|
| WS 帧格式 | **二进制帧 opcode=2**,payload 是 base64,解码后是 SSH PTY 明文 | **文本帧 opcode=1**,payload 直接是明文 ANSI |
|
|
81
|
-
| prompt 样式 |
|
|
82
|
-
| prompt
|
|
81
|
+
| prompt 样式 | 红帽系 `[root@host /dir]#`;Ubuntu 资产 `user@host:~/path$`(无方括号) | Arthas 风格 `arthas@pid>` 或 `[arthas@...]` |
|
|
82
|
+
| prompt 锚点 | 强匹配 `]\s*[#$]\s*$`;无方括号 prompt 走弱兜底(行尾 `$`/`#` + `user@host:` 特征,350ms 静默确认) | `arthas@\S+>\s*$` |
|
|
83
83
|
| sudo 检测 | 需要(cat/df 等可能被 alias 成 sudo) | 不适用(Java 诊断工具无 sudo 概念) |
|
|
84
84
|
|
|
85
85
|
**桥接层已自动处理这些差异**:content script 按是否有 `.xterm` 元素识别终端 frame(不依赖 URL),proxy 按 opcode 自动决定是否 base64 解码,prompt 锚点同时匹配两种风格。Agent 侧调用方式完全一致——都是发 `run` 帧、收 `result` 帧。
|
|
@@ -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,20 @@
|
|
|
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 行和命令回显行(折行感知)、首尾 trim。
|
|
67
|
+
**空输出兜底**:若清理后为空但原始帧有内容(≥512 字符),返回截断的原始内容
|
|
68
|
+
并附 `[warning] output filtered to empty...` 提示——绝不返回 ok:true + 空输出。
|
|
69
|
+
- `unterminated-quote`:命令含未闭合引号,远端 shell 卡在 PS2 续行(`>` 提示)。
|
|
70
|
+
代理检测到后 ~400ms 快速失败并自动 Ctrl+C 退出续行(终端可继续用),
|
|
71
|
+
`suggest` 会指向 base64 通道。
|
|
61
72
|
- `error` 枚举:
|
|
62
73
|
- `timeout` — 命令超时(交互式/持续命令会触发)
|
|
63
74
|
- `inject failed` — content script 注入失败(xterm 没捕捉到)
|
|
@@ -129,8 +140,11 @@ const PROMPT_RE = /\]\s*[#$]\s*$|>\s*$/;
|
|
|
129
140
|
|
|
130
141
|
| 终端 | prompt 样式 | 匹配部分 |
|
|
131
142
|
|------|------------|---------|
|
|
132
|
-
| JumpServer (shell) | `[root@host /path]#` 或 `]$` | `]\s*[#$]\s
|
|
133
|
-
|
|
|
143
|
+
| JumpServer (shell, 红帽系) | `[root@host /path]#` 或 `]$` | 强匹配 `]\s*[#$]\s*$`(命中即完成) |
|
|
144
|
+
| JumpServer (shell, Ubuntu/Debian) | `user@host:~/path$`(无方括号) | 弱匹配兜底:行尾 `$`/`#` + `user@host:`/`~/` 特征,需 350ms 静默确认 |
|
|
145
|
+
| Arthas | `arthas@pid>` 或 `[arthas@...]` | `arthas@\S+>\s*$` |
|
|
146
|
+
|
|
147
|
+
说明:Ubuntu/Debian 默认 PS1 没有方括号,强正则永远不命中会导致所有命令超时(曾出现在 Windows 同事的 Ubuntu 资产上)。弱兜底以 350ms 无新数据为确认条件,避免输出行恰好以 `#`/`$` 结尾时误判提前完成。
|
|
134
148
|
|
|
135
149
|
注意:单独的 `>` 较宽(命令输出里 `>` 偶尔出现),但配合"行尾 + ANSI 清理后 + 注入命令后才出现"三个条件,误判率可接受。
|
|
136
150
|
|
package/package.json
CHANGED