@qzsy/dsh-bridge 2.18.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.
Files changed (81) hide show
  1. package/CHANGELOG.md +12 -1098
  2. package/LICENSE +21 -21
  3. package/README.md +72 -476
  4. package/client/build.mjs +1 -3
  5. package/client/client.js +381 -9747
  6. package/client/index.js +242 -6828
  7. package/client/locales.mjs +99 -233
  8. package/cordis.patch.yml +5 -3
  9. package/lib/config.js +93 -0
  10. package/lib/data-dir.js +16 -0
  11. package/lib/dsh-cookie.js +111 -0
  12. package/lib/gate.js +382 -0
  13. package/lib/identity.js +23 -0
  14. package/lib/index.js +302 -3496
  15. package/lib/saas/tunnel-client.mjs +529 -467
  16. package/locale/en.json +3 -3
  17. package/locale/zh.json +3 -3
  18. package/package.json +15 -70
  19. package/README.en.md +0 -488
  20. package/client/host-families.js +0 -442
  21. package/client/mobile-styles.js +0 -1299
  22. package/client/mobile-yield.js +0 -219
  23. package/client/panel-locales.mjs +0 -777
  24. package/client/picker-yield.js +0 -77
  25. package/client/resource-url-compat.js +0 -90
  26. package/client/settings-nav-icon.js +0 -105
  27. package/client/theme-tokens.js +0 -49
  28. package/client/tunnel-guard.js +0 -46
  29. package/client/unlock-manager.js +0 -144
  30. package/lib/auth/dsh-native-cookie.js +0 -148
  31. package/lib/auth/login-template.js +0 -516
  32. package/lib/auth/manager.js +0 -596
  33. package/lib/bridge-rpc-constants.js +0 -66
  34. package/lib/bridge-rpc.js +0 -722
  35. package/lib/cloudflared-manager.mjs +0 -1277
  36. package/lib/cloudflared-network-args.js +0 -99
  37. package/lib/cloudflared-process-scan.js +0 -153
  38. package/lib/compat.js +0 -172
  39. package/lib/connection-compat.js +0 -115
  40. package/lib/feishu/comment.js +0 -50
  41. package/lib/feishu/gateway.js +0 -644
  42. package/lib/feishu/index.js +0 -432
  43. package/lib/feishu/lark-bundled.mjs +0 -131929
  44. package/lib/feishu/markdown.js +0 -148
  45. package/lib/feishu/node.js +0 -1152
  46. package/lib/feishu/slash-commands.js +0 -255
  47. package/lib/platform/account-ops.js +0 -248
  48. package/lib/platform/activity.js +0 -169
  49. package/lib/platform/ask-user.js +0 -293
  50. package/lib/platform/base.js +0 -147
  51. package/lib/platform/changes-digest.js +0 -93
  52. package/lib/platform/commands.js +0 -267
  53. package/lib/platform/config-restore.js +0 -78
  54. package/lib/platform/conversation-bridge.js +0 -1544
  55. package/lib/platform/dsh-storage.js +0 -222
  56. package/lib/platform/index.js +0 -10
  57. package/lib/platform/instances.js +0 -458
  58. package/lib/platform/manager.js +0 -130
  59. package/lib/platform/message-split.js +0 -229
  60. package/lib/platform/session-catalog.js +0 -405
  61. package/lib/platform/session-config.js +0 -52
  62. package/lib/platform/stream-slices.js +0 -21
  63. package/lib/qq/gateway.js +0 -1107
  64. package/lib/qq/index.js +0 -561
  65. package/lib/qq/node.js +0 -1153
  66. package/lib/qq/qqbot-bundled.mjs +0 -8273
  67. package/lib/restart-helper.mjs +0 -133
  68. package/lib/security/log-sanitize.js +0 -50
  69. package/lib/security/path-validator.js +0 -195
  70. package/lib/security/rate-limiter.js +0 -93
  71. package/lib/session-strip.js +0 -100
  72. package/lib/sse-keepalive.js +0 -115
  73. package/lib/telegram/gateway.js +0 -643
  74. package/lib/telegram/index.js +0 -255
  75. package/lib/telegram/node.js +0 -410
  76. package/lib/tunnel-client.mjs +0 -537
  77. package/lib/wechat/gateway.js +0 -1000
  78. package/lib/wechat/index.js +0 -282
  79. package/lib/wechat/media.js +0 -302
  80. package/lib/wechat/node.js +0 -360
  81. package/llms.txt +0 -36
@@ -1,77 +0,0 @@
1
- // DSH 官方目录选择器的「让位」判定(issue #28 第 2 条)
2
- //
3
- // 背景:DSH 0.1.5 起自带官方目录选择器,且注册到与 dsh-bridge 完全相同的两个 Slot:
4
- // conversation.hero.workspace.directoryFlow
5
- // sidebar.workspaces.directoryFlow
6
- // 后端由 @deepseek-ai/dsh-host-directory-picker-auto 按宿主事实分发:非回环绑定 /
7
- // SSH / Linux 无 zenity·kdialog 等图形 chooser → browse(网页树形选择器),
8
- // macOS·Windows 或 Linux 有 chooser → native(系统对话框)。
9
- //
10
- // 而 DSH 的 slots 是 shadow 语义:@deepseek-ai/dsh-cordis-client-runner 的注释明确
11
- // "Page-local shadowing rank. A later registration receives a lower priority."
12
- // "a dynamically registered entry is assigned a lower priority than the shipped
13
- // one, which makes it the winner"
14
- // 即数值越小越优先、后注册者胜。动态加载的 dsh-bridge 因此天然覆盖内置 picker,
15
- // 叠加本插件显式写的 `priority: -10`,连本机(127.0.0.1 / Electron)也被插件自己的
16
- // 远程目录选择器接管:点「添加工作区」弹的是插件的远程抽屉而非 DSH 官方选择器。
17
- //
18
- // 让位条件(两者同时成立才让位):
19
- // 1. 本机访问(isLocalEnvironment())—— 本机有完整权限,官方 picker 直接可用;
20
- // 2. DSH 已自带官方 picker —— 旧版 DSH 没有,必须继续由插件兜底,
21
- // 否则本机点「添加工作区」将彻底没有反应。
22
- // 远程 / 移动访问一律继续由插件接管:那里需要管理密码解锁与 local_only 目录策略,
23
- // 不能换成官方 picker(会绕过插件的访问限制)。
24
- //
25
- // 为什么探测用 ctx.get() 而不是 ctx.uiWorkspace:
26
- // cordis 本体对**未在插件 inject 中声明**的服务做属性访问会直接抛错:
27
- // cannot get property "uiWorkspace" without inject (cordis/lib/index.js)
28
- // —— dsh-bridge 的 client half 是静态 client 插件,其 ctx 是真 cordis Context
29
- // (不是动态包的 runner guard facade),所以拦在这里的是 cordis 本体的规则。
30
- // 而 `ctx.get(name)` 是允许的「可选查找」,不要求声明。
31
- // 又不能把 uiWorkspace 写进 inject:旧版 DSH(无该 seat)会因此把整个插件 park 掉
32
- // (provider 缺失即不 apply),所以只能用 ctx.get() 探测。
33
- //
34
- // ⚠️ 已知时序依赖(独立验收记录,真机 6/6 冷启动实测未触发):
35
- // cordis 的 ctx.get() 在 provider fiber 尚未进入 ACTIVE 时会返回 undefined。理论上若
36
- // 本判定早于 ui-workspace 插件 apply 完成,就会把「有官方 picker」误判为「无」而仍然
37
- // 注册,让位失效。当前稳定是因为 dsh-bridge 自身 inject 的 workspaces / sessions 由
38
- // roster 中排在 ui-workspace **之后**的插件提供,插件真正激活时 ui-workspace 早已
39
- // apply 完成 —— 这是对 roster 顺序的**隐式耦合,不是显式保证**。若将来调整 roster
40
- // 顺序或去掉 workspaces 依赖,需重新评估(加固思路:注册后等 uiWorkspace 可用时
41
- // 再撤销自己的注册)。
42
-
43
- /** 官方 workspace seat 的服务名(DSH 0.1.5 起提供 pickDirectory) */
44
- export const OFFICIAL_WORKSPACE_SEAT = 'uiWorkspace';
45
-
46
- /**
47
- * 探测 DSH 是否已提供官方目录选择器。
48
- *
49
- * 用 `ctx.get()` 做可选查找:seat 不存在(旧版 DSH)、宿主未暴露 pickDirectory、
50
- * 查询或属性读取抛错时一律返回 false —— 探测本身绝不外抛影响插件加载。
51
- *
52
- * @param {{ get?: (name: string) => unknown }} [ctx] cordis 客户端 context
53
- * @returns {boolean} 官方 picker 是否可用
54
- */
55
- export function hasOfficialDirectoryPicker(ctx) {
56
- try {
57
- if (typeof ctx?.get !== 'function') return false;
58
- const seat = ctx.get(OFFICIAL_WORKSPACE_SEAT);
59
- // typeof 也放在 try 内:seat 可能是带异常 getter 的对象或 Proxy,
60
- // 读 pickDirectory 本身就可能抛错。
61
- return typeof seat?.pickDirectory === 'function';
62
- } catch {
63
- // ctx.get 抛错(宿主异常),或属性读取抛错(异常 getter / Proxy):
64
- // 一律按「无官方 picker」处理,保持插件原有兜底行为,探测绝不外抛。
65
- return false;
66
- }
67
- }
68
-
69
- /**
70
- * 是否应让位给 DSH 官方目录选择器。
71
- *
72
- * @param {{ local?: boolean, officialPicker?: boolean }} [facts]
73
- * @returns {boolean} true = 不注册插件选择器,本机交给 DSH 官方处理
74
- */
75
- export function shouldYieldToOfficialPicker(facts) {
76
- return Boolean(facts?.local) && Boolean(facts?.officialPicker);
77
- }
@@ -1,90 +0,0 @@
1
- // 资源地址方言兼容(鸿蒙 ArkWeb / HuaweiBrowser 等内核对非特殊 scheme 的 URL 解析差异)
2
- //
3
- // 现象:某些内核里 `new URL('dsh-resource://file/session/<id>/<path>').hostname` 返回空串,
4
- // 而 Chrome/Safari 返回 "file"。DSH 的客户端资源注册表 `@deepseek-ai/dsh-client-resources`
5
- // 恰好用 URL 的 hostname 当"协议键"(protocolOf),取不到就把该资源的记录判定成"没有 provider":
6
- // - 记录创建时 protocol 为 undefined → 状态永远停在 "none";
7
- // - 注册表只在 provider 注册时把已有记录重新挂载一次,之后不会重试 → 刷新也不会恢复;
8
- // 表现即:右侧栏文件 / 对话里生成的文档预览永远显示「文件资源服务不可用。」,而
9
- // `resources.providers` 里其实是有 "file" 的(宿主 @deepseek-ai/dsh-api-workspace-files 的
10
- // provider 注册成功,坏掉的只是地址解析这一步)。鸿蒙(HuaweiBrowser / ArkWeb 7.0)实测复现。
11
- //
12
- // 处理:**先做特性探测**,只有在该引擎确有差异时才包一层 `window.URL`,并且只对
13
- // `dsh-resource:` 协议、且原生 hostname 为空、且原始串能解析出 host 的实例补一个兜底
14
- // hostname/host(写成实例自有访问器,setter 仍委托原生原型,避免影响赋值语义)。
15
- // 引擎正常时完全不改动 URL(返回 'not-needed')。
16
- //
17
- // 说明:根因在宿主侧(DSH 用 URL.hostname 当协议键),此处是桥侧最小兜底;
18
- // 宿主上游若改为从原始串解析协议,本模块会自动进入 'not-needed' 分支、不再介入。
19
-
20
- /**
21
- * 安装 `dsh-resource:` 地址的 URL 兼容层。
22
- *
23
- * @param {object} [win] 便于测试注入的 window(默认取全局 window)
24
- * @returns {'not-needed'|'installed'|'no-url'|'probe-throw'} 本次的处理结果
25
- * - `not-needed`:引擎解析正常,未做任何改动
26
- * - `installed`:引擎有差异,已包装 window.URL
27
- * - `no-url` / `probe-throw`:环境不适用(无 window.URL / 构造探测失败)
28
- */
29
- export function installResourceUrlCompat(win = typeof window === 'undefined' ? undefined : window) {
30
- if (!win || typeof win.URL !== 'function') return 'no-url';
31
- const NativeURL = win.URL;
32
- let probe;
33
- try {
34
- probe = new NativeURL('dsh-resource://file/__dsh_probe__/x');
35
- } catch {
36
- return 'probe-throw';
37
- }
38
- if (probe.hostname === 'file') return 'not-needed';
39
-
40
- const hostOf = (raw) => {
41
- const m = /^dsh-resource:\/\/([^/?#]*)/i.exec(String(raw || ''));
42
- return m && m[1] ? m[1].toLowerCase() : '';
43
- };
44
-
45
- function CompatURL(...args) {
46
- const url = new NativeURL(...args);
47
- try {
48
- if (String(url.protocol) === 'dsh-resource:' && !url.hostname) {
49
- const proto = NativeURL.prototype;
50
- const hostnameDesc = Object.getOwnPropertyDescriptor(proto, 'hostname');
51
- const hostDesc = Object.getOwnPropertyDescriptor(proto, 'host');
52
- // 读取顺序:原生解析结果优先(赋值/改 href 后立即反映新值),为空才回退到"从当前 href 现解析"。
53
- // 不能缓存成常量,否则 u.hostname='x' 或 u.href=... 之后读回仍是陈旧值(getter/setter 不自洽)。
54
- const hostOfUrl = (u) => {
55
- let native;
56
- try { native = hostnameDesc && hostnameDesc.get ? hostnameDesc.get.call(u) : (u.hostname || ''); } catch { native = ''; }
57
- if (native) return native;
58
- let href;
59
- try { href = String(u.href); } catch { href = ''; }
60
- return hostOf(href);
61
- };
62
- if (hostOfUrl(url)) {
63
- Object.defineProperty(url, 'hostname', {
64
- configurable: true,
65
- enumerable: hostnameDesc ? hostnameDesc.enumerable : true,
66
- get: () => hostOfUrl(url),
67
- set: (v) => { if (hostnameDesc && hostnameDesc.set) hostnameDesc.set.call(url, v); },
68
- });
69
- if (hostDesc) {
70
- Object.defineProperty(url, 'host', {
71
- configurable: true,
72
- enumerable: hostDesc.enumerable,
73
- get: () => hostOfUrl(url),
74
- set: (v) => { if (hostDesc.set) hostDesc.set.call(url, v); },
75
- });
76
- }
77
- }
78
- }
79
- } catch { /* 兜底失败就保持原生行为 */ }
80
- return url;
81
- }
82
-
83
- CompatURL.prototype = NativeURL.prototype;
84
- for (const key of Object.getOwnPropertyNames(NativeURL)) {
85
- if (key === 'length' || key === 'name' || key === 'prototype') continue;
86
- try { CompatURL[key] = NativeURL[key]; } catch { /* 只读静态属性忽略 */ }
87
- }
88
- win.URL = CompatURL;
89
- return 'installed';
90
- }
@@ -1,105 +0,0 @@
1
- // dsh-bridge 设置页左侧导航栏图标定制 (替换默认小齿轮)
2
- //
3
- // 背景:
4
- // DSH 0.1.x 的设置弹窗外壳(@deepseek-ai/dsh-client-ui-settings-general)仅内置了原生
5
- // 三个 section 的图标(models, agent-presets, plugins),对第三方插件注册的 settings.section,
6
- // 外壳一律 fallback 显示其默认的小齿轮图标(gear)。
7
- //
8
- // 解决方案(对齐 dshmarket 与 dsh-better-sidebar):
9
- // 监听设置弹窗挂载,定位到文本匹配「远程访问」的 nav 按钮,通过 CSS mask 将原生齿轮
10
- // 替换为专属的「手机 + 远程无线连接」矢量图标,颜色跟随 currentColor 保持原生悬停与激活态一致。
11
- //
12
- // 选择器策略(Issue #72):以结构锚点 `[role="dialog"] nav button` 为主——它不依赖任何
13
- // CSS-module 哈希,0.1.x 与 0.2.0 实测都命中;宿主前缀(VOzbGW_ / wCInkW_)改由
14
- // client/host-families.js 运行时解析,仅在解析成功时作为补充选择器。
15
- import { ABSENT_FAMILY } from './host-families.js';
16
- import { t } from './locales.mjs';
17
-
18
- export const SETTINGS_NAV_MARKER = 'data-dsh-bridge-settings-nav';
19
-
20
- /**
21
- * 远程访问专属图标:手机 + 无线连接电波 (24x24 viewBox)
22
- */
23
- export const REMOTE_NAV_SVG = `<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><rect width='10' height='16' x='3' y='4' rx='2'/><path d='M7 16h2'/><path d='M17 8a4 4 0 0 1 0 8'/><path d='M20 5a8 8 0 0 1 0 14'/></svg>`;
24
-
25
- /**
26
- * 生成导航栏图标样式
27
- * @param {string} [customSvg] 可选自定义 SVG 字符串
28
- * @returns {string}
29
- */
30
- export function getNavIconCss(customSvg = REMOTE_NAV_SVG) {
31
- const maskUrl = `data:image/svg+xml,${encodeURIComponent(customSvg)}`;
32
- return `
33
- [${SETTINGS_NAV_MARKER}] > svg:first-child {
34
- display: none !important;
35
- }
36
- [${SETTINGS_NAV_MARKER}]::before {
37
- content: '';
38
- flex: none;
39
- width: 16px;
40
- height: 16px;
41
- background-color: currentColor;
42
- -webkit-mask: url("${maskUrl}") center / contain no-repeat;
43
- mask: url("${maskUrl}") center / contain no-repeat;
44
- }
45
- `;
46
- }
47
-
48
- /**
49
- * 注册设置页导航图标适配器
50
- *
51
- * @param {() => string} [resolveLabel] 获取当前标签文本的回调
52
- * @param {() => Record<string,string>} [resolveFamilies] 取运行时解析出的宿主 CSS-module
53
- * 前缀表(见 client/host-families.js)。拿不到时退回纯结构锚点 `[role="dialog"] nav button`
54
- * —— 该锚点在 0.1.x 与 0.2.0 实测都命中,故即使前缀完全解析失败,本功能也不受影响。
55
- * @returns {() => void} 清理函数
56
- */
57
- export function registerSettingsNavIcon(resolveLabel = () => t('远程访问'), resolveFamilies = null) {
58
- if (typeof document === 'undefined') return () => {};
59
-
60
- const styleId = 'dsh-bridge-settings-nav-icon-style';
61
- if (!document.getElementById(styleId)) {
62
- const style = document.createElement('style');
63
- style.id = styleId;
64
- style.dataset.plugin = '@wenbin_wb/dsh-bridge';
65
- style.dataset.pluginCss = '@wenbin_wb/dsh-bridge/settings-nav-icon';
66
- style.textContent = getNavIconCss();
67
- document.head.appendChild(style);
68
- }
69
-
70
- let disposed = false;
71
- const sync = () => {
72
- if (disposed) return;
73
- const currentLabel = typeof resolveLabel === 'function' ? resolveLabel().trim() : t('远程访问');
74
- // 结构锚点优先(版本无关);解析到前缀时再补一条同族选择器,覆盖 nav 结构变化的宿主
75
- const families = typeof resolveFamilies === 'function' ? (resolveFamilies() || {}) : {};
76
- const settingsFamily = families.SETTINGS;
77
- const selector = settingsFamily && settingsFamily !== ABSENT_FAMILY
78
- ? `[role="dialog"] nav button, div[class*="${settingsFamily}_nav"] button`
79
- : '[role="dialog"] nav button';
80
- const buttons = document.querySelectorAll(selector);
81
- for (const button of buttons) {
82
- const text = button.textContent?.trim() || '';
83
- if (currentLabel.length > 0 && (text === currentLabel || text.includes(currentLabel))) {
84
- button.setAttribute(SETTINGS_NAV_MARKER, '');
85
- } else if (button.hasAttribute(SETTINGS_NAV_MARKER)) {
86
- button.removeAttribute(SETTINGS_NAV_MARKER);
87
- }
88
- }
89
- };
90
-
91
- sync();
92
- let observer = null;
93
- if (typeof MutationObserver !== 'undefined') {
94
- observer = new MutationObserver(sync);
95
- observer.observe(document.body, { childList: true, subtree: true, characterData: true });
96
- }
97
-
98
- return () => {
99
- disposed = true;
100
- if (observer) observer.disconnect();
101
- document.querySelectorAll(`[${SETTINGS_NAV_MARKER}]`).forEach((el) => el.removeAttribute(SETTINGS_NAV_MARKER));
102
- const styleEl = document.getElementById(styleId);
103
- if (styleEl) styleEl.remove();
104
- };
105
- }
@@ -1,49 +0,0 @@
1
- // dsh-bridge 主题令牌补齐 —— **无条件注入**(不经过移动端让位判断)
2
- //
3
- // 为什么必须独立成块:
4
- // 这些深色补丁原先写在 MOBILE_STYLES_CSS 里,而 injectMobileStyles() 在
5
- // __TAURI__ / __DSH_DESKTOP__ / 外部响应式插件存在时会直接 return
6
- // (shouldYieldMobileStyles)——深色适配被"移动端让位"一起让掉了,
7
- // 桌面端 Shell 下浅色 fallback 永远生效(浅底 + 浅字)。
8
- //
9
- // 覆盖范围:只补 **DSH 主题系统没有定义**的令牌(全树 grep 0 命中,不会改坏宿主配色):
10
- // DSH 只提供 state-*-{primary,secondary,tertiary,label},
11
- // 没有 *-bg / *-border、没有 info-* 与 danger-*、也没有 border-secondary。
12
- // 浅色沿用各处 var() 的 fallback(本来就是浅色值,无需另行定义),这里补暗色值。
13
- //
14
- // 注:DSH 自己定义的 state-{warn,error,success}-primary 是**强调填充色**(浅色
15
- // amber-500 / red-600 / green-500),当告警面上的正文只有 1.95~4.11:1(不达 AA)。
16
- // 文字一律改用 DSH 的 label-primary(中性正文色,随主题翻转),状态感由底色与边框承担,
17
- // 见 client/index.js 各处 color: 的用法与 test/dark-mode-contrast.test.mjs 的不变式。
18
- export const THEME_TOKENS_CSS = `
19
- /* 深色:告警面 / 边框 / info 与 danger 补色(DSH 未定义,靠 fallback 会永远拿到浅色) */
20
- body[data-ds-dark-theme] {
21
- --dsw-alias-state-info-bg: rgba(65, 118, 230, 0.12);
22
- --dsw-alias-state-info-border: rgba(65, 118, 230, 0.25);
23
- --dsw-alias-state-success-bg: rgba(34, 197, 94, 0.12);
24
- --dsw-alias-state-success-border: rgba(34, 197, 94, 0.25);
25
- --dsw-alias-state-warn-bg: rgba(245, 158, 11, 0.12);
26
- --dsw-alias-state-warn-border: rgba(245, 158, 11, 0.25);
27
- --dsw-alias-state-error-bg: rgba(239, 68, 68, 0.12);
28
- --dsw-alias-state-error-border: rgba(239, 68, 68, 0.25);
29
- --dsw-alias-state-info-primary: #60a5fa;
30
- --dsw-alias-state-danger-bg: rgba(239, 68, 68, 0.12);
31
- --dsw-alias-state-danger-primary: #f87171;
32
- --dsw-alias-border-secondary: #3c3c3d;
33
- }
34
-
35
- /* 深色模式:直接覆盖弹窗内所有使用 #fff/#ffffff fallback 的内联背景,
36
- 确保即使 CSS 变量未正确继承,弹窗也不会显示白色背景 */
37
- body[data-ds-dark-theme] #dsh-remote-workspace-modal .dsh-ws-dialog-card,
38
- body[data-ds-dark-theme] #dsh-remote-workspace-modal .dsh-ws-dialog-card * {
39
- --dsw-alias-bg-layer-1: #1b1b1c;
40
- --dsw-alias-bg-layer-2: #2c2c2e;
41
- --dsw-alias-bg-layer-3: #353638;
42
- --dsw-alias-border-l2: #3c3c3d;
43
- --dsw-alias-label-primary: #f9fafb;
44
- --dsw-alias-label-secondary: #adb2b8;
45
- --dsw-alias-label-tertiary: #81858c;
46
- --dsw-alias-brand-primary: #f9fafb;
47
- --dsw-alias-label-primary-foreground: #0f1115;
48
- }
49
- `;
@@ -1,46 +0,0 @@
1
- // 隧道类操作的前置条件判断(issue #75)——纯函数,便于单测。
2
- //
3
- // 背景:可信隧道相关的高危 RPC(startCloudflared / startCustomTunnel /
4
- // saveCloudflaredConfig / saveCustomTunnelConfig / saveExternalTunnel /
5
- // setTunnelAutoStart)在服务端都带 `requireConfigured: true` 守卫:
6
- //
7
- // 【例外】resetCloudflared 在服务端只是普通 checkAdminAuth(无 requireConfigured),
8
- // 客户端对它也做同一预检,是因为它是 stop+start 复合操作:无密码时 stop 会成功、
9
- // start 被拒,把隧道留在"已停用"状态。故按 start 的前置条件提前拦截——这条属产品
10
- // 取舍,不是与服务端逐端点同源。
11
- // 当「管理保护未关闭」(adminProtection !== false)、「策略非宽松」(adminPolicy !== 'open')
12
- // 且「从未设置任何访问密码/管理密码」时,服务端会直接拒绝并返回可读错误
13
- // (bridge-rpc.js 的 checkAdminAuth)。
14
- //
15
- // 问题在于提示位置:旧实现只在点击后把错误写到**面板顶部横幅**,而隧道卡片往往在
16
- // 长页面下方,用户看不到任何反馈,误以为"点了没反应"(issue #75 的现场观感)。
17
- // 因此客户端需要**与服务端同源**地判断该前置条件,用于「按钮旁常驻提示 + 点击前预检」,
18
- // 而不是自己发明一套规则:条件不一致会出现"提示说不行、点了却能成功"或反之。
19
- //
20
- // 注意判定必须与 bridge-rpc.js checkAdminAuth 的分支顺序一致:
21
- // 1) adminPolicy === 'local_only' → 另走 adminToken 校验,不受本条件约束(返回 false)
22
- // 2) adminProtection === false → 服务端直接放行
23
- // 3) adminPolicy === 'open' → 服务端直接放行
24
- // 4) 其余:无任何密码时 requireConfigured 拦截
25
-
26
- /**
27
- * 是否已设置过任意一种密码(访客访问密码或独立管理密码)。
28
- * @param {{hasPassword?: boolean, hasAdminPassword?: boolean}} [auth]
29
- */
30
- export function hasAnyPassword(auth) {
31
- return Boolean(auth?.hasPassword || auth?.hasAdminPassword)
32
- }
33
-
34
- /**
35
- * 隧道类操作是否会被服务端的前置条件拦下(缺访问密码)。
36
- * @param {object} [auth] 状态快照里的 auth 段(authManager.getStatus())
37
- * @returns {boolean} true = 需要先设置访问密码
38
- */
39
- export function needsTunnelPassword(auth) {
40
- // 没有 auth 快照(状态未加载)时不做判断,避免误导
41
- if (!auth || typeof auth !== 'object') return false
42
- if (auth.adminPolicy === 'local_only') return false
43
- if (auth.adminProtection === false) return false
44
- if (auth.adminPolicy === 'open') return false
45
- return !hasAnyPassword(auth)
46
- }
@@ -1,144 +0,0 @@
1
- import { t } from './locales.mjs';
2
-
3
- // 统一管理员解锁管理器(client/unlock-manager.js)
4
- //
5
- // 背景:多个 RPC 封装(BridgePanel 的 authRpcCall、目录选择器的 authRpc)都会遇到
6
- // "需要管理员权限" 的拦截,此前各自处理、目录选择器甚至没有解锁入口导致流程卡死。
7
- // 本模块把"token 管理 + 权限拦截检测 + 解锁动作 + 被拦截操作自动重放"统一收敛。
8
- //
9
- // 设计要点:
10
- // - token 与 sessionStorage 同步,是本插件唯一的 adminToken 缓存来源
11
- // - 权限失败 -> 解锁成功 -> 自动重放被拦截的 RPC(pending 队列)
12
- // - 与具体 UI 框架解耦,纯 JS 单例
13
-
14
- const SESSION_KEY = 'dsh_admin_token';
15
-
16
- let _token = '';
17
- let _pendingOps = []; // 被拦截待重放的操作队列(解锁后按序重放)
18
- let _onUnlocked = null; // 解锁成功后的全局回调(可多个)
19
-
20
- // 读写 sessionStorage
21
- function _readStorage() {
22
- try { return typeof window !== 'undefined' ? window.sessionStorage.getItem(SESSION_KEY) : ''; } catch { return ''; }
23
- }
24
- function _writeStorage(t) {
25
- try {
26
- if (typeof window === 'undefined') return;
27
- if (t) window.sessionStorage.setItem(SESSION_KEY, t);
28
- else window.sessionStorage.removeItem(SESSION_KEY);
29
- } catch {}
30
- }
31
-
32
- export function getAdminToken() {
33
- if (_token) return _token;
34
- const saved = _readStorage();
35
- if (saved) { _token = saved; return saved; }
36
- return '';
37
- }
38
-
39
- export function setAdminToken(t) {
40
- _token = t || '';
41
- _writeStorage(_token);
42
- }
43
-
44
- export function clearAdminToken() {
45
- setAdminToken('');
46
- }
47
-
48
- /**
49
- * 本机(回环)场景:从多候选 URL 获取 loopback adminToken(免输密码直通管理)。
50
- * 支持 3080 原生端口与 3082 代理端口;不信任 sessionStorage 里的旧 token
51
- * ——DSH 重启后 adminSessions 清空,旧 token 失效,因此强制每次获取新的。
52
- * 返回 null 表示获取失败(非本机 / 端点不可达)。
53
- * @param {boolean} [force] true 时忽略已缓存 token 强制重取(默认 true)
54
- */
55
- export async function fetchLoopbackTokenOnce(force = true) {
56
- if (typeof window === 'undefined') return null;
57
- if (!force) {
58
- const existing = getAdminToken();
59
- if (existing) return existing;
60
- }
61
- const candidates = [
62
- '/__dsh_bridge__/loopback-token',
63
- 'http://127.0.0.1:3082/__dsh_bridge__/loopback-token',
64
- 'http://localhost:3082/__dsh_bridge__/loopback-token',
65
- ];
66
- for (const url of [...new Set(candidates)]) {
67
- try {
68
- const res = await fetch(url, { method: 'POST' });
69
- if (res.ok) {
70
- const data = await res.json();
71
- if (data?.ok && data.adminToken) {
72
- setAdminToken(data.adminToken);
73
- return data.adminToken;
74
- }
75
- }
76
- } catch {}
77
- }
78
- return null;
79
- }
80
-
81
- /**
82
- * 暂存被拦截的操作,解锁成功后自动重放(队列:多个被拦操作按序重放)。
83
- * @param {function} retry 返回 Promise 的重试函数(带新 token 重新发起)
84
- */
85
- export function queuePendingOperation(retry) {
86
- _pendingOps.push(retry);
87
- }
88
-
89
- /** 解锁成功后重放所有 pending 操作(若有) */
90
- export async function replayPending() {
91
- if (_pendingOps.length === 0) return;
92
- const ops = _pendingOps;
93
- _pendingOps = [];
94
- for (const retry of ops) {
95
- try { await retry(); } catch {}
96
- }
97
- }
98
-
99
- /** 监听解锁成功(可多个);返回注销函数 */
100
- export function onUnlocked(cb) {
101
- if (!_onUnlocked) _onUnlocked = [];
102
- _onUnlocked.push(cb);
103
- return () => { _onUnlocked = _onUnlocked.filter((f) => f !== cb); };
104
- }
105
-
106
- /** 内部:解锁成功后触发 */
107
- function _emitUnlocked() {
108
- if (_onUnlocked) {
109
- for (const cb of _onUnlocked) { try { cb(); } catch {} }
110
- }
111
- }
112
-
113
- /**
114
- * 统一解锁动作:调 authAdminUnlock,成功存 token 并触发回调/重放。
115
- * @param {function} rpcCall 底层 rpcCall(用于调 authAdminUnlock)
116
- * @param {string} password 管理密码
117
- * @returns {Promise<{ok:boolean, error?:string}>}
118
- */
119
- export async function unlockAdmin(rpcCall, password) {
120
- try {
121
- const res = await rpcCall('authAdminUnlock', { password });
122
- if (res?.ok) {
123
- const token = res.value?.adminToken || '';
124
- setAdminToken(token);
125
- _emitUnlocked();
126
- await replayPending();
127
- return { ok: true };
128
- }
129
- return { ok: false, error: res?.error?.message || t('管理员密码错误') };
130
- } catch (err) {
131
- // 访问会话失效(HTTP 401):authAdminUnlock 即使豁免,主面板后续请求也会 401,
132
- // 解锁成功也进不去。此时应整页重载回绿色登录页重新获取访问会话。
133
- const msg = String(err?.message || err || '');
134
- if (msg.includes('401') || msg.includes('transport failure') || msg.includes('unauthorized')) {
135
- if (typeof window !== 'undefined' && typeof window.location?.reload === 'function') {
136
- try {
137
- window.sessionStorage.setItem('dsh_access_expired_reloaded', '1');
138
- } catch {}
139
- window.location.reload();
140
- }
141
- }
142
- return { ok: false, error: err?.message || t('解锁请求失败') };
143
- }
144
- }
@@ -1,148 +0,0 @@
1
- // DSH 原生浏览器鉴权的兼容层(lib/auth/dsh-native-cookie.js)
2
- //
3
- // 背景
4
- // ---------------------------------------------------------------
5
- // DSH 从 0.1.2 起为 `dsh web` 引入了"原生浏览器鉴权":
6
- // 1. 启动时打印带一次性 launch token 的地址:http://127.0.0.1:<port>/?token=…
7
- // 2. `GET /?token=<token>` 校验通过后 303 重定向到 `/`,并下发签名会话 cookie
8
- // 3. 此后所有受保护路由(`/`、`/api`、以及任何经
9
- // `ctx.connection.rpc.handle()` 注册的通道)都要求该 cookie,否则 401
10
- //
11
- // cookie 的构造(见 @deepseek-ai/dsh-client-connection 的 BrowserAuth):
12
- // 名称:`dsh-auth-` + base64url(sha256(authority)),authority = `127.0.0.1:<port>`
13
- // 值 :`v1.<base64url(JSON payload)>.<base64url(HMAC-SHA256(secret, body))>`
14
- // 载荷:`{ version: 1, authority, issuedAt, expiresAt }`(均为安全整数)
15
- // 密钥:`<DSH_HOME>/.credentials.yaml` 里 record
16
- // `client-connection/browser-session` 的 `payload.secret`(base64url 32 字节)
17
- //
18
- // 为什么本插件需要它
19
- // ---------------------------------------------------------------
20
- // 本插件的自建反向代理监听自己的端口(默认 3082),对外提供局域网/隧道访问,
21
- // 再把请求转发到 DSH 回环地址。浏览器持有的是"代理 origin"的 cookie,
22
- // 而 DSH 的 cookie 绑定的是 `127.0.0.1:<dshPort>` 这个 authority,两者不是同一个,
23
- // 因此代理必须自行注入一枚合法的 loopback 会话 cookie,否则远程访问会一律 401。
24
- //
25
- // 本模块只做"按 DSH 的格式生成一枚回环会话 cookie",不引入任何三方依赖。
26
-
27
- import { createHash, createHmac } from 'node:crypto';
28
- import { existsSync, readFileSync } from 'node:fs';
29
- import { homedir } from 'node:os';
30
- import { join } from 'node:path';
31
-
32
- /** DSH 会话 cookie 的名称前缀(与 dsh-client-connection 的 COOKIE_PREFIX 一致)。 */
33
- export const DSH_AUTH_COOKIE_PREFIX = 'dsh-auth-';
34
-
35
- /** cookie 载荷版本(与 dsh-client-connection 的 COOKIE_PAYLOAD_VERSION 一致)。 */
36
- export const DSH_AUTH_COOKIE_VERSION = 1;
37
-
38
- /** 会话 cookie 有效期(毫秒)。与 DSH 默认的 cookieMaxAgeDays=30 对齐。 */
39
- export const DSH_AUTH_COOKIE_MAX_AGE_MS = 30 * 24 * 3600 * 1000;
40
-
41
- /** 承载签名密钥的 DSH 凭据记录键。 */
42
- export const DSH_BROWSER_SESSION_RECORD = 'client-connection/browser-session';
43
-
44
- function base64Url(value) {
45
- return Buffer.from(value).toString('base64url');
46
- }
47
-
48
- /**
49
- * 计算 DSH 会话 cookie 的名称(authority 绑定)。
50
- * @param {number|string} targetPort DSH 回环端口
51
- * @param {string} [host] 默认 127.0.0.1
52
- * @returns {string} 形如 `dsh-auth-<base64url(sha256("127.0.0.1:3080"))>`
53
- */
54
- export function dshAuthCookieName(targetPort, host = '127.0.0.1') {
55
- const authority = `${host}:${targetPort}`;
56
- return DSH_AUTH_COOKIE_PREFIX + base64Url(createHash('sha256').update(authority).digest());
57
- }
58
-
59
- /**
60
- * 从 DSH 凭据文件内容中取出 browser-session 记录的签名密钥。
61
- *
62
- * 必须精确定位 `client-connection/browser-session` 这条 record:
63
- * 凭据文件里还有 `refs:` 段(各家模型 API key)以及可能存在其它插件写入的
64
- * record,一旦取到别处的 `secret:`,签名就会无效 —— 现象是"远程访问一律 401",
65
- * 且极难定位。因此先锚定 record 键,再在其后的小范围内取 secret;
66
- * 仅当锚点缺失(异常/未来格式)时才退回"全文件第一个 secret:"的旧行为。
67
- *
68
- * @param {string} content 凭据文件文本
69
- * @returns {string} base64url 编码的密钥;取不到时返回空串
70
- */
71
- export function readBrowserSessionSecret(content) {
72
- if (typeof content !== 'string' || content === '') return '';
73
-
74
- // 锚定 record 键所在行,随后只在该行往后的一小段内查找 secret
75
- const anchorMatch = /^[ \t]*client-connection\/browser-session:[ \t]*$/m.exec(content);
76
- if (anchorMatch) {
77
- const tail = content.slice(anchorMatch.index);
78
- const scoped = /secret:[ \t]*([A-Za-z0-9_-]+)/.exec(tail);
79
- if (scoped) return scoped[1];
80
- }
81
-
82
- // 兜底:老格式/异常格式下沿用全文件首个 secret:
83
- const fallback = /secret:[ \t]*([A-Za-z0-9_-]+)/.exec(content);
84
- return fallback ? fallback[1] : '';
85
- }
86
-
87
- /**
88
- * 生成一枚绑定到 loopback authority 的 DSH 原生会话 cookie。
89
- *
90
- * @param {object} opts
91
- * @param {number|string} opts.targetPort DSH 回环端口
92
- * @param {string} opts.secret base64url 编码的签名密钥
93
- * @param {string} [opts.host] 默认 127.0.0.1
94
- * @param {number} [opts.now] 当前时间戳(测试注入用)
95
- * @param {number} [opts.maxAgeMs] 有效期
96
- * @returns {string} `name=value`;密钥缺失时返回空串
97
- */
98
- export function buildDshLoopbackCookie({
99
- targetPort,
100
- secret,
101
- host = '127.0.0.1',
102
- now = Date.now(),
103
- maxAgeMs = DSH_AUTH_COOKIE_MAX_AGE_MS,
104
- }) {
105
- if (!secret || !targetPort) return '';
106
-
107
- const authority = `${host}:${targetPort}`;
108
- const name = dshAuthCookieName(targetPort, host);
109
- // 与 DSH 一致:issuedAt 略微回拨,避免与宿主的同秒比较产生边界问题
110
- const issuedAt = now - 1000;
111
- const expiresAt = issuedAt + maxAgeMs;
112
-
113
- const body = base64Url(Buffer.from(JSON.stringify({
114
- version: DSH_AUTH_COOKIE_VERSION,
115
- authority,
116
- issuedAt,
117
- expiresAt,
118
- }), 'utf8'));
119
- const key = Buffer.from(secret, 'base64url');
120
- const sig = base64Url(createHmac('sha256', key).update(body).digest());
121
- return `${name}=v${DSH_AUTH_COOKIE_VERSION}.${body}.${sig}`;
122
- }
123
-
124
- /**
125
- * 读取本机 DSH 凭据并生成回环会话 cookie(失败时返回空串,绝不抛出)。
126
- *
127
- * @param {number|string} targetPort DSH 回环端口
128
- * @param {object} [env] 便于测试注入的环境
129
- * @param {string} [env.dshHome] DSH 主目录(默认 $DSH_HOME 或 ~/.dsh)
130
- * @param {Function} [env.readFileSync] 文件读取实现(默认 node:fs)
131
- * @param {Function} [env.existsSync] 存在性判断实现
132
- * @param {number} [env.now] 当前时间戳
133
- * @returns {string} `name=value`,或空串
134
- */
135
- export function getDshLoopbackCookie(targetPort, env = {}) {
136
- const readFile = env.readFileSync ?? readFileSync;
137
- const exists = env.existsSync ?? existsSync;
138
- try {
139
- const dshHome = env.dshHome ?? process.env.DSH_HOME ?? join(homedir(), '.dsh');
140
- const credPath = join(dshHome, '.credentials.yaml');
141
- if (!exists(credPath)) return '';
142
- const secret = readBrowserSessionSecret(readFile(credPath, 'utf8'));
143
- if (!secret) return '';
144
- return buildDshLoopbackCookie({ targetPort, secret, now: env.now });
145
- } catch {
146
- return '';
147
- }
148
- }