terminal-bridge-setup 2.8.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)
@@ -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: cleanOutput(entry.buffer, entry.cmd),
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,前面的内容交给 cleanOutput 清理(删 prompt 行、碎片)
379
- const output = cleanOutput(entry.buffer, entry.cmd);
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: cleanOutput(entry.buffer, entry.cmd),
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. 删含 prompt 模式的行(命令回显行,含粘在前面的碎片)
452
- // 注:kitty 重绘碎片(cmd 文本的片段)可能残留几行,但不影响 Agent 理解输出。
453
- // 不做 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 再比对)
454
526
  function cleanOutput(text, cmd) {
455
527
  if (!text) return "";
456
528
  let out = text
@@ -458,18 +530,21 @@ 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
- // - shell 风格(红帽系):行里出现 [user@host /cwd]# ]$ 整行删
466
- // (删 "[root@host ~]# echo hello" 这种命令回显行,含粘在前面的碎片)
467
- // - shell 风格(Ubuntu/Debian,无方括号):user@host:~/path$ 结尾的行
468
- // - 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 就在行首;碎片通常几到几十字符。
469
545
  out = out.split("\n").filter(line => {
470
- if (/\[[^\]\n]+@[^\]\n]+\][^\n]*[#$]/.test(line)) return false;
471
- // Ubuntu prompt 行(含粘在命令回显前的情况)
472
- if (/[\w.-]+@[\w.-]+:\S*[$#]\s*$/.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;
473
548
  // Arthas / 通用 REPL prompt:行尾 > (允许前面有 arthas@xxx 等前缀)
474
549
  if (/>\s*$/.test(line) && line.trim().length <= 60) return false;
475
550
  return true;
@@ -483,14 +558,33 @@ function cleanOutput(text, cmd) {
483
558
  return true;
484
559
  }).join("\n");
485
560
 
486
- // 删除命令回显行:整行(去空白后)等于 cmd 的行
487
- // 注意是精确匹配整行,不是子串——避免误删真实输出
488
- if (cmd) {
561
+ // 删除命令回显行:整行(去空白后)等于 cmd 的行。
562
+ // 折行感知(问题 4):长命令在终端宽度处折行会拆开 token(--com\nmand=),
563
+ // 单行比对失败 → 残留回显碎片。这里允许把连续 1-4 行 join 后再与 cmd 比对,
564
+ // 匹配则整组删除。只做"合并后完全相等"的精确匹配,不做子串匹配(防误删真实输出)。
565
+ if (cmd && cmd.trim()) {
489
566
  const cmdNoWs = cmd.replace(/\s+/g, "");
490
- out = out.split("\n").filter(line => {
491
- const lineNoWs = line.replace(/\s+/g, "");
492
- return lineNoWs !== cmdNoWs;
493
- }).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");
494
588
  }
495
589
 
496
590
  return out
@@ -498,6 +592,29 @@ function cleanOutput(text, cmd) {
498
592
  .trim();
499
593
  }
500
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
+
501
618
  // ===================== WebSocket 服务 =====================
502
619
  const wss = new WebSocketServer({ host: HOST, port: PORT });
503
620
 
@@ -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 没捕捉到)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "terminal-bridge-setup",
3
- "version": "2.8.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",