terminal-bridge-setup 2.0.0 → 2.2.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/bin/setup.mjs CHANGED
@@ -1,11 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  // terminal-bridge-setup —— 终端桥接一次性安装器
3
3
  //
4
- // 做 4 件事:
5
- // 1. 把插件源码 + 代理 + native host 释放到 ~/.terminal-bridge/
4
+ // 做 5 件事:
5
+ // 1. 把插件源码 + 代理 + native host + skill 释放到 ~/.terminal-bridge/
6
6
  // 2. 在代理目录跑 npm install(装 ws)
7
7
  // 3. 注册 native messaging host 到 Chrome(复用 native/install.sh)
8
- // 4. 打开 chrome://extensions,引导用户"加载已解压扩展"
8
+ // 4. 安装 skill 到 ~/.agents/skills/(让 Agent 自动发现)
9
+ // 5. 打开 chrome://extensions + Finder,引导用户"加载已解压扩展"
9
10
  //
10
11
  // 用法(发布后):
11
12
  // npx terminal-bridge-setup
@@ -37,11 +38,12 @@ const c = {
37
38
  const log = (msg) => console.log(msg);
38
39
  const ok = (msg) => console.log(c.green("✓ ") + msg);
39
40
  const fail = (msg) => { console.error(c.red("✗ ") + msg); process.exit(1); };
40
- const step = (n, msg) => console.log(`\n${c.bold(c.cyan(`[${n}/4]`))} ${msg}`);
41
+ const step = (n, total, msg) => console.log(`\n${c.bold(c.cyan(`[${n}/${total}]`))} ${msg}`);
42
+ const STEPS = 5;
41
43
 
42
44
  // ===================== 步骤 1:释放文件 =====================
43
45
  function releaseFiles() {
44
- step(1, `释放文件到 ${c.dim(INSTALL_DIR)}`);
46
+ step(1, STEPS, `释放文件到 ${c.dim(INSTALL_DIR)}`);
45
47
 
46
48
  if (!existsSync(FILES_DIR)) {
47
49
  fail(`安装包不完整:未找到 ${FILES_DIR}`);
@@ -61,8 +63,9 @@ function releaseFiles() {
61
63
 
62
64
  mkdirSync(INSTALL_DIR, { recursive: true });
63
65
 
64
- // 复制三个子目录
65
- for (const sub of ["proxy", "native", "extension"]) {
66
+ // 复制四个子目录(skill 只释放到 ~/.terminal-bridge/skill 备份,
67
+ // 实际安装到 ~/.agents/skills/ installSkill 负责)
68
+ for (const sub of ["proxy", "native", "extension", "skill"]) {
66
69
  const src = join(FILES_DIR, sub);
67
70
  const dst = join(INSTALL_DIR, sub);
68
71
  if (!existsSync(src)) {
@@ -75,7 +78,7 @@ function releaseFiles() {
75
78
 
76
79
  // ===================== 步骤 2:装代理依赖 =====================
77
80
  function installProxyDeps() {
78
- step(2, "安装代理依赖 (ws)");
81
+ step(2, STEPS, "安装代理依赖 (ws)");
79
82
 
80
83
  // 如果 node_modules/ws 已存在(从包里带出来的),跳过
81
84
  const wsPath = join(INSTALL_DIR, "proxy", "node_modules", "ws");
@@ -99,7 +102,7 @@ function installProxyDeps() {
99
102
 
100
103
  // ===================== 步骤 3:注册 native host =====================
101
104
  function registerNativeHost() {
102
- step(3, "注册 Native Messaging Host");
105
+ step(3, STEPS, "注册 Native Messaging Host");
103
106
 
104
107
  // macOS / Linux 路径不同
105
108
  const plat = platform();
@@ -135,45 +138,117 @@ function registerNativeHost() {
135
138
  return true;
136
139
  }
137
140
 
138
- // ===================== 步骤 4:引导加载插件 =====================
141
+ // ===================== 步骤 4:安装 skill =====================
142
+ // 把 skill 释放到 ~/.agents/skills/jumpserver-term-bridge/
143
+ // 这样 Agent(如 ZCode)能自动发现并触发,知道怎么调用桥接、怎么处理各种错误。
144
+ function installSkill() {
145
+ step(4, STEPS, "安装 Agent skill");
146
+
147
+ const skillSrc = join(INSTALL_DIR, "skill");
148
+ if (!existsSync(skillSrc)) {
149
+ console.log(c.yellow(" ⚠ 未找到 skill 源文件,跳过(不影响核心功能)"));
150
+ return;
151
+ }
152
+
153
+ // skill 安装位置:~/.agents/skills/jumpserver-term-bridge/
154
+ // 这是 ZCode 的用户级 skill 目录,放这里 Agent 能自动发现
155
+ const skillDest = join(homedir(), ".agents", "skills", "jumpserver-term-bridge");
156
+ mkdirSync(skillDest, { recursive: true });
157
+ cpSync(skillSrc, skillDest, { recursive: true, force: true });
158
+ ok(`skill → ${skillDest}`);
159
+ console.log(c.dim(" Agent 现在能自动识别 jumpserver/arthas 终端并使用桥接"));
160
+ }
161
+
162
+ // ===================== 步骤 5:引导加载插件 =====================
139
163
  function guideLoadExtension() {
140
- step(4, "加载 Chrome 插件");
164
+ step(5, STEPS, "加载 Chrome 插件");
141
165
 
142
166
  const extDir = join(INSTALL_DIR, "extension");
167
+ const plat = platform();
168
+
143
169
  console.log("");
144
- console.log(c.bold("请在 Chrome 中操作:"));
170
+ console.log(c.bold("需要手动加载插件(一次性操作):"));
145
171
  console.log("");
146
- console.log(` ${c.cyan("1.")} 打开 ${c.bold("chrome://extensions")}`);
147
- console.log(` ${c.dim("(我会尝试帮你打开)")}`);
172
+ console.log(` ${c.cyan("1.")} 已为你打开 ${c.bold("chrome://extensions")}`);
148
173
  console.log("");
149
- console.log(` ${c.cyan("2.")} 右上角打开「${c.bold("开发者模式")}」`);
174
+ console.log(` ${c.cyan("2.")} 右上角打开「${c.bold("开发者模式")}」开关`);
150
175
  console.log("");
151
- console.log(` ${c.cyan("3.")} 点「${c.bold("加载已解压的扩展程序")}」`);
152
- console.log(` 选择目录:`);
176
+ console.log(` ${c.cyan("3.")} 点左上角「${c.bold("加载已解压的扩展程序")}」`);
177
+ console.log(` ${c.dim("已为你打开 Finder,选择这个文件夹:")}`);
153
178
  console.log(` ${c.green(extDir)}`);
154
179
  console.log("");
155
180
  console.log(` ${c.cyan("4.")} 加载后确认插件 ID 是:`);
156
181
  console.log(` ${c.bold(EXTENSION_ID)}`);
157
- console.log(` ${c.dim("(ID 由 manifest key 固定,native host 已按此 ID 注册)")}`);
182
+ console.log(` ${c.dim("(ID 固定,native host 已按此注册)")}`);
158
183
  console.log("");
159
184
 
160
- // 尝试用系统默认方式打开 chrome://extensions
161
- const plat = platform();
162
- let opened = false;
185
+ // 打开 chrome://extensions
186
+ // macOS: open -a 指定 Chrome;open URL scheme 更可靠
187
+ // Linux: 直接给 google-chrome 传 URL
188
+ let chromeOpened = false;
189
+ try {
190
+ if (plat === "darwin") {
191
+ // 优先用 Google Chrome,回退到默认浏览器
192
+ const r = spawnSync("open", ["-a", "Google Chrome", "chrome://extensions"], { stdio: "ignore" });
193
+ if (r.status !== 0) {
194
+ spawnSync("open", ["chrome://extensions"], { stdio: "ignore" });
195
+ }
196
+ chromeOpened = true;
197
+ } else if (plat === "linux") {
198
+ // 试几个常见的 Chrome 命令名
199
+ for (const bin of ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser"]) {
200
+ const r = spawnSync(bin, ["chrome://extensions"], { stdio: "ignore" });
201
+ if (r.status === 0) { chromeOpened = true; break; }
202
+ }
203
+ if (!chromeOpened) spawnSync("xdg-open", ["chrome://extensions"], { stdio: "ignore" });
204
+ chromeOpened = true;
205
+ }
206
+ } catch {}
207
+ if (chromeOpened) ok("已打开 chrome://extensions");
208
+
209
+ // ② 在 Finder/文件管理器里打开 extension 目录,方便用户直接拖拽选择
163
210
  try {
164
211
  if (plat === "darwin") {
165
- spawnSync("open", ["chrome://extensions"], { stdio: "ignore" });
166
- opened = true;
212
+ // open <dir> 会用 Finder 打开,并选中该文件夹
213
+ spawnSync("open", [extDir], { stdio: "ignore" });
214
+ ok(`已在 Finder 打开 ${extDir}`);
167
215
  } else if (plat === "linux") {
168
- spawnSync("xdg-open", ["chrome://extensions"], { stdio: "ignore" });
169
- opened = true;
216
+ spawnSync("xdg-open", [extDir], { stdio: "ignore" });
217
+ ok(`已在文件管理器打开 ${extDir}`);
170
218
  }
171
219
  } catch {}
172
- if (opened) ok("已尝试打开 chrome://extensions");
220
+
221
+ // ③ 交互式等待用户确认加载完成,然后校验
222
+ console.log("");
223
+ console.log(c.yellow("→ 完成上述操作后,按 Enter 继续(校验插件是否加载成功)..."));
224
+ console.log(c.dim(" (如果跳过,可稍后在插件 popup 里点「启动代理」验证)"));
225
+
226
+ // 等待用户按 Enter(阻塞读取 stdin)
227
+ return new Promise((resolve) => {
228
+ process.stdin.resume();
229
+ process.stdin.once("data", () => {
230
+ verifyExtensionLoaded();
231
+ resolve();
232
+ });
233
+ // 超时兜底:60 秒没按 Enter 就继续
234
+ setTimeout(() => {
235
+ console.log(c.dim("\n (等待超时,继续完成安装)"));
236
+ process.stdin.pause();
237
+ resolve();
238
+ }, 60000);
239
+ });
240
+ }
241
+
242
+ // 校验插件是否加载:通过探测 native host 间接判断
243
+ // (插件加载后 popup 才能点"启动代理",代理起来说明 native host + 插件都通了)
244
+ function verifyExtensionLoaded() {
245
+ console.log("");
246
+ console.log(c.dim(" 校验方式:稍后在插件 popup 里点「🚀 启动代理」"));
247
+ console.log(c.dim(" 绿灯亮 = 插件 + native host + 代理全部就绪"));
173
248
  }
174
249
 
175
250
  // ===================== 主流程 =====================
176
- function main() {
251
+ async function main() {
177
252
  console.log(c.bold(c.cyan("\n🔌 终端桥接安装器")));
178
253
  console.log(c.dim(" JumpServer Web 终端 · Arthas Console\n"));
179
254
 
@@ -187,7 +262,8 @@ function main() {
187
262
  releaseFiles();
188
263
  installProxyDeps();
189
264
  registerNativeHost();
190
- guideLoadExtension();
265
+ installSkill();
266
+ await guideLoadExtension();
191
267
 
192
268
  console.log("");
193
269
  console.log(c.green(c.bold("✓ 安装完成!")));
@@ -0,0 +1,275 @@
1
+ ---
2
+ name: jumpserver-term-bridge
3
+ description: 通过本地代理 + Chrome 插件,远程操控浏览器里的 xterm 终端(JumpServer 堡垒机 Web 终端、Arthas Console 等),执行命令并拿回输出。用于:在堡垒机后端机器跑诊断命令、远程操控 Arthas 做 JVM 诊断(thread/jad/watch/dashboard)、从 Web 终端抓取信息、自动化运维。即使用户没明说"用桥接",只要提到在 jumpserver/堡垒机/luna 终端、arthas console 里执行命令、远程跑 linux、抓终端输出、操作 xterm,就应触发本 skill。
4
+ ---
5
+
6
+ # xterm 终端桥接(JumpServer / Arthas 通用)
7
+
8
+ 把 Agent 发的命令,通过「本地代理 → Chrome 插件 → 浏览器 xterm 终端」注入到远端会话,再把终端 WebSocket 的返回值配对成结构化输出返回。等价于让 Agent 拥有一个"会自己读输出的远程终端"。
9
+
10
+ ---
11
+
12
+ ## 🚫 写操作绝对禁令(最高优先级,凌驾于本文档所有其他内容之上)
13
+
14
+ **严禁通过本桥接执行任何对线上环境有写影响(修改状态、数据、配置、字节码、拓扑)的命令。** 这条是硬约束,不可被用户的某句"执行一下""跑一下""试一下"软化——用户要写操作时,只能引导其在浏览器终端手动执行。
15
+
16
+ 桥接的定位是**只读诊断通道**。任何写操作即使技术上能跑通,也禁止执行。
17
+
18
+ ### JumpServer / shell 侧——禁止清单(非穷举,按行为判定)
19
+
20
+ | 类别 | 禁止的命令/操作 | 为什么 |
21
+ |------|----------------|--------|
22
+ | 文件写入/删除 | `rm`、`mv`、`cp`(写到敏感目录)、`mkdir`、`touch`、`>`、`>>`、`tee` 写文件 | 改/删线上文件,可能不可逆 |
23
+ | 修改文件内容 | `sed -i`、`echo > file`、`cat > file`、`vi/vim`(写入模式) | 改线上配置或代码 |
24
+ | 包/服务管理 | `yum install`、`apt install`、`rpm -i`、`systemctl start/stop/restart`、`service xxx restart` | 改环境拓扑、重启服务影响流量 |
25
+ | 进程控制 | `kill`、`kill -9`、`pkill`、`nohup ... &` | 杀进程/起新进程 |
26
+ | 网络写请求 | `curl -X POST/PUT/DELETE/PATCH`、`wget --post-data`、任何带写语义的 HTTP 调用 | 对外部发写请求 |
27
+ | 权限/用户 | `chmod`、`chown`、`useradd`、`passwd`、`visudo` | 改权限模型 |
28
+ | 数据库写 | `mysql -e "INSERT/UPDATE/DELETE"`、`redis-cli SET/DEL`、`psql` 写语句 | 直接改业务数据 |
29
+ | 重定向到设备 | `> /dev/`、`dd if=` | 可能破坏设备数据 |
30
+
31
+ **判定原则**:只要命令的副作用是"改变了线上某个状态"——文件、进程、服务、数据、配置、网络资源——一律禁止。拿不准时按禁止处理,告诉用户"这条命令可能对线上有写影响,请你在浏览器终端手动执行"。
32
+
33
+ **允许的只读命令**(示例):`cat`、`ls`、`ps`、`df`、`free`、`top`(只读模式)、`netstat`、`ss`、`grep`、`find`(不带 `-delete`)、`head`、`tail`、`less`(只看不编辑)、`curl -X GET`(只读查询接口)、`docker logs/ps/stats/inspect`。
34
+
35
+ ### Arthas 侧——禁止清单
36
+
37
+ Arthas 的能力不止于读,以下命令/用法一律禁止通过桥接执行:
38
+
39
+ | 命令 | 禁止原因 |
40
+ |------|---------|
41
+ | `retransform` | 改线上字节码,可能不可逆(代理已拦截) |
42
+ | `profiler` | 长期采样吃 CPU/内存(代理已拦截) |
43
+ | `stop` / `reset` | 关闭/重置 Arthas,影响诊断通道本身(代理已拦截) |
44
+ | **`ognl`** | 能执行**任意 Java 表达式**,可调用 setter、改静态字段、new 对象触发副作用、甚至改业务状态。`ognl` 默认按写操作处理,禁止执行;确需只读 `ognl` 查值(如读静态字段),须用户明确确认目标表达式是只读的,且不调用任何带副作用的方法 |
45
+ | **`vmtool`** | 含 `--action setInstanceField` / `setStaticField` 等写子命令,禁止使用写子命令;只读子命令(`getInstances`、`forceGc` 除外)可用 |
46
+ | **`vmoption`** | 能改 JVM 诊断参数。查值(无参或只读)允许;带 `=value` 的写用法禁止 |
47
+ | **`logger --level`** | 能动态改日志级别。只查(`logger` 无参)允许;改级别禁止 |
48
+ | `tt --replay` / `tt --play` | TimeTunnel 回放会真实重发方法调用,触发业务副作用,禁止 |
49
+ | `watch`/`trace`/`stack`/`monitor` 里调用写表达式 | 如 `watch xxx '#obj.setFoo(1)'`,条件表达式里调写方法,禁止 |
50
+
51
+ **Arthas 命令的默认判定**:若命令语法同时支持读和写(如 `vmoption`、`logger`、`ognl`),**默认视为写操作禁止**,只有当且仅当命令形态确认是只读时才允许。
52
+
53
+ ### 当用户要求写操作时——标准应对
54
+
55
+ 1. **不要执行,也不要"先执行再看看"**。直接拒绝并说明:桥接是只读诊断通道,写操作需手动。
56
+ 2. 给出明确的手动执行路径:让用户在浏览器里打开对应的终端(JumpServer 终端 / Arthas Console),手动输入命令。
57
+ 3. 如果命令复杂,把要执行的完整命令文本给用户,方便其复制粘贴。
58
+ 4. 绝对不要因为"用户很急""用户坚持""命令看起来无害"而软化这条约束。写操作的唯一出口是用户手动执行。
59
+
60
+ 支持两类终端(同一套桥接,自动适配):
61
+
62
+ | 终端类型 | 用途 | 命令风格 | 示例 |
63
+ |---------|------|---------|------|
64
+ | **JumpServer (koko)** | 堡垒机后端 SSH,跑 shell 命令 | linux 命令 | `uname -a`、`ps aux`、`systemctl status nginx` |
65
+ | **Arthas Console** | 线上 JVM 诊断 | Arthas 命令 | `thread`、`jad com.foo.Bar`、`watch`、`dashboard` |
66
+
67
+ ## 触发场景
68
+
69
+ - 用户要在 JumpServer 堡垒机 Web 终端里跑命令并拿结果
70
+ - 用户要在 Arthas Console 里做 JVM 诊断(查线程、反编译、watch 方法、看 dashboard)
71
+ - 需要远程诊断/巡检后端机器
72
+ - 自动化运维:批量在资产上执行命令
73
+
74
+ ## 两种终端的关键差异(重要)
75
+
76
+ | 维度 | JumpServer (koko) | Arthas Console |
77
+ |------|-------------------|----------------|
78
+ | 页面形态 | koko connect **iframe**(嵌在 luna 父页里) | **顶层文档**(无 iframe) |
79
+ | WebSocket URL | `wss://.../koko/ws/...` | `wss://.../ws?method=connectArthas...` |
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*$/` | `/>/(行尾)` |
83
+ | sudo 检测 | 需要(cat/df 等可能被 alias 成 sudo) | 不适用(Java 诊断工具无 sudo 概念) |
84
+
85
+ **桥接层已自动处理这些差异**:content script 按是否有 `.xterm` 元素识别终端 frame(不依赖 URL),proxy 按 opcode 自动决定是否 base64 解码,prompt 锚点同时匹配两种风格。Agent 侧调用方式完全一致——都是发 `run` 帧、收 `result` 帧。
86
+
87
+ ## 前置检查(每次用前确认)
88
+
89
+ ```bash
90
+ # 1. 代理在跑?端口 8787 应该在 LISTEN
91
+ lsof -i:8787 | grep LISTEN
92
+
93
+ # 2. 没跑就启动(也可从插件 popup 的"启动代理"按钮启动)
94
+ cd ~/Code/testwork/ws-sniffer/proxy && node server.js &
95
+ ```
96
+
97
+ 浏览器侧(任选其一或都开):
98
+ - **JumpServer**:WS Sniffer 插件已加载 + JumpServer 终端页面已打开 + 已连上一个资产(终端可见、能敲字)
99
+ - **Arthas**:WS Sniffer 插件已加载 + Arthas Console 页面已打开 + 已 Connect 上目标 JVM
100
+
101
+ 多终端场景:如果同时开了 JumpServer 和 Arthas 两个 tab,代理命令只会发给 popup 里"当前选中"的那个 tab。切换用 popup 的 tab 选择器,或让用户在 popup 点选。
102
+
103
+ 如果用户说"命令没反应"或"inject-failed":
104
+ 1. `chrome://extensions/` 刷新 WS Sniffer 插件 ↻
105
+ 2. 让用户在终端页面按 F5 刷新(让 content script 重新识别 xterm)
106
+ 3. 让用户在 popup 点"捕捉 xterm"按钮(手动扫描,免刷新)
107
+ 4. 确认终端 tab 顶部有黄色调试条(说明 CDP 已 attach)
108
+
109
+ ## 调用方式
110
+
111
+ **核心 API**:连 `ws://127.0.0.1:8787/ssh`,发 `run` 帧,等 `result` 帧。两种终端用法完全一致。
112
+
113
+ 最简方式(用示例客户端):
114
+
115
+ ```bash
116
+ cd ~/Code/testwork/ws-sniffer/proxy
117
+
118
+ # JumpServer 场景
119
+ node client-example.mjs "uname -a"
120
+ node client-example.mjs "df -h" 15000 # 第二参数是超时 ms
121
+ node client-example.mjs "ps aux | grep java" 20000
122
+
123
+ # Arthas 场景
124
+ node client-example.mjs "help"
125
+ node client-example.mjs "thread" # 查看线程概况
126
+ node client-example.mjs "jad com.foo.BarService" # 反编译类
127
+ node client-example.mjs "dashboard" 8000 # dashboard 是持续的,给够超时
128
+ ```
129
+
130
+ 在 Agent 代码里直接调(复制 `proxy/client-example.mjs` 里的 `run()` 函数):
131
+
132
+ ```js
133
+ const result = await run("systemctl status nginx", 15000);
134
+ // result = { ok: true, output: "● nginx.service - ...", elapsedMs: 234 }
135
+ // 失败时 result.ok === false,result.error 有原因,result.output 可能含部分输出
136
+ ```
137
+
138
+ ### Arthas 常用命令速查
139
+
140
+ | 命令 | 用途 | 备注 |
141
+ |------|------|------|
142
+ | `help` | 列出所有命令 | |
143
+ | `dashboard` | JVM 概览(线程/内存/GC/tomcat) | 持续刷新,给够超时(≥8s)或用 `q` 退出 |
144
+ | `thread` | 线程列表 | `thread <id>` 看具体线程栈,`thread -b` 找死锁 |
145
+ | `jad <类全限定名>` | 反编译类 | 如 `jad com.foo.BarService` |
146
+ | `watch <类> <方法> '{params, returnObj}'` | 观察方法入参/返回值 | 方法被调用时才触发 |
147
+ | `stack <类> <方法>` | 看方法调用栈 | |
148
+ | `trace <类> <方法>` | 方法调用链耗时 | |
149
+ | `sc -d <类名>` | 查类加载信息 | |
150
+ | `vmoption` | 看 JVM 诊断参数(只读) | 带 `=value` 的写用法禁止,详见「写操作绝对禁令」 |
151
+ | `version` | Arthas 版本 | |
152
+
153
+ **注意**:Arthas 的 `dashboard`、`monitor`、`watch`、`trace` 等命令是**持续运行**的(会一直刷新输出直到按 q 或 Ctrl+C)。这类命令用桥接跑时,要么给够大的超时让它自然输出一段时间后被超时截断(代理会发 Ctrl+C 退出),要么避免使用。一次性命令(`thread`、`jad`、`version`、`vmtool` 等)最稳。
154
+
155
+ ## sudo 自动重试(仅 JumpServer 场景)
156
+
157
+ 部分 JumpServer 后端机器把 `cat`/`df`/`ls` 等命令 alias 成 sudo 版本,普通用户执行时会触发 `[sudo] password for xxx:` 密码提示。代理会**实时检测**这个提示(不等超时),立即返回特殊错误:
158
+
159
+ ```json
160
+ { "ok": false, "error": "sudo-required", "suggest": "sudo su root", "message": "命令触发了 sudo 密码提示...是否切换到 root 后重试?" }
161
+ ```
162
+
163
+ **Agent 收到 `error: "sudo-required"` 时,必须按以下流程处理**(不要直接报错给用户):
164
+
165
+ 1. 用 AskUserQuestion 问用户:`检测到命令需要 sudo 权限(机器上 cat/df 等被 alias 成 sudo 版本)。是否切换到 root 后重试?`
166
+ 2. 用户**同意** → 先执行 `run("sudo su root")` 切 root(多数 JumpServer 配置 sudo 免密),再**重新执行原命令**
167
+ 3. 用户**拒绝** → 告知用户"命令需要 root 权限,已跳过",不要反复尝试
168
+
169
+ 参考实现见 `proxy/client-example.mjs` 的 `runWithSudoRetry()` 函数。Agent 用 Bash 跑 `node client-example.mjs "cmd"` 时自带这个流程(控制台 readline 提问);Agent 自己发 ws 请求时要自行实现等价逻辑。
170
+
171
+ 注意:切 root 后整个 SSH 会话都变成 root,后续命令也在 root 下执行。如果用户不想长期 root,需要重新连接终端。
172
+
173
+ (Arthas 场景不会触发 sudo 检测——Java 诊断工具无 sudo 概念。)
174
+
175
+ ## Arthas 安全基线(重要,保护线上 JVM)
176
+
177
+ Arthas 直接挂在线上 JVM 上,命令不当会拖垮服务。代理内置了安全基线(`proxy/arthas-guard.js`),按风险分级自动拦截 Arthas 命令。**Agent 必须理解三级风险和对应的处理流程。**
178
+
179
+ ### 风险分级
180
+
181
+ | 等级 | 命令 | 风险点 | 代理行为 |
182
+ |------|------|--------|---------|
183
+ | 🟢 安全 | `help` `version` `pwd` `session` `sysenv` `sysprop` `jvm` `memory` `mbean` `dashboard` `options` `history` `perfcounter` | 只读,无字节码增强 | 放行 |
184
+ | 🟡 低风险 | `thread` `sc` `sm` `jad` `getstatic` `classloader` `cat` `echo` `grep` `tee` `base64` | 一次性查询,增强完即释放 | 放行 |
185
+ | 🟠 中风险 | `watch` `trace` `stack` `monitor` `vmtool` `tt` `vmoption` `logger` `ognl` | **字节码增强 / 或语法同时支持读写**,误用会改状态或持续产生开销 | `watch/trace/stack/monitor` 未带 `-n`/`#cost` → 自动补 `-n 1`;`vmtool/tt/vmoption/logger/ognl` 的写子命令或写表达式**禁止执行**(见「写操作绝对禁令」),只读形态可用 |
186
+ | 🔴 高风险 | `retransform` `profiler` `stop` `reset` | 改字节码可能不可逆 / 长期采样吃资源 / 关闭服务 | **无条件禁用**,让用户去浏览器手动执行 |
187
+
188
+ ### Agent 处理流程(收到 Arthas 相关 error 时)
189
+
190
+ #### 收到 `error: "arthas-forbidden"`(高风险命令被禁用)
191
+
192
+ **这类命令通过桥接一律禁用,不要尝试绕过。** 处理方式:
193
+
194
+ 1. 把 `message` 字段的内容告知用户,说明为什么禁用
195
+ 2. 把 `suggest` 字段作为替代方案告诉用户——通常替代方案是"去浏览器 Arthas 终端手动执行"
196
+ 3. **不要重试,不要尝试加参数绕过**——代理层无条件拒绝
197
+
198
+ 高风险命令禁用清单及替代方案:
199
+ - `retransform`(改字节码)→ 用 `jad` 只读查看;确需改字节码去浏览器手动执行
200
+ - `profiler`(火焰图采样)→ 用 `thread`/`memory`/`jvm` 查概况;确需火焰图去浏览器手动执行(带 `-d` 限时)
201
+ - `stop`(关闭 arthas)→ 关闭浏览器页面即可断开,不需要 stop
202
+ - `reset`(重置增强)→ 去浏览器手动执行(建议只 reset 特定类)
203
+
204
+ #### 收到 `error: "arthas-needs-limit"`(严格模式下中风险命令缺限制)
205
+
206
+ 默认模式(`ARTHAS_AUTO_PATCH=1`)下不会出现这个错误——代理会自动补 `-n 1`。只在严格模式(`ARTHAS_AUTO_PATCH=0`)下出现。处理:按 `suggest` 字段补参数后重发。
207
+
208
+ #### 收到 `error: "arthas-quota-exceeded"`(中风险命令会话超限)
209
+
210
+ 中风险命令有会话级计数(默认 20 次/代理进程)。超限后拒绝,告知用户"为保护线上服务,中风险命令已达上限,如需继续请重启代理"。**不要反复重试。**
211
+
212
+ ### Arthas 命令使用最佳实践(即使代理放行,Agent 也应遵守)
213
+
214
+ 1. **中风险命令务必带频率限制**:`trace`/`watch`/`stack`/`monitor` 加 `-n 1`(只采样一次)或 `'#cost>100'`(只看慢调用)。代理会自动补 `-n 1`,但 Agent 自己构造命令时最好显式带上。
215
+ 2. **避免高频方法上裸跑 trace**:QPS 上千的接口,即使 `-n 1` 也会拦截每一次调用直到命中。优先用 `#cost` 过滤。
216
+ 3. **profiler / retransform 已禁用**:这两个命令通过桥接一律拒绝。需要火焰图或热更新字节码时,让用户去浏览器 Arthas 终端手动执行(profiler 务必带 `-d` 限时,retransform 前先 `jad` 确认)。
217
+ 4. **retransform 前先 jad 确认**:(在浏览器手动执行时)retransform 不可逆,先 `jad <类>` 看当前字节码,本地改好编译确认后再 retransform。
218
+ 5. **避免 dashboard/monitor 做"持续监控"**:它们持续刷新,桥接模式下会一直等到超时被 Ctrl+C 截断。要监控指标用 `thread`、`memory`、`jvm` 这类一次性命令替代。
219
+
220
+ ### 环境变量调参(代理启动时)
221
+
222
+ | 变量 | 默认 | 说明 |
223
+ |------|------|------|
224
+ | `ARTHAS_MAX_MEDIUM` | 20 | 中风险命令会话内最大次数 |
225
+ | `ARTHAS_AUTO_PATCH` | 1 | 中风险命令缺限制时:1=自动补 `-n 1`,0=拒绝让 Agent 显式补 |
226
+
227
+ ## 消息格式
228
+
229
+ Agent → 代理:
230
+ ```json
231
+ { "type": "run", "reqId": "<任意唯一 id>", "cmd": "<命令>", "timeoutMs": 10000 }
232
+ ```
233
+ (reqId 可省略,代理会生成)
234
+
235
+ 代理 → Agent:
236
+ ```json
237
+ { "type": "result", "reqId": "...", "ok": true, "output": "纯文本输出(ANSI 已清理)", "elapsedMs": 234 }
238
+ ```
239
+
240
+ 详见 `references/protocol.md`。
241
+
242
+ ## 关键约定与陷阱
243
+
244
+ 1. **命令是发到真实会话**——有副作用。**严禁执行任何对线上有写影响的命令**,详见文首「🚫 写操作绝对禁令」。JumpServer 的 `rm -rf`、`reboot`、`sed -i`、`systemctl restart`;Arthas 的 `retransform`、`stop`、`ognl`(写表达式)、`tt --replay` 等一律禁止通过桥接执行,只能引导用户在浏览器终端手动操作。只读诊断是桥接的唯一用途。
245
+ 2. **默认超时 10s**。长命令(`find /`、`yum update`、Arthas 的 `dashboard`)必须显式调大 `timeoutMs`,否则会被截断成 `ok:false error:timeout`。
246
+ 3. **串行执行**:代理做了队列,同一时刻只跑一条命令。连发多条会排队,不会交错污染输出。
247
+ 4. **输出已做清理**:ANSI 颜色/光标序列、prompt 行、命令回显行都已去掉,`\r\n` 转成 `\n`。可能残留少量 kitty 终端逐字符输入的重绘碎片(不影响理解输出)。
248
+ 5. **完成判定靠 prompt 锚点**:代理发 cmd 后,在 WebSocket 返回流里等 prompt 再次出现即视为完成。JumpServer 等的是 shell prompt(`[user@host /cwd]#`),Arthas 等的是 `>` 结尾的 prompt。
249
+ 6. **交互式命令不适用**:vim、less、top、Arthas 的持续刷新命令(dashboard/monitor)、`sudo`(需密码时)会卡住终端等待输入,prompt 永远不出现 → 超时。**超时时代理会自动发 Ctrl+C 复位终端**,避免下一条命令被当成输入。
250
+ 7. **多终端切换**:同时开 JumpServer 和 Arthas 时,命令只发给 popup 选中的 tab。让用户在 popup 点选目标 tab。
251
+ 8. **sudo 别名劫持(仅 JumpServer,自动处理)**:部分机器把 `cat`/`df`/`ls` 等 alias 成 sudo 版本,会触发密码提示。代理会自动检测并返回 `sudo-required` 错误,Agent 应按"sudo 自动重试"流程问用户是否切 root。
252
+
253
+ ## 排错速查
254
+
255
+ | 现象 | 原因 | 处理 |
256
+ |------|------|------|
257
+ | `error: extension not connected` | 代理没收到插件 hello | 检查插件是否启用、service worker 日志、代理是否在跑 |
258
+ | `error: sudo-required` | 命令触发 sudo 密码提示(alias 劫持,仅 JumpServer) | 按"sudo 自动重试"流程问用户是否切 root,确认后切 root 重发原命令 |
259
+ | `error: arthas-forbidden` | Arthas 高风险命令(retransform/profiler/stop/reset)被禁用 | 告知用户去浏览器 Arthas 终端手动执行,不要重试或绕过 |
260
+ | `error: arthas-needs-limit` | 中风险命令(trace/watch/stack/monitor)缺 -n/#cost(严格模式) | 按 suggest 补参数重发,或加 `-n 1` |
261
+ | `error: arthas-quota-exceeded` | 中风险命令会话内超限(默认 20 次) | 告知用户已达上限,不要重试;如需继续重启代理 |
262
+ | `error: timeout` + output 含 `[sudo] password` | 旧版代理未实现 sudo 检测 | 升级 proxy/server.js;临时用绝对路径 `/bin/cat` 绕过 |
263
+ | `error: timeout` + output 为空 | 命令是交互式/持续刷新(vim/dashboard/monitor) | 改用非交互等价命令,或 Arthas 用一次性命令(thread/jad) |
264
+ | 命令发出去但 Arthas/JumpServer 没反应 | content script 没识别到 xterm | 让用户 F5 刷新终端页,或在 popup 点"捕捉 xterm" |
265
+ | 输出含少量碎片行 | kitty 终端重绘残留(JumpServer) | 不影响 Agent 理解,可忽略 |
266
+ | 一直 `extension not connected` | 端口被占(dailytest 也用 8787) | `lsof -i:8787` 查冲突,停掉另一个 |
267
+
268
+ ## 实测协议结论
269
+
270
+ 经探针验证:
271
+
272
+ - **koko(JumpServer)**:WebSocket 走**二进制帧(opcode=2)**,CDP 返回的 payloadData 是 **base64 编码**,解码后是明文终端流(含 ANSI、SSH PTY 文本)。代理已自动 base64 解码。
273
+ - **Arthas Console**:WebSocket 走**文本帧(opcode=1)**,payloadData 直接是明文 ANSI,无需解码。
274
+
275
+ 代理按 opcode 自动决定是否解码(opcode=2 解 base64,opcode=1 直接用),两种终端透明兼容。详见 `references/protocol.md`。
@@ -0,0 +1,181 @@
1
+ # 消息协议详解
2
+
3
+ 代理端点 `ws://127.0.0.1:8787/ssh`,所有消息均为单行 JSON。
4
+
5
+ 支持两类终端,Agent 侧协议完全一致,差异在桥接层自动处理:
6
+ - **JumpServer (koko)**:堡垒机后端 SSH,跑 shell 命令
7
+ - **Arthas Console**:线上 JVM 诊断,跑 Arthas 命令(thread/jad/watch 等)
8
+
9
+ ## 角色与连接
10
+
11
+ 同一端点接两类客户端,靠消息 `type` 路由:
12
+
13
+ - **插件(Chrome extension background)**:唯一,连上后发 `hello{role:extension}` 声明身份。负责上报 WS 帧、接收注入指令。
14
+ - **Agent**:可有多个。发 `run` 请求,收 `result` 响应。
15
+
16
+ ## 完整消息列表
17
+
18
+ ### Agent → 代理
19
+
20
+ #### `run` — 发命令执行
21
+ ```json
22
+ {
23
+ "type": "run",
24
+ "reqId": "abc123",
25
+ "cmd": "ls -la /etc",
26
+ "timeoutMs": 10000
27
+ }
28
+ ```
29
+ - `reqId`:可选。不传则代理生成(8 位 hex)。用于配对 result。
30
+ - `cmd`:必填。命令本身,**不要自己加哨兵/换行**——代理会自动包末尾 `\r`。
31
+ - JumpServer:linux 命令,如 `ps aux | grep java`
32
+ - Arthas:Arthas 命令,如 `jad com.foo.Bar`
33
+ - `timeoutMs`:可选,默认 10000。超时则返回 `ok:false, error:timeout`。
34
+
35
+ ### 代理 → Agent
36
+
37
+ #### `result` — 命令执行结果
38
+ ```json
39
+ {
40
+ "type": "result",
41
+ "reqId": "abc123",
42
+ "ok": true,
43
+ "output": "total 48\ndrwxr-xr-x ...",
44
+ "elapsedMs": 234
45
+ }
46
+ ```
47
+ 失败时:
48
+ ```json
49
+ {
50
+ "type": "result",
51
+ "reqId": "abc123",
52
+ "ok": false,
53
+ "error": "timeout | inject failed | extension disconnected | sudo-required | arthas-forbidden | arthas-needs-limit | arthas-quota-exceeded",
54
+ "output": "部分输出(可能为空)",
55
+ "suggest": "更安全的替代命令(部分错误才有)",
56
+ "message": "给用户看的说明文字(部分错误才有)",
57
+ "elapsedMs": 10000
58
+ }
59
+ ```
60
+ - `output`:prompt 锚点出现前的所有 recv 帧拼接,已去 ANSI、`\r\n`→`\n`、删 prompt 行和命令回显行、首尾 trim。
61
+ - `error` 枚举:
62
+ - `timeout` — 命令超时(交互式/持续命令会触发)
63
+ - `inject failed` — content script 注入失败(xterm 没捕捉到)
64
+ - `extension disconnected` — 插件断开
65
+ - `sudo-required` — JumpServer sudo 别名劫持,按 sudo 重试流程处理
66
+ - `arthas-forbidden` — Arthas 高风险命令(retransform/profiler/stop/reset)被禁用,告知用户去浏览器手动执行,不重试
67
+ - `arthas-needs-limit` — Arthas 中风险命令缺 `-n`/`#cost`(严格模式),按 `suggest` 补参数
68
+ - `arthas-quota-exceeded` — Arthas 中风险命令会话超限,不重试
69
+
70
+ #### `hello-ack` — 握手回应
71
+ 任何 `hello` 都会收到 `{type:"hello-ack", payload:{ok:true}}`。
72
+
73
+ ### 插件 → 代理
74
+
75
+ #### `hello` — 声明角色
76
+ ```json
77
+ { "type": "hello", "payload": { "role": "extension" } }
78
+ ```
79
+
80
+ #### `ws-recv` / `ws-send` — 上报 WS 帧
81
+ ```json
82
+ { "type": "ws-recv", "payload": { "data": "帧内容", "opcode": 1, "t": 1786512876000 } }
83
+ ```
84
+ - `data`:CDP 的 `payloadData`。
85
+ - 文本帧(opcode=1,Arthas):直接是明文 ANSI string
86
+ - 二进制帧(opcode=2,koko):是 base64 string,代理解码后得明文
87
+ - `opcode`:1=文本,2=二进制。代理按此判断是否需要 base64 解码。
88
+
89
+ #### `ws-open` — 新 WS 连接
90
+ ```json
91
+ { "type": "ws-open", "payload": { "url": "wss://...", "requestId": "..." } }
92
+ ```
93
+
94
+ #### `inject-failed` — 注入失败通知
95
+ ```json
96
+ { "type": "inject-failed", "payload": { "reqId": "...", "error": "no terminal frame with xterm in active tab" } }
97
+ ```
98
+
99
+ ### 代理 → 插件
100
+
101
+ #### `run-cmd` — 注入指令
102
+ ```json
103
+ { "type": "run-cmd", "text": "ls -la\r", "reqId": "abc123" }
104
+ ```
105
+ `text` 是命令本身 + 末尾 `\r`(触发 xterm onData 提交)。插件批量注入 xterm textarea(一次 InputEvent 派发整段文本,末尾 Enter keydown 触发执行)。
106
+
107
+ ## prompt 锚点机制(核心)
108
+
109
+ ### 为什么不用标记字符串
110
+
111
+ 曾尝试过哨兵(`cmd; printf '哨兵'`)和双标记(`echo BEGIN; cmd; echo END`)方案,都失败了——**kitty 终端逐字符注入时会重绘输入行**,把标记字符串打散到 WebSocket 流里,无法可靠匹配。
112
+
113
+ ### prompt 锚点(expect/pexpect 经典方案)
114
+
115
+ 命令完成后,终端会输出 prompt。prompt 是**服务端输出的**,不受 kitty 输入重绘影响。
116
+
117
+ 流程:
118
+ 1. 代理发 `cmd\r`,插件批量注入 xterm
119
+ 2. 代理在 ws-recv 流里累积解码后的文本
120
+ 3. 每次 ANSI 清理后检查 buffer 尾部是否匹配 prompt 正则
121
+ 4. prompt 出现 = 命令执行完毕
122
+ 5. buffer 交给 cleanOutput 清理(删 prompt 行、命令回显行、ANSI)
123
+
124
+ ### 双 prompt 正则(兼容两种终端)
125
+
126
+ ```js
127
+ const PROMPT_RE = /\]\s*[#$]\s*$|>\s*$/;
128
+ ```
129
+
130
+ | 终端 | prompt 样式 | 匹配部分 |
131
+ |------|------------|---------|
132
+ | JumpServer (shell) | `[root@k8s-master /home/op]#` 或 `]$` | `]\s*[#$]\s*$` |
133
+ | Arthas | `arthas@pid>` 或 `[arthas@...]` | `>\s*$` |
134
+
135
+ 注意:单独的 `>` 较宽(命令输出里 `>` 偶尔出现),但配合"行尾 + ANSI 清理后 + 注入命令后才出现"三个条件,误判率可接受。
136
+
137
+ ### 输出清理规则
138
+
139
+ `cleanOutput(text, cmd)` 做了:
140
+ 1. 去 ANSI CSI(`\x1b\[[0-9;?]*[ -/]*[@-~]`)—— 颜色、光标移动、清行
141
+ 2. 去 ANSI OSC(`\x1b\]...(\x07|\x1b\\)`)—— 标题设置等
142
+ 3. `\r\n` 和孤立 `\r` → `\n`
143
+ 4. 删含 prompt 模式的行:
144
+ - shell 风格 `[user@host /cwd]#` → 整行删
145
+ - Arthas/REPL 风格:行尾是 `>` 且行不长(≤60 字符)→ 整行删
146
+ 5. 删 koko 控制消息行(`{"id":...,"type":...}` JSON)
147
+ 6. 删整行等于 cmd 的行(精确匹配,去空白后比较)—— 命令回显
148
+ 7. 压缩多余空行,首尾 trim
149
+
150
+ ### timeout 与 Ctrl+C 复位
151
+
152
+ 超时(默认 10s)时,代理自动发 `\x03`(Ctrl+C)复位终端。这很关键——交互式命令(sudo 密码提示、vim、Arthas 持续刷新命令)会让 prompt 永远不出现,如果不复位,下一条命令会被当成输入污染终端。
153
+
154
+ ### 已知限制
155
+
156
+ - **kitty 重绘碎片(JumpServer)**:长命令逐字符注入时,kitty 重绘产生的命令文本片段可能残留在输出里(1-3 行)。不影响 Agent 理解,但不是绝对干净。Arthas 无此问题(批量注入)。
157
+ - **交互式/持续命令不适用**:vim、less、top、需密码的 sudo、Arthas 的 dashboard/monitor/watch(持续刷新)会超时。
158
+ - **sudo 别名(仅 JumpServer)**:部分机器把常用命令 alias 成 sudo 版本,用绝对路径(`/bin/cat`)绕过,或走 sudo 自动重试流程。
159
+
160
+ ## 探针(probe)
161
+
162
+ 代理收到第一个 `ws-recv` 帧时,会把原始数据形态打到日志。两种终端的典型输出:
163
+
164
+ **koko(二进制帧):**
165
+ ```
166
+ [proxy] [PROBE] opcode = 2 (二进制帧)
167
+ [proxy] [PROBE] 原始 payloadData(前 120 字符): "ZWNobyBoZWxsbw=="...
168
+ [proxy] [PROBE] base64 解码后(前 200 字符): "\r\n..."
169
+ [proxy] [PROBE] 结论:二进制帧,但 payload 是明文终端流(已自动解码)
170
+ ```
171
+
172
+ **Arthas(文本帧):**
173
+ ```
174
+ [proxy] [PROBE] opcode = 1 (文本帧)
175
+ [proxy] [PROBE] 原始 payloadData(前 120 字符): "\u001b[1;31m ,---. ..."
176
+ ```
177
+
178
+ 判读:
179
+ - `opcode = 1` → 文本帧。payload 直接是明文(可能带 ANSI 颜色码),无需解码。
180
+ - `opcode = 2` → 二进制帧。代理自动 base64 解码。
181
+ - 关闭探针日志:启动时 `PROBE_LOG=0 node server.js`。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "terminal-bridge-setup",
3
- "version": "2.0.0",
3
+ "version": "2.2.0",
4
4
  "description": "一次性安装器:释放终端桥接(JumpServer / Arthas)的本地代理 + Chrome 插件,并注册 native messaging host。让 Agent 能通过浏览器 xterm 终端执行命令并拿回输出。",
5
5
  "license": "MIT",
6
6
  "author": "encorearon",