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.
@@ -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
- ws.send(JSON.stringify({ type: "run", reqId, cmd, timeoutMs }));
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
- const cmd = process.argv[2] || "uname -a";
182
- const timeoutMs = Number(process.argv[3] || 10000);
183
-
184
- console.log(`→ run: ${cmd}`);
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
  }
@@ -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
- const wrapped = `${job.cmd}\r`;
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: cleanOutput(entry.buffer, entry.cmd),
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[user@host /dir]# 或 ]$
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 特征:[user@host /dir]# 或 ]$
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,前面的内容交给 cleanOutput 清理(删 prompt 行、碎片)
346
- const output = cleanOutput(entry.buffer, entry.cmd);
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. 删含 prompt 模式的行(命令回显行,含粘在前面的碎片)
388
- // 注:kitty 重绘碎片(cmd 文本的片段)可能残留几行,但不影响 Agent 理解输出。
389
- // 不做 cmd 子串清理——它会误删真实输出(如 cmd="echo hello",输出"hello"会被删)。
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
- // - shell 风格:行里出现 [user@host /cwd]# ]$ 整行删
402
- // (删 "[root@host ~]# echo hello" 这种命令回显行,含粘在前面的碎片)
403
- // - Arthas 风格:行尾是 arthas@xxx> 或单独的 > → 整行删
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 (/\[[^\]\n]+@[^\]\n]+\][^\n]*[#$]/.test(line)) return false;
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
- if (cmd) {
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
- out = out.split("\n").filter(line => {
424
- const lineNoWs = line.replace(/\s+/g, "");
425
- return lineNoWs !== cmdNoWs;
426
- }).join("\n");
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
 
@@ -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 样式 | shell 风格 `[root@host /dir]#` | Arthas 风格 `arthas@pid>` 或 `[arthas@...]` |
82
- | prompt 锚点正则 | `/\]\s*[#$]\s*$/` | `/>/(行尾)` |
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`:必填。命令本身,**不要自己加哨兵/换行**——代理会自动包末尾 `\r`。
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`、删 prompt 行和命令回显行、首尾 trim。
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
- | Arthas | `arthas@pid>` `[arthas@...]` | `>\s*$` |
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "terminal-bridge-setup",
3
- "version": "2.7.0",
3
+ "version": "2.9.0",
4
4
  "description": "一次性安装器:释放终端桥接(JumpServer / Arthas)的本地代理 + Chrome 插件,并注册 native messaging host。让 Agent 能通过浏览器 xterm 终端执行命令并拿回输出。",
5
5
  "license": "MIT",
6
6
  "author": "encorearon",