rdsh-gateway 0.9.0 → 0.11.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/dist/join.js CHANGED
@@ -22,6 +22,23 @@ import { acquireJoinLock, releaseJoinLock } from "./lock.js";
22
22
  import { responderHandshake, Aead } from "./e2ee.js";
23
23
  import { loadOrCreateE2eeKeyPair } from "./e2ee-key-store.js";
24
24
  import { GATE_COOKIE, signGateCookie, verifyGateCookie, verifyGateCode } from "./access-gate.js";
25
+ import { startDirect, directBootstrapScript } from "./direct.js";
26
+ import { createDirectTicketManager } from "./direct-ticket.js";
27
+ import { loadOrCreateDirectSecret } from "./direct-secret.js";
28
+ import { RDSH_WEBVIEW_API, injectHtmlScript } from "./rdsh-webview-api.js";
29
+ /**
30
+ * 页面授权脚本(plain 与 raw 两条 inner dispatcher 共用,禁止各写一份):
31
+ * 直连 bootstrap + (设了口令时)raw 门禁解锁 token。
32
+ *
33
+ * 为什么 raw 通道也**必须**注入:raw(E2EE)流按设计不向 host 转发任何 cookie,
34
+ * 页面只能靠这段脚本带 token 请求 `/__rdsh/authorize` 才能把 rawAuthorized 置真;
35
+ * 漏注入 ⇒ raw 门禁(accessCode 非空时为 fail-closed)永远无法满足 ⇒ 经 E2EE 的
36
+ * 客户端每个请求都被 CLOSE 403、表现为"一直 connecting"。
37
+ * (24-direct-first 首版只在 plain 注入,E2EE 客户端因此被锁死;本函数即该回归的单一来源。)
38
+ */
39
+ export function pageAuthorizeScript(accessCode) {
40
+ return directBootstrapScript(accessCode === null ? null : signGateCookie(accessCode).value);
41
+ }
25
42
  /** 判断错误是否为 TLS 证书类错误(自签/过期/域名不匹配)。 */
26
43
  function isCertError(err) {
27
44
  const code = err?.code ?? "";
@@ -157,6 +174,12 @@ export function isJsContentType(headers) {
157
174
  const s = Array.isArray(ct) ? ct.join(";") : (ct ?? "");
158
175
  return /javascript/i.test(s);
159
176
  }
177
+ /** HTML 响应判定(content-type 含 text/html)。 */
178
+ export function isHtmlContentType(headers) {
179
+ const ct = headers["content-type"];
180
+ const s = Array.isArray(ct) ? ct.join(";") : (ct ?? "");
181
+ return /text\/html/i.test(s);
182
+ }
160
183
  /**
161
184
  * 最小 patch:把 DSH 客户端 bundle 里的前端 isLoopback 判定替换为 true
162
185
  * (持久设置/API key 输入只对 loopback 开放;E2EE 流上信任基础等同 loopback)。
@@ -250,6 +273,10 @@ export function startJoin(opts) {
250
273
  const uiCompat = { trustE2EEAsLoopback: opts.dshUiCompat?.trustE2EEAsLoopback !== false };
251
274
  // 访问口令(feature 15):可变引用 → setAccessCode 运行中切换;null = gate off
252
275
  const gate = { accessCode: opts.gateway?.accessCode ?? null };
276
+ // raw 门禁 + 候选(24-direct-first):提供 direct 回调即启用候选端点;raw 门禁(authorize)仅设口令时生效
277
+ const rawGate = opts.direct !== undefined
278
+ ? { candidates: opts.direct.candidates, mintTicket: opts.direct.mintTicket }
279
+ : undefined;
253
280
  const hostName = opts.name ?? "本主机";
254
281
  const gateFailures = { count: 0, lockedUntil: 0 };
255
282
  const log = (level, message) => {
@@ -309,6 +336,10 @@ export function startJoin(opts) {
309
336
  * 键含 query 且不截断:combo 路由用 query 选内容,资源 URL 由内容决定(rev/hash),内容一变 URL 就变。
310
337
  */
311
338
  const patchSkipped = new Set();
339
+ /** rdsh WebView API 是否已注入过(每个进程只打一次日志,避免页面加载刷屏)。 */
340
+ let adapterInjectionLogged = false;
341
+ /** raw 门禁:当前 raw 流是否已被页面授权(动态判定:见 handleOpen 的条件,含运行中 gate 开关) */
342
+ let rawAuthorized = false;
312
343
  /** 从转发头里取 rdsh_gate cookie(hub D12 白名单透传)。 */
313
344
  function gateCookie(headers) {
314
345
  const ck = headers["cookie"];
@@ -335,11 +366,11 @@ export function startJoin(opts) {
335
366
  const html = gateChallengeHtml(hostName, path, error, acceptLanguage);
336
367
  sendSyntheticHttp(send, streamId, 200, { "content-type": "text/html; charset=utf-8" }, Buffer.from(html));
337
368
  }
338
- /** 校验 code 提交(POST gate_code)→ 302 回跳 + 发 cookie,或回 challenge 错误。 */
369
+ /** 校验 code 提交(POST gate_code)→ 303 回跳 + 发 cookie,或回 challenge 错误。 */
339
370
  function handleGateSubmit(streamId, state) {
340
371
  const code = gate.accessCode;
341
372
  if (code === null) {
342
- sendSyntheticHttp(send, streamId, 302, { location: state.path }, Buffer.alloc(0));
373
+ sendSyntheticHttp(send, streamId, 303, { location: state.path }, Buffer.alloc(0));
343
374
  return;
344
375
  }
345
376
  if (gateBlocked()) {
@@ -358,7 +389,7 @@ export function startJoin(opts) {
358
389
  if (input !== null && verifyGateCode(input, code)) {
359
390
  gateFailures.count = 0;
360
391
  const { value } = signGateCookie(code);
361
- sendSyntheticHttp(send, streamId, 302, { location: state.path, "set-cookie": `${GATE_COOKIE}=${value}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${7 * 24 * 3600}` }, Buffer.alloc(0));
392
+ sendSyntheticHttp(send, streamId, 303, { location: state.path, "set-cookie": `${GATE_COOKIE}=${value}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${7 * 24 * 3600}` }, Buffer.alloc(0));
362
393
  }
363
394
  else {
364
395
  gateFailures.count += 1;
@@ -439,6 +470,28 @@ export function startJoin(opts) {
439
470
  send(encodeFrame(FRAME_TYPE.ERROR, frame.streamId, jsonPayload({ code: "BAD_OPEN", message: "malformed open" })));
440
471
  return;
441
472
  }
473
+ // ---- raw 门禁 + 候选端点(仅 raw inner dispatcher:dio.rawGate 定义时)----
474
+ if (dio?.rawGate !== undefined) {
475
+ // raw 门禁(仅设口令时):授权前只接受 authorize 请求,否则 fail-closed 关流(修 raw 绕过,R7)
476
+ if (gate.accessCode !== null && !rawAuthorized) {
477
+ if (path.startsWith("/__rdsh/authorize")) {
478
+ const token = new URL(path, "http://rdsh.local").searchParams.get("token") ?? "";
479
+ if (token !== "" && verifyGateCookie(gate.accessCode, token)) {
480
+ rawAuthorized = true;
481
+ sendSyntheticHttp(send, frame.streamId, 204, {}, Buffer.alloc(0));
482
+ return;
483
+ }
484
+ }
485
+ send(encodeFrame(FRAME_TYPE.CLOSE, frame.streamId, jsonPayload({ code: 403, message: "raw stream not authorized" })));
486
+ return;
487
+ }
488
+ // 候选 + 一次性直连票(无论是否设口令都提供;经 E2EE,hub 不可见,R3/R4)
489
+ if (path === "/__rdsh/direct-candidates" || path.startsWith("/__rdsh/direct-candidates?")) {
490
+ const body = Buffer.from(JSON.stringify({ candidates: dio.rawGate.candidates(), ticket: dio.rawGate.mintTicket() }));
491
+ sendSyntheticHttp(send, frame.streamId, 200, { "content-type": "application/json" }, body);
492
+ return;
493
+ }
494
+ }
442
495
  // ---- 访问口令 gate(仅 plain dispatcher:dio.gate=true 且已设 accessCode)----
443
496
  if (dio?.gate === true && gate.accessCode !== null) {
444
497
  const code = gate.accessCode;
@@ -484,6 +537,34 @@ export function startJoin(opts) {
484
537
  const baseHeaders = normalizeRespHeaders(upRes.headers);
485
538
  // 已确认该 URL 不含目标串(DSH 前端壳资源就属此类)→ 直接流式,省掉白解压与全量扫描
486
539
  const wantsPatch = dio?.jsPatch?.() === true && isJsContentType(upRes.headers) && !patchSkipped.has(path);
540
+ const wantsInject = dio?.htmlInject !== undefined && isHtmlContentType(upRes.headers) && upRes.headers["content-encoding"] === undefined;
541
+ // HTML + 注入:缓冲后注入(htmlInject 会改 body 长度,须先处理再发 OPEN 并重算 content-length)
542
+ if (wantsInject) {
543
+ const htmlChunks = [];
544
+ upRes.on("data", (chunk) => htmlChunks.push(chunk));
545
+ upRes.on("end", () => {
546
+ const inject = typeof dio.htmlInject === "function" ? dio.htmlInject() : dio.htmlInject;
547
+ const html = injectHtmlScript(Buffer.concat(htmlChunks).toString("utf8"), inject);
548
+ const outHeaders = {
549
+ ...baseHeaders,
550
+ "content-length": String(Buffer.byteLength(html)),
551
+ };
552
+ delete outHeaders["transfer-encoding"];
553
+ if (!adapterInjectionLogged) {
554
+ adapterInjectionLogged = true;
555
+ console.log("[rdsh] rdsh webview api injected (window.__rdshWebViewApi v1)");
556
+ }
557
+ send(encodeFrame(FRAME_TYPE.OPEN, streamId, jsonPayload({ kind: "http", status, reason: upRes.statusMessage, headers: outHeaders })));
558
+ sendChunkedBody(send, streamId, Buffer.from(html));
559
+ send(encodeFrame(FRAME_TYPE.CLOSE, streamId, jsonPayload({ code: 0 })));
560
+ httpStreams.delete(streamId);
561
+ });
562
+ upRes.on("error", () => {
563
+ send(encodeFrame(FRAME_TYPE.CLOSE, streamId, jsonPayload({ code: 502, message: "upstream error" })));
564
+ httpStreams.delete(streamId);
565
+ });
566
+ return;
567
+ }
487
568
  // 非 JS / 未开补丁:保持原流式路径(OPEN 立即发,body 边到边发)
488
569
  if (!wantsPatch) {
489
570
  send(encodeFrame(FRAME_TYPE.OPEN, streamId, jsonPayload({ kind: "http", status, reason: upRes.statusMessage, headers: baseHeaders })));
@@ -636,7 +717,14 @@ export function startJoin(opts) {
636
717
  }
637
718
  return { handleFrame, cleanup };
638
719
  }
639
- const plainDispatcher = makeInnerDispatcher(sendTunnelFrame, { jsPatch: () => uiCompat.trustE2EEAsLoopback, gate: true });
720
+ const plainDispatcher = makeInnerDispatcher(sendTunnelFrame, {
721
+ jsPatch: () => uiCompat.trustE2EEAsLoopback,
722
+ gate: true,
723
+ htmlInject: () => {
724
+ // 直连 bootstrap 始终注入(join 模式);页面授权 token 仅设口令时生成(无口令则跳过 raw 授权、只取候选)
725
+ return RDSH_WEBVIEW_API + pageAuthorizeScript(gate.accessCode);
726
+ },
727
+ });
640
728
  // host 端 E2EE 静态密钥对(持久化 ~/.rdsh/e2ee-key.json;join 注册时上送指纹)
641
729
  let hostE2eeKeypair;
642
730
  try {
@@ -668,7 +756,13 @@ export function startJoin(opts) {
668
756
  // 最后一道:任何加密/封帧异常只记录,绝不重抛打死 host
669
757
  console.error(`[join] e2ee send failed: ${err instanceof Error ? err.message : String(err)}`);
670
758
  }
671
- }, { jsPatch: () => uiCompat.trustE2EEAsLoopback });
759
+ }, {
760
+ jsPatch: () => uiCompat.trustE2EEAsLoopback,
761
+ rawGate,
762
+ // raw 门禁的解锁脚本:与 plain 同一来源(pageAuthorizeScript)。
763
+ // raw 流不转发 cookie,这里是页面唯一的授权入口;缺了它 raw 门禁必然 fail-closed。
764
+ htmlInject: () => pageAuthorizeScript(gate.accessCode),
765
+ });
672
766
  rawStreams.set(streamId, {
673
767
  handshakeBuf: Buffer.alloc(0),
674
768
  keys: null,
@@ -934,6 +1028,12 @@ export async function join(opts) {
934
1028
  const { token, insecure, name } = await registerJoin(opts);
935
1029
  console.log(`rdsh join: dsh web on 127.0.0.1:${dsh.port}`);
936
1030
  console.log(`rdsh join: connecting to ${opts.hubUrl}...`);
1031
+ const accessCode = opts.gateway?.accessCode ?? null;
1032
+ // 直连密钥(per-host 随机,自动生成)+ 一次性直连票管理器(R4):密钥独立于口令(方案 B)
1033
+ const directSecret = loadOrCreateDirectSecret();
1034
+ const ticketManager = createDirectTicketManager(directSecret);
1035
+ // 直连口句柄引用(candidates 回调在 startJoin 内被调用;直连口稍后才启动,故用可变引用)
1036
+ let directHandle;
937
1037
  const handle = startJoin({
938
1038
  hubUrl: opts.hubUrl,
939
1039
  token,
@@ -942,6 +1042,9 @@ export async function join(opts) {
942
1042
  role: "cli",
943
1043
  dshUiCompat: opts.dshUiCompat,
944
1044
  gateway: opts.gateway,
1045
+ direct: opts.direct !== undefined
1046
+ ? { candidates: () => directHandle?.candidates() ?? [], mintTicket: () => ticketManager.mint() }
1047
+ : undefined,
945
1048
  dshAuthCookieHeader,
946
1049
  name,
947
1050
  hooks: {
@@ -955,6 +1058,27 @@ export async function join(opts) {
955
1058
  },
956
1059
  },
957
1060
  });
1061
+ // 直连口(24-direct-first,方案 B):join 模式**始终监听**,门禁 = ticket(可选口令为增强层)
1062
+ if (opts.direct !== undefined) {
1063
+ try {
1064
+ directHandle = await startDirect({
1065
+ dshPort: dsh.port,
1066
+ dshAuthCookieHeader,
1067
+ secret: directSecret,
1068
+ accessCode,
1069
+ consumeTicket: (t) => ticketManager.consume(t),
1070
+ host: opts.direct.host,
1071
+ port: opts.direct.port,
1072
+ dshUiCompat: { trustPairedAsLoopback: opts.dshUiCompat?.trustE2EEAsLoopback !== false },
1073
+ });
1074
+ const cands = directHandle.candidates();
1075
+ console.log(`rdsh join: direct on :${directHandle.actualPort}(${accessCode === null ? "ticket 门禁" : "ticket + 口令门禁"})—— ${cands.map((c) => `http://${c.host}:${c.port}`).join(", ") || "(无内网网卡)"}`);
1076
+ }
1077
+ catch (err) {
1078
+ // 直连口监听失败(端口占用等)不阻断隧道:隧道照常,仅直连不可用
1079
+ console.error(`rdsh join: direct listener failed to start: ${err instanceof Error ? err.message : String(err)}`);
1080
+ }
1081
+ }
958
1082
  let shuttingDown = false;
959
1083
  const shutdown = async (signal, code = 0) => {
960
1084
  if (shuttingDown)
@@ -962,6 +1086,8 @@ export async function join(opts) {
962
1086
  shuttingDown = true;
963
1087
  if (signal !== "")
964
1088
  console.log(`\nrdsh: received ${signal}, shutting down...`);
1089
+ if (directHandle !== undefined)
1090
+ await directHandle.stop().catch(() => undefined);
965
1091
  await handle.stop();
966
1092
  await dsh.stop();
967
1093
  process.exit(code);
@@ -0,0 +1,17 @@
1
+ /**
2
+ * rdsh-webview-api.ts — 智能体 API 契约适配脚本(注入 DSH 首页,feature 22)。
3
+ *
4
+ * 挂出稳定契约 `window.__rdshWebViewApi`(version 1),供 WebView 客户端(如 garsync App)优先消费,
5
+ * 以把「DSH 版本适配」从 App(发版慢)挪到 gateway(随 npm 包快速发布)。
6
+ *
7
+ * 契约正文与语义以 garsync `doc/feature/108_agent_api_contract/req.md` R7 为准;
8
+ * 本脚本是 garsync `lib/rdsh/rdsh_web_bridge.dart` 的 `_bridgeJs` 的**精简等价物**:
9
+ * - 保留:isDshPage / readReply / fillAndSend 三方法及其 DOM 细节(Lexical 写入管线、
10
+ * 键盘守卫、最后一轮答案提取、UTF-8 安全 base64);
11
+ * - 去掉:`report()` 通道与 105 诊断探针(那是 App 侧独有的 debug 机制)。
12
+ *
13
+ * 安全约定:幂等(已注入即返回)、防御式(任何 DOM 缺失/异常返回安全默认值、绝不抛错)。
14
+ */
15
+ export declare const RDSH_WEBVIEW_API = "(function () {\n if (window.__rdshWebViewApi) return;\n function findComposer() {\n var el = document.querySelector('[data-lexical-editor=\"true\"]');\n if (el) return { el: el, kind: 'lexical' };\n el = document.querySelector('[contenteditable=\"true\"]');\n if (el) return { el: el, kind: 'contenteditable' };\n el = document.querySelector('textarea');\n if (el) return { el: el, kind: 'textarea' };\n return null;\n }\n function selectAll(el) {\n var tag = el.tagName;\n if (tag === 'TEXTAREA' || tag === 'INPUT') { el.select(); return; }\n var range = document.createRange();\n range.selectNodeContents(el);\n var sel = window.getSelection();\n sel.removeAllRanges();\n sel.addRange(range);\n }\n function insertText(el, text) {\n el.focus();\n try { selectAll(el); } catch (e) {}\n // 1) execCommand \u8D70\u6D4F\u89C8\u5668\u7F16\u8F91\u7BA1\u7EBF\uFF0C\u8BA9 Lexical \u6536\u5230 input \u4E8B\u4EF6\u3002\n try { if (document.execCommand('insertText', false, text)) return true; } catch (e) {}\n // 2) \u663E\u5F0F beforeinput\u3002\n try {\n el.dispatchEvent(new InputEvent('beforeinput', { inputType: 'insertText', data: text, bubbles: true, cancelable: true }));\n return true;\n } catch (e) {}\n // 3) \u515C\u5E95\uFF1A\u76F4\u63A5\u5199 DOM + \u89E6\u53D1 input\u3002\n try {\n var tag = el.tagName;\n if (tag === 'TEXTAREA' || tag === 'INPUT') {\n var proto = tag === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;\n Object.getOwnPropertyDescriptor(proto, 'value').set.call(el, text);\n } else {\n el.textContent = text;\n }\n el.dispatchEvent(new Event('input', { bubbles: true }));\n return true;\n } catch (e) {}\n return false;\n }\n function pressEnter(el) {\n var opts = { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true, cancelable: true, composed: true, isComposing: false };\n try {\n el.dispatchEvent(new KeyboardEvent('keydown', opts));\n el.dispatchEvent(new KeyboardEvent('keypress', opts));\n el.dispatchEvent(new KeyboardEvent('keyup', opts));\n } catch (e) {}\n }\n // \u952E\u76D8\u5B88\u536B\uFF1A\u8BED\u97F3\u5199\u5165\u540E\u5173\u8F6F\u952E\u76D8\uFF1B\u7528\u6237\u771F\u5B9E\u89E6\u6478\u9875\u9762\u5373\u89E3\u9664\u3002\n var keyboardGuard = false;\n function dismissKeyboard() {\n try {\n var active = document.activeElement;\n if (active && active !== document.body && typeof active.blur === 'function') active.blur();\n var selection = window.getSelection();\n if (selection && selection.removeAllRanges) selection.removeAllRanges();\n } catch (e) {}\n }\n function onUserTouch() { if (keyboardGuard) keyboardGuard = false; }\n document.addEventListener('focusin', function () { if (!keyboardGuard) return; dismissKeyboard(); }, true);\n document.addEventListener('pointerdown', onUserTouch, true);\n document.addEventListener('touchstart', onUserTouch, true);\n\n // \u5355\u4E2A flow \u8282\u70B9\u7684\u53EF\u89C1\u6587\u672C\uFF1BproseOnly \u989D\u5916\u4E22\u5F03\u6574\u5757\u4EE3\u7801\u4E0E\u8868\u683C\uFF08105 \u53EA\u8BFB\u7B54\u6848\u6B63\u6587\uFF09\u3002\n function answerTextOf(el, proseOnly) {\n var clone = el.cloneNode(true);\n var selector = '[data-variant=\"think\"],[data-variant=\"others\"]';\n if (proseOnly) selector += ',pre,table';\n var drop = clone.querySelectorAll(selector);\n for (var i = 0; i < drop.length; i++) { if (drop[i].parentNode) drop[i].parentNode.removeChild(drop[i]); }\n return (clone.innerText || clone.textContent || '').trim();\n }\n // \u67D0\u4E2A flow kind \u7684\u6700\u5927 turn\uFF08\u53D6\u4E0D\u5230 = -1\uFF09\u3002\n // 111\uFF1AApp \u7528 turn / userTurn \u5224\u65AD\"\u7528\u6237\u662F\u5426\u53D1\u4E86\u65B0\u6D88\u606F\"\uFF0C\u4ECE\u800C\u7ACB\u5373\u505C\u6B62\u6717\u8BFB\u3002\n function maxTurnOf(kind) {\n var nodes = document.querySelectorAll('[data-chat-flow-kind=\"' + kind + '\"]');\n var max = -1;\n for (var i = 0; i < nodes.length; i++) {\n var t = parseInt(nodes[i].getAttribute('data-chat-turn'), 10);\n if (!isNaN(t) && t > max) max = t;\n }\n return max;\n }\n // \u6700\u540E\u4E00\u8F6E\u7684\u7B54\u6848\u90E8\u5206\uFF08\u6392\u9664 reasoning\uFF0C\u53D6\u6700\u5927 turn\uFF0C\u9010 part \u62FC\u63A5\uFF09\u3002\n // \u8FD4\u56DE { text, turn }\uFF1Aturn \u4E0E\u8BE5\u8F6E\u7B54\u6848\u5BF9\u5E94\uFF0C\u4F9B readReply \u56DE\u5E26\u7ED9 App\u3002\n function latestAnswer() {\n var steps = Array.prototype.slice.call(document.querySelectorAll('[data-chat-flow-kind=\"assistant-step\"]'));\n var answers = steps.filter(function (el) { return el.getAttribute('data-chat-group-part') !== 'reasoning'; });\n var maxTurn = -1;\n for (var i = 0; i < answers.length; i++) {\n var turn = parseInt(answers[i].getAttribute('data-chat-turn'), 10);\n if (!isNaN(turn) && turn > maxTurn) maxTurn = turn;\n }\n var parts = answers.filter(function (el) { return parseInt(el.getAttribute('data-chat-turn'), 10) === maxTurn; });\n var texts = [];\n for (var j = 0; j < parts.length; j++) {\n var text = answerTextOf(parts[j], true);\n if (text.length) texts.push(text);\n }\n return { text: texts.join('\\n\\n'), turn: maxTurn };\n }\n\n window.__rdshWebViewApi = {\n version: 1,\n // 104\uFF1A\u5199\u5165 composer \u5E76\u63D0\u4EA4\u3002\u8FD4\u56DE\u88F8\u72B6\u6001 token\u3002\n fillAndSend: function (text) {\n var found = findComposer();\n if (!found) return 'no-composer';\n keyboardGuard = false;\n if (!insertText(found.el, text)) return 'fill-failed';\n keyboardGuard = true;\n dismissKeyboard();\n setTimeout(function () { pressEnter(found.el); dismissKeyboard(); }, 80);\n return 'ok';\n },\n // 105/111\uFF1A\u5F85\u6717\u8BFB\u5185\u5BB9 + \u72B6\u6001\u3002\u8FD4\u56DE UTF-8 \u5B89\u5168\u7684 base64 JSON\n // {dsh, streaming, text, turn, userTurn}\uFF08turn / userTurn \u53D6\u4E0D\u5230\u65F6\u4E3A -1\uFF09\u3002\n readReply: function () {\n var out = { dsh: false, streaming: 0, text: '', turn: -1, userTurn: -1 };\n try {\n out.streaming = document.querySelectorAll('[data-streaming]').length;\n if (document.querySelectorAll('[data-chat-flow-kind]').length > 0) {\n out.dsh = true;\n var answer = latestAnswer();\n out.text = answer.text;\n out.turn = answer.turn;\n out.userTurn = maxTurnOf('user');\n } else {\n out.text = document.body ? document.body.innerText : '';\n }\n } catch (e) {}\n return window.btoa(unescape(encodeURIComponent(JSON.stringify(out))));\n },\n // 107\uFF1A\u662F\u5426 DSH \u4F1A\u8BDD\u9875\uFF08\u8F7B\u91CF\u5224\u5B9A\uFF0C\u53EF\u8F6E\u8BE2\uFF09\u3002\u8FD4\u56DE\u88F8 '1' / '0'\u3002\n isDshPage: function () {\n try {\n return document.querySelectorAll('[data-chat-flow-kind]').length > 0 ? '1' : '0';\n } catch (e) { return '0'; }\n }\n };\n})();";
16
+ /** 把 `<script>${script}</script>` 插到 `</head>` 前(无则前置);纯函数,可单测。 */
17
+ export declare function injectHtmlScript(html: string, script: string): string;
@@ -0,0 +1,167 @@
1
+ /**
2
+ * rdsh-webview-api.ts — 智能体 API 契约适配脚本(注入 DSH 首页,feature 22)。
3
+ *
4
+ * 挂出稳定契约 `window.__rdshWebViewApi`(version 1),供 WebView 客户端(如 garsync App)优先消费,
5
+ * 以把「DSH 版本适配」从 App(发版慢)挪到 gateway(随 npm 包快速发布)。
6
+ *
7
+ * 契约正文与语义以 garsync `doc/feature/108_agent_api_contract/req.md` R7 为准;
8
+ * 本脚本是 garsync `lib/rdsh/rdsh_web_bridge.dart` 的 `_bridgeJs` 的**精简等价物**:
9
+ * - 保留:isDshPage / readReply / fillAndSend 三方法及其 DOM 细节(Lexical 写入管线、
10
+ * 键盘守卫、最后一轮答案提取、UTF-8 安全 base64);
11
+ * - 去掉:`report()` 通道与 105 诊断探针(那是 App 侧独有的 debug 机制)。
12
+ *
13
+ * 安全约定:幂等(已注入即返回)、防御式(任何 DOM 缺失/异常返回安全默认值、绝不抛错)。
14
+ */
15
+ export const RDSH_WEBVIEW_API = `(function () {
16
+ if (window.__rdshWebViewApi) return;
17
+ function findComposer() {
18
+ var el = document.querySelector('[data-lexical-editor="true"]');
19
+ if (el) return { el: el, kind: 'lexical' };
20
+ el = document.querySelector('[contenteditable="true"]');
21
+ if (el) return { el: el, kind: 'contenteditable' };
22
+ el = document.querySelector('textarea');
23
+ if (el) return { el: el, kind: 'textarea' };
24
+ return null;
25
+ }
26
+ function selectAll(el) {
27
+ var tag = el.tagName;
28
+ if (tag === 'TEXTAREA' || tag === 'INPUT') { el.select(); return; }
29
+ var range = document.createRange();
30
+ range.selectNodeContents(el);
31
+ var sel = window.getSelection();
32
+ sel.removeAllRanges();
33
+ sel.addRange(range);
34
+ }
35
+ function insertText(el, text) {
36
+ el.focus();
37
+ try { selectAll(el); } catch (e) {}
38
+ // 1) execCommand 走浏览器编辑管线,让 Lexical 收到 input 事件。
39
+ try { if (document.execCommand('insertText', false, text)) return true; } catch (e) {}
40
+ // 2) 显式 beforeinput。
41
+ try {
42
+ el.dispatchEvent(new InputEvent('beforeinput', { inputType: 'insertText', data: text, bubbles: true, cancelable: true }));
43
+ return true;
44
+ } catch (e) {}
45
+ // 3) 兜底:直接写 DOM + 触发 input。
46
+ try {
47
+ var tag = el.tagName;
48
+ if (tag === 'TEXTAREA' || tag === 'INPUT') {
49
+ var proto = tag === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
50
+ Object.getOwnPropertyDescriptor(proto, 'value').set.call(el, text);
51
+ } else {
52
+ el.textContent = text;
53
+ }
54
+ el.dispatchEvent(new Event('input', { bubbles: true }));
55
+ return true;
56
+ } catch (e) {}
57
+ return false;
58
+ }
59
+ function pressEnter(el) {
60
+ var opts = { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true, cancelable: true, composed: true, isComposing: false };
61
+ try {
62
+ el.dispatchEvent(new KeyboardEvent('keydown', opts));
63
+ el.dispatchEvent(new KeyboardEvent('keypress', opts));
64
+ el.dispatchEvent(new KeyboardEvent('keyup', opts));
65
+ } catch (e) {}
66
+ }
67
+ // 键盘守卫:语音写入后关软键盘;用户真实触摸页面即解除。
68
+ var keyboardGuard = false;
69
+ function dismissKeyboard() {
70
+ try {
71
+ var active = document.activeElement;
72
+ if (active && active !== document.body && typeof active.blur === 'function') active.blur();
73
+ var selection = window.getSelection();
74
+ if (selection && selection.removeAllRanges) selection.removeAllRanges();
75
+ } catch (e) {}
76
+ }
77
+ function onUserTouch() { if (keyboardGuard) keyboardGuard = false; }
78
+ document.addEventListener('focusin', function () { if (!keyboardGuard) return; dismissKeyboard(); }, true);
79
+ document.addEventListener('pointerdown', onUserTouch, true);
80
+ document.addEventListener('touchstart', onUserTouch, true);
81
+
82
+ // 单个 flow 节点的可见文本;proseOnly 额外丢弃整块代码与表格(105 只读答案正文)。
83
+ function answerTextOf(el, proseOnly) {
84
+ var clone = el.cloneNode(true);
85
+ var selector = '[data-variant="think"],[data-variant="others"]';
86
+ if (proseOnly) selector += ',pre,table';
87
+ var drop = clone.querySelectorAll(selector);
88
+ for (var i = 0; i < drop.length; i++) { if (drop[i].parentNode) drop[i].parentNode.removeChild(drop[i]); }
89
+ return (clone.innerText || clone.textContent || '').trim();
90
+ }
91
+ // 某个 flow kind 的最大 turn(取不到 = -1)。
92
+ // 111:App 用 turn / userTurn 判断"用户是否发了新消息",从而立即停止朗读。
93
+ function maxTurnOf(kind) {
94
+ var nodes = document.querySelectorAll('[data-chat-flow-kind="' + kind + '"]');
95
+ var max = -1;
96
+ for (var i = 0; i < nodes.length; i++) {
97
+ var t = parseInt(nodes[i].getAttribute('data-chat-turn'), 10);
98
+ if (!isNaN(t) && t > max) max = t;
99
+ }
100
+ return max;
101
+ }
102
+ // 最后一轮的答案部分(排除 reasoning,取最大 turn,逐 part 拼接)。
103
+ // 返回 { text, turn }:turn 与该轮答案对应,供 readReply 回带给 App。
104
+ function latestAnswer() {
105
+ var steps = Array.prototype.slice.call(document.querySelectorAll('[data-chat-flow-kind="assistant-step"]'));
106
+ var answers = steps.filter(function (el) { return el.getAttribute('data-chat-group-part') !== 'reasoning'; });
107
+ var maxTurn = -1;
108
+ for (var i = 0; i < answers.length; i++) {
109
+ var turn = parseInt(answers[i].getAttribute('data-chat-turn'), 10);
110
+ if (!isNaN(turn) && turn > maxTurn) maxTurn = turn;
111
+ }
112
+ var parts = answers.filter(function (el) { return parseInt(el.getAttribute('data-chat-turn'), 10) === maxTurn; });
113
+ var texts = [];
114
+ for (var j = 0; j < parts.length; j++) {
115
+ var text = answerTextOf(parts[j], true);
116
+ if (text.length) texts.push(text);
117
+ }
118
+ return { text: texts.join('\\n\\n'), turn: maxTurn };
119
+ }
120
+
121
+ window.__rdshWebViewApi = {
122
+ version: 1,
123
+ // 104:写入 composer 并提交。返回裸状态 token。
124
+ fillAndSend: function (text) {
125
+ var found = findComposer();
126
+ if (!found) return 'no-composer';
127
+ keyboardGuard = false;
128
+ if (!insertText(found.el, text)) return 'fill-failed';
129
+ keyboardGuard = true;
130
+ dismissKeyboard();
131
+ setTimeout(function () { pressEnter(found.el); dismissKeyboard(); }, 80);
132
+ return 'ok';
133
+ },
134
+ // 105/111:待朗读内容 + 状态。返回 UTF-8 安全的 base64 JSON
135
+ // {dsh, streaming, text, turn, userTurn}(turn / userTurn 取不到时为 -1)。
136
+ readReply: function () {
137
+ var out = { dsh: false, streaming: 0, text: '', turn: -1, userTurn: -1 };
138
+ try {
139
+ out.streaming = document.querySelectorAll('[data-streaming]').length;
140
+ if (document.querySelectorAll('[data-chat-flow-kind]').length > 0) {
141
+ out.dsh = true;
142
+ var answer = latestAnswer();
143
+ out.text = answer.text;
144
+ out.turn = answer.turn;
145
+ out.userTurn = maxTurnOf('user');
146
+ } else {
147
+ out.text = document.body ? document.body.innerText : '';
148
+ }
149
+ } catch (e) {}
150
+ return window.btoa(unescape(encodeURIComponent(JSON.stringify(out))));
151
+ },
152
+ // 107:是否 DSH 会话页(轻量判定,可轮询)。返回裸 '1' / '0'。
153
+ isDshPage: function () {
154
+ try {
155
+ return document.querySelectorAll('[data-chat-flow-kind]').length > 0 ? '1' : '0';
156
+ } catch (e) { return '0'; }
157
+ }
158
+ };
159
+ })();`;
160
+ /** 把 `<script>${script}</script>` 插到 `</head>` 前(无则前置);纯函数,可单测。 */
161
+ export function injectHtmlScript(html, script) {
162
+ const tag = `<script>${script}</script>`;
163
+ if (html.includes("</head>"))
164
+ return html.replace("</head>", `${tag}</head>`);
165
+ return `${tag}${html}`;
166
+ }
167
+ //# sourceMappingURL=rdsh-webview-api.js.map
@@ -1,10 +1,14 @@
1
1
  /**
2
2
  * secure-context-polyfill.ts — 非 secure context 兼容脚本(注入 DSH 首页)。
3
3
  *
4
- * 背景(查档):DSH 浏览器侧 RPC(dsh-client-connection/lib/client.js)用
5
- * crypto.randomUUID() 生成 MessageId/RpcId;但该 API 仅在 secure context
6
- * (HTTPS / localhost)可用。局域网模式为 http://<LAN-IP>(非 secure
7
- * context),故需 polyfill:crypto.getRandomValues 是唯一非 secure context
8
- * 也可用的 WebCrypto API,用它实现 UUID v4。
4
+ * 背景(查档):DSH 浏览器侧 RPC 用 crypto.randomUUID();但该 API 仅在 secure context
5
+ * (HTTPS / localhost)可用。局域网模式为 http://<LAN-IP>(非 secure context),故需
6
+ * polyfill:crypto.getRandomValues 是唯一非 secure context 也可用的 WebCrypto API。
7
+ *
8
+ * 另补 `navigator.clipboard`(同样仅 secure context 可用):`writeText` 用
9
+ * `document.execCommand("copy")` + 隐藏 textarea 实现(execCommand 不受 secure context
10
+ * 限制,只需用户手势)。`readText` 无法安全 polyfill,保留 undefined。
9
11
  */
10
12
  export declare const SECURE_CONTEXT_POLYFILL = "(function () {\n if (typeof crypto === \"undefined\") return;\n if (typeof crypto.randomUUID === \"function\") return;\n try {\n Object.defineProperty(crypto, \"randomUUID\", {\n value: function () {\n var b = crypto.getRandomValues(new Uint8Array(16));\n b[6] = (b[6] & 0x0f) | 0x40;\n b[8] = (b[8] & 0x3f) | 0x80;\n var h = \"\";\n for (var i = 0; i < 16; i++) {\n if (i === 4 || i === 6 || i === 8 || i === 10) h += \"-\";\n h += b[i].toString(16).padStart(2, \"0\");\n }\n return h;\n },\n writable: true\n });\n } catch (e) { /* ignore */ }\n})();";
13
+ /** 剪贴板 polyfill:非 secure context 下补 `navigator.clipboard.writeText`(execCommand('copy') 兜底)。 */
14
+ export declare const CLIPBOARD_POLYFILL = "(function () {\n if (typeof navigator === \"undefined\") return;\n if (navigator.clipboard && typeof navigator.clipboard.writeText === \"function\") return;\n function writeText(text) {\n return new Promise(function (resolve, reject) {\n var ta = document.createElement(\"textarea\");\n ta.value = String(text);\n ta.setAttribute(\"readonly\", \"\");\n ta.style.position = \"fixed\";\n ta.style.left = \"-9999px\";\n document.body.appendChild(ta);\n ta.select();\n var ok = false;\n try { ok = document.execCommand(\"copy\"); } catch (e) { /* ignore */ }\n document.body.removeChild(ta);\n if (ok) resolve(); else reject(new Error(\"copy failed\"));\n });\n }\n try {\n Object.defineProperty(navigator, \"clipboard\", {\n value: { writeText: writeText },\n writable: true,\n configurable: true\n });\n } catch (e) { /* ignore */ }\n})();";
@@ -1,11 +1,13 @@
1
1
  /**
2
2
  * secure-context-polyfill.ts — 非 secure context 兼容脚本(注入 DSH 首页)。
3
3
  *
4
- * 背景(查档):DSH 浏览器侧 RPC(dsh-client-connection/lib/client.js)用
5
- * crypto.randomUUID() 生成 MessageId/RpcId;但该 API 仅在 secure context
6
- * (HTTPS / localhost)可用。局域网模式为 http://<LAN-IP>(非 secure
7
- * context),故需 polyfill:crypto.getRandomValues 是唯一非 secure context
8
- * 也可用的 WebCrypto API,用它实现 UUID v4。
4
+ * 背景(查档):DSH 浏览器侧 RPC 用 crypto.randomUUID();但该 API 仅在 secure context
5
+ * (HTTPS / localhost)可用。局域网模式为 http://<LAN-IP>(非 secure context),故需
6
+ * polyfill:crypto.getRandomValues 是唯一非 secure context 也可用的 WebCrypto API。
7
+ *
8
+ * 另补 `navigator.clipboard`(同样仅 secure context 可用):`writeText` 用
9
+ * `document.execCommand("copy")` + 隐藏 textarea 实现(execCommand 不受 secure context
10
+ * 限制,只需用户手势)。`readText` 无法安全 polyfill,保留 undefined。
9
11
  */
10
12
  export const SECURE_CONTEXT_POLYFILL = `(function () {
11
13
  if (typeof crypto === "undefined") return;
@@ -27,4 +29,31 @@ export const SECURE_CONTEXT_POLYFILL = `(function () {
27
29
  });
28
30
  } catch (e) { /* ignore */ }
29
31
  })();`;
32
+ /** 剪贴板 polyfill:非 secure context 下补 `navigator.clipboard.writeText`(execCommand('copy') 兜底)。 */
33
+ export const CLIPBOARD_POLYFILL = `(function () {
34
+ if (typeof navigator === "undefined") return;
35
+ if (navigator.clipboard && typeof navigator.clipboard.writeText === "function") return;
36
+ function writeText(text) {
37
+ return new Promise(function (resolve, reject) {
38
+ var ta = document.createElement("textarea");
39
+ ta.value = String(text);
40
+ ta.setAttribute("readonly", "");
41
+ ta.style.position = "fixed";
42
+ ta.style.left = "-9999px";
43
+ document.body.appendChild(ta);
44
+ ta.select();
45
+ var ok = false;
46
+ try { ok = document.execCommand("copy"); } catch (e) { /* ignore */ }
47
+ document.body.removeChild(ta);
48
+ if (ok) resolve(); else reject(new Error("copy failed"));
49
+ });
50
+ }
51
+ try {
52
+ Object.defineProperty(navigator, "clipboard", {
53
+ value: { writeText: writeText },
54
+ writable: true,
55
+ configurable: true
56
+ });
57
+ } catch (e) { /* ignore */ }
58
+ })();`;
30
59
  //# sourceMappingURL=secure-context-polyfill.js.map
package/dist/serve.d.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  export interface ServeOptions {
2
2
  host?: string;
3
3
  port?: number;
4
- pairCode?: string;
5
4
  sessionTtlSeconds?: number;
6
5
  dshPath?: string;
7
6
  reset?: boolean;
package/dist/serve.js CHANGED
@@ -20,7 +20,7 @@ export async function serve(opts) {
20
20
  const sessionTtlSeconds = opts.sessionTtlSeconds ?? config.sessionTtlSeconds;
21
21
  const dshPath = opts.dshPath ?? config.dshPath;
22
22
  const authMode = opts.noCode ? "none" : config.auth.mode;
23
- const pairCode = opts.pairCode ?? config.auth.pairCode;
23
+ const accessCode = config.gateway?.accessCode ?? null;
24
24
  const foundDsh = findDsh(dshPath);
25
25
  if (foundDsh === null) {
26
26
  throw new Error("cannot find 'dsh' in PATH. Install DeepSeek Harness first, or set dshPath in config / pass --dsh <path>.");
@@ -47,22 +47,22 @@ export async function serve(opts) {
47
47
  // password + http + 非反代 → server.ts 安全约束拒绝启动(需自行提供证书)。
48
48
  const tlsMaterial = await loadTls(config.tls, config.behindProxy);
49
49
  const userManager = authMode === "password" ? new UserManager(configPath) : undefined;
50
- if (opts.noCode || authMode === "none") {
51
- console.warn("\n⚠ rdsh: 认证已禁用(auth.mode=none / --no-code)—— 任何能访问该端口的设备将直接操作 DSH!\n" +
52
- " 仅限完全可信网络!\n");
50
+ if (accessCode === null && authMode !== "password") {
51
+ console.warn("\n⚠ rdsh: 认证已禁用(未设访问口令且 auth.mode=none)—— 任何能访问该端口的设备将直接操作 DSH!\n" +
52
+ " 仅限完全可信网络!用 `rdsh host gate set <口令>` 开启访问口令。\n");
53
53
  }
54
54
  let gateway;
55
55
  try {
56
56
  gateway = await startGateway({
57
57
  host,
58
58
  port,
59
- pairCode,
60
59
  sessionTtlSeconds,
61
60
  dshPort: dsh.port,
62
61
  dshAuthCookieHeader,
63
62
  reset: opts.reset,
64
63
  noCode: opts.noCode,
65
64
  authMode,
65
+ accessCode,
66
66
  authVersion: config.auth.version,
67
67
  allowFrom: config.allowFrom,
68
68
  behindProxy: config.behindProxy,
@@ -85,7 +85,7 @@ export async function serve(opts) {
85
85
  console.log(`rdsh serve: LAN: ${lan.map((ip) => `${scheme}://${ip}:${gateway.actualPort}`).join(", ")}`);
86
86
  }
87
87
  console.log(`rdsh serve: dsh web on 127.0.0.1:${dsh.port}`);
88
- console.log(`rdsh serve: auth mode: ${authMode}${authMode === "password" ? ` (config: ${configPath})` : authMode === "pair" ? ` (pair code: ${gateway.pair.codeValue()})` : ""}`);
88
+ console.log(`rdsh serve: auth mode: ${accessCode !== null ? "accessCode" : authMode}${authMode === "password" ? ` (config: ${configPath})` : ""}`);
89
89
  if (config.allowFrom.length > 0)
90
90
  console.log(`rdsh serve: allow_from: ${config.allowFrom.join(", ")}`);
91
91
  if (tlsMaterial !== null)
@@ -94,8 +94,8 @@ export async function serve(opts) {
94
94
  console.log("rdsh serve: behind_proxy: true (TLS terminated by reverse proxy)");
95
95
  if (authMode === "password")
96
96
  console.log("rdsh serve: sign in with `rdsh user` credentials in the browser login page.");
97
- else if (authMode === "pair")
98
- console.log("rdsh serve: enter the pair code in the browser on your other device.");
97
+ else if (accessCode !== null)
98
+ console.log("rdsh serve: 访问口令已启用(经隧道或直连访问都需先输入口令)。");
99
99
  let shuttingDown = false;
100
100
  const shutdown = async (signal) => {
101
101
  if (shuttingDown)