dsh-in-hand 1.7.0 → 1.7.1

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/README.md CHANGED
@@ -109,11 +109,12 @@ DSH 的 Web GUI 没有内置鉴权,而默认 preset 往往是 `danger-full-acc
109
109
  | `/taskwatch/chat/page?...` | JSON | 往更早翻会话历史 |
110
110
  | `/taskwatch/chat/stream?sessionId=` | SSE | 跟随一个会话:开场快照 + 增量事件 + 逐字输出 |
111
111
  | `/taskwatch/chat/queue?sessionId=` | JSON | 该会话未领取的消息:`items`(下一轮队列,可重排)与 `steering`(已插到本回合下一步) |
112
+ | `/taskwatch/entries` | JSON | 双入口清单:`{entries:[{kind:'remote'\|'lan',label,url,current}]}`;没配 `publicUrl` 时只有局域网一条 |
112
113
  | `/taskwatch/manifest.webmanifest` | JSON | PWA manifest |
113
114
  | `/taskwatch/sw.js` | JS | service worker |
114
115
  | `/taskwatch/icon-192.png` · `/taskwatch/icon-512.png` | PNG | 图标 |
115
116
 
116
- 写(`POST`)—— **只有这 6 条**:
117
+ 写(`POST`)—— 只有这 6 条对话入口,外加一条不写宿主状态的换路票据:
117
118
 
118
119
  | 路径 | 请求体 | 说明 |
119
120
  |---|---|---|
@@ -123,9 +124,11 @@ DSH 的 Web GUI 没有内置鉴权,而默认 preset 往往是 `danger-full-acc
123
124
  | `/taskwatch/chat/create` | `{}` 或 `{workspace}` | 新建会话;`workspace` 必须命中 `/taskwatch/chat/workspaces` 列出的路径 |
124
125
  | `/taskwatch/chat/model` | `{sessionId,provider,model}` | 切换该会话使用的模型 |
125
126
  | `/taskwatch/chat/queue/reorder` | `{sessionId,ids:[...]}` | 重排下一轮队列;`ids` 必须是当前队列的完整 id 列表 |
127
+ | `/taskwatch/ticket` | `{to:'remote'\|'lan'}` | 签发 60 秒、一次性的换路票据;只在内存里放一张纸条,不碰会话 / 文件 / 配置。没配 `publicUrl` 时远端请求回 `409 no-public-entry` |
126
128
 
127
129
  全部注册在 `ctx.webServer` 上,不代理 DSH 自身的 `/api`,也不转发任意路径。
128
- 写入口就只有上面 6 条 —— 契约测试会**正面清点**路由表,增删都会让构建失败
130
+ 写入口就只有上面 6 条对话入口 + 1 条换路票据 —— 契约测试会**正面清点**路由表,
131
+ 新增路由必须在只读 / 对话 / 非对话写三张白名单里显式归类,漏一个就让构建失败
129
132
  (早先那条断言写的是"源码里不出现 `method === 'POST'`"。加了 5 个写路由之后它
130
133
  照样通过,是条假绿灯:它证明的是"没这么写",不是"没有这个能力")。
131
134
 
@@ -459,21 +462,41 @@ PWA 外壳由中继本地提供(而不是转发上游),这样它不随上
459
462
  换路等于换了一个**源**(`https://…` ↔ `http://192.168.x.x:3099`),而浏览器的 cookie 是
460
463
  **按源隔离**的 —— 在远端那条路上换到的 cookie,到了局域网这条路等于不存在。
461
464
 
462
- 所以页面点切换时,先向当前这条路的中继要一张凭据:
465
+ 所以页面点切换时,先向当前这条路的**宿主**(中继,或插件宿主本身)要一张凭据:
463
466
 
464
- 1. `POST /taskwatch/ticket`(带令牌)→ 中继返回另一条路的 URL,末尾带一次性 `?t=…`;
467
+ 1. `POST /taskwatch/ticket`(中继上要带令牌)→ 返回另一条路的 URL,末尾带一次性 `?t=…`;
465
468
  2. 手机跳过去,中继兑换这张票:**60 秒有效、兑换即作废、重放无效**,同时给那条路种下
466
469
  cookie;
467
470
  3. 于是不用重新输那 6 位配对码。
468
471
 
469
- 这三个接口都在中继侧,**中继不在本仓库**:`GET /taskwatch/entries`(入口清单与当前入口)、
470
- `POST /taskwatch/ticket`(换路凭据)、`GET /taskwatch/upstream-health`(上游体检)。
471
- 详细的实现与部署文档在中继仓库 **`dsh-in-hand-relay`**(该仓库尚未创建,此处只写仓库名)。
472
- 中继没升级时,页面上的胶囊不会出现,其余功能不受影响。
472
+ **v1.7.1 起,前两条插件宿主也自己实现了**(同一套语义):`GET /taskwatch/entries`
473
+ 回答「现在有哪几条路」,`POST /taskwatch/ticket` 给一张 60 秒、用后即焚的票据。
474
+ 所以从宿主页面(例如 `http://127.0.0.1:52676/taskwatch`)点「去远端」不再拿到 405 / 404。
475
+ `GET /taskwatch/upstream-health` 仍只有中继实现;宿主页面拿到它的 404 时按「未知」处理,
476
+ 并停掉探测 —— **不会**把「没有体检接口」误判成「这条路断了」。
477
+
478
+ 公网地址只来自插件配置项 `publicUrl`(也支持环境变量 `DSH_TASKWATCH_PUBLIC_URL`),
479
+ 代码里不写死任何域名:
480
+
481
+ ```yaml
482
+ - insert:
483
+ - id: dsh-in-hand
484
+ name: dsh-in-hand
485
+ config:
486
+ publicUrl: https://dsh.example.com
487
+ ```
488
+
489
+ 没配就只报局域网入口;`ticket` 的远端请求明确回 `409 no-public-entry`。宿主这层仍然
490
+ 不做令牌校验(401 由中继负责),但票据是写路由,所以和中继的写路由同一套门禁:
491
+ `POST` + `application/json` + 同源(`Origin` / `Sec-Fetch-Site`)校验。
492
+
493
+ 页面侧遇到这两条接口的 **404 / 405** 会进入**静态降级**:不重试、不弹错,胶囊按
494
+ 「只有一个入口」隐藏;设置 / 任务详情里留一行说明与一个手动「去远端」链接
495
+ (上次成功的地址记在 `localStorage['tw-remote-url']`)。500 / 502 / 网络错误只提示一次
496
+ (同一句 30 秒内不重复弹),不会变成重试风暴。
473
497
 
474
- > 注:上面「在外网 / 手机上访问」一节描述的是只转发读路径的只读中继;实际的中继同时
475
- > 承载 `/taskwatch/chat/*` 的写入口(见上面的路由表)和本节这三个双入口接口。中继代码
476
- > 不在本仓库。
498
+ > 中继那边另有独立实现,见中继仓库 `dsh-in-hand-relay`(该仓库尚未创建,此处只写仓库名)。
499
+ > 中继没升级时,页面在宿主上仍能正常用,只是没有远端切换。
477
500
 
478
501
  ### 502 排查
479
502
 
package/lib/index.js CHANGED
@@ -8,14 +8,16 @@
8
8
  * 采集面:会话(运行/空闲、最后活动、activeJobs)、后台任务 jobs、
9
9
  * 子代理树、目标轮次与阻塞、工作流阶段、等待审批/提问。
10
10
  *
11
- * 只读:不提供任何写操作、不接受请求体、不代理 DSH 自身的 /api。
11
+ * 只读为主:除对话接口外不写宿主状态、不代理 DSH 自身的 /api。唯一的例外是
12
+ * 换路票据(POST /taskwatch/ticket)—— 它只在进程内的短命 Map 里放一张 60 秒的
13
+ * 纸条,不碰会话、文件或配置,见下方「双入口」一节。
12
14
  *
13
15
  * 静态资源(PWA)都挂在 /taskwatch/ 之下,service worker 的作用域也限定为
14
16
  * /taskwatch —— 绝不允许它有机会拦截主 GUI 的请求。
15
17
  */
16
18
  import { readFileSync, statSync } from 'node:fs'
17
- import { createHash, randomUUID } from 'node:crypto'
18
- import { homedir } from 'node:os'
19
+ import { createHash, randomBytes, randomUUID } from 'node:crypto'
20
+ import { homedir, networkInterfaces } from 'node:os'
19
21
  import { fileURLToPath } from 'node:url'
20
22
  import { dirname, join } from 'node:path'
21
23
  // 交付文件的解析与安全边界都在 files.js —— 白名单、穿越防护、类型白名单需要能被
@@ -149,6 +151,117 @@ const why = (e) => (e && e.message ? String(e.message) : String(e))
149
151
 
150
152
  const ENV_ASSIGN = /^\$env:[A-Za-z_][A-Za-z0-9_]*\s*=/i
151
153
 
154
+ // ── 双入口:入口清单与一次性换路票据 ────────────────────────────────────────
155
+ //
156
+ // 背景(2026-10-21,v1.7.1 修的真实故障):页面在插件宿主上打开时,点「去远端」
157
+ // 会打 POST /taskwatch/ticket 与 GET /taskwatch/entries。这两个接口原先**只有中继
158
+ // 实现**,宿主没有:实测宿主回 405 / 404,页面拿不到 url,探测循环每 12 秒再试一次,
159
+ // 于是在手机上看就是"点了去远端就一直报错"。
160
+ //
161
+ // 现在宿主按中继的语义补齐这两条;公网地址只从配置项 publicUrl 来,**绝不写死域名**。
162
+ // 没配就只报价局域网入口,ticket 明确回 409 no-public-entry。
163
+ //
164
+ // 鉴权边界不变:宿主这层不做令牌校验(和中继的分工见 README),但 ticket 是**写**路由,
165
+ // 所以和中继的写路由一样要求 POST + application/json(跨站表单发不出这个 content-type,
166
+ // 天然需要 preflight,而宿主不应答 CORS 预检),另加一道 Origin / Sec-Fetch-Site 校验。
167
+ export const TICKET_TTL_MS = 60 * 1000
168
+ const TICKET_MAX = 256
169
+
170
+ /** 公网入口只接受纯 origin(https://host[:port]),带路径的一律当作没配 ——
171
+ * 否则 302 的 Location 是根绝对路径,和子路径部署对不上,切换会静默跳到错地方。 */
172
+ export function normalizePublicUrl(raw) {
173
+ const text = typeof raw === 'string' ? raw.trim().replace(/\/+$/, '') : ''
174
+ if (!text) return ''
175
+ try {
176
+ const parsed = new URL(text)
177
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return ''
178
+ if (!parsed.hostname) return ''
179
+ if (parsed.pathname && parsed.pathname !== '/') return ''
180
+ return parsed.origin
181
+ } catch {
182
+ return ''
183
+ }
184
+ }
185
+
186
+ /** 局域网 IPv4 地址。虚拟网卡(WSL / Hyper-V / docker)不算"手机能连的入口",
187
+ * 否则入口清单里会出现一个手机永远打不开的地址 —— 那比不显示更糟。 */
188
+ const VIRTUAL_IFACE = /vEthernet|Hyper-V|WSL|VMware|VirtualBox|Loopback|docker|Bluetooth|TAP-/i
189
+ export function lanAddresses(ifaces, includeVirtual = false) {
190
+ const table = ifaces && typeof ifaces === 'object' ? ifaces : networkInterfaces()
191
+ const out = []
192
+ for (const name of Object.keys(table)) {
193
+ if (!includeVirtual && VIRTUAL_IFACE.test(name)) continue
194
+ for (const addr of table[name] || []) {
195
+ if (!addr || addr.internal) continue
196
+ if (addr.family !== 'IPv4' && addr.family !== 4) continue
197
+ const ip = asText(addr.address)
198
+ if (ip) out.push({ iface: name, ip })
199
+ }
200
+ }
201
+ return out
202
+ }
203
+
204
+ /** 票据存储:60 秒、一次性。单独抽出来的原因是"过期"要能被测试钉住 ——
205
+ * 真等 60 秒的测试不会有人跑,而"只删不校验过期"是这类实现最常见的漏洞。 */
206
+ export function createTicketStore({ ttlMs = TICKET_TTL_MS, now = Date.now } = {}) {
207
+ const tickets = new Map()
208
+ return {
209
+ mint() {
210
+ const at = now()
211
+ for (const [key, exp] of tickets) if (exp <= at) tickets.delete(key)
212
+ // 突发时丢最早的一张:票据只有 60 秒寿命,超出上限的多半是重试风暴的残留。
213
+ while (tickets.size >= TICKET_MAX) {
214
+ const oldest = tickets.keys().next().value
215
+ if (oldest === undefined) break
216
+ tickets.delete(oldest)
217
+ }
218
+ const ticket = randomBytes(18).toString('base64url')
219
+ tickets.set(ticket, at + ttlMs)
220
+ return ticket
221
+ },
222
+ /** 命中即删 —— 无论是否过期,同一张票据第二次兑换一律无效。 */
223
+ redeem(ticket) {
224
+ const key = asText(ticket)
225
+ if (!key) return false
226
+ const exp = tickets.get(key)
227
+ if (exp === undefined) return false
228
+ tickets.delete(key)
229
+ return exp > now()
230
+ },
231
+ get size() { return tickets.size },
232
+ }
233
+ }
234
+
235
+ /** 写路由的跨站门禁:显式声明 cross-site 的直接拒;带 Origin 的必须与 Host 同源。
236
+ * 没有 Origin 的(curl、非浏览器客户端)放行 —— 与"宿主不做令牌校验"的边界一致,
237
+ * 这里只负责挡住浏览器里的跨站页面。 */
238
+ function isCrossSite(req) {
239
+ const headers = (req && req.headers) || {}
240
+ if (String(headers['sec-fetch-site'] || '').toLowerCase() === 'cross-site') return true
241
+ const origin = asText(headers.origin)
242
+ if (!origin) return false
243
+ let originHost = ''
244
+ try { originHost = new URL(origin).host.toLowerCase() } catch { return true }
245
+ const host = asText(headers.host).toLowerCase()
246
+ return Boolean(host) && originHost !== host
247
+ }
248
+
249
+ function hostOnly(value) {
250
+ return asText(value).toLowerCase().replace(/:\d+$/, '')
251
+ }
252
+
253
+ function requestHost(req) {
254
+ return hostOnly(req && req.headers && req.headers.host)
255
+ }
256
+
257
+ /** 端口取自请求的 Host 头 —— 这样同一个插件在任意端口上都报得对,不需要写死 52676。 */
258
+ function requestPort(req) {
259
+ const m = /:(\d+)$/.exec(asText(req && req.headers && req.headers.host))
260
+ if (!m) return ''
261
+ const port = Number(m[1])
262
+ return Number.isFinite(port) && port > 0 && port < 65536 ? String(port) : ''
263
+ }
264
+
152
265
  /** 后台任务的 label 常是多行 shell 命令(含纯环境变量赋值行),折成一行可读文本。 */
153
266
  function cleanLabel(raw) {
154
267
  const all = asText(raw).split(/\r?\n/).map((line) => line.trim()).filter(Boolean)
@@ -170,7 +283,7 @@ function safe(fn, fallback, errors, label) {
170
283
  }
171
284
  }
172
285
 
173
- export function apply(ctx) {
286
+ export function apply(ctx, config) {
174
287
  const jobsSvc = ctx.get('jobs')
175
288
  const agentsSvc = ctx.get('agents')
176
289
  const sessionsSvc = ctx.get('sessions')
@@ -1733,10 +1846,198 @@ export function apply(ctx) {
1733
1846
  }), 'in-hand: chat model route')
1734
1847
  }
1735
1848
 
1849
+ // ---- 双入口(远端 / 局域网)--------------------------------------------
1850
+ //
1851
+ // 与 taskwatch-relay 的 /taskwatch/entries、/taskwatch/ticket 同一套语义:
1852
+ // 入口清单说明"现在有哪几条路",ticket 给一张 60 秒、一次性、用后即焚的通行证。
1853
+ // 差别只有一个:宿主自己不做鉴权,所以兑换后的 Set-Cookie 只是与中继同形的
1854
+ // "换路已完成"标记,不承担授权(授权仍由中继那层负责)。
1855
+ //
1856
+ // 2026-10-21(v1.7.1):这两条原先**只有中继实现**,宿主没有 —— 页面从宿主加载时
1857
+ // 点「去远端」拿到 405/404,拿不到 url 又每 12 秒重试一次,就是"点了就一直报错"。
1858
+ const settings = config && typeof config === 'object' ? config : {}
1859
+ // 公网地址只从配置来:插件配置项 publicUrl,其次环境变量(与 DSH_TASKWATCH_ROOTS
1860
+ // 同一习惯)。没有配置就只报局域网入口 —— 代码里绝不写死任何域名。
1861
+ const publicUrl = normalizePublicUrl(
1862
+ asText(settings.publicUrl) || asText(process.env.DSH_TASKWATCH_PUBLIC_URL),
1863
+ )
1864
+ const tickets = createTicketStore()
1865
+ const routeJson = (res, status, obj) =>
1866
+ send(res, status, 'application/json; charset=utf-8', JSON.stringify(obj))
1867
+
1868
+ /** 入口清单。current 用布尔标在每条上(新契约);同时保留中继的
1869
+ * remote / lan / current 字段,好让同一份 page.html 在两种外壳下都能读。 */
1870
+ function entryList(req) {
1871
+ const host = requestHost(req)
1872
+ const port = requestPort(req)
1873
+ const remoteHost = publicUrl ? hostOnly(new URL(publicUrl).host) : ''
1874
+ const isRemote = Boolean(remoteHost) && host === remoteHost
1875
+ const lan = lanAddresses()
1876
+ const lanUrls = lan.map((a) => 'http://' + a.ip + (port ? ':' + port : '') + '/taskwatch')
1877
+ const lanCurrent = lan.findIndex((a) => a.ip === host)
1878
+ const entries = []
1879
+ if (publicUrl) {
1880
+ entries.push({
1881
+ kind: 'remote',
1882
+ label: '远端(公网)',
1883
+ url: publicUrl + '/taskwatch',
1884
+ current: isRemote,
1885
+ })
1886
+ }
1887
+ lanUrls.forEach((url, i) => {
1888
+ // 本机回环访问时没有哪条局域网地址"等于"当前地址,但它们连的是同一个服务,
1889
+ // 所以把第一条标成当前 —— 只影响胶囊文案,不影响任何跳转。
1890
+ const current = !isRemote && (lanCurrent >= 0 ? i === lanCurrent : i === 0)
1891
+ entries.push({ kind: 'lan', label: '局域网 · ' + lan[i].ip, url, current })
1892
+ })
1893
+ return { entries, remote: publicUrl, lan: lanUrls, current: isRemote ? 'remote' : 'lan' }
1894
+ }
1895
+
1896
+ // ticket 的请求体上限比对话接口还小:这里只该有一个 {"to":"remote"}。
1897
+ function readTicketBody(req) {
1898
+ return new Promise((resolve, reject) => {
1899
+ let size = 0
1900
+ const chunks = []
1901
+ req.on('data', (chunk) => {
1902
+ size += chunk.length
1903
+ if (size > 1024) {
1904
+ reject(new Error('请求体过大'))
1905
+ try { req.destroy() } catch { /* 已断开 */ }
1906
+ return
1907
+ }
1908
+ chunks.push(chunk)
1909
+ })
1910
+ req.on('end', () => {
1911
+ try {
1912
+ const raw = Buffer.concat(chunks).toString('utf8')
1913
+ const parsed = raw ? JSON.parse(raw) : {}
1914
+ resolve(parsed && typeof parsed === 'object' ? parsed : {})
1915
+ } catch (e) { reject(e) }
1916
+ })
1917
+ req.on('error', reject)
1918
+ })
1919
+ }
1920
+
1921
+ ctx.effect(() => webServer.register({
1922
+ kind: 'exact',
1923
+ path: '/taskwatch/entries',
1924
+ handler: (req, res) => {
1925
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
1926
+ res.setHeader('allow', 'GET, HEAD')
1927
+ routeJson(res, 405, { ok: false, code: 'method-not-allowed', error: '只接受 GET' })
1928
+ return
1929
+ }
1930
+ const view = entryList(req)
1931
+ routeJson(res, 200, {
1932
+ ok: true,
1933
+ entries: view.entries,
1934
+ // 中继那套字段(页面旧逻辑仍读它们;新逻辑读 entries)
1935
+ remote: view.remote,
1936
+ lan: view.lan,
1937
+ current: view.current,
1938
+ })
1939
+ },
1940
+ }), 'in-hand: entries route')
1941
+
1942
+ // 换路票据:写路由,沿用中继写路由的门禁 —— POST + application/json + 同源。
1943
+ // 宿主不校验令牌(和中继的分工),但绝不能让任意网页用表单 POST 触发一次跳转。
1944
+ ctx.effect(() => webServer.register({
1945
+ kind: 'exact',
1946
+ path: '/taskwatch/ticket',
1947
+ handler: async (req, res) => {
1948
+ if (req.method !== 'POST') {
1949
+ res.setHeader('allow', 'POST')
1950
+ routeJson(res, 405, { ok: false, code: 'method-not-allowed', error: '只接受 POST' })
1951
+ return
1952
+ }
1953
+ if (isCrossSite(req)) {
1954
+ routeJson(res, 403, { ok: false, code: 'cross-site', error: '禁止跨站触发换路' })
1955
+ return
1956
+ }
1957
+ const type = String(req.headers['content-type'] || '').split(';', 1)[0].trim().toLowerCase()
1958
+ if (type !== 'application/json') {
1959
+ routeJson(res, 415, { ok: false, code: 'unsupported-media-type', error: '请求必须使用 application/json' })
1960
+ return
1961
+ }
1962
+ let want = 'lan'
1963
+ try {
1964
+ const body = await readTicketBody(req)
1965
+ if (asText(body.to) === 'remote') want = 'remote'
1966
+ } catch {
1967
+ routeJson(res, 400, { ok: false, code: 'bad-request', error: '请求体不合法' })
1968
+ return
1969
+ }
1970
+ if (want === 'remote') {
1971
+ if (!publicUrl) {
1972
+ routeJson(res, 409, {
1973
+ ok: false,
1974
+ code: 'no-public-entry',
1975
+ error: '还没有配置公网入口(插件配置项 publicUrl),这里只能走局域网',
1976
+ })
1977
+ return
1978
+ }
1979
+ const ticket = tickets.mint()
1980
+ routeJson(res, 200, {
1981
+ ok: true,
1982
+ to: 'remote',
1983
+ url: publicUrl + '/taskwatch?t=' + ticket,
1984
+ expiresInMs: TICKET_TTL_MS,
1985
+ })
1986
+ return
1987
+ }
1988
+ const lan = lanAddresses()
1989
+ if (!lan.length) {
1990
+ routeJson(res, 409, {
1991
+ ok: false,
1992
+ code: 'no-lan-entry',
1993
+ error: '没有局域网地址(网卡没连上 Wi-Fi?)',
1994
+ })
1995
+ return
1996
+ }
1997
+ // 和中继一样优先挑与请求方同网段的那条,否则第一条。
1998
+ const peer = String((req.socket && req.socket.remoteAddress) || '').replace(/^::ffff:/, '')
1999
+ const net = (ip) => ip.split('.').slice(0, 3).join('.')
2000
+ const pick = lan.find((a) => net(a.ip) === net(peer)) || lan[0]
2001
+ const port = requestPort(req)
2002
+ const ticket = tickets.mint()
2003
+ routeJson(res, 200, {
2004
+ ok: true,
2005
+ to: 'lan',
2006
+ address: pick.ip,
2007
+ iface: pick.iface,
2008
+ url: 'http://' + pick.ip + (port ? ':' + port : '') + '/taskwatch?t=' + ticket,
2009
+ expiresInMs: TICKET_TTL_MS,
2010
+ })
2011
+ },
2012
+ }), 'in-hand: ticket route')
2013
+
2014
+ // 页面外壳。带 ?t= 时先兑换票据:命中 → 302 到 /taskwatch 并种一张换路标记 cookie
2015
+ // (与中继同形);未知/过期 → 401,和中继在换路凭据无效时的选择保持一致。
2016
+ // 一次性在这里落地:redeem 命中即删,手机上的"后退"再进来不会重放。
1736
2017
  ctx.effect(() => webServer.register({
1737
2018
  kind: 'exact',
1738
2019
  path: '/taskwatch',
1739
- handler: (req, res) => send(res, 200, 'text/html; charset=utf-8', readPage()),
2020
+ handler: (req, res) => {
2021
+ let ticket = ''
2022
+ try { ticket = new URL(req.url || '/', 'http://127.0.0.1').searchParams.get('t') || '' } catch { ticket = '' }
2023
+ if (ticket) {
2024
+ if (!tickets.redeem(ticket)) {
2025
+ send(res, 401, 'text/plain; charset=utf-8', '换路凭据无效或已过期,请再点一次切换。\n')
2026
+ return
2027
+ }
2028
+ try {
2029
+ res.writeHead(302, {
2030
+ location: '/taskwatch',
2031
+ // 宿主不鉴权,这张 cookie 只是"换路已完成"的标记,不承担授权。
2032
+ 'set-cookie': 'tw-route=remote; Path=/taskwatch; HttpOnly; SameSite=Lax; Max-Age=2592000',
2033
+ 'cache-control': 'no-store',
2034
+ })
2035
+ res.end()
2036
+ } catch { /* 已断开 */ }
2037
+ return
2038
+ }
2039
+ send(res, 200, 'text/html; charset=utf-8', readPage())
2040
+ },
1740
2041
  }), 'in-hand: page route')
1741
2042
 
1742
2043
  ctx.effect(() => webServer.register({
package/lib/page.html CHANGED
@@ -573,6 +573,20 @@ main{
573
573
 
574
574
  .note{font-size:var(--fs-2);color:var(--faint);text-align:center;padding:var(--sp-2) var(--sp-4)}
575
575
 
576
+ /* 双入口的静态降级说明(2026-10-21,v1.7.1)。
577
+ 页面由插件宿主提供、宿主没有中继的换路接口时,这里只留一句说明 + 一个手动回跳链接;
578
+ 链接的 padding/margin 是让 44px 的命中区不把这一行撑高(视觉仍是一行)。 */
579
+ .route-note{
580
+ margin:var(--sp-2) 0;padding:var(--sp-2) var(--sp-3);text-align:left;line-height:1.65;
581
+ color:var(--faint);font-size:var(--fs-2);background:color-mix(in srgb,currentColor 6%,transparent);
582
+ border:1px solid color-mix(in srgb,currentColor 16%,transparent);
583
+ border-radius:var(--dsw-radius-sm);overflow-wrap:anywhere;
584
+ }
585
+ .route-note a{
586
+ color:var(--accent);text-decoration:underline;text-underline-offset:2px;
587
+ display:inline-block;padding:12px 2px;margin:-12px 2px;
588
+ }
589
+
576
590
  /* ── 输入区 ─────────────────────────────────────────────────────────── */
577
591
  footer{
578
592
  flex:0 0 auto;background:var(--panel);border-top:1px solid var(--line);
@@ -3436,6 +3450,12 @@ function bindSheetClose(){
3436
3450
  for (var i = 0; i < xs.length; i++) xs[i].addEventListener('click', closeSheet)
3437
3451
  }
3438
3452
 
3453
+ // 双入口的静态降级说明由页面末尾那段独立脚本提供(window.twRouteNote):
3454
+ // 只在「宿主没有中继换路接口」时返回一行,其余时候是空串。
3455
+ function routeNote(){
3456
+ try { return (window.twRouteNote && window.twRouteNote()) || '' } catch (e) { return '' }
3457
+ }
3458
+
3439
3459
  function openCapability(kind){
3440
3460
  var title = kind === 'plugins' ? '插件' : '自动化'
3441
3461
  var h = sheetHead(title)
@@ -3465,7 +3485,7 @@ async function openSessions(){
3465
3485
  catch (e) { openSheet(sheetHead('会话') + '<div class="note">读取失败:' + esc(e.message) + '</div>'); bindSheetClose(); return }
3466
3486
  if (d && d.error) { openSheet(sheetHead('会话') + '<div class="note">读取失败:' + esc(d.error) + '</div>'); bindSheetClose(); return }
3467
3487
  S.sessions = (d && d.sessions) || []
3468
- var h = sheetHead('会话', S.sessions.length)
3488
+ var h = sheetHead('会话', S.sessions.length) + routeNote()
3469
3489
  h += '<label class="sheet-search"><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="10.8" cy="10.8" r="6.4"></circle><path d="m16 16 4.2 4.2"></path></svg><input id="sessionSearch" type="search" placeholder="搜索会话或工作区" autocomplete="off" aria-label="搜索会话或工作区"></label>'
3470
3490
  // 显示设置:字号三档 + 只看对话。放在会话列表上方,打开「对话」就能看到。
3471
3491
  var fcur = document.documentElement.getAttribute('data-font') || 'medium'
@@ -3640,15 +3660,15 @@ function kv(k, v){
3640
3660
  async function openStatus(){
3641
3661
  var stBtn = document.getElementById('status')
3642
3662
  if (stBtn) stBtn.setAttribute('aria-expanded', 'true')
3643
- openSheet(sheetHead('任务详情') + '<div class="note">读取中…</div>')
3663
+ openSheet(sheetHead('任务详情') + routeNote() + '<div class="note">读取中…</div>')
3644
3664
  var d = lastData
3645
3665
  if (!d || d.error) {
3646
3666
  try { d = await (await fetch('/taskwatch/data', { cache:'no-cache' })).json() } catch (e) { d = null }
3647
3667
  }
3648
- if (!d) { openSheet(sheetHead('任务详情') + '<div class="note">读取失败</div>'); bindSheetClose(); return }
3649
- if (d.error) { openSheet(sheetHead('任务详情') + '<div class="note">读取失败:' + esc(d.error) + '</div>'); bindSheetClose(); return }
3668
+ if (!d) { openSheet(sheetHead('任务详情') + routeNote() + '<div class="note">读取失败</div>'); bindSheetClose(); return }
3669
+ if (d.error) { openSheet(sheetHead('任务详情') + routeNote() + '<div class="note">读取失败:' + esc(d.error) + '</div>'); bindSheetClose(); return }
3650
3670
  var t = d.totals || {}
3651
- var h = sheetHead('任务详情') + '<h3>当前会话</h3>'
3671
+ var h = sheetHead('任务详情') + routeNote() + '<h3>当前会话</h3>'
3652
3672
  h += kv('会话', S.title || shortId(S.sessionId) || '未选择')
3653
3673
  h += kv('会话 ID', S.sessionId || '未选择')
3654
3674
  h += kv('工作区', S.workspace || '默认工作区')
@@ -4511,12 +4531,24 @@ if ('serviceWorker' in navigator) {
4511
4531
  (function () {
4512
4532
  try {
4513
4533
  var PREF_KEY = 'tw-route-pref'
4534
+ var REMOTE_KEY = 'tw-remote-url'
4514
4535
  var ORIGIN = 'unknown'
4515
4536
  var REMOTE = ''
4516
4537
  var LAN = []
4517
4538
  var failed = false
4518
4539
  var upstreamOk = true
4519
4540
  var busy = false
4541
+ // 2026-10-21(v1.7.1):静态降级。宿主没有中继的 ticket / entries 接口时
4542
+ // (拿到的就是 404 / 405),**永远不再重试**,也不弹错 —— 只是把胶囊按
4543
+ // 「只有一个入口」隐藏,并在设置 / 详情里留一行说明与一个手动回跳链接。
4544
+ var DEGRADED = false
4545
+ var probeOff = false // 宿主没有上游体检:不知道 ≠ 失败,关掉探测别每 12 秒一个 404
4546
+ var timer = 0 // 轮询句柄;降级 / 探测不可用时必须清掉
4547
+ var failAt = 0 // 换路失败后的冷却起点,防止每次轮询都重试同一次失败
4548
+ var toastMsg = ''
4549
+ var toastAt = 0
4550
+ var TOAST_COOLDOWN = 30 * 1000
4551
+ var RETRY_COOLDOWN = 30 * 1000
4520
4552
 
4521
4553
  /* ROUTE-LOGIC-BEGIN */
4522
4554
  function decideRoute(c) {
@@ -4536,6 +4568,102 @@ if ('serviceWorker' in navigator) {
4536
4568
  }
4537
4569
  function setPref(v) { try { localStorage.setItem(PREF_KEY, v) } catch (e) {} }
4538
4570
 
4571
+ function readStore(key) { try { return localStorage.getItem(key) || '' } catch (e) { return '' } }
4572
+ function writeStore(key, value) { try { localStorage.setItem(key, value) } catch (e) {} }
4573
+
4574
+ /** 上次成功切换 / 打开过的远端地址。只认 http(s),避免 localStorage 被第三方
4575
+ * 写进 javascript: 一类的东西后,这个链接变成一个可点的执行入口。 */
4576
+ function rememberedRemote() {
4577
+ var value = readStore(REMOTE_KEY)
4578
+ return /^https?:\/\//i.test(value) ? value : ''
4579
+ }
4580
+ function rememberRemote(url) {
4581
+ if (!url) return
4582
+ var base = String(url).split('?')[0]
4583
+ if (/^https?:\/\//i.test(base)) writeStore(REMOTE_KEY, base)
4584
+ }
4585
+
4586
+ /** 其它错误的提示:同一句 30 秒内只弹一次(404/405 的降级路径不弹)。 */
4587
+ function toast(message) {
4588
+ if (!message) return
4589
+ var now = Date.now()
4590
+ if (message === toastMsg && now - toastAt < TOAST_COOLDOWN) return
4591
+ toastMsg = message
4592
+ toastAt = now
4593
+ var el = document.getElementById('tw-route-toast')
4594
+ if (!el) {
4595
+ el = document.createElement('div')
4596
+ el.id = 'tw-route-toast'
4597
+ el.setAttribute('role', 'status')
4598
+ el.setAttribute('aria-live', 'polite')
4599
+ document.body.appendChild(el)
4600
+ }
4601
+ el.textContent = message
4602
+ el.className = 'on'
4603
+ clearTimeout(el._hide)
4604
+ el._hide = setTimeout(function () { el.className = '' }, 3200)
4605
+ }
4606
+
4607
+ function stopTimer() {
4608
+ if (timer) { clearInterval(timer); timer = 0 }
4609
+ }
4610
+ function startTimer() {
4611
+ if (timer || DEGRADED || probeOff) return
4612
+ timer = setInterval(function () { apply(); probe() }, 12000)
4613
+ }
4614
+
4615
+ /** 降级:宿主没有换路接口。清掉轮询、隐藏胶囊、不报错。 */
4616
+ function degrade() {
4617
+ if (DEGRADED) return
4618
+ DEGRADED = true
4619
+ stopTimer()
4620
+ ORIGIN = 'unknown'
4621
+ apply()
4622
+ }
4623
+
4624
+ function isGone(status) { return status === 404 || status === 405 }
4625
+
4626
+ /** 兼容两种 entries 形状:中继的 {remote, lan, current} 与新契约的
4627
+ * {entries:[{kind, label, url, current}]}。同一份页面在中继和宿主下都能跑。 */
4628
+ function parseEntries(j) {
4629
+ if (!j || j.ok !== true) return false
4630
+ var list = Array.isArray(j.entries) ? j.entries : null
4631
+ if (list) {
4632
+ var rem = ''
4633
+ var lan = []
4634
+ var current = ''
4635
+ for (var i = 0; i < list.length; i++) {
4636
+ var e = list[i]
4637
+ if (!e || typeof e.url !== 'string' || !/^https?:\/\//i.test(e.url)) continue
4638
+ if (e.kind === 'remote') { if (!rem) rem = e.url }
4639
+ else if (e.kind === 'lan') { if (lan.indexOf(e.url) < 0) lan.push(e.url) }
4640
+ else continue
4641
+ if (e.current === true) current = e.kind
4642
+ }
4643
+ REMOTE = rem
4644
+ LAN = lan
4645
+ ORIGIN = current || (lan.length ? 'lan' : (rem ? 'remote' : 'unknown'))
4646
+ } else {
4647
+ if (j.current !== 'lan' && j.current !== 'remote') return false
4648
+ ORIGIN = j.current
4649
+ REMOTE = typeof j.remote === 'string' ? j.remote : ''
4650
+ LAN = Array.isArray(j.lan) ? j.lan.slice() : []
4651
+ }
4652
+ if (ORIGIN === 'remote') rememberRemote(location.origin + '/taskwatch')
4653
+ return true
4654
+ }
4655
+
4656
+ /** 降级态在设置 / 详情里的一行说明。宿主页面没有中继接口这件事本身不是错误,
4657
+ * 所以这里只陈述事实,并把「上次成功用过的远端地址」留成手动入口。 */
4658
+ function noteHtml() {
4659
+ if (!DEGRADED) return ''
4660
+ var last = rememberedRemote()
4661
+ var text = '当前页面由插件宿主提供,远端切换需要中继接口'
4662
+ if (last) text += ';也可直接打开 <a href="' + esc(last) + '">' + esc(last) + '</a>'
4663
+ return '<div class="note route-note">' + text + '</div>'
4664
+ }
4665
+ window.twRouteNote = noteHtml
4666
+
4539
4667
  function mount() {
4540
4668
  // 2026-10-21:胶囊必须待在**标题行**里(标题与连接三点之间)。
4541
4669
  // 旧写法是 append 到 header 末尾、再靠 margin-left:auto 靠右,它于是成了
@@ -4580,6 +4708,7 @@ if ('serviceWorker' in navigator) {
4580
4708
 
4581
4709
  function apply() {
4582
4710
  var el = mount()
4711
+ if (DEGRADED) { el.style.display = 'none'; el.setAttribute('aria-hidden', 'true'); return }
4583
4712
  var text = label()
4584
4713
  if (!text) { el.style.display = 'none'; el.setAttribute('aria-hidden', 'true'); return }
4585
4714
  el.textContent = text
@@ -4591,22 +4720,56 @@ if ('serviceWorker' in navigator) {
4591
4720
  hasLan: LAN.length > 0, hasRemote: !!REMOTE,
4592
4721
  })
4593
4722
  if (decision === 'stay' || busy) return
4723
+ // 刚失败过就先停 30 秒:轮询每 12 秒一次,不设冷却就变成"点一次、失败、每 12 秒重试"。
4724
+ if (Date.now() - failAt < RETRY_COOLDOWN) return
4594
4725
  busy = true
4595
4726
  fetch('/taskwatch/ticket', {
4596
4727
  method: 'POST',
4597
4728
  headers: { 'content-type': 'application/json' },
4598
4729
  body: JSON.stringify({ to: decision === 'toLan' ? 'lan' : 'remote' }),
4599
- }).then(function (r) { return r.json() }).then(function (j) {
4600
- if (j && j.ok && j.url) location.href = j.url
4601
- else busy = false
4602
- }).catch(function () { busy = false })
4730
+ }).then(function (r) {
4731
+ // 宿主没有换路接口(405)或这条路由不存在(404):静态降级,绝不重试。
4732
+ if (isGone(r.status)) { degrade(); return null }
4733
+ if (!r.ok) throw new Error('HTTP ' + r.status)
4734
+ return r.json()
4735
+ }).then(function (j) {
4736
+ if (j === null) { busy = false; return }
4737
+ if (j && j.ok && j.url) {
4738
+ if (decision === 'toRemote') rememberRemote(j.url)
4739
+ location.href = j.url
4740
+ return
4741
+ }
4742
+ busy = false
4743
+ failAt = Date.now()
4744
+ toast((j && j.error) || '无法切换连接方式')
4745
+ }).catch(function (e) {
4746
+ busy = false
4747
+ failAt = Date.now()
4748
+ toast('切换连接方式失败:' + ((e && e.message) || '网络错误'))
4749
+ })
4603
4750
  }
4604
4751
 
4605
4752
  function probe() {
4753
+ if (DEGRADED || probeOff) return
4606
4754
  fetch('/taskwatch/upstream-health', { cache: 'no-store' })
4607
- .then(function (r) { return r.json() })
4608
- .then(function (j) { failed = false; upstreamOk = !!(j && j.ok); apply() })
4609
- .catch(function () { failed = true; apply() })
4755
+ .then(function (r) {
4756
+ // 404 / 405 = 这个外壳没有上游体检(插件宿主)。拿它当"这条路断了"会误触发
4757
+ // 一次到远端的切换,所以只关掉探测,不改失败判据。
4758
+ if (isGone(r.status)) { probeOff = true; stopTimer(); return null }
4759
+ if (!r.ok) throw new Error('HTTP ' + r.status)
4760
+ return r.json()
4761
+ })
4762
+ .then(function (j) {
4763
+ if (j === null) return
4764
+ failed = false
4765
+ upstreamOk = !!(j && j.ok)
4766
+ apply()
4767
+ })
4768
+ .catch(function (e) {
4769
+ failed = true
4770
+ apply()
4771
+ toast('状态检查失败:' + ((e && e.message) || '网络错误'))
4772
+ })
4610
4773
  }
4611
4774
 
4612
4775
  // 样式随手挂上,不依赖页面里已有的 class(两份外壳的样式表可能不同)
@@ -4624,20 +4787,35 @@ if ('serviceWorker' in navigator) {
4624
4787
  + 'border:1px solid color-mix(in srgb,currentColor 24%,transparent);cursor:pointer;white-space:nowrap}'
4625
4788
  + '#tw-route-pill[style*="inline-flex"]{display:inline-flex!important}'
4626
4789
  + '#tw-route-pill:active{transform:translateY(1px)}'
4790
+ // 换路失败的一次性提示(同一错误 30 秒内不重复弹)。同样只用 currentColor 混合,
4791
+ // 明暗两套都能读;max-width 留出两侧 12px,390 宽下不会出现横向滚动。
4792
+ + '#tw-route-toast{position:fixed;left:50%;bottom:calc(96px + var(--safe-b,0px));'
4793
+ + 'transform:translateX(-50%);z-index:90;max-width:calc(100vw - 24px);box-sizing:border-box;'
4794
+ + 'padding:10px 14px;border-radius:12px;opacity:0;pointer-events:none;transition:opacity .18s;'
4795
+ + 'font:600 12.5px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC",system-ui,sans-serif;'
4796
+ + 'color:inherit;background:color-mix(in srgb,currentColor 12%,transparent);'
4797
+ + 'border:1px solid color-mix(in srgb,currentColor 22%,transparent);overflow-wrap:anywhere}'
4798
+ + '#tw-route-toast.on{opacity:1}'
4799
+ + '@media (prefers-reduced-motion:reduce){#tw-route-toast{transition:none}}'
4627
4800
  document.head.appendChild(css)
4628
4801
 
4802
+ // 入口清单只取一次:404/405 → 静态降级并不再重试;其它错误 → 提示一次后收手。
4629
4803
  fetch('/taskwatch/entries', { cache: 'no-store' })
4630
- .then(function (r) { return r.ok ? r.json() : null })
4804
+ .then(function (r) {
4805
+ if (isGone(r.status)) { degrade(); return null }
4806
+ if (!r.ok) throw new Error('HTTP ' + r.status)
4807
+ return r.json()
4808
+ })
4631
4809
  .then(function (j) {
4632
- if (!j || !j.ok || (j.current !== 'lan' && j.current !== 'remote')) return
4633
- ORIGIN = j.current
4634
- REMOTE = j.remote || ''
4635
- LAN = j.lan || []
4810
+ if (j === null) return
4811
+ if (!parseEntries(j)) return
4636
4812
  apply()
4637
4813
  probe()
4638
- setInterval(function () { apply(); probe() }, 12000)
4814
+ if (!probeOff) startTimer()
4815
+ })
4816
+ .catch(function (e) {
4817
+ toast('入口清单读取失败:' + ((e && e.message) || '网络错误'))
4639
4818
  })
4640
- .catch(function () { /* 插件侧没有这些路由(例如从 DSH GUI 打开)→ 安静地不显示 */ })
4641
4819
  } catch (e) { console.warn('taskwatch: 双入口切换未启用', e) }
4642
4820
  })()
4643
4821
  </script>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-in-hand",
3
- "version": "1.7.0",
3
+ "version": "1.7.1",
4
4
  "description": "掌上 DSH:DeepSeek Harness 的手机端,不用装 App——会话、实时对话、交付文件、后台任务与待批准。 / DSH in hand: the phone client for DeepSeek Harness with no app to install.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -33,7 +33,7 @@
33
33
  "node": ">=20"
34
34
  },
35
35
  "scripts": {
36
- "check": "node scripts/check-bundle.mjs && node scripts/check-files.mjs && node scripts/check-uploads.mjs && node scripts/check-queue.mjs",
36
+ "check": "node scripts/check-bundle.mjs && node scripts/check-files.mjs && node scripts/check-uploads.mjs && node scripts/check-queue.mjs && node scripts/check-entries.mjs",
37
37
  "demo": "node scripts/make-demo.mjs",
38
38
  "icons": "node scripts/make-icons.mjs"
39
39
  },