dsh-mobile-plugin 0.0.0-stage → 0.1.57

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 (87) hide show
  1. package/README.md +128 -2
  2. package/config/agent-presets/standard/agent.cordis.yml +170 -0
  3. package/config/agent-presets/standard/preset.yml +3 -0
  4. package/cordis.mobile-host.yml +48 -0
  5. package/cordis.patch.yml +21 -0
  6. package/dsh-mobile-credentials/lib/credentials.js +135 -0
  7. package/dsh-mobile-credentials/package.json +13 -0
  8. package/host.js +128 -0
  9. package/lib/auto-archive.cjs +98 -0
  10. package/lib/client.js +690 -0
  11. package/lib/desktop-half-stats.cjs +101 -0
  12. package/lib/desktop-view.cjs +109 -0
  13. package/lib/host-guard.cjs +230 -0
  14. package/lib/host-patches.cjs +46 -0
  15. package/lib/http/package.json +3 -0
  16. package/lib/http/routing.js +84 -0
  17. package/lib/impl.js +406 -0
  18. package/lib/mirror/pusher.cjs +706 -0
  19. package/lib/mirror-snapshot.cjs +39 -0
  20. package/lib/mobile/agent-presets.js +182 -0
  21. package/lib/mobile/attachments.js +116 -0
  22. package/lib/mobile/auth.js +78 -0
  23. package/lib/mobile/completion.js +114 -0
  24. package/lib/mobile/credentials.js +150 -0
  25. package/lib/mobile/directory-browse.js +295 -0
  26. package/lib/mobile/envelope.js +51 -0
  27. package/lib/mobile/events.js +293 -0
  28. package/lib/mobile/history-derive.js +608 -0
  29. package/lib/mobile/host-bridge/endpoints.js +189 -0
  30. package/lib/mobile/host-bridge/event-synthesis.js +232 -0
  31. package/lib/mobile/host-bridge/shims.js +218 -0
  32. package/lib/mobile/host-bridge/transport.js +168 -0
  33. package/lib/mobile/host-bridge.js +71 -0
  34. package/lib/mobile/host-settings.js +282 -0
  35. package/lib/mobile/idempotency.js +78 -0
  36. package/lib/mobile/jobs.js +97 -0
  37. package/lib/mobile/local-images.js +153 -0
  38. package/lib/mobile/package.json +3 -0
  39. package/lib/mobile/plugins.js +149 -0
  40. package/lib/mobile/questions.js +71 -0
  41. package/lib/mobile/references.js +209 -0
  42. package/lib/mobile/rpc.js +76 -0
  43. package/lib/mobile/session-cache.js +263 -0
  44. package/lib/mobile/session-goal.js +44 -0
  45. package/lib/mobile/session-log.js +94 -0
  46. package/lib/mobile/session-mirror.js +177 -0
  47. package/lib/mobile/session-models.js +153 -0
  48. package/lib/mobile/session-presets.js +52 -0
  49. package/lib/mobile/session-search.js +65 -0
  50. package/lib/mobile/session-send.js +278 -0
  51. package/lib/mobile/session-snapshot.js +142 -0
  52. package/lib/mobile/sessions.js +462 -0
  53. package/lib/mobile/skills.js +80 -0
  54. package/lib/mobile/stream.js +268 -0
  55. package/lib/mobile/subagents.js +265 -0
  56. package/lib/mobile/workspaces.js +214 -0
  57. package/lib/panel/index.html +33 -0
  58. package/lib/read-receipt.cjs +124 -0
  59. package/lib/reconnect-scheduler.cjs +37 -0
  60. package/lib/relay/core.js +77 -0
  61. package/lib/relay/lan-network.cjs +85 -0
  62. package/lib/relay/lan-proxy.js +593 -0
  63. package/lib/relay/package.json +3 -0
  64. package/lib/relay/pairing-policy.js +45 -0
  65. package/lib/relay/port-allocator.cjs +60 -0
  66. package/lib/relay/preview.js +374 -0
  67. package/lib/relay/protocol.js +177 -0
  68. package/lib/relay/status.js +145 -0
  69. package/lib/relay/tunnel.js +721 -0
  70. package/lib/routes/control-plane.js +311 -0
  71. package/lib/routes/mobile-api.js +208 -0
  72. package/lib/routes/package.json +3 -0
  73. package/lib/session-cleanup.cjs +153 -0
  74. package/lib/shared/credentials-entry.js +48 -0
  75. package/lib/shared/dshtoken.js +62 -0
  76. package/lib/shared/fresh-request.js +35 -0
  77. package/lib/shared/http-util.js +71 -0
  78. package/lib/shared/list-revision.js +51 -0
  79. package/lib/shared/list-signal.js +87 -0
  80. package/lib/shared/package.json +3 -0
  81. package/lib/shared/paths.js +27 -0
  82. package/lib/shared/session-signal.js +110 -0
  83. package/lib/shared/workspace-guard.js +127 -0
  84. package/lib/sidecar-facade-worker.cjs +353 -0
  85. package/lib/sidecar-facade.cjs +141 -0
  86. package/lib/sidecar-lifecycle.cjs +246 -0
  87. package/package.json +74 -4
@@ -0,0 +1,124 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 「已读回执」台账:手机端读过哪个会话,桌面端据此清掉自己的未读绿点。
5
+ *
6
+ * 背景:两端侧边栏的绿点来自**两份互不相干**的状态。
7
+ * - 手机端绿点 = 宿主按设备维度维护的完成提醒(lib/mobile/completion.js),
8
+ * 由 `POST /api/mobile/v1/session/read` 清掉;
9
+ * - 桌面端绿点 = 浏览器渲染进程内存里的 `UiSession.completionUnread`
10
+ * (@deepseek-ai/dsh-client-ui-session),既不落盘也不过服务端。
11
+ * 所以手机上点开一个会话,桌面那份完全不知情,绿点照旧。
12
+ *
13
+ * 这里补的就是这条缺失的通道:手机端每点开一个会话,宿主除了清自己那份提醒,
14
+ * 还往本模块记一条 `sessionId → readAt` 水位;插件的客户端半边
15
+ * (lib/client.js 的 startReadReceiptSync)每 2 秒拉一次 `GET /api/relay/read-receipt`,
16
+ * 对水位前进过的会话做一次瞬时 `sessions.retain(id, { source: 'mainView' })` +
17
+ * `release()`,借桌面自己的 `reconcileStatus()` 把 `completionUnread` 摘掉。
18
+ *
19
+ * 为什么存 readAt 而不是一个「已读」布尔:冲突策略是 last-write-wins。
20
+ * 桌面点开 → 客户端半边把该会话 POST 回 `/api/relay/desktop-read` → 宿主清掉
21
+ * **所有设备**维度上的完成提醒(`clearCompletionForAllDevices`)。而「读完又来一轮」
22
+ * 必须还能重新亮起来 —— 这一条由 completion.js 的 delete 语义本身保证:条目被清掉后
23
+ * 重建出来的 `wasRunning` 是 false,历史折叠会拒绝确认,于是只有**之后**真正跑完的
24
+ * 轮次才会把 completed 重新置真。
25
+ */
26
+
27
+ const fs = require('node:fs');
28
+ const path = require('node:path');
29
+ const paths = require('./shared/paths');
30
+
31
+ /** 单个 sessionId 长度上限;超长的一律丢弃(真实 id 只有十几字节)。 */
32
+ const MAX_ID_LENGTH = 200;
33
+ /** 台账条目数上限:超出时按 readAt 保留最新的,防止文件无限增长。 */
34
+ const MAX_ENTRIES = 512;
35
+
36
+ /** 台账文件:$DSH_HOME/read-receipts.json(DSH_HOME 在调用时解析,便于测试注入)。 */
37
+ function stateFile() {
38
+ return path.join(paths.dshHome(), 'read-receipts.json');
39
+ }
40
+
41
+ /** null = 台账还没写过任何一条;否则 { readAt: { [sessionId]: number } }。 */
42
+ let cached = null;
43
+ let loaded = false;
44
+
45
+ /**
46
+ * 归一化台账:只保留「合法的 sessionId → 有限正数 readAt」,并按 readAt 截到 MAX_ENTRIES。
47
+ * 形状不对返回 null(调用方按「还没有台账」处理,不报错)。
48
+ */
49
+ function normalizeReceipts(input) {
50
+ if (!input || typeof input !== 'object' || Array.isArray(input)) return null;
51
+ const entries = [];
52
+ for (const rawKey of Object.keys(input)) {
53
+ const value = input[rawKey];
54
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) continue;
55
+ const key = rawKey.trim();
56
+ if (!key || key.length > MAX_ID_LENGTH) continue;
57
+ entries.push([key, value]);
58
+ }
59
+ entries.sort((a, b) => b[1] - a[1]);
60
+ const out = {};
61
+ for (const [key, value] of entries.slice(0, MAX_ENTRIES)) out[key] = value;
62
+ return out;
63
+ }
64
+
65
+ function load() {
66
+ if (loaded) return cached;
67
+ loaded = true;
68
+ try {
69
+ const raw = JSON.parse(fs.readFileSync(stateFile(), 'utf8'));
70
+ const readAt = normalizeReceipts(raw && raw.readAt);
71
+ if (!readAt) {
72
+ cached = null;
73
+ return cached;
74
+ }
75
+ cached = { readAt };
76
+ } catch {
77
+ // 文件不存在 / 损坏都按「还没有台账」处理:客户端半边这轮就什么都不做。
78
+ cached = null;
79
+ }
80
+ return cached;
81
+ }
82
+
83
+ /** 当前台账的副本;从未写过时返回 null。 */
84
+ function snapshot() {
85
+ const value = load();
86
+ if (!value) return null;
87
+ return { readAt: { ...value.readAt } };
88
+ }
89
+
90
+ function persist(readAt) {
91
+ const file = stateFile();
92
+ fs.mkdirSync(path.dirname(file), { recursive: true });
93
+ const tmp = `${file}.tmp`;
94
+ fs.writeFileSync(tmp, JSON.stringify({ readAt }, null, 2), { mode: 0o600 });
95
+ fs.renameSync(tmp, file);
96
+ cached = { readAt };
97
+ loaded = true;
98
+ }
99
+
100
+ /**
101
+ * 记一条已读:`sessionId` 在 `now` 被读过。返回 `{ sessionId, readAt, changed }`。
102
+ *
103
+ * 水位**只前进不后退**:`now` 不比已记的小就保留原值,`changed` 为 false。
104
+ * 这样客户端半边可以拿「本地见过的最大 readAt」当游标,重复读同一个会话不会
105
+ * 反复触发桌面侧的清除动作。
106
+ */
107
+ function recordRead(sessionId, now = Date.now()) {
108
+ const id = typeof sessionId === 'string' ? sessionId.trim() : '';
109
+ if (!id || id.length > MAX_ID_LENGTH) {
110
+ return { sessionId: id, readAt: 0, changed: false };
111
+ }
112
+ const at = Number.isFinite(now) && now > 0 ? now : Date.now();
113
+ const current = load();
114
+ const previous = current ? current.readAt[id] : undefined;
115
+ if (typeof previous === 'number' && previous >= at) {
116
+ return { sessionId: id, readAt: previous, changed: false };
117
+ }
118
+ const next = { ...(current ? current.readAt : {}), [id]: at };
119
+ const pruned = normalizeReceipts(next);
120
+ persist(pruned || { [id]: at });
121
+ return { sessionId: id, readAt: at, changed: true };
122
+ }
123
+
124
+ module.exports = { stateFile, snapshot, recordRead };
@@ -0,0 +1,37 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * 幂等重连调度器。
5
+ *
6
+ * worker 的隧道有两条失败路径:RelayTunnel 的 `onClose` 与 `start()` 的 reject
7
+ * (见 lib/sidecar-facade-worker.cjs 的 startTunnel)。两条路径都会请求重连,
8
+ * 如果各自排一个定时器,每次失败就会重连两次,形成重连风暴。
9
+ *
10
+ * 这里把「同一个待触发窗口内只保留一个定时器」这一不变量收敛成一个模块,
11
+ * 以便用毫秒级延迟做真实的行为测试。端到端数连接次数是分不出来的:
12
+ * 生产里延迟固定 5s,且 startTunnel 开头的 stopTunnel 会吸收重复定时器
13
+ * 造成的大部分可见差异。
14
+ */
15
+ function createReconnectScheduler({ delayMs, onFire, setTimer = setTimeout, clearTimer = clearTimeout } = {}) {
16
+ if (typeof delayMs !== 'number' || !(delayMs >= 0)) throw new Error('invalid delayMs')
17
+ if (typeof onFire !== 'function') throw new Error('invalid onFire')
18
+ let timer = null
19
+ return {
20
+ /** 排程一次重连;已有待触发的定时器时返回 false,表示被幂等吸收。 */
21
+ schedule() {
22
+ if (timer !== null) return false
23
+ timer = setTimer(() => { timer = null; onFire() }, delayMs)
24
+ return true
25
+ },
26
+ /** 取消待触发的重连(显式停止隧道时调用)。 */
27
+ cancel() {
28
+ if (timer === null) return
29
+ clearTimer(timer)
30
+ timer = null
31
+ },
32
+ /** 是否已有待触发的重连。 */
33
+ pending() { return timer !== null },
34
+ }
35
+ }
36
+
37
+ module.exports = { createReconnectScheduler }
@@ -0,0 +1,77 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const os = require('node:os');
5
+ const path = require('node:path');
6
+ const crypto = require('node:crypto');
7
+ const sharedToken = require('../shared/dshtoken');
8
+ const paths = require('../shared/paths');
9
+
10
+ const STATE_FILE = paths.relayStateFile();
11
+ /** 浏览器/局域网代理签发的 `dsh_lan` Cookie 有效期(30 天)。 */
12
+ const MOBILE_TOKEN_TTL_SEC = 30 * 24 * 3600;
13
+ /**
14
+ * App 侧长期 bearer token 的有效期(1 年)。
15
+ *
16
+ * App 没有续签流程,这个 token 只能靠宿主轮换 `lanSecret`(`/api/relay/revoke-all`)
17
+ * 整体作废;单设备吊销改由 `dshc_` 设备凭据承担(见 `lib/mobile/auth.js`)。
18
+ * 旧实现把它写成 10 年,且与 Cookie 的 30 天完全脱钩,`MOBILE_TOKEN_TTL_SEC`
19
+ * 这个常量实际上从未被任何人使用过。
20
+ */
21
+ const MOBILE_BEARER_TTL_SEC = 365 * 24 * 3600;
22
+
23
+ function genInstanceId() { return crypto.randomBytes(4).toString('hex'); }
24
+ function genPairingCode() { return String(crypto.randomInt(0, 1000000)).padStart(6, '0'); }
25
+ function defaultState() {
26
+ return {
27
+ instanceId: genInstanceId(), pairingCode: genPairingCode(), relayUrl: '', name: os.hostname(),
28
+ lanEnabled: true, lanPort: 18787, lanSecret: crypto.randomBytes(32).toString('hex'), enabled: false,
29
+ // 中转侧 TUNNEL_REGISTER_SECRET 的对偶值。为空时隧道注册会被中转以
30
+ // close(1008, 'bad registration secret') 拒掉(除非中转根本没开校验)。
31
+ registerSecret: '',
32
+ // 首次接入引导走完没有。**刻意不进 RemoteControlSettingsSchema**:它不是一项
33
+ // 「远程控制设置」,改它不该触发 sidecar 重配(那条链上每加一个字段都要同步改
34
+ // schema、worker 的 allowed 数组与 buildConfig,漏一处就抛 unknown field)。
35
+ // 它只是面板用来决定「打开先走引导还是直接进主页」的一个落盘标记,
36
+ // `settings.update()` 的 Object.assign 不会碰它(schema 里没这个键)。
37
+ onboarded: false,
38
+ };
39
+ }
40
+ function loadState() {
41
+ try {
42
+ const raw = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
43
+ return { ...defaultState(), ...raw };
44
+ } catch {
45
+ const state = defaultState();
46
+ saveState(state);
47
+ return state;
48
+ }
49
+ }
50
+ function saveState(state) {
51
+ fs.mkdirSync(path.dirname(STATE_FILE), { recursive: true });
52
+ const tmp = `${STATE_FILE}.tmp`;
53
+ fs.writeFileSync(tmp, JSON.stringify(state, null, 2), { mode: 0o600 });
54
+ fs.renameSync(tmp, STATE_FILE);
55
+ }
56
+ /**
57
+ * 中转地址 → 隧道 WebSocket 地址。
58
+ *
59
+ * 旧实现只是把 `http` 前缀替换成 `ws`,因此 `example.com:8700`、`//relay.example`
60
+ * 这类缺协议的输入会生成一个 `new WebSocket()` 直接拒绝的字符串,错误要等到
61
+ * 隧道连接阶段才以 `tunnel.state='error'` 的形式浮现。这里提前抛错,让
62
+ * `/api/relay/config` 能在保存时就给出明确反馈。
63
+ */
64
+ function tunnelWsUrl(relayUrl) {
65
+ const raw = String(relayUrl || '').trim().replace(/\/+$/, '');
66
+ if (!/^https?:\/\/[^\s/]+/i.test(raw)) {
67
+ throw new Error(`无效的中转地址「${relayUrl || ''}」:必须以 http:// 或 https:// 开头`);
68
+ }
69
+ return `${raw.replace(/^http/i, 'ws')}/tunnel`;
70
+ }
71
+ function signLanToken(secret, instanceId, ttlSec = MOBILE_TOKEN_TTL_SEC) { return sharedToken.signLanToken(secret, instanceId, ttlSec); }
72
+ function verifyLanToken(secret, token, instanceId) { return sharedToken.verifyLanToken(secret, token, instanceId); }
73
+
74
+ module.exports = {
75
+ STATE_FILE, MOBILE_TOKEN_TTL_SEC, MOBILE_BEARER_TTL_SEC, loadState, saveState, genInstanceId, genPairingCode,
76
+ tunnelWsUrl, signLanToken, verifyLanToken,
77
+ };
@@ -0,0 +1,85 @@
1
+ 'use strict'
2
+
3
+ const os = require('node:os')
4
+
5
+ // Only advertise RFC1918 addresses. This intentionally excludes VPN/CGNAT,
6
+ // link-local and benchmarking/test ranges (notably 198.18.0.0/15).
7
+ function ipv4Number(address) {
8
+ const parts = String(address).split('.').map(Number)
9
+ if (parts.length !== 4 || parts.some((part, i) => !Number.isInteger(part) || part < 0 || part > 255 || String(part) !== String(address).split('.')[i])) return null
10
+ return (((parts[0] * 256 + parts[1]) * 256 + parts[2]) * 256 + parts[3]) >>> 0
11
+ }
12
+ function inRange(value, start, end) { return value >= start && value <= end }
13
+ function isPrivateLanAddress(address) {
14
+ const value = ipv4Number(address)
15
+ if (value === null) return false
16
+ return inRange(value, 0x0a000000, 0x0affffff) // 10/8
17
+ || inRange(value, 0xac100000, 0xac1fffff) // 172.16/12
18
+ || inRange(value, 0xc0a80000, 0xc0a8ffff) // 192.168/16
19
+ }
20
+ function isVpnInterface(name) {
21
+ return /^(?:utun|tun|tap|ppp|ipsec|wg|tailscale|vpn|zt|nebula)/i.test(String(name))
22
+ }
23
+
24
+ // Interfaces that routinely hold an RFC1918 address but are NOT reachable from a
25
+ // phone sitting on the physical LAN: container bridges, hypervisor host-only
26
+ // networks and overlay VPNs. Advertising them used to cost the app one 1.2s
27
+ // probe per dead candidate before it reached the real address.
28
+ //
29
+ // Container/WAF bridges are the easy ones to miss because they are named after
30
+ // the product, not after Docker: `safeline-ce` (SafeLine WAF) sits on
31
+ // 192.168.0.1/24 and used to be the ONLY candidate on a host whose physical NIC
32
+ // carries public addresses, so the app was told to dial a WAF container. Same
33
+ // for `cni0` / `lxcbr0` / `podman0` / `flannel.1` / `cali<hash>` / `kube-ipvs0`.
34
+ //
35
+ // Deliberately NOT filtered: macOS `bridge100` / `ap*`, because Internet Sharing
36
+ // hands out a genuine LAN on those interfaces.
37
+ const VIRTUAL_INTERFACE = /^(?:docker|br-|veth|virbr|virbr-|vmnet|vboxnet|vbox|vnic|vEthernet|Hyper-V|Default Switch|zt|nebula|ham\d|llw\d|awdl\d|safeline|cni|lxcbr|podman|flannel|cali|kube)/i
38
+ function isVirtualInterface(name) { return VIRTUAL_INTERFACE.test(String(name)) }
39
+
40
+ // Rank candidates so the address most likely to face the phone goes first. The
41
+ // relay and the app both probe in order, so ordering is a latency optimisation,
42
+ // not a correctness requirement.
43
+ function interfaceRank(name) {
44
+ const n = String(name)
45
+ if (/^(?:eth\d+$|enp\ds?\d*$|ens\d|eno\d|enx)/i.test(n)) return 0 // Linux ethernet
46
+ if (/^en\d+$/i.test(n)) return 1 // macOS: en0 (Wi-Fi) / enN (thunderbolt ethernet)
47
+ if (/^(?:wl|wlan)/i.test(n)) return 2 // Linux Wi-Fi
48
+ return 3
49
+ }
50
+
51
+ // Structured view of the usable LAN endpoints. The relay's `normalizeLanMetadata`
52
+ // and the app's `candidateUrls()` both accept this shape, so callers that want
53
+ // interface metadata (for diagnostics or smarter ordering) should prefer it.
54
+ function lanInterfaces(port, interfaces = os.networkInterfaces()) {
55
+ const found = []
56
+ let order = 0
57
+ for (const [name, entries] of Object.entries(interfaces || {})) {
58
+ const current = order++
59
+ if (isVpnInterface(name) || isVirtualInterface(name)) continue
60
+ for (const entry of entries || []) {
61
+ if (!entry || entry.internal) continue
62
+ if (entry.family !== 'IPv4' && entry.family !== 4) continue
63
+ if (!isPrivateLanAddress(entry.address)) continue
64
+ found.push({ name, address: entry.address, order: current })
65
+ }
66
+ }
67
+ const seen = new Set()
68
+ return found
69
+ .sort((a, b) => interfaceRank(a.name) - interfaceRank(b.name) || a.order - b.order)
70
+ .filter((item) => {
71
+ if (seen.has(item.address)) return false
72
+ seen.add(item.address)
73
+ return true
74
+ })
75
+ .slice(0, 16)
76
+ .map((item) => ({ url: `http://${item.address}:${port}`, interface: item.name, family: 'IPv4', address: item.address }))
77
+ }
78
+ function lanUrls(port, interfaces = os.networkInterfaces()) {
79
+ return lanInterfaces(port, interfaces).map((candidate) => candidate.url)
80
+ }
81
+ function lanCandidates(port, interfaces = os.networkInterfaces()) {
82
+ return lanInterfaces(port, interfaces)
83
+ }
84
+
85
+ module.exports = { ipv4Number, isPrivateLanAddress, isVpnInterface, isVirtualInterface, interfaceRank, lanInterfaces, lanUrls, lanCandidates }