dsh-dock 0.5.0 → 0.7.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.
@@ -0,0 +1,279 @@
1
+ // dsh-dock · 手机接力局域网安全网关
2
+ //
3
+ // DSH 0.1.1-rc.2 有意拒绝直接绑定 0.0.0.0:浏览器表层尚无登录认证,直接开放
4
+ // 会把代码执行能力交给同一网络里的任何人。本文件提供一个最小认证反向代理:主 DSH
5
+ // 继续只监听 127.0.0.1,手机凭 256-bit 一次性链接换取 HttpOnly 会话后才能访问。
6
+ import { createServer, request as httpRequest } from 'node:http'
7
+ import { networkInterfaces } from 'node:os'
8
+ import { randomBytes } from 'node:crypto'
9
+
10
+ const COOKIE_NAME = 'dsh_mobile_session'
11
+ const CONNECT_PATH = '/__dsh_mobile/connect'
12
+ const HEALTH_PATH = '/__dsh_mobile/health'
13
+ const COMPAT_PATH = '/__dsh_mobile/compat.js'
14
+ const SESSION_TTL_MS = 12 * 60 * 60 * 1000
15
+ const MAX_CONNECT_BODY = 4096
16
+ const MAX_HTML_BYTES = 2 * 1024 * 1024
17
+
18
+ // LAN HTTP is not a browser secure context. Some mobile browsers therefore expose
19
+ // crypto.getRandomValues() but omit crypto.randomUUID(), while the official DSH
20
+ // connection/workspace clients call randomUUID() directly. This tiny standards-compatible
21
+ // UUID v4 fallback is injected before every DSH bootstrap script through the proxy only.
22
+ const MOBILE_COMPAT_JS = `(()=>{const c=globalThis.crypto;if(!c||typeof c.randomUUID==='function'||typeof c.getRandomValues!=='function')return;const make=()=>{const b=new Uint8Array(16);c.getRandomValues(b);b[6]=(b[6]&15)|64;b[8]=(b[8]&63)|128;const h=Array.from(b,x=>x.toString(16).padStart(2,'0')).join('');return h.slice(0,8)+'-'+h.slice(8,12)+'-'+h.slice(12,16)+'-'+h.slice(16,20)+'-'+h.slice(20)};try{Object.defineProperty(c,'randomUUID',{value:make,configurable:true})}catch{try{c.randomUUID=make}catch{}}})()`
23
+
24
+ function secret() { return randomBytes(32).toString('base64url') }
25
+ function now() { return Date.now() }
26
+ function isPrivateIpv4(ip) {
27
+ const p = String(ip).split('.').map(Number)
28
+ return p.length === 4 && (p[0] === 10 || (p[0] === 172 && p[1] >= 16 && p[1] <= 31) || (p[0] === 192 && p[1] === 168))
29
+ }
30
+ function interfacePriority(name) {
31
+ if (/^en0$/.test(name)) return 100
32
+ if (/^en\d+$/.test(name)) return 90
33
+ if (/^(eth|enp|wlan|wl)/.test(name)) return 80
34
+ if (/^bridge/.test(name)) return 20
35
+ if (/^(utun|tun|tap)/.test(name)) return 10
36
+ return 50
37
+ }
38
+
39
+ /** 当前可用于手机连接的非回环 IPv4,私网地址优先。 */
40
+ export function lanAddresses() {
41
+ const rows = []
42
+ const table = networkInterfaces()
43
+ for (const [name, list] of Object.entries(table)) {
44
+ for (const item of list || []) {
45
+ if (!item || item.internal || item.family !== 'IPv4') continue
46
+ rows.push({ address: item.address, interface: name, private: isPrivateIpv4(item.address) })
47
+ }
48
+ }
49
+ return rows.sort((a, b) => Number(b.private) - Number(a.private)
50
+ || interfacePriority(b.interface) - interfacePriority(a.interface)
51
+ || a.interface.localeCompare(b.interface))
52
+ }
53
+
54
+ function parseCookies(header) {
55
+ const result = {}
56
+ for (const part of String(header || '').split(';')) {
57
+ const at = part.indexOf('=')
58
+ if (at <= 0) continue
59
+ result[part.slice(0, at).trim()] = part.slice(at + 1).trim()
60
+ }
61
+ return result
62
+ }
63
+ function sendJson(res, status, value, headers = {}) {
64
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', ...headers })
65
+ res.end(JSON.stringify(value))
66
+ }
67
+ function readSmallJson(req) {
68
+ return new Promise((resolve, reject) => {
69
+ let size = 0
70
+ const chunks = []
71
+ req.on('data', (chunk) => {
72
+ size += chunk.length
73
+ if (size > MAX_CONNECT_BODY) { reject(new Error('请求过大')); req.destroy(); return }
74
+ chunks.push(chunk)
75
+ })
76
+ req.on('end', () => {
77
+ try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}')) }
78
+ catch { reject(new Error('请求格式错误')) }
79
+ })
80
+ req.on('error', reject)
81
+ })
82
+ }
83
+ function connectHtml() {
84
+ return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="color-scheme" content="dark light"><title>连接 DSH</title><style>html{font-family:system-ui,-apple-system,sans-serif;color-scheme:dark light}body{margin:0;min-height:100dvh;display:grid;place-items:center;padding:env(safe-area-inset-top) 0 env(safe-area-inset-bottom);background:#111318;color:#f4f6fa}.card{box-sizing:border-box;width:min(420px,calc(100vw - 32px));padding:28px;border:1px solid #30343d;border-radius:18px;background:#1c1f26;box-shadow:0 18px 60px #0007;text-align:center}.icon{width:48px;height:48px;margin:0 auto 16px;display:grid;place-items:center;border-radius:15px;background:#38bdf822;color:#38bdf8}.icon svg{width:24px;height:24px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}h1{margin:0 0 8px;font-size:20px}p{margin:0;color:#abb1bd;line-height:1.6;font-size:14px}.error{margin-top:16px;padding:10px 12px;border-radius:10px;background:#ef44441a;color:#ff8b8b;text-align:left}[hidden]{display:none}@media(prefers-color-scheme:light){body{background:#f1f4f8;color:#16181d}.card{background:#fff;border-color:#d8dde6}.card p{color:#586173}}</style></head><body><main class="card"><div class="icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M10 13a5 5 0 0 0 7.54.54l2-2a5 5 0 0 0-7.07-7.07l-1.15 1.15"/><path d="M14 11a5 5 0 0 0-7.54-.54l-2 2a5 5 0 0 0 7.07 7.07l1.14-1.14"/></svg></div><h1>正在安全连接</h1><p id="status">验证一次性链接并进入 DSH…</p><div id="error" class="error" role="alert" hidden></div></main><script>(()=>{const status=document.getElementById('status'),error=document.getElementById('error'),token=decodeURIComponent(location.hash.slice(1));history.replaceState(null,'',location.pathname);if(!token){status.textContent='连接链接不完整';error.hidden=false;error.textContent='请回到电脑端重新复制手机接力链接。';return}fetch(${JSON.stringify(CONNECT_PATH)},{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({token})}).then(async r=>{const d=await r.json().catch(()=>({}));if(!r.ok||!d.ok)throw new Error(d.error||'连接验证失败');status.textContent='验证成功,正在打开任务…';location.replace('/#dsh-mobile-relay='+encodeURIComponent(d.launch))}).catch(e=>{status.textContent='无法连接';error.hidden=false;error.textContent=e&&e.message?e.message:String(e)})})()</script></body></html>`
85
+ }
86
+ function rewriteProxyHeaders(headers, upstreamAuthority) {
87
+ const next = { ...headers, host: upstreamAuthority }
88
+ delete next.cookie
89
+ // HTML must stay uncompressed so the proxy can inject the early compatibility script.
90
+ delete next['accept-encoding']
91
+ if (next.origin) next.origin = 'http://' + upstreamAuthority
92
+ return next
93
+ }
94
+ function injectMobileCompat(html) {
95
+ if (html.includes('data-dsh-mobile-compat')) return html
96
+ const match = /<head(?:\s[^>]*)?>/i.exec(html)
97
+ const script = `<script data-dsh-mobile-compat src="${COMPAT_PATH}"></script>`
98
+ if (!match) return script + html
99
+ const at = match.index + match[0].length
100
+ return html.slice(0, at) + script + html.slice(at)
101
+ }
102
+ function writeUpgradeHead(socket, response) {
103
+ const lines = [`HTTP/1.1 ${response.statusCode || 101} ${response.statusMessage || 'Switching Protocols'}`]
104
+ for (let i = 0; i < response.rawHeaders.length; i += 2) lines.push(response.rawHeaders[i] + ': ' + response.rawHeaders[i + 1])
105
+ socket.write(lines.join('\r\n') + '\r\n\r\n')
106
+ }
107
+
108
+ /**
109
+ * 开启一个需要一次性链接认证的局域网代理。返回的 issue() 生成只在 fragment 中传输的令牌。
110
+ */
111
+ export function startProtectedLanGateway({ port, upstreamHost = '127.0.0.1', upstreamPort }) {
112
+ return new Promise((resolve, reject) => {
113
+ const pending = new Map() // gateway token -> { pairId, launch, expiresAt }
114
+ const sessions = new Map() // cookie secret -> { pairId, expiresAt, seenAt }
115
+ const upgradedSockets = new Map() // client socket -> { sessionId, pairId, upstreamSocket }
116
+ const authority = upstreamHost + ':' + upstreamPort
117
+
118
+ function purge() {
119
+ const stamp = now()
120
+ for (const [key, value] of pending) if (value.expiresAt <= stamp) pending.delete(key)
121
+ const expiredSessions = new Set()
122
+ for (const [key, value] of sessions) {
123
+ if (value.expiresAt <= stamp) { sessions.delete(key); expiredSessions.add(key) }
124
+ }
125
+ if (expiredSessions.size) {
126
+ for (const [socket, value] of upgradedSockets) {
127
+ if (expiredSessions.has(value.sessionId)) {
128
+ if (value.upstreamSocket) value.upstreamSocket.destroy()
129
+ socket.destroy()
130
+ }
131
+ }
132
+ }
133
+ }
134
+ function authorize(req) {
135
+ purge()
136
+ const value = parseCookies(req.headers.cookie)[COOKIE_NAME]
137
+ const session = value ? sessions.get(value) : null
138
+ if (!session) return null
139
+ session.seenAt = now()
140
+ return { ...session, sessionId: value }
141
+ }
142
+ function proxyHttp(req, res) {
143
+ const upstream = httpRequest({
144
+ hostname: upstreamHost, port: upstreamPort, method: req.method, path: req.url,
145
+ headers: rewriteProxyHeaders(req.headers, authority),
146
+ }, (upstreamRes) => {
147
+ const contentType = String(upstreamRes.headers['content-type'] || '').toLowerCase()
148
+ if (!contentType.includes('text/html')) {
149
+ res.writeHead(upstreamRes.statusCode || 502, upstreamRes.headers)
150
+ upstreamRes.pipe(res)
151
+ return
152
+ }
153
+ const chunks = []
154
+ let size = 0
155
+ upstreamRes.on('data', (chunk) => {
156
+ size += chunk.length
157
+ if (size <= MAX_HTML_BYTES) chunks.push(chunk)
158
+ })
159
+ upstreamRes.on('end', () => {
160
+ if (size > MAX_HTML_BYTES) {
161
+ if (!res.headersSent) sendJson(res, 502, { ok: false, error: 'DSH 页面过大,无法注入手机兼容层。' })
162
+ return
163
+ }
164
+ const headers = { ...upstreamRes.headers }
165
+ delete headers['content-length']
166
+ delete headers['content-encoding']
167
+ headers['cache-control'] = 'no-store'
168
+ res.writeHead(upstreamRes.statusCode || 502, headers)
169
+ res.end(injectMobileCompat(Buffer.concat(chunks).toString('utf8')))
170
+ })
171
+ })
172
+ upstream.on('error', (error) => {
173
+ if (!res.headersSent) sendJson(res, 502, { ok: false, error: 'DSH 主服务连接失败:' + error.message })
174
+ else res.destroy(error)
175
+ })
176
+ req.pipe(upstream)
177
+ }
178
+
179
+ const server = createServer(async (req, res) => {
180
+ const url = new URL(req.url || '/', 'http://dsh-mobile.internal')
181
+ if (url.pathname === HEALTH_PATH) return sendJson(res, 200, { ok: true })
182
+ if (url.pathname === CONNECT_PATH && req.method === 'GET') {
183
+ res.writeHead(200, {
184
+ 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store',
185
+ 'content-security-policy': "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
186
+ 'x-content-type-options': 'nosniff', 'referrer-policy': 'no-referrer',
187
+ })
188
+ return res.end(connectHtml())
189
+ }
190
+ if (url.pathname === CONNECT_PATH && req.method === 'POST') {
191
+ try {
192
+ const body = await readSmallJson(req)
193
+ const token = String(body && body.token || '')
194
+ const grant = pending.get(token)
195
+ if (!grant || grant.expiresAt <= now()) return sendJson(res, 403, { ok: false, error: '一次性链接无效或已过期,请在电脑端重新开启。' })
196
+ pending.delete(token)
197
+ const sessionId = secret()
198
+ sessions.set(sessionId, { pairId: grant.pairId, expiresAt: now() + SESSION_TTL_MS, seenAt: now() })
199
+ return sendJson(res, 200, { ok: true, launch: grant.launch }, {
200
+ 'set-cookie': `${COOKIE_NAME}=${sessionId}; HttpOnly; SameSite=Strict; Path=/`,
201
+ })
202
+ } catch (error) {
203
+ return sendJson(res, 400, { ok: false, error: error.message || String(error) })
204
+ }
205
+ }
206
+ if (!authorize(req)) return sendJson(res, 401, { ok: false, error: '手机连接未认证或已过期,请使用电脑端生成的接力链接重新进入。' })
207
+ if (url.pathname === COMPAT_PATH && req.method === 'GET') {
208
+ res.writeHead(200, {
209
+ 'content-type': 'text/javascript; charset=utf-8',
210
+ 'cache-control': 'no-store',
211
+ 'x-content-type-options': 'nosniff',
212
+ })
213
+ return res.end(MOBILE_COMPAT_JS)
214
+ }
215
+ return proxyHttp(req, res)
216
+ })
217
+
218
+ server.on('upgrade', (req, socket, head) => {
219
+ const authorized = authorize(req)
220
+ if (!authorized) { socket.end('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n'); return }
221
+ const connection = { sessionId: authorized.sessionId, pairId: authorized.pairId, upstreamSocket: null }
222
+ upgradedSockets.set(socket, connection)
223
+ const forgetSocket = () => upgradedSockets.delete(socket)
224
+ socket.once('close', forgetSocket)
225
+ const upstream = httpRequest({
226
+ hostname: upstreamHost, port: upstreamPort, method: req.method, path: req.url,
227
+ headers: rewriteProxyHeaders(req.headers, authority),
228
+ })
229
+ upstream.on('upgrade', (response, upstreamSocket, upstreamHead) => {
230
+ connection.upstreamSocket = upstreamSocket
231
+ writeUpgradeHead(socket, response)
232
+ if (head && head.length) upstreamSocket.write(head)
233
+ if (upstreamHead && upstreamHead.length) socket.write(upstreamHead)
234
+ socket.once('close', () => upstreamSocket.destroy())
235
+ upstreamSocket.once('close', () => socket.destroy())
236
+ socket.pipe(upstreamSocket).pipe(socket)
237
+ })
238
+ upstream.on('response', (response) => {
239
+ socket.end(`HTTP/1.1 ${response.statusCode || 502} ${response.statusMessage || 'Bad Gateway'}\r\nConnection: close\r\n\r\n`)
240
+ })
241
+ upstream.on('error', () => socket.destroy())
242
+ upstream.end()
243
+ })
244
+ server.once('error', reject)
245
+ server.listen(port, '0.0.0.0', () => {
246
+ server.removeListener('error', reject)
247
+ const listened = server.address()
248
+ const listenedPort = listened && typeof listened === 'object' ? listened.port : port
249
+ resolve({
250
+ port: listenedPort,
251
+ addresses: lanAddresses(),
252
+ issue(pairId, launch, expiresAt) {
253
+ const token = secret()
254
+ pending.set(token, { pairId, launch, expiresAt })
255
+ return token
256
+ },
257
+ revokePair(pairId) {
258
+ for (const [key, value] of pending) if (value.pairId === pairId) pending.delete(key)
259
+ for (const [key, value] of sessions) if (value.pairId === pairId) sessions.delete(key)
260
+ for (const [socket, value] of upgradedSockets) {
261
+ if (value.pairId !== pairId) continue
262
+ if (value.upstreamSocket) value.upstreamSocket.destroy()
263
+ socket.destroy()
264
+ }
265
+ },
266
+ purge,
267
+ close() {
268
+ pending.clear(); sessions.clear()
269
+ for (const [socket, value] of upgradedSockets) {
270
+ if (value.upstreamSocket) value.upstreamSocket.destroy()
271
+ socket.destroy()
272
+ }
273
+ upgradedSockets.clear()
274
+ return new Promise((done) => server.close(() => done()))
275
+ },
276
+ })
277
+ })
278
+ })
279
+ }
@@ -0,0 +1,328 @@
1
+ // dsh-dock · 功能模块【手机接力】· 宿主半部
2
+ //
3
+ // 不复制或迁移 DSH 会话:手机与电脑都连接同一个 DSH 宿主时,会话本来就是同一份。
4
+ // 本模块只负责短时配对、在线状态、实时任务摘要和接力备注,避免把任务状态再做成一套
5
+ // 容易分叉的副本。所有配对凭据均只存内存,宿主重启后自动失效。
6
+ import { randomBytes } from 'node:crypto'
7
+ import { readBody, sendJson } from '../../src/host-core.js'
8
+ import { lanAddresses, startProtectedLanGateway } from './gateway.js'
9
+
10
+ const PAIR_TTL_MS = 10 * 60 * 1000
11
+ const PRESENCE_TTL_MS = 45 * 1000
12
+ const NOTE_MAX_LENGTH = 1200
13
+ const MAX_JOIN_ATTEMPTS = 8
14
+ const DEFAULT_GATEWAY_PORT = 3081
15
+
16
+ function now() { return Date.now() }
17
+ function cleanText(value, max) { return String(value || '').replace(/\u0000/g, '').trim().slice(0, max) }
18
+ function publicId() { return randomBytes(9).toString('base64url') }
19
+ function joinCode() { return randomBytes(5).toString('hex').toUpperCase() }
20
+ function phaseOf(event) {
21
+ if (event && event.type === 'assistant/chunk') {
22
+ const type = event.data && event.data.chunk && event.data.chunk.type
23
+ if (type === 'reasoning-delta') return 'think'
24
+ if (type === 'text-delta') return 'write'
25
+ }
26
+ if (event && event.type === 'tool/call') {
27
+ const name = String(event.data && event.data.name || '').toLowerCase()
28
+ return /web|search|fetch|grep|glob|find|read|ls$|^ls|tree|locate/.test(name) ? 'search' : 'code'
29
+ }
30
+ return ''
31
+ }
32
+ function extractText(content) {
33
+ if (!Array.isArray(content)) return ''
34
+ return content.filter((x) => x && x.type === 'text' && typeof x.text === 'string')
35
+ .map((x) => x.text).join(' ').replace(/\s+/g, ' ').trim()
36
+ }
37
+ function routeMethod(req) {
38
+ const url = new URL(req.url || '/', 'http://dsh.internal')
39
+ return url.pathname.replace(/^\/dsh-dock\/mobile-relay\/?/, '').split('/')[0] || ''
40
+ }
41
+
42
+ export const feature = {
43
+ id: 'mobile-relay',
44
+ name: '手机接力',
45
+ description: '安全反向代理与扫码接力,同步当前任务状态和备注',
46
+ defaultEnabled: true,
47
+ setup(ctx) {
48
+ const disposers = []
49
+ const pairs = new Map()
50
+ const tasks = new Map()
51
+ const joinAttempts = new Map()
52
+ let gateway = null
53
+ let gatewayStarting = null
54
+
55
+ async function stopGateway() {
56
+ const current = gateway
57
+ gateway = null
58
+ gatewayStarting = null
59
+ if (current) await current.close()
60
+ }
61
+
62
+ async function ensureGateway(webServer, requestedPort) {
63
+ const port = Number(requestedPort === undefined ? DEFAULT_GATEWAY_PORT : requestedPort)
64
+ if (!Number.isInteger(port) || port < 1024 || port > 65535) {
65
+ const error = new Error('局域网端口需为 1024 到 65535 之间的整数')
66
+ error.statusCode = 400
67
+ throw error
68
+ }
69
+ if (port === webServer.port) {
70
+ const error = new Error(`局域网端口不能与 DSH 主服务端口 ${webServer.port} 相同`)
71
+ error.statusCode = 400
72
+ throw error
73
+ }
74
+ if (gateway && gateway.port === port) return gateway
75
+ if (gateway) {
76
+ const error = new Error(`局域网网关已在 ${gateway.port} 端口运行,请先关闭当前手机连接`)
77
+ error.statusCode = 409
78
+ throw error
79
+ }
80
+ if (!gatewayStarting) {
81
+ gatewayStarting = startProtectedLanGateway({ port, upstreamHost: '127.0.0.1', upstreamPort: webServer.port })
82
+ .then((started) => { gateway = started; gatewayStarting = null; return started })
83
+ .catch((error) => { gatewayStarting = null; throw error })
84
+ }
85
+ try { return await gatewayStarting }
86
+ catch (cause) {
87
+ const error = new Error(cause && cause.code === 'EADDRINUSE'
88
+ ? `端口 ${port} 已被占用,请换一个端口`
89
+ : '无法开启局域网网关:' + ((cause && cause.message) || String(cause)))
90
+ error.statusCode = 400
91
+ throw error
92
+ }
93
+ }
94
+
95
+ function purge() {
96
+ const stamp = now()
97
+ for (const [id, pair] of pairs) {
98
+ for (const [deviceId, device] of pair.devices) {
99
+ if (stamp - device.seenAt > PRESENCE_TTL_MS) pair.devices.delete(deviceId)
100
+ }
101
+ if (pair.joinExpiresAt <= stamp && pair.devices.size === 0) {
102
+ pairs.delete(id)
103
+ if (gateway) gateway.revokePair(id)
104
+ }
105
+ }
106
+ for (const [key, stampAt] of joinAttempts) if (stamp - stampAt.firstAt > PAIR_TTL_MS) joinAttempts.delete(key)
107
+ if (gateway) gateway.purge()
108
+ if (gateway && pairs.size === 0) void stopGateway()
109
+ }
110
+ const purgeTimer = setInterval(purge, 15 * 1000)
111
+ disposers.push(() => clearInterval(purgeTimer))
112
+
113
+ function taskSnapshot() {
114
+ const stamp = now()
115
+ return [...tasks.values()].filter((task) => task.running).map((task) => ({
116
+ sessionId: task.sessionId,
117
+ title: task.title || task.firstPrompt || '(等待任务标题)',
118
+ phase: task.phase || 'think',
119
+ startedAt: task.startedAt,
120
+ elapsed: stamp - task.startedAt,
121
+ lastActivityAt: task.lastActivityAt,
122
+ })).sort((a, b) => b.lastActivityAt - a.lastActivityAt)
123
+ }
124
+ function visiblePair(pair) {
125
+ const devices = [...pair.devices.values()].map((device) => ({
126
+ id: device.id,
127
+ role: device.role,
128
+ label: device.label,
129
+ seenAt: device.seenAt,
130
+ })).sort((a, b) => b.seenAt - a.seenAt)
131
+ return {
132
+ id: pair.id,
133
+ expiresAt: pair.joinExpiresAt,
134
+ note: pair.note,
135
+ noteAt: pair.noteAt,
136
+ noteFrom: pair.noteFrom,
137
+ devices,
138
+ tasks: taskSnapshot(),
139
+ now: now(),
140
+ }
141
+ }
142
+ function requirePair(payload) {
143
+ const pairId = cleanText(payload && payload.pairId, 48)
144
+ const deviceId = cleanText(payload && payload.deviceId, 80)
145
+ const secret = cleanText(payload && payload.secret, 160)
146
+ const pair = pairs.get(pairId)
147
+ if (!pair) {
148
+ const error = new Error('配对不存在,请在电脑端重新开启连接')
149
+ error.statusCode = 410
150
+ throw error
151
+ }
152
+ const device = pair.devices.get(deviceId)
153
+ if (!device || device.secret !== secret) {
154
+ const error = new Error('此设备未完成配对,请重新打开手机接力链接')
155
+ error.statusCode = 403
156
+ throw error
157
+ }
158
+ device.seenAt = now()
159
+ return { pair, device }
160
+ }
161
+ function joinRateKey(req) {
162
+ return (req.socket && req.socket.remoteAddress) || 'unknown'
163
+ }
164
+ function recordFailedJoin(req) {
165
+ const key = joinRateKey(req)
166
+ const item = joinAttempts.get(key) || { count: 0, firstAt: now() }
167
+ if (now() - item.firstAt > PAIR_TTL_MS) { item.count = 0; item.firstAt = now() }
168
+ item.count++
169
+ joinAttempts.set(key, item)
170
+ return item.count
171
+ }
172
+ function canJoin(req) {
173
+ const item = joinAttempts.get(joinRateKey(req))
174
+ return !item || now() - item.firstAt > PAIR_TTL_MS || item.count < MAX_JOIN_ATTEMPTS
175
+ }
176
+
177
+ // 任务状态只取最小必要字段。真正的会话内容仍由 DSH 的既有会话服务管理,
178
+ // 这样手机继续输入后不会和电脑产生两个任务副本。
179
+ disposers.push(ctx.inject(['sessionQuery'], (sqCtx) => {
180
+ const sessionQuery = sqCtx.sessionQuery
181
+ const titleFor = async (task) => {
182
+ try {
183
+ const snapshot = sessionQuery && typeof sessionQuery.readTitle === 'function'
184
+ ? await sessionQuery.readTitle(task.sessionId) : null
185
+ if (snapshot && snapshot.title && tasks.get(task.sessionId) === task) task.title = snapshot.title
186
+ } catch { /* 标题缺失时以首条输入兜底 */ }
187
+ }
188
+ disposers.push(sqCtx.on('agent/status', (payload) => {
189
+ const agent = payload && payload.agent
190
+ const sid = agent && agent.id ? String(agent.id) : ''
191
+ if (!sid) return
192
+ if (payload.status === 'running') {
193
+ let task = tasks.get(sid)
194
+ if (!task) {
195
+ task = { sessionId: sid, title: '', firstPrompt: '', phase: 'think', startedAt: now(), lastActivityAt: now(), running: true }
196
+ tasks.set(sid, task)
197
+ titleFor(task)
198
+ }
199
+ task.running = true
200
+ task.lastActivityAt = now()
201
+ } else if (payload.status === 'idle') {
202
+ const task = tasks.get(sid)
203
+ if (task) { task.running = false; task.lastActivityAt = now() }
204
+ }
205
+ }))
206
+ disposers.push(sqCtx.on('agent/disposed', (payload) => {
207
+ const sid = payload && payload.agent && payload.agent.id ? String(payload.agent.id) : ''
208
+ const task = tasks.get(sid)
209
+ if (task) { task.running = false; task.lastActivityAt = now() }
210
+ }))
211
+ disposers.push(sqCtx.on('session/event', (session, event) => {
212
+ const sid = typeof session === 'object' && session ? String(session.id || '') : String(session || '')
213
+ const task = tasks.get(sid)
214
+ if (!task) return
215
+ const phase = phaseOf(event)
216
+ if (phase) task.phase = phase
217
+ if (!task.firstPrompt && event && event.type === 'user/message') {
218
+ const text = extractText(event.data && event.data.content)
219
+ if (text) task.firstPrompt = text.slice(0, 120)
220
+ }
221
+ task.lastActivityAt = (event && event.time) || now()
222
+ }))
223
+ }))
224
+
225
+ disposers.push(ctx.inject(['webServer'], (wsCtx) => {
226
+ wsCtx.effect(() => wsCtx.webServer.register({
227
+ kind: 'prefix',
228
+ path: '/dsh-dock/mobile-relay',
229
+ async handler(req, res) {
230
+ try {
231
+ const method = routeMethod(req)
232
+ const payload = await readBody(req)
233
+ purge()
234
+ if (method === 'network') {
235
+ return sendJson(res, 200, { ok: true, data: {
236
+ addresses: lanAddresses(),
237
+ defaultPort: DEFAULT_GATEWAY_PORT,
238
+ main: { host: wsCtx.webServer.host, port: wsCtx.webServer.port },
239
+ gateway: gateway ? { active: true, port: gateway.port, addresses: gateway.addresses } : { active: false },
240
+ } })
241
+ }
242
+ if (method === 'start') {
243
+ const activeGateway = await ensureGateway(wsCtx.webServer, payload && payload.port)
244
+ const id = publicId()
245
+ const code = joinCode()
246
+ const desktopId = cleanText(payload && payload.deviceId, 80) || publicId()
247
+ const desktopSecret = publicId() + publicId()
248
+ const pair = {
249
+ id, code, joinExpiresAt: now() + PAIR_TTL_MS, note: '', noteAt: 0, noteFrom: '', devices: new Map(),
250
+ }
251
+ pair.devices.set(desktopId, { id: desktopId, secret: desktopSecret, role: 'desktop', label: '电脑端', seenAt: now() })
252
+ pairs.set(id, pair)
253
+ const gatewayToken = activeGateway.issue(id, `${id}.${code}`, pair.joinExpiresAt)
254
+ return sendJson(res, 200, { ok: true, data: {
255
+ pairId: id, code, secret: desktopSecret, expiresAt: pair.joinExpiresAt, gatewayToken,
256
+ gateway: { active: true, port: activeGateway.port, addresses: activeGateway.addresses },
257
+ pair: visiblePair(pair),
258
+ } })
259
+ }
260
+ if (method === 'join') {
261
+ if (!canJoin(req)) {
262
+ const error = new Error('尝试次数过多,请稍后重试或在电脑端重新生成链接')
263
+ error.statusCode = 429
264
+ throw error
265
+ }
266
+ const pairId = cleanText(payload && payload.pairId, 48)
267
+ const code = cleanText(payload && payload.code, 32).toUpperCase()
268
+ const pair = pairs.get(pairId)
269
+ if (!pair || pair.joinExpiresAt <= now() || code !== pair.code) {
270
+ recordFailedJoin(req)
271
+ const error = new Error('连接码无效或已过期,请回到电脑端重新开启')
272
+ error.statusCode = 403
273
+ throw error
274
+ }
275
+ const deviceId = cleanText(payload && payload.deviceId, 80) || publicId()
276
+ const secret = publicId() + publicId()
277
+ pair.devices.set(deviceId, { id: deviceId, secret, role: 'mobile', label: cleanText(payload && payload.label, 32) || '手机端', seenAt: now() })
278
+ return sendJson(res, 200, { ok: true, data: { pairId, secret, expiresAt: pair.joinExpiresAt, pair: visiblePair(pair) } })
279
+ }
280
+ if (method === 'status') {
281
+ const { pair } = requirePair(payload)
282
+ return sendJson(res, 200, { ok: true, data: { pair: visiblePair(pair) } })
283
+ }
284
+ if (method === 'note') {
285
+ const { pair, device } = requirePair(payload)
286
+ const note = cleanText(payload && payload.note, NOTE_MAX_LENGTH)
287
+ if (!note) {
288
+ const error = new Error('请先写下接力内容')
289
+ error.statusCode = 400
290
+ throw error
291
+ }
292
+ pair.note = note
293
+ pair.noteAt = now()
294
+ pair.noteFrom = device.role === 'mobile' ? '手机端' : '电脑端'
295
+ return sendJson(res, 200, { ok: true, data: { pair: visiblePair(pair) } })
296
+ }
297
+ if (method === 'end') {
298
+ const { pair } = requirePair(payload)
299
+ pairs.delete(pair.id)
300
+ if (gateway) gateway.revokePair(pair.id)
301
+ const shouldStopGateway = pairs.size === 0
302
+ sendJson(res, 200, { ok: true, data: { ended: true } })
303
+ // 手机端的关闭请求本身也经过网关。先完成响应,再异步停止监听,
304
+ // 避免 server.close() 等待当前请求、当前请求又等待 close() 的死锁。
305
+ if (shouldStopGateway) setTimeout(() => { void stopGateway() }, 50)
306
+ return
307
+ }
308
+ return sendJson(res, 404, { ok: false, error: { code: 'method-not-found', message: 'unknown method: ' + method } })
309
+ } catch (error) {
310
+ const status = error && error.statusCode ? error.statusCode : 500
311
+ if (status >= 500) console.error('[dsh-dock] mobile relay HTTP error:', error && error.message)
312
+ return sendJson(res, status, { ok: false, error: { code: status >= 500 ? 'internal' : 'bad-request', message: (error && error.message) || String(error) } })
313
+ }
314
+ },
315
+ }), 'dsh-dock mobile relay: /dsh-dock/mobile-relay HTTP route')
316
+ }))
317
+
318
+ return () => {
319
+ while (disposers.length) {
320
+ const dispose = disposers.pop()
321
+ try { if (typeof dispose === 'function') dispose() } catch { /* ignore */ }
322
+ }
323
+ pairs.clear()
324
+ tasks.clear()
325
+ void stopGateway()
326
+ }
327
+ },
328
+ }