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.
- package/README.md +4 -0
- package/files/extension/background.js +324 -11
- package/files/extension/content-yearning-main.js +56 -0
- package/files/extension/content-yearning.js +307 -0
- package/files/extension/manifest.json +14 -1
- package/files/extension/popup.html +29 -0
- package/files/extension/popup.js +92 -1
- package/files/proxy/package.json +1 -0
- package/files/proxy/server.js +297 -16
- package/files/proxy/tap-example.mjs +137 -0
- package/files/proxy/yr-example.mjs +110 -0
- package/files/skill/SKILL.md +2 -0
- package/files/skill/references/protocol.md +28 -4
- package/package.json +1 -1
package/files/proxy/server.js
CHANGED
|
@@ -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
|
-
//
|
|
542
|
-
//
|
|
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]
|
|
547
|
-
if (/^.{0,40}[\w.-]
|
|
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
|
-
//
|
|
597
|
-
//
|
|
598
|
-
//
|
|
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
|
|
604
|
-
|
|
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
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/files/skill/SKILL.md
CHANGED
|
@@ -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
|
|
67
|
-
|
|
68
|
-
|
|
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 通道。
|