terminal-bridge-setup 3.2.0 → 3.3.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.
@@ -23,6 +23,9 @@ import {
23
23
  auditArthasCommand,
24
24
  isArthasCommand,
25
25
  } from "./arthas-guard.js";
26
+ import {
27
+ validateReadonlySql,
28
+ } from "./yr-sql-guard.js";
26
29
 
27
30
  const PORT = Number(process.env.PORT || 8787);
28
31
  const HOST = process.env.HOST || "127.0.0.1";
@@ -80,6 +83,7 @@ function broadcastTap(payload) {
80
83
  url,
81
84
  data: payload.data,
82
85
  opcode: payload.opcode,
86
+ dir: payload.dir, // sent=上行(客户端→服务器)/recv=下行,探针观测口
83
87
  t: payload.t
84
88
  }));
85
89
  } catch {}
@@ -95,18 +99,27 @@ function broadcastTap(payload) {
95
99
  const yrCmdWaiters = new Map(); // reqId -> resolve
96
100
  const yrRunWaiters = new Set(); // { tryConsume(payload) -> boolean }
97
101
 
98
- function sendYrCmd(sub, sql, tabId) {
102
+ // sub 配对超时:db-select/source-switch 含点击+枚举+验证的多轮往返,放宽到 25s
103
+ const YR_CMD_TIMEOUT_MS = {
104
+ "db-select": 25000,
105
+ "source-switch": 25000,
106
+ };
107
+ const DEFAULT_YR_CMD_TIMEOUT_MS = 5000;
108
+
109
+ function sendYrCmd(sub, sql, tabId, extra = {}) {
99
110
  const reqId = genReqId();
111
+ const timeoutMs = YR_CMD_TIMEOUT_MS[sub] || DEFAULT_YR_CMD_TIMEOUT_MS;
100
112
  return new Promise((resolve) => {
101
113
  const timer = setTimeout(() => {
102
114
  yrCmdWaiters.delete(reqId);
103
115
  resolve({ ok: false, error: `yr-cmd ${sub} timeout` });
104
- }, 5000);
116
+ }, timeoutMs);
105
117
  yrCmdWaiters.set(reqId, (res) => {
106
118
  clearTimeout(timer);
107
119
  resolve(res);
108
120
  });
109
- const ok = sendToExtension({ type: "yr-cmd", sub, sql, reqId, tabId });
121
+ // sub=new-sql/db-select/source-switch sql 字段承载目标参数(库名/源名)
122
+ const ok = sendToExtension({ type: "yr-cmd", sub, sql, reqId, tabId, ...extra });
110
123
  if (!ok) {
111
124
  yrCmdWaiters.delete(reqId);
112
125
  clearTimeout(timer);
@@ -115,12 +128,15 @@ function sendYrCmd(sub, sql, tabId) {
115
128
  });
116
129
  }
117
130
 
118
- // 插件回的 yr-result:唤醒对应的 yr-cmd 等待者
131
+ // 插件回的 yr-result:唤醒对应的 yr-cmd 等待者。
132
+ // 控制字段外的全部业务负载(editor/buttons/sourceEntry/selects/hash 等)原样下发,
133
+ // 让编排层与探测输出拿到完整的页面回执。
119
134
  function handleYrResult(msg) {
120
135
  const waiter = yrCmdWaiters.get(msg.reqId);
121
136
  if (waiter) {
122
137
  yrCmdWaiters.delete(msg.reqId);
123
- waiter({ ok: !!msg.ok, via: msg.via, error: msg.error, info: msg.editor || msg.buttons });
138
+ const { type, reqId, tabId, ...payload } = msg;
139
+ waiter(payload);
124
140
  }
125
141
  }
126
142
 
@@ -174,21 +190,56 @@ async function handleYrRun(ws, msg) {
174
190
  const reqId = msg.reqId || genReqId();
175
191
  const sql = (msg.sql || "").toString();
176
192
  const tabId = msg.tabId != null ? Number(msg.tabId) : activeYearningTabId;
177
- const timeoutMs = Math.min(Number(msg.timeoutMs || 60000), 300000);
193
+ const autoQuery = msg.autoQuery !== false; // 默认自动点查询(兼容旧客户端)
194
+ const prepareMode = !autoQuery;
195
+ // prepare 模式等的是人点「查 询」,时限放宽;普通模式维持原上限
196
+ const timeoutCap = prepareMode ? 1800000 : 300000;
197
+ const timeoutDefault = prepareMode ? 600000 : 60000;
198
+ const timeoutMs = Math.min(Number(msg.timeoutMs || timeoutDefault), timeoutCap);
178
199
  if (!sql.trim()) {
179
200
  ws.send(JSON.stringify({ type: "result", reqId, ok: false, error: "empty sql" }));
180
201
  return;
181
202
  }
203
+ // 只读白名单:写操作在此拒绝,不进页面
204
+ const guard = validateReadonlySql(sql);
205
+ if (!guard.ok) {
206
+ console.warn(TAG, `[yr-run ${reqId}] SQL 白名单拦截: ${guard.error}`);
207
+ ws.send(JSON.stringify({ type: "result", reqId, ok: false, error: guard.error, message: guard.message }));
208
+ return;
209
+ }
182
210
 
183
- // 结果帧等待者:收到第一个 results 非空帧即完成
211
+ // 编排失败快速返回:清理 waiter 后回错误
184
212
  let settled = false;
213
+ let timer = null;
214
+ const failFast = (error, message) => {
215
+ if (settled) return true;
216
+ settled = true;
217
+ yrRunWaiters.delete(runEntry);
218
+ if (timer) clearTimeout(timer);
219
+ ws.send(JSON.stringify({ type: "result", reqId, ok: false, error, message }));
220
+ return true;
221
+ };
222
+
223
+ // 结果帧等待者:收到第一个 results 非空帧即完成
224
+ const sentAt = Date.now();
185
225
  const runEntry = {
186
226
  tabId,
187
- sentAt: Date.now(),
227
+ ws,
228
+ sentAt,
229
+ abort: () => {
230
+ // 客户端断开时由 ws close 调用:停定时器并摘除,防死 waiter 残留
231
+ if (settled) return;
232
+ settled = true;
233
+ clearTimeout(timer);
234
+ yrRunWaiters.delete(runEntry);
235
+ },
188
236
  tryConsume: (obj) => {
189
- if (settled) return true;
237
+ // 已完成的 waiter 必须返回 false 放行——返回 true 会"假消费",
238
+ // 把后续新 waiter 的结果帧截走饿到超时(死客户端残留时实测踩过)
239
+ if (settled) return false;
190
240
  settled = true;
191
241
  yrRunWaiters.delete(runEntry);
242
+ clearTimeout(timer);
192
243
  console.log(TAG, `[yr-run ${reqId}] 结果帧到达(query_time=${obj.query_time ?? "?"})`);
193
244
  // 同步发给插件:浏览器侧生成 CSV 落下载(popup 可见、可重新下载)
194
245
  sendToExtension({
@@ -204,54 +255,80 @@ async function handleYrRun(ws, msg) {
204
255
  ws.send(JSON.stringify({
205
256
  type: "result", reqId, ok: true,
206
257
  output: JSON.stringify(obj),
207
- elapsedMs: Date.now() - runEntry.sentAt,
258
+ elapsedMs: Date.now() - sentAt,
208
259
  }));
209
260
  return true;
210
261
  },
211
262
  };
212
263
  yrRunWaiters.add(runEntry);
213
264
 
214
- const timer = setTimeout(() => {
265
+ timer = setTimeout(() => {
215
266
  if (settled) return;
216
267
  settled = true;
217
268
  yrRunWaiters.delete(runEntry);
218
269
  ws.send(JSON.stringify({
219
270
  type: "result", reqId, ok: false, error: "timeout",
220
- message: "Yearning 查询未在时限内返回结果帧(确认页面已点「监听当前页 WS」且查询能正常执行)",
221
- elapsedMs: Date.now() - runEntry.sentAt,
271
+ message: prepareMode
272
+ ? "prepare 模式超时:仍在等待用户点「查 询」后产生的结果帧"
273
+ : "Yearning 查询未在时限内返回结果帧(确认页面已点「监听当前页 WS」且查询能正常执行)",
274
+ elapsedMs: Date.now() - sentAt,
222
275
  }));
223
276
  }, timeoutMs);
224
277
 
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 编辑器失败" }));
278
+ // 1. 可选:切数据源(目标名经由 yr-cmd 的 sql 字段携带)
279
+ if (msg.source) {
280
+ const srcRes = await sendYrCmd("source-switch", String(msg.source), tabId);
281
+ if (!srcRes.ok && !failFast(
282
+ "source-switch failed: " + (srcRes.error || ""),
283
+ `切换数据源 ${msg.source} 失败;现场证据: ${JSON.stringify(srcRes).slice(0, 800)}`
284
+ )) {
285
+ return;
233
286
  }
287
+ console.log(TAG, `[yr-run ${reqId}] 已切数据源 → ${msg.source}(via ${srcRes.via})`);
288
+ }
289
+
290
+ // 2. 指定目标库时:先显式新建查询 tab 并把 schema 选好,
291
+ // 再注入 SQL(bg 对空库有 database-not-selected 预检,顺序不能反)
292
+ let skipNew = false;
293
+ if (msg.database) {
294
+ const newRes = await sendYrCmd("new-sql", "", tabId);
295
+ if (!newRes.ok) {
296
+ // 零 SQL tab 状态下页面没有「新建」按钮(实测清空 tab 后触发),
297
+ // 此时本就有现成编辑器——跳过新建直接注入,其余错误照旧快速失败
298
+ if (String(newRes.error || "").includes("新建按钮未找到")) {
299
+ console.warn(TAG, `[yr-run ${reqId}] 无「新建」按钮(零 tab 状态),使用当前编辑器`);
300
+ } else if (!failFast("new-sql failed: " + (newRes.error || ""), "新建查询 tab 失败")) {
301
+ return;
302
+ }
303
+ }
304
+ const dbRes = await sendYrCmd("db-select", String(msg.database), tabId);
305
+ if (!dbRes.ok && !failFast(
306
+ "db-select failed: " + (dbRes.error || ""),
307
+ `选择数据库 ${msg.database} 失败;现场证据: ${JSON.stringify(dbRes).slice(0, 800)}`
308
+ )) return;
309
+ console.log(TAG, `[yr-run ${reqId}] 已选库 → ${msg.database}`);
310
+ skipNew = true; // 已建过 tab(或零 tab 状态),sql-set 不再重复新建
311
+ }
312
+
313
+ // 3. 注入 SQL
314
+ const setRes = await sendYrCmd("sql-set", sql, tabId, { skipNew });
315
+ if (!setRes.ok && !failFast("sql-set failed: " + (setRes.error || ""), "SQL 注入 Yearning 编辑器失败")) {
234
316
  return;
235
317
  }
236
318
  console.log(TAG, `[yr-run ${reqId}] SQL 已注入(via ${setRes.via})`);
237
319
 
238
- // 2. 点「查询」
320
+ // 4. 点「查询」(autoQuery=false 时留给人点)
321
+ if (prepareMode) {
322
+ console.log(TAG, `[yr-run ${reqId}] prepare 完成,等待用户点「查 询」(timeout ${timeoutMs}ms)...`);
323
+ return;
324
+ }
239
325
  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
- }
326
+ if (!clickRes.ok && !failFast("query-click failed: " + (clickRes.error || ""),
327
+ "未找到「查询」按钮;页面按钮: " + JSON.stringify(clickRes.info || []).slice(0, 300))) {
251
328
  return;
252
329
  }
253
330
  console.log(TAG, `[yr-run ${reqId}] 已点「查询」(via ${clickRes.via}),等待结果帧...`);
254
- // 3. 结果帧由 feedYearningWaiters 消费(timer 兜底)
331
+ // 5. 结果帧由 feedYearningWaiters 消费(timer 兜底)
255
332
  }
256
333
 
257
334
  // ===================== 请求-响应配对 =====================
@@ -892,20 +969,33 @@ wss.on("connection", (ws, req) => {
892
969
  return;
893
970
  }
894
971
  if (msg.type === "yr-ping") {
895
- // 探测:编辑器类型 + 查询按钮(不执行任何操作)
972
+ // 探测:编辑器类型 + 查询按钮 + Select/切源入口/路由(不执行任何操作)
896
973
  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) }));
974
+ ws.send(JSON.stringify({ type: "result", reqId: msg.reqId || "", ok: r.ok, output: JSON.stringify(r, null, 1) }));
898
975
  });
899
976
  return;
900
977
  }
901
978
  if (msg.type === "yr-set") {
902
979
  // 只注入 SQL 不点查询(用户手动点,配合 tap 探针收结果)
980
+ const guard = validateReadonlySql(msg.sql || "");
981
+ if (!guard.ok) {
982
+ ws.send(JSON.stringify({ type: "result", reqId: msg.reqId || "", ok: false, error: guard.error, output: guard.message }));
983
+ return;
984
+ }
903
985
  sendYrCmd("sql-set", msg.sql || "", msg.tabId != null ? Number(msg.tabId) : activeYearningTabId).then(r => {
904
986
  ws.send(JSON.stringify({ type: "result", reqId: msg.reqId || "", ok: r.ok, output: JSON.stringify({ via: r.via, error: r.error }) }));
905
987
  });
906
988
  return;
907
989
  }
908
990
 
991
+ if (msg.type === "yr-dom-probe") {
992
+ // 只读 DOM 探测:Select/下拉/弹窗结构(诊断未知 UI 用)
993
+ sendYrCmd("dom-probe", String(msg.selector || ""), msg.tabId != null ? Number(msg.tabId) : activeYearningTabId).then(r => {
994
+ ws.send(JSON.stringify({ type: "result", reqId: msg.reqId || "", ok: r.ok, output: JSON.stringify(r, null, 1) }));
995
+ });
996
+ return;
997
+ }
998
+
909
999
  // --- Agent 发来的 WS 监听(tap)请求:非终端页面(Yearning 等)的帧流 ---
910
1000
  if (msg.type === "tap-start") {
911
1001
  const urlIncludes = (msg.urlIncludes || "").toString();
@@ -966,6 +1056,13 @@ wss.on("connection", (ws, req) => {
966
1056
  if (tapClients.delete(ws)) {
967
1057
  console.log(TAG, `tap client 已断开 (total=${tapClients.size})`);
968
1058
  }
1059
+ // 客户端断开即摘除其 yr-run 等待者,防止死 waiter 残留截走后续结果帧
1060
+ for (const waiter of [...yrRunWaiters]) {
1061
+ if (waiter.ws === ws) {
1062
+ waiter.abort();
1063
+ console.log(TAG, `[yr-run] 等待者随客户端断开移除 (剩余 ${yrRunWaiters.size})`);
1064
+ }
1065
+ }
969
1066
  if (ws === extensionWs) {
970
1067
  extensionWs = null;
971
1068
  console.log(TAG, "extension 已断开");
@@ -1,14 +1,19 @@
1
1
  // Yearning SQL 自动化客户端
2
2
  //
3
3
  // 用法:
4
- // node yr-example.mjs ping # 探测编辑器/按钮
4
+ // node yr-example.mjs ping # 探测编辑器/按钮/Select/切源入口
5
5
  // node yr-example.mjs "SELECT 1" # 执行查询(注入+点查询+收结果)
6
6
  // node yr-example.mjs "SELECT ..." 30000 # 指定超时 ms
7
7
  // node yr-example.mjs "SELECT ..." 60000 --csv # 结果另存 CSV(当前目录)
8
+ // node yr-example.mjs "SELECT ..." --prepare --db 库名 [--source 数据源名]
9
+ // # 只切源+建tab+选库+写SQL,不点查询;
10
+ // # 挂起等用户手动点「查 询」后收结果
8
11
  //
9
12
  // 多页面时用环境变量指定目标 tab:YEARNING_TAB_ID=123 node yr-example.mjs "..."
10
13
  // 不指定时使用 popup「Yearning 监听」列表中选中的页面。
11
14
  //
15
+ // 安全:代理侧只读白名单,仅放行 SELECT/SHOW/DESC/EXPLAIN;写语句一律拒绝。
16
+ //
12
17
  // 前置:Yearning 页面已开 + popup 已点「📡 监听当前 Yearning 页」;多页面时先选中目标 tab
13
18
 
14
19
  import WebSocket from "ws";
@@ -20,6 +25,11 @@ import { cwd } from "node:process";
20
25
  const BRIDGE = process.env.BRIDGE || "ws://127.0.0.1:8787/ssh";
21
26
  const selectedTabId = process.env.YEARNING_TAB_ID ? Number(process.env.YEARNING_TAB_ID) : undefined;
22
27
 
28
+ function flagValue(name, args) {
29
+ const i = args.indexOf(name);
30
+ return i >= 0 ? (args[i + 1] || "") : undefined;
31
+ }
32
+
23
33
  function yrRun(msg, timeoutMs) {
24
34
  return new Promise((resolve) => {
25
35
  const ws = new WebSocket(BRIDGE);
@@ -77,9 +87,23 @@ function saveResultCsv(jsonText) {
77
87
 
78
88
  const mode = process.argv[2] || "ping";
79
89
  const csvExport = process.argv.includes("--csv");
90
+ const cliSelector = process.argv.slice(3).find(a => !a.startsWith("-")) || "";
80
91
 
81
- if (mode === "ping") {
92
+ if (mode === "probe") {
93
+ // 只读 DOM 探测:node yr-example.mjs probe [selector]
82
94
  const ws = new WebSocket(BRIDGE);
95
+ ws.on("open", () => ws.send(JSON.stringify({ type: "yr-dom-probe", reqId: randomBytes(4).toString("hex"), tabId: selectedTabId, selector: cliSelector })));
96
+ ws.on("message", (raw) => {
97
+ let m;
98
+ try { m = JSON.parse(raw.toString()); } catch { process.exit(1); }
99
+ if (m.type !== "result") return;
100
+ console.log(m.ok ? "✓ probe ok" : "✗ probe failed");
101
+ console.log(m.output || m.error || "");
102
+ process.exit(m.ok ? 0 : 1);
103
+ });
104
+ ws.on("error", (e) => { console.error("✗", e.message); process.exit(1); });
105
+ setTimeout(() => { console.error("✗ timeout"); process.exit(1); }, 8000);
106
+ } else if (mode === "ping") { const ws = new WebSocket(BRIDGE);
83
107
  ws.on("open", () => ws.send(JSON.stringify({ type: "yr-ping", reqId: randomBytes(4).toString("hex"), tabId: selectedTabId })));
84
108
  ws.on("message", (raw) => {
85
109
  let m;
@@ -94,9 +118,21 @@ if (mode === "ping") {
94
118
  } else {
95
119
  const cliArgs = process.argv.slice(2).filter(a => a !== "--csv");
96
120
  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);
121
+ const prepare = cliArgs.includes("--prepare");
122
+ const database = flagValue("--db", cliArgs);
123
+ const source = flagValue("--source", cliArgs);
124
+ // 超时只认纯数字位置参数(第 2 个位置起),跳过 --db/--source 及其值
125
+ const numericArg = cliArgs.slice(1).find(a => /^\d+$/.test(a));
126
+ const timeoutMs = Number(numericArg || (prepare ? 600000 : 60000));
127
+ // prepare 模式默认等用户点查询,client 兜底超时要与等待时限同量级
128
+ if (prepare && !cliArgs[1]) {
129
+ console.log(`→ prepare 模式:最多等待 ${timeoutMs / 1000}s 用户点「查 询」`);
130
+ }
131
+ const runMsg = { sql, timeoutMs, autoQuery: !prepare };
132
+ if (database) runMsg.database = database;
133
+ if (source) runMsg.source = source;
134
+ console.log(`→ yr-run${prepare ? " [prepare]" : ""}${database ? ` [db ${database}]` : ""}${source ? ` [source ${source}]` : ""}${selectedTabId ? ` [tab ${selectedTabId}]` : ""}: ${sql.slice(0, 100)}`);
135
+ const r = await yrRun(runMsg, timeoutMs);
100
136
  if (r.ok) {
101
137
  console.log(`✓ ok (${r.elapsedMs}ms)`);
102
138
  if (csvExport) saveResultCsv(r.output);
@@ -0,0 +1,43 @@
1
+ // Yearning SQL 只读白名单(纯函数,供 server.js 与测试脚本共用)
2
+ //
3
+ // 红线:Yearning 通道只允许只读查询。剥离注释后按分号拆条,逐条校验首动词。
4
+ // 校验放在代理层,yr-run / yr-set / 编排模式全覆盖;不依赖页面端兜底。
5
+
6
+ // SELECT 前缀下的写副作用例外:INTO OUTFILE/DUMPFILE 写数据库服务器文件、FOR UPDATE 锁行
7
+ const HIDDEN_WRITE_RE = /\binto\s+(outfile|dumpfile)\b|\bfor\s+update\b/i;
8
+
9
+ export function stripSqlComments(sql) {
10
+ return String(sql || "")
11
+ .replace(/\/\*[\s\S]*?\*\//g, " ") // 块注释
12
+ .replace(/--[^\n]*/g, " ") // 行注释
13
+ .replace(/#[^\n]*/g, " "); // MySQL # 注释
14
+ }
15
+
16
+ export function validateReadonlySql(sql) {
17
+ const statements = stripSqlComments(sql)
18
+ .split(";")
19
+ .map(s => s.trim())
20
+ .filter(s => s.length > 0);
21
+ if (statements.length === 0) {
22
+ return { ok: false, error: "empty-sql", message: "SQL 为空" };
23
+ }
24
+ for (const stmt of statements) {
25
+ const verb = (stmt.match(/^\S+/) || [""])[0];
26
+ const verbUpper = verb.toUpperCase();
27
+ if (!["SELECT", "SHOW", "DESC", "DESCRIBE", "EXPLAIN"].includes(verbUpper)) {
28
+ return {
29
+ ok: false,
30
+ error: "write-forbidden",
31
+ message: `非只读语句被拦截(${verb})。Yearning 通道仅允许 SELECT/SHOW/DESC/EXPLAIN。`,
32
+ };
33
+ }
34
+ if (HIDDEN_WRITE_RE.test(stmt)) {
35
+ return {
36
+ ok: false,
37
+ error: "write-forbidden",
38
+ message: "SELECT 中含 INTO OUTFILE/DUMPFILE 或 FOR UPDATE 等副作用子句,被拦截。",
39
+ };
40
+ }
41
+ }
42
+ return { ok: true, statementCount: statements.length };
43
+ }
@@ -50,6 +50,19 @@ Arthas 的能力不止于读,以下命令/用法一律禁止通过桥接执行
50
50
 
51
51
  **Arthas 命令的默认判定**:若命令语法同时支持读和写(如 `vmoption`、`logger`、`ognl`),**默认视为写操作禁止**,只有当且仅当命令形态确认是只读时才允许。
52
52
 
53
+ ### Yearning 侧——禁止清单
54
+
55
+ | 类别 | 禁止的 SQL/操作 | 为什么 |
56
+ |------|----------------|--------|
57
+ | 数据写 | `INSERT` / `UPDATE` / `DELETE` / `REPLACE` / `MERGE` | 改业务数据 |
58
+ | 结构写 | `CREATE` / `ALTER` / `DROP` / `TRUNCATE` / `RENAME` | 改表结构 |
59
+ | 事务/锁 | `SET`、`USE`、`LOCK`、`SELECT ... FOR UPDATE` | 改会话状态或锁行 |
60
+ | 服务端副作用 | `SELECT ... INTO OUTFILE/DUMPFILE`、`LOAD_FILE()` | 在数据库服务器上读写文件 |
61
+
62
+ 代理已内置只读白名单(仅放行 SELECT/SHOW/DESC/EXPLAIN),写语句会在代理层直接
63
+ 返回 `error:"write-forbidden"`。Agent 收到该错误不要换写法绕过(注释混淆、拆分
64
+ 语句等),直接走「用户手动执行」路径。
65
+
53
66
  ### 当用户要求写操作时——标准应对
54
67
 
55
68
  1. **不要执行,也不要"先执行再看看"**。直接拒绝并说明:桥接是只读诊断通道,写操作需手动。
@@ -230,6 +243,66 @@ Arthas 直接挂在线上 JVM 上,命令不当会拖垮服务。代理内置
230
243
  | `ARTHAS_MAX_MEDIUM` | 20 | 中风险命令会话内最大次数 |
231
244
  | `ARTHAS_AUTO_PATCH` | 1 | 中风险命令缺限制时:1=自动补 `-n 1`,0=拒绝让 Agent 显式补 |
232
245
 
246
+ ## Yearning SQL 查询与「备查」协作模式
247
+
248
+ Yearning(sql.meiyunji.net)页面在 popup「Yearning 监听」里监听并选中后,Agent 可以查数据:
249
+
250
+ ```bash
251
+ cd ~/.terminal-bridge/proxy
252
+
253
+ # 自动执行:注入 + 点查询 + 收结果
254
+ node yr-example.mjs "show index from t_dk_message__8;" 30000 --csv
255
+ # 指定库/数据源:先切源 → 新建查询 tab → 选 schema → 注入 → 点查询
256
+ node yr-example.mjs "select * from t_order limit 10;" --db dk_order_3 --source dk-test-3 --csv
257
+ # 诊断页面状态(只读):编辑器/按钮/Select/切源入口/路由
258
+ node yr-example.mjs ping
259
+ node yr-example.mjs probe
260
+ ```
261
+
262
+ ### 已知环境(实测 2026-08)
263
+
264
+ - **数据源清单**(「切换数据源」弹层全量):`dk-shard-0..7-tdsql-c`、`dk-prod-1-tdsql-c`、`dk-doris`、`dk-chat-1`、`dk-livechat-1`
265
+ - **中心库**:`dk-prod-1-tdsql-c` 的中心库是 `dk1`(其余为 xxl_job 调度库等);分库分表源(dk-shard-*)内是 `dk_shard`
266
+ - 用户说"xxx 分库"通常指某个 dk-shard-N 源;说"中心库/主库"通常指 dk-prod-1 的 dk1
267
+
268
+ ### 名称不确定时:假名探测(零副作用)
269
+
270
+ 源名/库名拿不准时,用假名跑一次,失败报告会带回**全量候选清单**(弹层会自动关闭):
271
+
272
+ ```bash
273
+ node yr-example.mjs "select 1;" --source zz_probe --db zz_probe --prepare
274
+ # db-select failed: 下拉中无匹配的库 ... "options":["information_schema","dk1","xxl_job","xxl_job_prod2"]
275
+ ```
276
+
277
+ 拿到清单后按语义选目标(如中心库=dk1),再用真名重跑。
278
+
279
+ ### prepare 模式:AI 备查,人点查询(推荐协作流)
280
+
281
+ 用户说"帮我查 xxx 库下 xxx 表的数据"时:
282
+
283
+ 1. **Agent 备好一切但不执行**:
284
+ ```bash
285
+ node yr-example.mjs "select ... from t_shop where puid=... limit 20;" --source dk-prod-1-tdsql-c --db dk1 --prepare
286
+ ```
287
+ 命令依次:切数据源(已在目标源时自动跳过)→ 新建查询 tab(零 tab 状态自动降级用现有编辑器)→
288
+ antd 下拉选 schema → CDP 注入 SQL → **不点查询**,挂起等待
289
+ (默认最长 10 分钟,`--prepare` 后可再传超时 ms,上限 30 分钟)。
290
+ 2. **告知用户**:「SQL 已就绪,请在 Yearning 页面点击『查 询』」。
291
+ 3. **阻塞等待结果帧**。用户点查询后结果自动回传(同一条命令的 stdout 返回 JSON),
292
+ CSV 同时进 popup 列表;期间可随时向用户同步进度。
293
+ 4. **兜底**:若等待超时/连接断开,结果也会以 CSV 形式落在 popup 列表
294
+ (`~/Downloads/yearning-csv/`),可用 popup 的复制按钮拿到读文件提示词继续干活。
295
+
296
+ 编排失败快速返回明确错误:`source-switch failed`(含候选清单)、`db-select failed`
297
+ (含 options 清单)、`write-forbidden`(只读白名单拦截)、`no tap tab`(需 popup 重新监听选中)。
298
+
299
+ ### 运维注意
300
+
301
+ - **代理重启会丢内存态**(active Yearning tab 等):重启后让用户在 popup 重新点选监听页
302
+ - **切源是页面路由切换**(hash 变化),CDP attach 不受影响,tap 监听持续有效
303
+ - **改插件代码后**必须 `chrome://extensions ↻` **再 F5 页面**再 popup 重新选中,
304
+ 顺序不能反;popup 标题旁的版本号可快速确认加载的是否最新代码
305
+
233
306
  ## 消息格式
234
307
 
235
308
  Agent → 代理:
@@ -34,6 +34,33 @@ Yearning 页面必须先在 popup 的「Yearning 监听」列表中监听并选
34
34
  WebSocket 结果帧。Yearning 结果帧是 opcode=2 二进制帧,解码后通常为
35
35
  `{ export, error, results, query_time, status, heartbeat, is_only }`。
36
36
 
37
+ 可选编排参数(执行顺序:切源 → 建查询 tab → 选库 → 注入 SQL → 点查询):
38
+
39
+ - `source`:目标数据源名。幂等:已在目标源(URL hash 一致)时直接成功不重复点击。
40
+ 名称不确定时可用假名探测,失败报告带回全量候选。已验证清单:
41
+ dk-shard-0..7-tdsql-c / dk-prod-1-tdsql-c / dk-doris / dk-chat-1 / dk-livechat-1。
42
+ - `database`:目标库(schema)。显式新建查询 tab 后打开 antd Select 下拉点选,
43
+ 以重读 meta 的 database 值验证。零 SQL tab 状态自动降级为使用现有编辑器。
44
+ 同样支持假名探测(失败带回 options 清单)。
45
+ - `autoQuery`:默认 `true`。设为 `false` 进入 **prepare 模式**——写好 SQL 后
46
+ 不点查询,挂起等待用户手动点「查 询」产生的结果帧;此模式超时默认 600s、
47
+ 上限 1800s。适合「AI 备查、人点查询、结果回传继续干活」的协作流程。
48
+
49
+ **只读白名单**:代理对下发的 SQL 强制校验(剥注释后逐条),仅放行
50
+ SELECT/SHOW/DESC/EXPLAIN;命中 INTO OUTFILE/DUMPFILE/FOR UPDATE 等副作用子句
51
+ 同样拒绝。失败返回 `ok:false, error:"write-forbidden"`。yr-set 路径同受约束。
52
+
53
+ prepare 模式示例:
54
+
55
+ ```json
56
+ { "type": "yr-run", "sql": "select * from t_order limit 10;", "source": "dk-prod-1-tdsql-c", "database": "dk1", "autoQuery": false, "timeoutMs": 600000, "tabId": 123 }
57
+ ```
58
+
59
+ 诊断消息(均只读):
60
+
61
+ - `yr-ping`(客户端 `ping`):编辑器/按钮/Select 状态/切源入口/路由 hash
62
+ - `yr-dom-probe`(客户端 `probe`):`.ant-select` 与弹层的 DOM 结构、坐标、meta 现值
63
+
37
64
  ### 完整消息列表
38
65
 
39
66
  ### Agent → 代理
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "terminal-bridge-setup",
3
- "version": "3.2.0",
4
- "description": "一次性安装器:释放终端桥接(JumpServer / Arthas)的本地代理 + Chrome 插件,并注册 native messaging host。让 Agent 能通过浏览器 xterm 终端执行命令并拿回输出。",
3
+ "version": "3.3.0",
4
+ "description": "一次性安装器:释放终端桥接(JumpServer / Arthas / Yearning)的本地代理 + Chrome 插件,并注册 native messaging host。让 Agent 能通过浏览器 xterm 终端执行命令并拿回输出。",
5
5
  "license": "MIT",
6
6
  "author": "encorearon",
7
7
  "homepage": "https://github.com/encorearon/terminal-bridge",