terminal-bridge-setup 2.9.0 → 3.0.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.
@@ -13,6 +13,7 @@
13
13
 
14
14
  import { WebSocketServer } from "ws";
15
15
  import { randomBytes } from "node:crypto";
16
+ import { decode as msgpackDecode } from "@msgpack/msgpack";
16
17
  import { writeFileSync, unlinkSync } from "node:fs";
17
18
  import { fileURLToPath } from "node:url";
18
19
 
@@ -53,6 +54,206 @@ function sendToExtension(obj) {
53
54
  return false;
54
55
  }
55
56
 
57
+ // ===================== WS 监听(tap)通道 =====================
58
+ // 非终端页面(Yearning SQL 结果等)没有 xterm/prompt,无法走命令配对。
59
+ // Agent 客户端发 {type:"tap-start", urlIncludes:"..."} 注册后,代理把
60
+ // URL 匹配的原始 ws-recv 帧以 {type:"tap-frame", ...} 转发给它,由 Agent
61
+ // 自行解析协议。与终端命令通道互不影响(终端配对只吃终端特征 URL)。
62
+ const tapClients = new Map(); // ws -> { urlIncludes, tabId? }
63
+ let activeYearningTabId = null; // background 选中的 Yearning tab
64
+
65
+ function broadcastTap(payload) {
66
+ // 内部 Yearning 编排等待者也吃一份(yr-run 等结果帧)
67
+ feedYearningWaiters(payload);
68
+ if (tapClients.size === 0) return;
69
+ const url = (payload && payload.url) || "";
70
+ for (const [client, filter] of tapClients) {
71
+ if (client.readyState !== client.OPEN) continue;
72
+ // url 匹配则转发;url 为空也转发(CDP 在 attach 前建立的连接会错过
73
+ // webSocketCreated,帧拿不到 url——宁可多送让客户端判断,不能静默丢弃)
74
+ if (filter.tabId != null && payload.tabId != null && filter.tabId !== payload.tabId) continue;
75
+ if (url && url.indexOf(filter.urlIncludes) === -1) continue;
76
+ try {
77
+ client.send(JSON.stringify({
78
+ type: "tap-frame",
79
+ tabId: payload.tabId,
80
+ url,
81
+ data: payload.data,
82
+ opcode: payload.opcode,
83
+ t: payload.t
84
+ }));
85
+ } catch {}
86
+ }
87
+ }
88
+
89
+ // ===================== Yearning SQL 自动化编排 =====================
90
+ // Agent 发 {type:"yr-run", sql, timeoutMs}:
91
+ // 1. yr-cmd sql-set → 插件把 SQL 注入 Yearning 编辑器(CodeMirror/monaco/DOM)
92
+ // 2. yr-cmd query-click → 插件点「查询」按钮
93
+ // 3. 等 tap 帧里出现 results != null 的结果帧(msgpack 解码)→ resolve 返回
94
+ // 前置条件:用户已在 Yearning 页面点「📡 监听当前页 WS」(tap tab 存在)。
95
+ const yrCmdWaiters = new Map(); // reqId -> resolve
96
+ const yrRunWaiters = new Set(); // { tryConsume(payload) -> boolean }
97
+
98
+ function sendYrCmd(sub, sql, tabId) {
99
+ const reqId = genReqId();
100
+ return new Promise((resolve) => {
101
+ const timer = setTimeout(() => {
102
+ yrCmdWaiters.delete(reqId);
103
+ resolve({ ok: false, error: `yr-cmd ${sub} timeout` });
104
+ }, 5000);
105
+ yrCmdWaiters.set(reqId, (res) => {
106
+ clearTimeout(timer);
107
+ resolve(res);
108
+ });
109
+ const ok = sendToExtension({ type: "yr-cmd", sub, sql, reqId, tabId });
110
+ if (!ok) {
111
+ yrCmdWaiters.delete(reqId);
112
+ clearTimeout(timer);
113
+ resolve({ ok: false, error: "extension not connected" });
114
+ }
115
+ });
116
+ }
117
+
118
+ // 插件回的 yr-result:唤醒对应的 yr-cmd 等待者
119
+ function handleYrResult(msg) {
120
+ const waiter = yrCmdWaiters.get(msg.reqId);
121
+ if (waiter) {
122
+ yrCmdWaiters.delete(msg.reqId);
123
+ waiter({ ok: !!msg.ok, via: msg.via, error: msg.error, info: msg.editor || msg.buttons });
124
+ }
125
+ }
126
+
127
+ // tap 帧喂给 yr-run 等待者:结果帧(msgpack 解码后 results 非空)被消费。
128
+ // 没有 yr-run 等待者时(用户在页面上手点「查 询」),同样生成 CSV 导出记录——
129
+ // 桥接查询和手动查询的产出统一进 popup 列表。
130
+ function feedYearningWaiters(payload) {
131
+ const url = (payload && payload.url) || "";
132
+ if (url && url.indexOf("sql.meiyunji.net") === -1) return;
133
+ const frameTabId = payload && payload.tabId;
134
+
135
+ const opcode = payload && payload.opcode;
136
+ const data = payload && payload.data;
137
+ if (opcode !== 2 || typeof data !== "string") return;
138
+ let obj = null;
139
+ try {
140
+ obj = msgpackDecode(Buffer.from(data, "base64"));
141
+ } catch { return; }
142
+ if (!obj || obj.results == null) return; // 心跳帧忽略
143
+
144
+ let consumed = false;
145
+ if (yrRunWaiters.size > 0) {
146
+ for (const waiter of [...yrRunWaiters]) {
147
+ // tabId 双向可识别时严格隔离;帧缺 tabId(attach 前建立的旧 WS 连接,
148
+ // CDP 拿不到 webSocketCreated)时:仅当恰好只有一个 waiter 才兜底消费——
149
+ // 多 waiter 场景宁可不消费走超时,也不能猜错页面串结果。
150
+ if (waiter.tabId != null && payload.tabId != null && waiter.tabId !== payload.tabId) continue;
151
+ if (waiter.tabId != null && payload.tabId == null && yrRunWaiters.size > 1) continue;
152
+ if (waiter.tryConsume(obj)) { consumed = true; break; }
153
+ }
154
+ }
155
+
156
+ // 未被 yr-run 消费的结果帧 = 手动查询(或 yr-run 已完成后的重复帧),
157
+ // 也生成 CSV 记录。同一帧 yr-run 路径已经发过 export,不重复。
158
+ if (!consumed) {
159
+ const rows = Array.isArray(obj.results)
160
+ ? obj.results.map(t => (t && t.data ? t.data.length : 0)).reduce((a, b) => a + b, 0)
161
+ : 0;
162
+ sendToExtension({
163
+ type: "yr-export-csv",
164
+ reqId: "manual-" + genReqId(),
165
+ tabId: frameTabId ?? activeYearningTabId,
166
+ sql: "manual-query",
167
+ rows,
168
+ payload: JSON.stringify(obj),
169
+ });
170
+ }
171
+ }
172
+
173
+ async function handleYrRun(ws, msg) {
174
+ const reqId = msg.reqId || genReqId();
175
+ const sql = (msg.sql || "").toString();
176
+ const tabId = msg.tabId != null ? Number(msg.tabId) : activeYearningTabId;
177
+ const timeoutMs = Math.min(Number(msg.timeoutMs || 60000), 300000);
178
+ if (!sql.trim()) {
179
+ ws.send(JSON.stringify({ type: "result", reqId, ok: false, error: "empty sql" }));
180
+ return;
181
+ }
182
+
183
+ // 结果帧等待者:收到第一个 results 非空帧即完成
184
+ let settled = false;
185
+ const runEntry = {
186
+ tabId,
187
+ sentAt: Date.now(),
188
+ tryConsume: (obj) => {
189
+ if (settled) return true;
190
+ settled = true;
191
+ yrRunWaiters.delete(runEntry);
192
+ console.log(TAG, `[yr-run ${reqId}] 结果帧到达(query_time=${obj.query_time ?? "?"})`);
193
+ // 同步发给插件:浏览器侧生成 CSV 落下载(popup 可见、可重新下载)
194
+ sendToExtension({
195
+ type: "yr-export-csv",
196
+ reqId,
197
+ tabId: tabId ?? activeYearningTabId,
198
+ sql: sql.slice(0, 120),
199
+ rows: Array.isArray(obj.results)
200
+ ? obj.results.map(t => (t && t.data ? t.data.length : 0)).reduce((a, b) => a + b, 0)
201
+ : 0,
202
+ payload: JSON.stringify(obj),
203
+ });
204
+ ws.send(JSON.stringify({
205
+ type: "result", reqId, ok: true,
206
+ output: JSON.stringify(obj),
207
+ elapsedMs: Date.now() - runEntry.sentAt,
208
+ }));
209
+ return true;
210
+ },
211
+ };
212
+ yrRunWaiters.add(runEntry);
213
+
214
+ const timer = setTimeout(() => {
215
+ if (settled) return;
216
+ settled = true;
217
+ yrRunWaiters.delete(runEntry);
218
+ ws.send(JSON.stringify({
219
+ type: "result", reqId, ok: false, error: "timeout",
220
+ message: "Yearning 查询未在时限内返回结果帧(确认页面已点「监听当前页 WS」且查询能正常执行)",
221
+ elapsedMs: Date.now() - runEntry.sentAt,
222
+ }));
223
+ }, timeoutMs);
224
+
225
+ // 1. 注入 SQL
226
+ const setRes = await sendYrCmd("sql-set", sql, tabId);
227
+ if (!setRes.ok) {
228
+ if (!settled) {
229
+ settled = true;
230
+ yrRunWaiters.delete(runEntry);
231
+ clearTimeout(timer);
232
+ ws.send(JSON.stringify({ type: "result", reqId, ok: false, error: "sql-set failed: " + (setRes.error || ""), message: "SQL 注入 Yearning 编辑器失败" }));
233
+ }
234
+ return;
235
+ }
236
+ console.log(TAG, `[yr-run ${reqId}] SQL 已注入(via ${setRes.via})`);
237
+
238
+ // 2. 点「查询」
239
+ const clickRes = await sendYrCmd("query-click", "", tabId);
240
+ if (!clickRes.ok) {
241
+ if (!settled) {
242
+ settled = true;
243
+ yrRunWaiters.delete(runEntry);
244
+ clearTimeout(timer);
245
+ ws.send(JSON.stringify({
246
+ type: "result", reqId, ok: false,
247
+ error: "query-click failed: " + (clickRes.error || ""),
248
+ message: "未找到「查询」按钮;页面按钮: " + JSON.stringify(clickRes.info || []).slice(0, 300),
249
+ }));
250
+ }
251
+ return;
252
+ }
253
+ console.log(TAG, `[yr-run ${reqId}] 已点「查询」(via ${clickRes.via}),等待结果帧...`);
254
+ // 3. 结果帧由 feedYearningWaiters 消费(timer 兜底)
255
+ }
256
+
56
257
  // ===================== 请求-响应配对 =====================
57
258
  // pending: reqId -> { resolve, timer, buffer, cmd, sentAt }
58
259
  //
@@ -537,19 +738,32 @@ function cleanOutput(text, cmd) {
537
738
  .replace(/\r\n?/g, "\n");
538
739
 
539
740
  // 删除 prompt 行(命令回显行 = prompt + 命令文本,可能粘有 kitty 重绘碎片):
540
- // - 红帽系:[user@host /cwd]# —— 要求 ] 后紧跟 #$(不再允许中间隔任意内容)
541
- // - Ubuntu/Debian:user@host:~/path$ —— 要求出现在行首附近(≤40 字符前缀内,
542
- // 覆盖 kitty 碎片),且 prompt 段内无空格
741
+ // - 红帽系:[user@host /cwd]# —— user/host 段各限长 64(真实 prompt 远短于此)。
742
+ // 不限长时的实测反例(Case A):head -c 600 截断的 JSON 无闭合 ],
743
+ // "data":[ [ 落在前缀窗口内,[^\]]+ 跨过整段 JSON 匹配到 prompt 的 @…]
744
+ // → 647 字符合并行整行被误删。长度约束让这种跨吞匹配失败。
745
+ // - Ubuntu/Debian:user@host:~/path$ —— 同样限长
543
746
  // - Arthas:行尾 > 且行短(≤60 字符)
544
747
  // 前缀窗口 40 字符:正常 prompt 行 prompt 就在行首;碎片通常几到几十字符。
545
748
  out = out.split("\n").filter(line => {
546
- if (/^.{0,40}\[[^\]\n]+@[^\]\n]+\]\s*[#$]/.test(line)) return false;
547
- if (/^.{0,40}[\w.-]+@[\w.-]+:[^\s]*[$#]/.test(line)) return false;
749
+ if (/^.{0,40}\[[^\]\n]{1,64}@[^\]\n]{1,64}\]\s*[#$]/.test(line)) return false;
750
+ if (/^.{0,40}[\w.-]{1,64}@[\w.-]{1,64}:[^\s]{0,128}[$#]/.test(line)) return false;
548
751
  // Arthas / 通用 REPL prompt:行尾 > (允许前面有 arthas@xxx 等前缀)
549
752
  if (/>\s*$/.test(line) && line.trim().length <= 60) return false;
550
753
  return true;
551
754
  }).join("\n");
552
755
 
756
+ // 行尾 prompt 后缀剥离:无尾换行的输出(head -c N 截断)会与后续 prompt
757
+ // 合并成同一物理行。上面的整行过滤(带前缀窗口)会放过这种合并行——内容
758
+ // 保住了,但 prompt 碎片粘在输出尾巴上(如 JSON 尾 + [root@host dir]#),
759
+ // 污染 Agent 的后续解析。这里只剥离行尾的 prompt 形态后缀,内容原样保留。
760
+ out = out.split("\n").map(line => {
761
+ if (line.length <= 100) return line; // 短行已由整行过滤处理,避免误伤
762
+ return line
763
+ .replace(/\[[^\]\n]{1,64}@[^\]\n]{1,64}\]\s*[#$]\s*$/, "") // 红帽系后缀
764
+ .replace(/[\w.-]{1,64}@[\w.-]{1,64}:[^\s]{0,128}[$#]\s*$/, ""); // Ubuntu 后缀
765
+ }).join("\n");
766
+
553
767
  // 删除 koko 控制消息:koko 偶尔在终端流里发送 JSON 控制消息
554
768
  // (TERMINAL_RESIZE、PING 等),特征是以 {"id": 开头的 JSON 行(可能前面带 # )
555
769
  out = out.split("\n").filter(line => {
@@ -593,26 +807,35 @@ function cleanOutput(text, cmd) {
593
807
  }
594
808
 
595
809
  // ===================== 输出兜底(问题 1 的安全网)=====================
596
- // 清理后为空、但原始 WS 帧里有实际内容时,绝不能返回 ok:true + 空输出——
597
- // 那会让 Agent 误判"命令无输出/文件为空"。这里返回去 ANSI 的原始内容
598
- // (截断到 4KB)+ raw 字节数提示,把"被过滤"的事实显式暴露给调用方。
810
+ // 两层防御:
811
+ // 1. 清理后为空 + 原始内容 ≥512 字符 → 返回截断原始内容 + warning
812
+ // 2. 清理损失率 >80%(原始 ≥512 字符但清理后 <20%)→ 保留清理结果,
813
+ // 但附加 warning + 原始内容截断——任何未来的 filter 误杀都不再静默
814
+ // (Case A 教训:REDHAT 正则曾跨吞 647 字节合并行,靠这层兜底可暴露)
599
815
  const RAW_FALLBACK_THRESHOLD = 512; // 原始内容低于此值视为真无输出
600
816
  const RAW_FALLBACK_LIMIT = 4000; // 兜底输出截断长度
601
817
 
602
818
  function finalizeOutput(entry) {
603
- const cleaned = cleanOutput(entry.buffer, entry.echoCmd != null ? entry.echoCmd : entry.cmd);
604
- if (cleaned.length > 0) return cleaned;
819
+ const echoCmd = entry.echoCmd != null ? entry.echoCmd : entry.cmd;
820
+ const cleaned = cleanOutput(entry.buffer, echoCmd);
605
821
 
606
822
  const raw = stripAnsi(entry.buffer)
607
823
  .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, " ")
608
824
  .replace(/\r\n?/g, "\n")
609
825
  .trim();
610
- if (raw.length < RAW_FALLBACK_THRESHOLD) return "";
826
+ if (raw.length < RAW_FALLBACK_THRESHOLD) return cleaned;
611
827
 
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;
828
+ const truncated = raw.length > RAW_FALLBACK_LIMIT
829
+ ? raw.slice(0, RAW_FALLBACK_LIMIT) + "\n...[truncated]"
830
+ : raw;
831
+
832
+ if (cleaned.length === 0) {
833
+ return truncated + `\n[warning] output filtered to empty by cleaner; raw ${raw.length} chars shown]`;
834
+ }
835
+ if (cleaned.length < raw.length * 0.2) {
836
+ return cleaned + `\n[warning] cleaner dropped ${(100 - Math.round(cleaned.length / raw.length * 100))}% of output; raw tail:\n${truncated}`;
837
+ }
838
+ return cleaned;
616
839
  }
617
840
 
618
841
  // ===================== WebSocket 服务 =====================
@@ -654,9 +877,64 @@ wss.on("connection", (ws, req) => {
654
877
  return;
655
878
  }
656
879
 
880
+ // --- Agent 发来的 Yearning SQL 查询(注入编辑器 + 点查询 + 收 WS 结果)---
881
+ if (msg.type === "yr-run") {
882
+ handleYrRun(ws, msg);
883
+ return;
884
+ }
885
+ if (msg.type === "yr-result") {
886
+ handleYrResult(msg);
887
+ return;
888
+ }
889
+ if (msg.type === "yr-active-tab") {
890
+ activeYearningTabId = msg.tabId != null ? Number(msg.tabId) : null;
891
+ console.log(TAG, `[yr] active tab = ${activeYearningTabId}`);
892
+ return;
893
+ }
894
+ if (msg.type === "yr-ping") {
895
+ // 探测:编辑器类型 + 查询按钮(不执行任何操作)
896
+ sendYrCmd("ping", "", msg.tabId != null ? Number(msg.tabId) : activeYearningTabId).then(r => {
897
+ ws.send(JSON.stringify({ type: "result", reqId: msg.reqId || "", ok: r.ok, output: JSON.stringify({ via: r.via, error: r.error, info: r.info }, null, 1) }));
898
+ });
899
+ return;
900
+ }
901
+ if (msg.type === "yr-set") {
902
+ // 只注入 SQL 不点查询(用户手动点,配合 tap 探针收结果)
903
+ sendYrCmd("sql-set", msg.sql || "", msg.tabId != null ? Number(msg.tabId) : activeYearningTabId).then(r => {
904
+ ws.send(JSON.stringify({ type: "result", reqId: msg.reqId || "", ok: r.ok, output: JSON.stringify({ via: r.via, error: r.error }) }));
905
+ });
906
+ return;
907
+ }
908
+
909
+ // --- Agent 发来的 WS 监听(tap)请求:非终端页面(Yearning 等)的帧流 ---
910
+ if (msg.type === "tap-start") {
911
+ const urlIncludes = (msg.urlIncludes || "").toString();
912
+ if (!urlIncludes) {
913
+ ws.send(JSON.stringify({ type: "tap-error", error: "missing urlIncludes" }));
914
+ return;
915
+ }
916
+ const tapTabId = msg.tabId != null ? Number(msg.tabId) : null;
917
+ tapClients.set(ws, { urlIncludes, tabId: tapTabId });
918
+ console.log(TAG, `tap client 已注册 (urlIncludes=${urlIncludes}, tabId=${tapTabId}, total=${tapClients.size})`);
919
+ ws.send(JSON.stringify({ type: "tap-started", urlIncludes }));
920
+ return;
921
+ }
922
+ if (msg.type === "tap-stop") {
923
+ tapClients.delete(ws);
924
+ console.log(TAG, `tap client 已注销 (total=${tapClients.size})`);
925
+ ws.send(JSON.stringify({ type: "tap-stopped" }));
926
+ return;
927
+ }
928
+
657
929
  // --- 插件上报的 WS 帧 ---
658
930
  if (msg.type === "ws-recv") {
659
- handleWsRecv(msg.payload);
931
+ // 先喂 tap 通道(按 URL 过滤转发原始帧),再喂终端配对。
932
+ // 终端配对只吃终端特征的 URL(koko / arthas / 空 = 旧插件不带 url),
933
+ // 防止 tap 页面(Yearning JSON 帧)污染终端命令的输出配对。
934
+ broadcastTap(msg.payload);
935
+ const frameUrl = (msg.payload && msg.payload.url) || "";
936
+ const isTerminalUrl = !frameUrl || /\/koko\/ws|connectArthas/i.test(frameUrl);
937
+ if (isTerminalUrl) handleWsRecv(msg.payload);
660
938
  return;
661
939
  }
662
940
  if (msg.type === "ws-send") {
@@ -685,6 +963,9 @@ wss.on("connection", (ws, req) => {
685
963
 
686
964
  ws.on("close", () => {
687
965
  clients.delete(ws);
966
+ if (tapClients.delete(ws)) {
967
+ console.log(TAG, `tap client 已断开 (total=${tapClients.size})`);
968
+ }
688
969
  if (ws === extensionWs) {
689
970
  extensionWs = null;
690
971
  console.log(TAG, "extension 已断开");
@@ -0,0 +1,137 @@
1
+ // WS 监听(tap)探针客户端 —— 捕获非终端页面(Yearning 等)的 WebSocket 帧
2
+ //
3
+ // 用法:
4
+ // node tap-example.mjs <urlIncludes> [maxFrames] [maxSeconds] [--quiet] [--csv]
5
+ // node tap-example.mjs sql.meiyunji.net # 监听 URL 含该关键字的帧,直到 Ctrl+C
6
+ // node tap-example.mjs sql.meiyunji.net 20 60 # 收满 20 帧或 60 秒退出
7
+ // node tap-example.mjs sql.meiyunji.net 0 300 --quiet --csv # 只看有效帧并把每次查询结果存 CSV
8
+ //
9
+ // 工作原理:连本地代理发 tap-start,代理把插件 CDP 抓到的、URL 匹配的
10
+ // ws-recv 原始帧以 tap-frame 转发过来。opcode=2 的帧 data 是 base64,
11
+ // 本脚本自动解码后打印。配合插件 popup 的「📡 监听当前 Yearning 页」按钮使用。
12
+ // --csv:结果帧(results 数组)自动转 CSV 保存到当前目录(yearning-<时间戳>.csv,
13
+ // 多结果集会带 -1/-2 序号;带 BOM,Excel 直接打开中文不乱码)。
14
+
15
+ import WebSocket from "ws";
16
+ import { decode as msgpackDecode } from "@msgpack/msgpack";
17
+ import { writeFileSync } from "node:fs";
18
+ import { join } from "node:path";
19
+ import { cwd } from "node:process";
20
+
21
+ const BRIDGE = process.env.BRIDGE || "ws://127.0.0.1:8787/ssh";
22
+ const urlIncludes = process.argv[2] || "";
23
+ const maxFrames = Number(process.argv[3] || 0); // 0 = 不限
24
+ const maxSeconds = Number(process.argv[4] || 0); // 0 = 不限
25
+ const quiet = process.argv.includes("--quiet"); // 跳过心跳帧,只打印有效负载
26
+ const csvExport = process.argv.includes("--csv"); // 结果帧自动保存 CSV
27
+
28
+ // CSV cell 转义:含逗号/引号/换行的值加引号,内部引号翻倍(RFC 4180)
29
+ function csvCell(value) {
30
+ if (value == null) return "";
31
+ const s = String(value);
32
+ if (/[",\r\n]/.test(s)) return '"' + s.replace(/"/g, '""') + '"';
33
+ return s;
34
+ }
35
+
36
+ if (!urlIncludes) {
37
+ console.error("用法: node tap-example.mjs <urlIncludes> [maxFrames] [maxSeconds]");
38
+ process.exit(1);
39
+ }
40
+
41
+ const ws = new WebSocket(BRIDGE);
42
+ let frames = 0;
43
+ let bytes = 0;
44
+
45
+ ws.on("open", () => {
46
+ console.log(`→ tap-start (urlIncludes=${urlIncludes})`);
47
+ console.log(" 请在浏览器目标页面操作(如执行 SQL 查询),帧将实时打印:\n");
48
+ ws.send(JSON.stringify({ type: "tap-start", urlIncludes }));
49
+ });
50
+
51
+ ws.on("message", (raw) => {
52
+ let msg;
53
+ try { msg = JSON.parse(raw.toString()); } catch { return; }
54
+
55
+ if (msg.type === "tap-started") {
56
+ console.log(`✓ 监听已建立(${msg.urlIncludes})\n`);
57
+ return;
58
+ }
59
+ if (msg.type === "tap-error") {
60
+ console.error("✗ tap-error:", msg.error);
61
+ process.exit(1);
62
+ }
63
+ if (msg.type !== "tap-frame") return;
64
+
65
+ let text = msg.data || "";
66
+ if (msg.opcode === 2 && text) {
67
+ // 二进制帧:CDP 给的是 base64。解码后若像 MessagePack(map/array/str 头)
68
+ // 自动 msgpack 解码成 JSON 展示(Yearning 走 msgpack 二进制帧)
69
+ const buf = Buffer.from(text, "base64");
70
+ const first = buf[0];
71
+ const looksMsgpack =
72
+ (first >= 0x80 && first <= 0x8f) || first === 0xde || first === 0xdf ||
73
+ first === 0x91 || (first >= 0x90 && first <= 0x9f);
74
+ if (looksMsgpack) {
75
+ try {
76
+ text = JSON.stringify(msgpackDecode(buf), null, 1);
77
+ } catch {
78
+ text = buf.toString("utf8"); // 解码失败退回原文(乱码但可见)
79
+ }
80
+ } else {
81
+ text = buf.toString("utf8");
82
+ }
83
+ }
84
+
85
+ // --quiet:跳过纯心跳帧(results 为 null 且 heartbeat=pong),只打印有效负载帧
86
+ if (quiet && text.includes('"results": null')) return;
87
+
88
+ // CSV 导出:结果帧(results 数组)自动落盘。
89
+ // 每个 result 元素是一张表(field=列定义 data=数据行),多表写多个文件。
90
+ if (csvExport) {
91
+ let obj = null;
92
+ try { obj = JSON.parse(text); } catch {}
93
+ if (obj && Array.isArray(obj.results) && obj.results.length > 0) {
94
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
95
+ obj.results.forEach((table, idx) => {
96
+ if (!table || !Array.isArray(table.field)) return;
97
+ const headers = table.field.map(f => csvCell(f.title || f.dataIndex || ""));
98
+ const rows = (table.data || []).map(row =>
99
+ table.field.map(f => csvCell(row[f.dataIndex])).join(",")
100
+ );
101
+ const csv = "\uFEFF" + [headers.join(","), ...rows].join("\r\n") + "\r\n";
102
+ const file = join(cwd(), `yearning-${stamp}${obj.results.length > 1 ? "-" + (idx + 1) : ""}.csv`);
103
+ writeFileSync(file, csv, "utf8");
104
+ console.log(`💾 CSV 已保存: ${file}(${rows.length} 行)`);
105
+ });
106
+ }
107
+ }
108
+
109
+ frames++;
110
+ bytes += text.length;
111
+
112
+ console.log(`--- frame #${frames} [${new Date(msg.t || Date.now()).toISOString()}] opcode=${msg.opcode} len=${text.length} url=${msg.url || "(unknown)"} ---`);
113
+ console.log(text.length > 6000 ? text.slice(0, 6000) + `\n...[截断,共 ${text.length} 字符]` : text);
114
+ console.log("");
115
+
116
+ if (maxFrames > 0 && frames >= maxFrames) {
117
+ console.log(`✓ 已收满 ${maxFrames} 帧(共 ${bytes} 字符),退出`);
118
+ process.exit(0);
119
+ }
120
+ });
121
+
122
+ ws.on("error", (err) => {
123
+ console.error("✗ ws error:", err.message);
124
+ process.exit(1);
125
+ });
126
+
127
+ if (maxSeconds > 0) {
128
+ setTimeout(() => {
129
+ console.log(`✓ 到达 ${maxSeconds}s 时限(收帧 ${frames} 个 / ${bytes} 字符),退出`);
130
+ process.exit(0);
131
+ }, maxSeconds * 1000);
132
+ }
133
+
134
+ process.on("SIGINT", () => {
135
+ console.log(`\n✓ 手动停止(收帧 ${frames} 个 / ${bytes} 字符)`);
136
+ process.exit(0);
137
+ });
@@ -0,0 +1,110 @@
1
+ // Yearning SQL 自动化客户端
2
+ //
3
+ // 用法:
4
+ // node yr-example.mjs ping # 探测编辑器/按钮
5
+ // node yr-example.mjs "SELECT 1" # 执行查询(注入+点查询+收结果)
6
+ // node yr-example.mjs "SELECT ..." 30000 # 指定超时 ms
7
+ // node yr-example.mjs "SELECT ..." 60000 --csv # 结果另存 CSV(当前目录)
8
+ //
9
+ // 多页面时用环境变量指定目标 tab:YEARNING_TAB_ID=123 node yr-example.mjs "..."
10
+ // 不指定时使用 popup「Yearning 监听」列表中选中的页面。
11
+ //
12
+ // 前置:Yearning 页面已开 + popup 已点「📡 监听当前 Yearning 页」;多页面时先选中目标 tab
13
+
14
+ import WebSocket from "ws";
15
+ import { randomBytes } from "node:crypto";
16
+ import { writeFileSync } from "node:fs";
17
+ import { join } from "node:path";
18
+ import { cwd } from "node:process";
19
+
20
+ const BRIDGE = process.env.BRIDGE || "ws://127.0.0.1:8787/ssh";
21
+ const selectedTabId = process.env.YEARNING_TAB_ID ? Number(process.env.YEARNING_TAB_ID) : undefined;
22
+
23
+ function yrRun(msg, timeoutMs) {
24
+ return new Promise((resolve) => {
25
+ const ws = new WebSocket(BRIDGE);
26
+ const reqId = randomBytes(4).toString("hex");
27
+ let settled = false;
28
+ const finish = (r) => {
29
+ if (settled) return;
30
+ settled = true;
31
+ try { ws.close(); } catch {}
32
+ resolve(r);
33
+ };
34
+ const fallback = setTimeout(
35
+ () => finish({ ok: false, error: "client timeout" }),
36
+ timeoutMs + 5000
37
+ );
38
+ ws.on("open", () => ws.send(JSON.stringify({ type: "yr-run", reqId, tabId: selectedTabId, ...msg })));
39
+ ws.on("message", (raw) => {
40
+ let m;
41
+ try { m = JSON.parse(raw.toString()); } catch { return; }
42
+ if (m.type === "result" && m.reqId === reqId) {
43
+ clearTimeout(fallback);
44
+ finish({ ok: !!m.ok, output: m.output, error: m.error, message: m.message, elapsedMs: m.elapsedMs });
45
+ }
46
+ });
47
+ ws.on("error", (err) => { clearTimeout(fallback); finish({ ok: false, error: "ws error: " + err.message }); });
48
+ });
49
+ }
50
+
51
+ // CSV cell 转义:含逗号/引号/换行的值加引号,内部引号翻倍(RFC 4180)
52
+ function csvCell(value) {
53
+ if (value == null) return "";
54
+ const s = String(value);
55
+ if (/[",\r\n]/.test(s)) return '"' + s.replace(/"/g, '""') + '"';
56
+ return s;
57
+ }
58
+
59
+ // 结果 JSON(yr-run output)转 CSV 文件。多结果集写多个文件。
60
+ function saveResultCsv(jsonText) {
61
+ let obj;
62
+ try { obj = JSON.parse(jsonText); } catch { return; }
63
+ if (!Array.isArray(obj.results)) return;
64
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
65
+ obj.results.forEach((table, idx) => {
66
+ if (!table || !Array.isArray(table.field)) return;
67
+ const headers = table.field.map(f => csvCell(f.title || f.dataIndex || ""));
68
+ const rows = (table.data || []).map(row =>
69
+ table.field.map(f => csvCell(row[f.dataIndex])).join(",")
70
+ );
71
+ const csv = "\uFEFF" + [headers.join(","), ...rows].join("\r\n") + "\r\n";
72
+ const file = join(cwd(), `yearning-${stamp}${obj.results.length > 1 ? "-" + (idx + 1) : ""}.csv`);
73
+ writeFileSync(file, csv, "utf8");
74
+ console.log(`💾 CSV 已保存: ${file}(${rows.length} 行)`);
75
+ });
76
+ }
77
+
78
+ const mode = process.argv[2] || "ping";
79
+ const csvExport = process.argv.includes("--csv");
80
+
81
+ if (mode === "ping") {
82
+ const ws = new WebSocket(BRIDGE);
83
+ ws.on("open", () => ws.send(JSON.stringify({ type: "yr-ping", reqId: randomBytes(4).toString("hex"), tabId: selectedTabId })));
84
+ ws.on("message", (raw) => {
85
+ let m;
86
+ try { m = JSON.parse(raw.toString()); } catch { process.exit(1); }
87
+ if (m.type !== "result") return;
88
+ console.log(m.ok ? "✓ ping ok" : "✗ ping failed");
89
+ console.log(m.output || m.error || "");
90
+ process.exit(m.ok ? 0 : 1);
91
+ });
92
+ ws.on("error", (e) => { console.error("✗", e.message); process.exit(1); });
93
+ setTimeout(() => { console.error("✗ timeout"); process.exit(1); }, 8000);
94
+ } else {
95
+ const cliArgs = process.argv.slice(2).filter(a => a !== "--csv");
96
+ const sql = cliArgs[0];
97
+ const timeoutMs = Number(cliArgs[1] || 60000);
98
+ console.log(`→ yr-run${selectedTabId ? ` [tab ${selectedTabId}]` : ""}: ${sql.slice(0, 100)}`);
99
+ const r = await yrRun({ sql, timeoutMs }, timeoutMs);
100
+ if (r.ok) {
101
+ console.log(`✓ ok (${r.elapsedMs}ms)`);
102
+ if (csvExport) saveResultCsv(r.output);
103
+ console.log(r.output);
104
+ } else {
105
+ console.error(`✗ failed: ${r.error}`);
106
+ if (r.message) console.error(r.message);
107
+ if (r.output) console.log(r.output);
108
+ process.exit(1);
109
+ }
110
+ }
@@ -100,6 +100,8 @@ cd ~/.terminal-bridge/proxy && node server.js &
100
100
 
101
101
  多终端场景:如果同时开了 JumpServer 和 Arthas 两个 tab,代理命令只会发给 popup 里"当前选中"的那个 tab。切换用 popup 的 tab 选择器,或让用户在 popup 点选。
102
102
 
103
+ Yearning 场景:popup 的「Yearning 监听」会列出所有已监听页面,并显示数据库名、数据源、host 和当前标记。多个 Yearning 页面同时打开时,必须先点击列表项选择「当前 Yearning 页面」,SQL 只会注入该页面,结果也按 tab 隔离,不会串页。
104
+
103
105
  如果用户说"命令没反应"或"inject-failed":
104
106
  1. `chrome://extensions/` 刷新 Terminal Bridge 插件 ↻
105
107
  2. 让用户在终端页面按 F5 刷新(让 content script 重新识别 xterm)
@@ -13,7 +13,28 @@
13
13
  - **插件(Chrome extension background)**:唯一,连上后发 `hello{role:extension}` 声明身份。负责上报 WS 帧、接收注入指令。
14
14
  - **Agent**:可有多个。发 `run` 请求,收 `result` 响应。
15
15
 
16
- ## 完整消息列表
16
+ ## Yearning 多页面监听
17
+
18
+ Yearning 页面必须先在 popup 的「Yearning 监听」列表中监听并选择一个页面。
19
+ 每个监听项包含页面标题、数据库名/数据源、host 和 tabId;`✓ 当前 Yearning 页面`
20
+ 表示 SQL 自动化的目标页面,`● 当前浏览器页`表示用户当前正在看的 tab。
21
+
22
+ - 多个 Yearning 页面可以同时监听,但 SQL 只会注入到选中的 active tab。
23
+ - 结果帧带 `tabId`,代理只消费与目标 tab 相同的结果,避免多个页面串结果。
24
+ - 手动客户端可通过环境变量显式指定:`YEARNING_TAB_ID=123 node yr-example.mjs "show index from t_dk_message__8;"`
25
+ - `yr-run` 未传 `tabId` 时使用 popup 当前选中的 Yearning 页面。
26
+
27
+ ### Yearning 自动化消息
28
+
29
+ ```json
30
+ { "type": "yr-run", "reqId": "abc123", "sql": "show index from t_dk_message__8;", "timeoutMs": 60000, "tabId": 123 }
31
+ ```
32
+
33
+ `yr-run` 会注入 SQL、点击 Yearning 的「查 询」按钮,并等待该 tab 的 MessagePack
34
+ WebSocket 结果帧。Yearning 结果帧是 opcode=2 二进制帧,解码后通常为
35
+ `{ export, error, results, query_time, status, heartbeat, is_only }`。
36
+
37
+ ### 完整消息列表
17
38
 
18
39
  ### Agent → 代理
19
40
 
@@ -63,9 +84,12 @@
63
84
  }
64
85
  ```
65
86
  - `output`:prompt 锚点出现前的所有 recv 帧拼接,已去 ANSI、`\r\n`→`\n`、
66
- 控制字符(NUL 等)替换为空格、删 prompt 行和命令回显行(折行感知)、首尾 trim。
67
- **空输出兜底**:若清理后为空但原始帧有内容(≥512 字符),返回截断的原始内容
68
- 并附 `[warning] output filtered to empty...` 提示——绝不返回 ok:true + 空输出。
87
+ 控制字符(NUL 等)替换为空格、删 prompt 行和命令回显行(折行感知)、
88
+ 剥离行尾 prompt 后缀(无尾换行输出与 prompt 合并形态)、首尾 trim。
89
+ prompt 段识别带长度约束(user/host ≤64 字符),防止截断 JSON `[`
90
+ 被当作 prompt 起点跨吞整行(Case A 根因)。
91
+ **双层兜底**:清理后为空或清理损失率 >80%(原始 ≥512 字符)时,附加
92
+ `[warning]` + 截断原始内容——绝不静默丢弃大段输出。
69
93
  - `unterminated-quote`:命令含未闭合引号,远端 shell 卡在 PS2 续行(`>` 提示)。
70
94
  代理检测到后 ~400ms 快速失败并自动 Ctrl+C 退出续行(终端可继续用),
71
95
  `suggest` 会指向 base64 通道。