dsh-in-hand 1.7.0 → 1.7.2
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 +74 -15
- package/lib/index.js +370 -5
- package/lib/page.html +197 -19
- package/lib/ticket.js +194 -0
- package/package.json +2 -2
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`)——
|
|
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 秒的 **v2 HMAC 换路票据**(`v2.<exp>.<nonce>.<hmac>`),宿主与中继用同一密钥互认;只在内存里记已用的 nonce,不碰会话 / 文件 / 配置。没配 `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,60 @@ 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
|
|
465
|
-
2.
|
|
466
|
-
|
|
467
|
-
|
|
467
|
+
1. `POST /taskwatch/ticket`(中继上要带令牌)→ 返回另一条路的 URL,末尾带 `?t=…`;
|
|
468
|
+
2. 手机跳过去,对面兑换这张票并给那条路种下 cookie,于是不用重新输那 6 位配对码;
|
|
469
|
+
3. 中继与插件宿主**跑在同一台机器、同一个用户下**,用同一个密钥验签,所以宿主签的票
|
|
470
|
+
打到公网(域名后面是中继)也能被中继认,反之亦然。
|
|
468
471
|
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
472
|
+
**v1.7.1 起,前两条插件宿主也自己实现了**(同一套语义):`GET /taskwatch/entries`
|
|
473
|
+
回答「现在有哪几条路」,`POST /taskwatch/ticket` 给一张换路票据。
|
|
474
|
+
所以从宿主页面(例如 `http://127.0.0.1:52676/taskwatch`)点「去远端」不再拿到 405 / 404。
|
|
475
|
+
`GET /taskwatch/upstream-health` 仍只有中继实现;宿主页面拿到它的 404 时按「未知」处理,
|
|
476
|
+
并停掉探测 —— **不会**把「没有体检接口」误判成「这条路断了」。
|
|
473
477
|
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
478
|
+
#### 票据格式(v1.7.2)
|
|
479
|
+
|
|
480
|
+
```text
|
|
481
|
+
t = 'v2.' + <expMs 十进制> + '.' + <nonce base64url> + '.' + <hmac base64url>
|
|
482
|
+
HMAC = HMAC-SHA256(secret, 'v2|' + expMs + '|' + nonce)
|
|
483
|
+
nonce = crypto.randomBytes(16) # base64url 后 22 个字符
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
校验顺序:格式 → `timingSafeEqual` 比 HMAC → `expMs > now` → `expMs - now <= 180000`
|
|
487
|
+
(60 秒 TTL + 时钟偏差)。签名不对 / 过期 / 格式不符一律当作「不是本票」;只有**不是**
|
|
488
|
+
`v2.` 形状的字符串才回落到原来的进程内 Map(老页面、老票仍然能用)。票据有效期内
|
|
489
|
+
两边各自维护一个已用 nonce 的 Set(带 TTL 清理)做**进程内 best-effort 去重** ——
|
|
490
|
+
这不是严格一次性,详见下面「安全边界」。
|
|
491
|
+
|
|
492
|
+
公网地址只来自插件配置项 `publicUrl`(也支持环境变量 `DSH_TASKWATCH_PUBLIC_URL`),
|
|
493
|
+
局域网入口可以选配 `lanUrl`(也支持 `DSH_TASKWATCH_LAN_URL`),代码里不写死任何域名:
|
|
494
|
+
|
|
495
|
+
```yaml
|
|
496
|
+
- insert:
|
|
497
|
+
- id: dsh-in-hand
|
|
498
|
+
name: dsh-in-hand
|
|
499
|
+
config:
|
|
500
|
+
publicUrl: https://dsh.example.com
|
|
501
|
+
lanUrl: http://192.168.1.5:3099 # 指向中继的局域网入口(有令牌门)
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
配了 `lanUrl` 就把 `entries` 的局域网条目换成它,不再自动探测内网 IP;没配保持现状。
|
|
505
|
+
**插件自己的路由没有令牌门**(授权由中继那层负责),所以建议把 `lanUrl` 指到有鉴权的
|
|
506
|
+
中继,而不是直接指回插件宿主。
|
|
507
|
+
|
|
508
|
+
没配 `publicUrl` 时只报局域网入口;`ticket` 的远端请求明确回 `409 no-public-entry`。
|
|
509
|
+
宿主这层仍然不做令牌校验(401 由中继负责),但票据是写路由,所以和中继的写路由同一套
|
|
510
|
+
门禁:`POST` + `application/json` + 同源(`Origin` / `Sec-Fetch-Site`)校验。
|
|
511
|
+
|
|
512
|
+
页面侧遇到这两条接口的 **404 / 405** 会进入**静态降级**:不重试、不弹错,胶囊按
|
|
513
|
+
「只有一个入口」隐藏;设置 / 任务详情里留一行说明与一个手动「去远端」链接
|
|
514
|
+
(上次成功的地址记在 `localStorage['tw-remote-url']`)。500 / 502 / 网络错误只提示一次
|
|
515
|
+
(同一句 30 秒内不重复弹),不会变成重试风暴。
|
|
516
|
+
|
|
517
|
+
> 中继那边另有独立实现,见中继仓库 `dsh-in-hand-relay`(该仓库尚未创建,此处只写仓库名)。
|
|
518
|
+
> 中继没升级时,页面在宿主上仍能正常用,只是没有远端切换。
|
|
477
519
|
|
|
478
520
|
### 502 排查
|
|
479
521
|
|
|
@@ -500,6 +542,17 @@ PWA 外壳由中继本地提供(而不是转发上游),这样它不随上
|
|
|
500
542
|
- 手机上做的每一步都会落进该会话的日志,桌面上打开同一个会话就能看到全部经过 ——
|
|
501
543
|
这是"远程操作可追溯"的那一半。
|
|
502
544
|
- 插件的路由只在**回环**上监听,从不对外。
|
|
545
|
+
- **跨进程票据密钥**放在 `<用户主目录>/.dsh/taskwatch-ticket.key`(UTF-8、`trim` 后
|
|
546
|
+
至少 32 字符)。两边的解析顺序完全一致:环境变量 `TASKWATCH_TICKET_SECRET` → 该文件
|
|
547
|
+
→ 都没有时由**先用到的一方生成**(`crypto.randomBytes(32).toString('base64url')`)。
|
|
548
|
+
Windows 下生成后用 `icacls <file> /inheritance:r /grant:r <当前用户>:F` 去掉继承、
|
|
549
|
+
只留当前用户;Linux/macOS 用 `chmod 600`。该文件**绝不能进任何 git 仓库**:插件仓库的
|
|
550
|
+
`.gitignore` 已加 `taskwatch-ticket.key` 与 `*.key`,真实文件在 `~/.dsh/` 下、
|
|
551
|
+
本来就不在仓库里。
|
|
552
|
+
- **票据不是严格一次性**。签名 + 60 秒时效 + 两边各自的进程内 nonce 去重,把重放窗口
|
|
553
|
+
压得很小,但跨进程没有共享的「已用」状态 —— 一张从未被这两个进程见过的票,理论上
|
|
554
|
+
在有效期内仍可能被重放一次。要严格一次性就得引入共享存储(Redis / SQLite),
|
|
555
|
+
当前「换路跳转」的场景不值得为此多一个依赖与故障点。
|
|
503
556
|
- 页面带 `X-Robots-Tag: noindex, nofollow`,响应带 `nosniff`。除 `/taskwatch/data`
|
|
504
557
|
(见下)外一律 `no-store`。
|
|
505
558
|
- service worker 的作用域**限定为 `/taskwatch`**(注册时即指定,脚本也放在
|
|
@@ -546,7 +599,7 @@ PWA 外壳由中继本地提供(而不是转发上游),这样它不随上
|
|
|
546
599
|
## 开发
|
|
547
600
|
|
|
548
601
|
```sh
|
|
549
|
-
npm run check # bundle
|
|
602
|
+
npm run check # bundle、文件边界、上传路由、页面契约与票据 HMAC 互认测试
|
|
550
603
|
node scripts/make-icons.mjs # 重新生成图标(自实现 PNG 编码,零依赖)
|
|
551
604
|
node scripts/make-demo.mjs # 生成 docs/demo.html(假数据,供截图与预览)
|
|
552
605
|
```
|
|
@@ -561,6 +614,12 @@ node scripts/make-demo.mjs # 生成 docs/demo.html(假数据,供截图
|
|
|
561
614
|
失败重试、数量 / 总大小限制、错误的请求方法与类型,以及宿主缺少上传服务时的错误提示;
|
|
562
615
|
测试只使用内存 fake 服务,不会连接真实模型。
|
|
563
616
|
|
|
617
|
+
`check-ticket-hmac.mjs` 覆盖跨进程票据:宿主签 → 中继实例验、中继签 → 宿主验、篡改签名、
|
|
618
|
+
过期 / 超长跨度、老格式随机票仍走原 Map、以及密钥文件自动生成(只断言存在与长度,
|
|
619
|
+
从不打印内容)。中继那一侧在本机存在时是**真的 spawn 一个临时端口实例**(临时令牌、
|
|
620
|
+
不碰 3099 生产进程,测完杀掉);中继目录不在本机时这两条明确 SKIP,公开仓库的 CI 不会
|
|
621
|
+
因此变红。
|
|
622
|
+
|
|
564
623
|
为什么宿主也要钉住:Cordis 的 `inject` 决定框架等哪些服务就绪后才调用 `apply`。
|
|
565
624
|
`webServer` 的服务是在启动阶段之后才发布的,而 `apply` 在组合期就会被调用——
|
|
566
625
|
只写 `ctx.get('webServer')` 软读会在那一刻拿到 `undefined`,于是整段路由注册被
|
package/lib/index.js
CHANGED
|
@@ -8,19 +8,25 @@
|
|
|
8
8
|
* 采集面:会话(运行/空闲、最后活动、activeJobs)、后台任务 jobs、
|
|
9
9
|
* 子代理树、目标轮次与阻塞、工作流阶段、等待审批/提问。
|
|
10
10
|
*
|
|
11
|
-
*
|
|
11
|
+
* 只读为主:除对话接口外不写宿主状态、不代理 DSH 自身的 /api。唯一的例外是
|
|
12
|
+
* 换路票据(POST /taskwatch/ticket)—— v1.7.2 起签的是 60 秒、HMAC 签名的 v2 票,
|
|
13
|
+
* 与中继共用同一个本机密钥,所以跨进程能互认;进程内只记已用的 nonce,不碰会话、
|
|
14
|
+
* 文件或配置,见下方「双入口」一节与 lib/ticket.js。
|
|
12
15
|
*
|
|
13
16
|
* 静态资源(PWA)都挂在 /taskwatch/ 之下,service worker 的作用域也限定为
|
|
14
17
|
* /taskwatch —— 绝不允许它有机会拦截主 GUI 的请求。
|
|
15
18
|
*/
|
|
16
19
|
import { readFileSync, statSync } from 'node:fs'
|
|
17
|
-
import { createHash, randomUUID } from 'node:crypto'
|
|
18
|
-
import { homedir } from 'node:os'
|
|
20
|
+
import { createHash, randomBytes, randomUUID } from 'node:crypto'
|
|
21
|
+
import { homedir, networkInterfaces } from 'node:os'
|
|
19
22
|
import { fileURLToPath } from 'node:url'
|
|
20
23
|
import { dirname, join } from 'node:path'
|
|
21
24
|
// 交付文件的解析与安全边界都在 files.js —— 白名单、穿越防护、类型白名单需要能被
|
|
22
25
|
// 测试直接调用,放在路由闭包里就证明不了。
|
|
23
26
|
import { MAX_BYTES, dispositionFor, infoOf, resolveTarget, rootsFrom } from './files.js'
|
|
27
|
+
// 跨进程换路票据:宿主与中继共用同一套 v2 HMAC 格式与密钥解析顺序。
|
|
28
|
+
// 单独成模块是为了纯函数能被测试直接钉住,也避免两边各写一份实现后悄悄漂移。
|
|
29
|
+
import { createTicketGate, loadTicketSecret } from './ticket.js'
|
|
24
30
|
|
|
25
31
|
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
26
32
|
|
|
@@ -149,6 +155,130 @@ const why = (e) => (e && e.message ? String(e.message) : String(e))
|
|
|
149
155
|
|
|
150
156
|
const ENV_ASSIGN = /^\$env:[A-Za-z_][A-Za-z0-9_]*\s*=/i
|
|
151
157
|
|
|
158
|
+
// ── 双入口:入口清单与一次性换路票据 ────────────────────────────────────────
|
|
159
|
+
//
|
|
160
|
+
// 背景(2026-10-21,v1.7.1 修的真实故障):页面在插件宿主上打开时,点「去远端」
|
|
161
|
+
// 会打 POST /taskwatch/ticket 与 GET /taskwatch/entries。这两个接口原先**只有中继
|
|
162
|
+
// 实现**,宿主没有:实测宿主回 405 / 404,页面拿不到 url,探测循环每 12 秒再试一次,
|
|
163
|
+
// 于是在手机上看就是"点了去远端就一直报错"。
|
|
164
|
+
//
|
|
165
|
+
// 现在宿主按中继的语义补齐这两条;公网地址只从配置项 publicUrl 来,**绝不写死域名**。
|
|
166
|
+
// 没配就只报价局域网入口,ticket 明确回 409 no-public-entry。
|
|
167
|
+
//
|
|
168
|
+
// v1.7.2:票据从"进程内随机串"升级成 v2 HMAC。根因是宿主签的票打到公网(域名后面是
|
|
169
|
+
// 中继)→ 中继只认自己 Map 里的串 → 401。两边共用 <home>/.dsh/taskwatch-ticket.key,
|
|
170
|
+
// 所以现在互认;老格式随机票仍回落到进程内 Map。
|
|
171
|
+
//
|
|
172
|
+
// 鉴权边界不变:宿主这层不做令牌校验(和中继的分工见 README),但 ticket 是**写**路由,
|
|
173
|
+
// 所以和中继的写路由一样要求 POST + application/json(跨站表单发不出这个 content-type,
|
|
174
|
+
// 天然需要 preflight,而宿主不应答 CORS 预检),另加一道 Origin / Sec-Fetch-Site 校验。
|
|
175
|
+
export const TICKET_TTL_MS = 60 * 1000
|
|
176
|
+
const TICKET_MAX = 256
|
|
177
|
+
|
|
178
|
+
/** 公网入口只接受纯 origin(https://host[:port]),带路径的一律当作没配 ——
|
|
179
|
+
* 否则 302 的 Location 是根绝对路径,和子路径部署对不上,切换会静默跳到错地方。 */
|
|
180
|
+
export function normalizePublicUrl(raw) {
|
|
181
|
+
const text = typeof raw === 'string' ? raw.trim().replace(/\/+$/, '') : ''
|
|
182
|
+
if (!text) return ''
|
|
183
|
+
try {
|
|
184
|
+
const parsed = new URL(text)
|
|
185
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return ''
|
|
186
|
+
if (!parsed.hostname) return ''
|
|
187
|
+
if (parsed.pathname && parsed.pathname !== '/') return ''
|
|
188
|
+
return parsed.origin
|
|
189
|
+
} catch {
|
|
190
|
+
return ''
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** 局域网 IPv4 地址。虚拟网卡(WSL / Hyper-V / docker)不算"手机能连的入口",
|
|
195
|
+
* 否则入口清单里会出现一个手机永远打不开的地址 —— 那比不显示更糟。 */
|
|
196
|
+
const VIRTUAL_IFACE = /vEthernet|Hyper-V|WSL|VMware|VirtualBox|Loopback|docker|Bluetooth|TAP-/i
|
|
197
|
+
export function lanAddresses(ifaces, includeVirtual = false) {
|
|
198
|
+
const table = ifaces && typeof ifaces === 'object' ? ifaces : networkInterfaces()
|
|
199
|
+
const out = []
|
|
200
|
+
for (const name of Object.keys(table)) {
|
|
201
|
+
if (!includeVirtual && VIRTUAL_IFACE.test(name)) continue
|
|
202
|
+
for (const addr of table[name] || []) {
|
|
203
|
+
if (!addr || addr.internal) continue
|
|
204
|
+
if (addr.family !== 'IPv4' && addr.family !== 4) continue
|
|
205
|
+
const ip = asText(addr.address)
|
|
206
|
+
if (ip) out.push({ iface: name, ip })
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return out
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** 局域网入口也可以指向别处(通常是中继的局域网入口)。只接受纯 origin:
|
|
213
|
+
* 带路径的值和 publicUrl 一样会让 302 / 入口清单对不上,一律当作没配。 */
|
|
214
|
+
export function normalizeLanUrl(raw) {
|
|
215
|
+
return normalizePublicUrl(raw)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** 票据存储:60 秒、一次性。单独抽出来的原因是"过期"要能被测试钉住 ——
|
|
219
|
+
* 真等 60 秒的测试不会有人跑,而"只删不校验过期"是这类实现最常见的漏洞。
|
|
220
|
+
*
|
|
221
|
+
* v1.7.2 起它只承担**向后兼容**:老页面的随机串仍在这个 Map 里兑换;新签发的
|
|
222
|
+
* 票是 v2 HMAC(见 lib/ticket.js),跨进程可用。 */
|
|
223
|
+
export function createTicketStore({ ttlMs = TICKET_TTL_MS, now = Date.now } = {}) {
|
|
224
|
+
const tickets = new Map()
|
|
225
|
+
return {
|
|
226
|
+
mint() {
|
|
227
|
+
const at = now()
|
|
228
|
+
for (const [key, exp] of tickets) if (exp <= at) tickets.delete(key)
|
|
229
|
+
// 突发时丢最早的一张:票据只有 60 秒寿命,超出上限的多半是重试风暴的残留。
|
|
230
|
+
while (tickets.size >= TICKET_MAX) {
|
|
231
|
+
const oldest = tickets.keys().next().value
|
|
232
|
+
if (oldest === undefined) break
|
|
233
|
+
tickets.delete(oldest)
|
|
234
|
+
}
|
|
235
|
+
const ticket = randomBytes(18).toString('base64url')
|
|
236
|
+
tickets.set(ticket, at + ttlMs)
|
|
237
|
+
return ticket
|
|
238
|
+
},
|
|
239
|
+
/** 命中即删 —— 无论是否过期,同一张票据第二次兑换一律无效。 */
|
|
240
|
+
redeem(ticket) {
|
|
241
|
+
const key = asText(ticket)
|
|
242
|
+
if (!key) return false
|
|
243
|
+
const exp = tickets.get(key)
|
|
244
|
+
if (exp === undefined) return false
|
|
245
|
+
tickets.delete(key)
|
|
246
|
+
return exp > now()
|
|
247
|
+
},
|
|
248
|
+
get size() { return tickets.size },
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** 写路由的跨站门禁:显式声明 cross-site 的直接拒;带 Origin 的必须与 Host 同源。
|
|
253
|
+
* 没有 Origin 的(curl、非浏览器客户端)放行 —— 与"宿主不做令牌校验"的边界一致,
|
|
254
|
+
* 这里只负责挡住浏览器里的跨站页面。 */
|
|
255
|
+
function isCrossSite(req) {
|
|
256
|
+
const headers = (req && req.headers) || {}
|
|
257
|
+
if (String(headers['sec-fetch-site'] || '').toLowerCase() === 'cross-site') return true
|
|
258
|
+
const origin = asText(headers.origin)
|
|
259
|
+
if (!origin) return false
|
|
260
|
+
let originHost = ''
|
|
261
|
+
try { originHost = new URL(origin).host.toLowerCase() } catch { return true }
|
|
262
|
+
const host = asText(headers.host).toLowerCase()
|
|
263
|
+
return Boolean(host) && originHost !== host
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function hostOnly(value) {
|
|
267
|
+
return asText(value).toLowerCase().replace(/:\d+$/, '')
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function requestHost(req) {
|
|
271
|
+
return hostOnly(req && req.headers && req.headers.host)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** 端口取自请求的 Host 头 —— 这样同一个插件在任意端口上都报得对,不需要写死 52676。 */
|
|
275
|
+
function requestPort(req) {
|
|
276
|
+
const m = /:(\d+)$/.exec(asText(req && req.headers && req.headers.host))
|
|
277
|
+
if (!m) return ''
|
|
278
|
+
const port = Number(m[1])
|
|
279
|
+
return Number.isFinite(port) && port > 0 && port < 65536 ? String(port) : ''
|
|
280
|
+
}
|
|
281
|
+
|
|
152
282
|
/** 后台任务的 label 常是多行 shell 命令(含纯环境变量赋值行),折成一行可读文本。 */
|
|
153
283
|
function cleanLabel(raw) {
|
|
154
284
|
const all = asText(raw).split(/\r?\n/).map((line) => line.trim()).filter(Boolean)
|
|
@@ -170,7 +300,7 @@ function safe(fn, fallback, errors, label) {
|
|
|
170
300
|
}
|
|
171
301
|
}
|
|
172
302
|
|
|
173
|
-
export function apply(ctx) {
|
|
303
|
+
export function apply(ctx, config) {
|
|
174
304
|
const jobsSvc = ctx.get('jobs')
|
|
175
305
|
const agentsSvc = ctx.get('agents')
|
|
176
306
|
const sessionsSvc = ctx.get('sessions')
|
|
@@ -1733,10 +1863,245 @@ export function apply(ctx) {
|
|
|
1733
1863
|
}), 'in-hand: chat model route')
|
|
1734
1864
|
}
|
|
1735
1865
|
|
|
1866
|
+
// ---- 双入口(远端 / 局域网)--------------------------------------------
|
|
1867
|
+
//
|
|
1868
|
+
// 与 taskwatch-relay 的 /taskwatch/entries、/taskwatch/ticket 同一套语义:
|
|
1869
|
+
// 入口清单说明"现在有哪几条路",ticket 给一张 60 秒、一次性、用后即焚的通行证。
|
|
1870
|
+
// 差别只有一个:宿主自己不做鉴权,所以兑换后的 Set-Cookie 只是与中继同形的
|
|
1871
|
+
// "换路已完成"标记,不承担授权(授权仍由中继那层负责)。
|
|
1872
|
+
//
|
|
1873
|
+
// 2026-10-21(v1.7.1):这两条原先**只有中继实现**,宿主没有 —— 页面从宿主加载时
|
|
1874
|
+
// 点「去远端」拿到 405/404,拿不到 url 又每 12 秒重试一次,就是"点了就一直报错"。
|
|
1875
|
+
const settings = config && typeof config === 'object' ? config : {}
|
|
1876
|
+
// 公网地址只从配置来:插件配置项 publicUrl,其次环境变量(与 DSH_TASKWATCH_ROOTS
|
|
1877
|
+
// 同一习惯)。没有配置就只报局域网入口 —— 代码里绝不写死任何域名。
|
|
1878
|
+
const publicUrl = normalizePublicUrl(
|
|
1879
|
+
asText(settings.publicUrl) || asText(process.env.DSH_TASKWATCH_PUBLIC_URL),
|
|
1880
|
+
)
|
|
1881
|
+
// 局域网入口可以指到别处(通常是有令牌门的中继)。没配就沿用自动探测内网 IP。
|
|
1882
|
+
const lanUrl = normalizeLanUrl(asText(settings.lanUrl) || asText(process.env.DSH_TASKWATCH_LAN_URL))
|
|
1883
|
+
// 票据 v2:密钥解析顺序在 lib/ticket.js 里与中继保持一致;文件权限也在那里收紧。
|
|
1884
|
+
// **按需初始化**(第一次签发/兑换时才解析)—— 只读监控、不换路的会话不该在启动时
|
|
1885
|
+
// 就去碰用户主目录,测试里也能避免无意写入真实 ~/.dsh。
|
|
1886
|
+
// 注意日志只写来源与路径,绝不打印密钥本身。
|
|
1887
|
+
let ticketGate = null
|
|
1888
|
+
function tickets() {
|
|
1889
|
+
if (ticketGate) return ticketGate
|
|
1890
|
+
const resolved = loadTicketSecret()
|
|
1891
|
+
if (resolved.source === 'generated') {
|
|
1892
|
+
console.log('dsh-in-hand: 已生成跨进程票据密钥 ' + resolved.file
|
|
1893
|
+
+ '(权限:' + resolved.permission + ')')
|
|
1894
|
+
} else if (resolved.source === 'memory') {
|
|
1895
|
+
console.error('dsh-in-hand: 票据密钥无法写入 ' + resolved.file
|
|
1896
|
+
+ ',只能在本进程内使用(跨进程互认不可用)')
|
|
1897
|
+
}
|
|
1898
|
+
ticketGate = createTicketGate({
|
|
1899
|
+
secret: resolved.secret,
|
|
1900
|
+
// 老格式随机票的向后兼容仍然走这个进程内 Map。
|
|
1901
|
+
store: createTicketStore(),
|
|
1902
|
+
})
|
|
1903
|
+
return ticketGate
|
|
1904
|
+
}
|
|
1905
|
+
const routeJson = (res, status, obj) =>
|
|
1906
|
+
send(res, status, 'application/json; charset=utf-8', JSON.stringify(obj))
|
|
1907
|
+
|
|
1908
|
+
/** 入口清单。current 用布尔标在每条上(新契约);同时保留中继的
|
|
1909
|
+
* remote / lan / current 字段,好让同一份 page.html 在两种外壳下都能读。 */
|
|
1910
|
+
function entryList(req) {
|
|
1911
|
+
const host = requestHost(req)
|
|
1912
|
+
const port = requestPort(req)
|
|
1913
|
+
const remoteHost = publicUrl ? hostOnly(new URL(publicUrl).host) : ''
|
|
1914
|
+
const isRemote = Boolean(remoteHost) && host === remoteHost
|
|
1915
|
+
const entries = []
|
|
1916
|
+
let lanUrls = []
|
|
1917
|
+
if (publicUrl) {
|
|
1918
|
+
entries.push({
|
|
1919
|
+
kind: 'remote',
|
|
1920
|
+
label: '远端(公网)',
|
|
1921
|
+
url: publicUrl + '/taskwatch',
|
|
1922
|
+
current: isRemote,
|
|
1923
|
+
})
|
|
1924
|
+
}
|
|
1925
|
+
if (lanUrl) {
|
|
1926
|
+
// 配了 lanUrl 就不再探测内网 IP:那通常是中继的局域网入口,比自动探测更准,
|
|
1927
|
+
// 也更可能是"有鉴权"的那一条(插件自己的路由没有令牌门)。
|
|
1928
|
+
lanUrls = [lanUrl + '/taskwatch']
|
|
1929
|
+
entries.push({
|
|
1930
|
+
kind: 'lan',
|
|
1931
|
+
label: '局域网 · 中继',
|
|
1932
|
+
url: lanUrls[0],
|
|
1933
|
+
current: !isRemote,
|
|
1934
|
+
})
|
|
1935
|
+
} else {
|
|
1936
|
+
const lan = lanAddresses()
|
|
1937
|
+
lanUrls = lan.map((a) => 'http://' + a.ip + (port ? ':' + port : '') + '/taskwatch')
|
|
1938
|
+
const lanCurrent = lan.findIndex((a) => a.ip === host)
|
|
1939
|
+
lanUrls.forEach((url, i) => {
|
|
1940
|
+
// 本机回环访问时没有哪条局域网地址"等于"当前地址,但它们连的是同一个服务,
|
|
1941
|
+
// 所以把第一条标成当前 —— 只影响胶囊文案,不影响任何跳转。
|
|
1942
|
+
const current = !isRemote && (lanCurrent >= 0 ? i === lanCurrent : i === 0)
|
|
1943
|
+
entries.push({ kind: 'lan', label: '局域网 · ' + lan[i].ip, url, current })
|
|
1944
|
+
})
|
|
1945
|
+
}
|
|
1946
|
+
return { entries, remote: publicUrl, lan: lanUrls, current: isRemote ? 'remote' : 'lan' }
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
// ticket 的请求体上限比对话接口还小:这里只该有一个 {"to":"remote"}。
|
|
1950
|
+
function readTicketBody(req) {
|
|
1951
|
+
return new Promise((resolve, reject) => {
|
|
1952
|
+
let size = 0
|
|
1953
|
+
const chunks = []
|
|
1954
|
+
req.on('data', (chunk) => {
|
|
1955
|
+
size += chunk.length
|
|
1956
|
+
if (size > 1024) {
|
|
1957
|
+
reject(new Error('请求体过大'))
|
|
1958
|
+
try { req.destroy() } catch { /* 已断开 */ }
|
|
1959
|
+
return
|
|
1960
|
+
}
|
|
1961
|
+
chunks.push(chunk)
|
|
1962
|
+
})
|
|
1963
|
+
req.on('end', () => {
|
|
1964
|
+
try {
|
|
1965
|
+
const raw = Buffer.concat(chunks).toString('utf8')
|
|
1966
|
+
const parsed = raw ? JSON.parse(raw) : {}
|
|
1967
|
+
resolve(parsed && typeof parsed === 'object' ? parsed : {})
|
|
1968
|
+
} catch (e) { reject(e) }
|
|
1969
|
+
})
|
|
1970
|
+
req.on('error', reject)
|
|
1971
|
+
})
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
ctx.effect(() => webServer.register({
|
|
1975
|
+
kind: 'exact',
|
|
1976
|
+
path: '/taskwatch/entries',
|
|
1977
|
+
handler: (req, res) => {
|
|
1978
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
1979
|
+
res.setHeader('allow', 'GET, HEAD')
|
|
1980
|
+
routeJson(res, 405, { ok: false, code: 'method-not-allowed', error: '只接受 GET' })
|
|
1981
|
+
return
|
|
1982
|
+
}
|
|
1983
|
+
const view = entryList(req)
|
|
1984
|
+
routeJson(res, 200, {
|
|
1985
|
+
ok: true,
|
|
1986
|
+
entries: view.entries,
|
|
1987
|
+
// 中继那套字段(页面旧逻辑仍读它们;新逻辑读 entries)
|
|
1988
|
+
remote: view.remote,
|
|
1989
|
+
lan: view.lan,
|
|
1990
|
+
current: view.current,
|
|
1991
|
+
})
|
|
1992
|
+
},
|
|
1993
|
+
}), 'in-hand: entries route')
|
|
1994
|
+
|
|
1995
|
+
// 换路票据:写路由,沿用中继写路由的门禁 —— POST + application/json + 同源。
|
|
1996
|
+
// 宿主不校验令牌(和中继的分工),但绝不能让任意网页用表单 POST 触发一次跳转。
|
|
1997
|
+
ctx.effect(() => webServer.register({
|
|
1998
|
+
kind: 'exact',
|
|
1999
|
+
path: '/taskwatch/ticket',
|
|
2000
|
+
handler: async (req, res) => {
|
|
2001
|
+
if (req.method !== 'POST') {
|
|
2002
|
+
res.setHeader('allow', 'POST')
|
|
2003
|
+
routeJson(res, 405, { ok: false, code: 'method-not-allowed', error: '只接受 POST' })
|
|
2004
|
+
return
|
|
2005
|
+
}
|
|
2006
|
+
if (isCrossSite(req)) {
|
|
2007
|
+
routeJson(res, 403, { ok: false, code: 'cross-site', error: '禁止跨站触发换路' })
|
|
2008
|
+
return
|
|
2009
|
+
}
|
|
2010
|
+
const type = String(req.headers['content-type'] || '').split(';', 1)[0].trim().toLowerCase()
|
|
2011
|
+
if (type !== 'application/json') {
|
|
2012
|
+
routeJson(res, 415, { ok: false, code: 'unsupported-media-type', error: '请求必须使用 application/json' })
|
|
2013
|
+
return
|
|
2014
|
+
}
|
|
2015
|
+
let want = 'lan'
|
|
2016
|
+
try {
|
|
2017
|
+
const body = await readTicketBody(req)
|
|
2018
|
+
if (asText(body.to) === 'remote') want = 'remote'
|
|
2019
|
+
} catch {
|
|
2020
|
+
routeJson(res, 400, { ok: false, code: 'bad-request', error: '请求体不合法' })
|
|
2021
|
+
return
|
|
2022
|
+
}
|
|
2023
|
+
if (want === 'remote') {
|
|
2024
|
+
if (!publicUrl) {
|
|
2025
|
+
routeJson(res, 409, {
|
|
2026
|
+
ok: false,
|
|
2027
|
+
code: 'no-public-entry',
|
|
2028
|
+
error: '还没有配置公网入口(插件配置项 publicUrl),这里只能走局域网',
|
|
2029
|
+
})
|
|
2030
|
+
return
|
|
2031
|
+
}
|
|
2032
|
+
const ticket = tickets().mint()
|
|
2033
|
+
routeJson(res, 200, {
|
|
2034
|
+
ok: true,
|
|
2035
|
+
to: 'remote',
|
|
2036
|
+
url: publicUrl + '/taskwatch?t=' + ticket,
|
|
2037
|
+
expiresInMs: TICKET_TTL_MS,
|
|
2038
|
+
})
|
|
2039
|
+
return
|
|
2040
|
+
}
|
|
2041
|
+
if (lanUrl) {
|
|
2042
|
+
// 指到中继的局域网入口:票据是同一套 v2 HMAC,中继能直接验签兑换。
|
|
2043
|
+
routeJson(res, 200, {
|
|
2044
|
+
ok: true,
|
|
2045
|
+
to: 'lan',
|
|
2046
|
+
address: hostOnly(new URL(lanUrl).host),
|
|
2047
|
+
url: lanUrl + '/taskwatch?t=' + tickets().mint(),
|
|
2048
|
+
expiresInMs: TICKET_TTL_MS,
|
|
2049
|
+
})
|
|
2050
|
+
return
|
|
2051
|
+
}
|
|
2052
|
+
const lan = lanAddresses()
|
|
2053
|
+
if (!lan.length) {
|
|
2054
|
+
routeJson(res, 409, {
|
|
2055
|
+
ok: false,
|
|
2056
|
+
code: 'no-lan-entry',
|
|
2057
|
+
error: '没有局域网地址(网卡没连上 Wi-Fi?)',
|
|
2058
|
+
})
|
|
2059
|
+
return
|
|
2060
|
+
}
|
|
2061
|
+
// 和中继一样优先挑与请求方同网段的那条,否则第一条。
|
|
2062
|
+
const peer = String((req.socket && req.socket.remoteAddress) || '').replace(/^::ffff:/, '')
|
|
2063
|
+
const net = (ip) => ip.split('.').slice(0, 3).join('.')
|
|
2064
|
+
const pick = lan.find((a) => net(a.ip) === net(peer)) || lan[0]
|
|
2065
|
+
const port = requestPort(req)
|
|
2066
|
+
const ticket = tickets().mint()
|
|
2067
|
+
routeJson(res, 200, {
|
|
2068
|
+
ok: true,
|
|
2069
|
+
to: 'lan',
|
|
2070
|
+
address: pick.ip,
|
|
2071
|
+
iface: pick.iface,
|
|
2072
|
+
url: 'http://' + pick.ip + (port ? ':' + port : '') + '/taskwatch?t=' + ticket,
|
|
2073
|
+
expiresInMs: TICKET_TTL_MS,
|
|
2074
|
+
})
|
|
2075
|
+
},
|
|
2076
|
+
}), 'in-hand: ticket route')
|
|
2077
|
+
|
|
2078
|
+
// 页面外壳。带 ?t= 时先兑换票据:命中 → 302 到 /taskwatch 并种一张换路标记 cookie
|
|
2079
|
+
// (与中继同形);未知/过期 → 401,和中继在换路凭据无效时的选择保持一致。
|
|
2080
|
+
// v1.7.2:v2 票靠签名 + 60 秒时效 + 进程内 nonce 去重;老格式仍走 Map(用后即焚)。
|
|
1736
2081
|
ctx.effect(() => webServer.register({
|
|
1737
2082
|
kind: 'exact',
|
|
1738
2083
|
path: '/taskwatch',
|
|
1739
|
-
handler: (req, res) =>
|
|
2084
|
+
handler: (req, res) => {
|
|
2085
|
+
let ticket = ''
|
|
2086
|
+
try { ticket = new URL(req.url || '/', 'http://127.0.0.1').searchParams.get('t') || '' } catch { ticket = '' }
|
|
2087
|
+
if (ticket) {
|
|
2088
|
+
if (!tickets().redeem(ticket)) {
|
|
2089
|
+
send(res, 401, 'text/plain; charset=utf-8', '换路凭据无效或已过期,请再点一次切换。\n')
|
|
2090
|
+
return
|
|
2091
|
+
}
|
|
2092
|
+
try {
|
|
2093
|
+
res.writeHead(302, {
|
|
2094
|
+
location: '/taskwatch',
|
|
2095
|
+
// 宿主不鉴权,这张 cookie 只是"换路已完成"的标记,不承担授权。
|
|
2096
|
+
'set-cookie': 'tw-route=remote; Path=/taskwatch; HttpOnly; SameSite=Lax; Max-Age=2592000',
|
|
2097
|
+
'cache-control': 'no-store',
|
|
2098
|
+
})
|
|
2099
|
+
res.end()
|
|
2100
|
+
} catch { /* 已断开 */ }
|
|
2101
|
+
return
|
|
2102
|
+
}
|
|
2103
|
+
send(res, 200, 'text/html; charset=utf-8', readPage())
|
|
2104
|
+
},
|
|
1740
2105
|
}), 'in-hand: page route')
|
|
1741
2106
|
|
|
1742
2107
|
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) {
|
|
4600
|
-
|
|
4601
|
-
|
|
4602
|
-
|
|
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) {
|
|
4608
|
-
|
|
4609
|
-
|
|
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) {
|
|
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 (
|
|
4633
|
-
|
|
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
|
-
|
|
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/lib/ticket.js
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 跨进程换路票据(v2):HMAC 签名,让插件宿主与中继互认同一张票。
|
|
3
|
+
*
|
|
4
|
+
* 背景(2026-10-21 的真实故障):宿主签的票打到公网(域名后面是中继)→ 中继只认
|
|
5
|
+
* 自己进程内 Map 里的随机串 → 401。两边跑在同一台机器、同一个用户下,所以只要
|
|
6
|
+
* **共用同一个密钥**,就能互相验签而不必共享内存。
|
|
7
|
+
*
|
|
8
|
+
* 票格式(两边必须一致):
|
|
9
|
+
* t = 'v2.' + <expMs 十进制> + '.' + <nonce base64url> + '.' + <hmac base64url>
|
|
10
|
+
* HMAC = HMAC-SHA256(secret, 'v2|' + expMs + '|' + nonce)
|
|
11
|
+
*
|
|
12
|
+
* 安全边界(诚实写清,别把"签名"说成"严格一次性"):
|
|
13
|
+
* - 签名保证票是本机签的、内容没被改;短时效(60 秒 TTL)压缩重放窗口;
|
|
14
|
+
* - 跨进程无法共享"已用过"的状态,所以只在**各自进程内**做 best-effort 去重
|
|
15
|
+
* (nonce Set + TTL 清理)。两个进程都还没见过的票在窗口内理论上可重放一次。
|
|
16
|
+
* - 这不是"严格一次性"。要严格一次性得引入共享存储(Redis/SQLite),代价与
|
|
17
|
+
* 可用性影响都不小,当前场景(换路跳转,60 秒)不值得。
|
|
18
|
+
*
|
|
19
|
+
* 这个模块不碰 HTTP、不碰宿主服务,只做纯函数与密钥解析,方便直接测。
|
|
20
|
+
*/
|
|
21
|
+
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
|
|
22
|
+
import { spawnSync } from 'node:child_process'
|
|
23
|
+
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
24
|
+
import { homedir } from 'node:os'
|
|
25
|
+
import { dirname, join } from 'node:path'
|
|
26
|
+
|
|
27
|
+
export const TICKET_TTL_MS = 60 * 1000
|
|
28
|
+
/** 允许 60 秒 TTL + 时钟偏差;超过这个跨度的一律当伪造/过期,不给无限期票。 */
|
|
29
|
+
export const TICKET_MAX_AGE_MS = 180 * 1000
|
|
30
|
+
export const TICKET_RE = /^v2\.\d{13}\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{20,}$/
|
|
31
|
+
export const SECRET_MIN_LENGTH = 32
|
|
32
|
+
export const SECRET_FILE_NAME = 'taskwatch-ticket.key'
|
|
33
|
+
|
|
34
|
+
/** 任何端口上的这个插件/中继都读同一个文件:同一台机器、同一个用户。 */
|
|
35
|
+
export function secretPath(home = homedir()) {
|
|
36
|
+
return join(home, '.dsh', SECRET_FILE_NAME)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function normalizeSecret(raw) {
|
|
40
|
+
const text = typeof raw === 'string' ? raw.trim() : ''
|
|
41
|
+
return text.length >= SECRET_MIN_LENGTH ? text : ''
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 签发一张 v2 票。nonce 必须是 16 字节(base64url 后 22 字符)。 */
|
|
45
|
+
export function signTicket(secret, { now = Date.now, ttlMs = TICKET_TTL_MS } = {}) {
|
|
46
|
+
if (!normalizeSecret(secret)) throw new Error('票据密钥至少需要 ' + SECRET_MIN_LENGTH + ' 个字符')
|
|
47
|
+
const expMs = now() + ttlMs
|
|
48
|
+
const nonce = randomBytes(16).toString('base64url')
|
|
49
|
+
const hmac = createHmac('sha256', secret).update('v2|' + expMs + '|' + nonce).digest('base64url')
|
|
50
|
+
return 'v2.' + expMs + '.' + nonce + '.' + hmac
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 校验 v2 票。格式不符 / 签名不对 / 过期 / 超出最大跨度 → false(当作"不是本票")。 */
|
|
54
|
+
export function verifyTicket(secret, ticket, { now = Date.now } = {}) {
|
|
55
|
+
if (!normalizeSecret(secret) || typeof ticket !== 'string' || !TICKET_RE.test(ticket)) return false
|
|
56
|
+
const parts = ticket.split('.')
|
|
57
|
+
const expMs = Number(parts[1])
|
|
58
|
+
if (!Number.isSafeInteger(expMs)) return false
|
|
59
|
+
const expected = createHmac('sha256', secret).update('v2|' + expMs + '|' + parts[2]).digest()
|
|
60
|
+
let given
|
|
61
|
+
try {
|
|
62
|
+
given = Buffer.from(parts[3], 'base64url')
|
|
63
|
+
} catch {
|
|
64
|
+
return false
|
|
65
|
+
}
|
|
66
|
+
// timingSafeEqual 要求等长;长度不同本身就不是本票。
|
|
67
|
+
if (given.length !== expected.length) return false
|
|
68
|
+
if (!timingSafeEqual(given, expected)) return false
|
|
69
|
+
const at = now()
|
|
70
|
+
if (!(expMs > at)) return false
|
|
71
|
+
if (expMs - at > TICKET_MAX_AGE_MS) return false
|
|
72
|
+
return true
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function nonceOf(ticket) {
|
|
76
|
+
return typeof ticket === 'string' ? String(ticket.split('.')[2] || '') : ''
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function expOf(ticket) {
|
|
80
|
+
const n = Number(typeof ticket === 'string' ? ticket.split('.')[1] : NaN)
|
|
81
|
+
return Number.isSafeInteger(n) ? n : 0
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 进程内 best-effort 去重:记下见过的 nonce,过期就清。不是跨进程的严格一次性。 */
|
|
85
|
+
export function createReplayGuard({ now = Date.now, max = 1024 } = {}) {
|
|
86
|
+
const seen = new Map()
|
|
87
|
+
const prune = (at) => {
|
|
88
|
+
for (const [nonce, exp] of seen) if (exp <= at) seen.delete(nonce)
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
/** 第一次见到返回 false;重复的 nonce 返回 true。 */
|
|
92
|
+
replayed(nonce, expMs) {
|
|
93
|
+
const at = now()
|
|
94
|
+
if (seen.size >= max) prune(at)
|
|
95
|
+
if (seen.has(nonce)) return true
|
|
96
|
+
seen.set(nonce, Math.max(expMs || 0, at + 1000))
|
|
97
|
+
return false
|
|
98
|
+
},
|
|
99
|
+
get size() { return seen.size },
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 票据闸门:统一签发 v2、统一兑换。
|
|
105
|
+
*
|
|
106
|
+
* redeem 的回落规则是刻意写成"只认格式"的:非 v2 形状(老页面/老票)才走原来的
|
|
107
|
+
* 进程内 Map;一旦长得像 v2 就只按签名/时效判断,签名不对直接拒绝,**不**回落 ——
|
|
108
|
+
* 否则伪造一张 `v2.…` 反而比随机串更容易被误放行。
|
|
109
|
+
*/
|
|
110
|
+
export function createTicketGate({ secret, store, now = Date.now, ttlMs = TICKET_TTL_MS, replay } = {}) {
|
|
111
|
+
const guard = replay || createReplayGuard({ now })
|
|
112
|
+
return {
|
|
113
|
+
mint() {
|
|
114
|
+
return signTicket(secret, { now, ttlMs })
|
|
115
|
+
},
|
|
116
|
+
redeem(ticket) {
|
|
117
|
+
const text = typeof ticket === 'string' ? ticket.trim() : ''
|
|
118
|
+
if (!text) return false
|
|
119
|
+
if (TICKET_RE.test(text)) {
|
|
120
|
+
if (!verifyTicket(secret, text, { now })) return false
|
|
121
|
+
return !guard.replayed(nonceOf(text), expOf(text))
|
|
122
|
+
}
|
|
123
|
+
// 向后兼容:老格式随机票仍只在本进程 Map 里有效。
|
|
124
|
+
return store ? store.redeem(text) : false
|
|
125
|
+
},
|
|
126
|
+
get replaySize() { return guard.size },
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 两边同一套解析顺序:
|
|
132
|
+
* 1. 环境变量 TASKWATCH_TICKET_SECRET(trim 后至少 32 字符)
|
|
133
|
+
* 2. <home>/.dsh/taskwatch-ticket.key(UTF-8、trim、至少 32 字符)
|
|
134
|
+
* 3. 都没有 → 先生成 32 字节随机值写进该文件,**收紧权限**后使用。
|
|
135
|
+
*
|
|
136
|
+
* 返回 { secret, source, file, permission };任何失败都退回内存密钥,
|
|
137
|
+
* 绝不因为一个密钥文件写不了就让插件/中继启动失败。调用方不得把 secret 打日志。
|
|
138
|
+
*/
|
|
139
|
+
export function loadTicketSecret({
|
|
140
|
+
env = process.env,
|
|
141
|
+
home = homedir(),
|
|
142
|
+
platform = process.platform,
|
|
143
|
+
fs = { mkdirSync, writeFileSync, readFileSync, chmodSync },
|
|
144
|
+
run = spawnSync,
|
|
145
|
+
} = {}) {
|
|
146
|
+
const fromEnv = normalizeSecret(env && env.TASKWATCH_TICKET_SECRET)
|
|
147
|
+
if (fromEnv) return { secret: fromEnv, source: 'env', file: '', permission: 'n/a' }
|
|
148
|
+
|
|
149
|
+
const file = secretPath(home)
|
|
150
|
+
let existing = ''
|
|
151
|
+
try {
|
|
152
|
+
existing = normalizeSecret(fs.readFileSync(file, 'utf8'))
|
|
153
|
+
} catch { /* 没有文件就走生成 */ }
|
|
154
|
+
if (existing) return { secret: existing, source: 'file', file, permission: 'existing' }
|
|
155
|
+
|
|
156
|
+
const secret = randomBytes(32).toString('base64url')
|
|
157
|
+
let permission = 'unknown'
|
|
158
|
+
try {
|
|
159
|
+
fs.mkdirSync(dirname(file), { recursive: true, mode: 0o700 })
|
|
160
|
+
// mode 只在创建时生效;已存在的文件由下面的 chmod / icacls 收紧。
|
|
161
|
+
fs.writeFileSync(file, secret + '\n', { mode: 0o600 })
|
|
162
|
+
if (platform === 'win32') {
|
|
163
|
+
// Windows 没有 POSIX 权限位。用 icacls 去掉继承、只给当前用户完全控制。
|
|
164
|
+
// 这一步失败时文件仍在(用户目录下),但权限状态要如实标成 failed。
|
|
165
|
+
const user = String((env && (env.USERNAME || env.USER)) || '').trim()
|
|
166
|
+
if (!user) {
|
|
167
|
+
permission = 'icacls-no-user'
|
|
168
|
+
} else {
|
|
169
|
+
const result = run('icacls', [file, '/inheritance:r', '/grant:r', user + ':F'], {
|
|
170
|
+
windowsHide: true,
|
|
171
|
+
timeout: 5000,
|
|
172
|
+
})
|
|
173
|
+
permission = result && result.status === 0 ? 'icacls-owner' : 'icacls-failed'
|
|
174
|
+
}
|
|
175
|
+
} else {
|
|
176
|
+
try {
|
|
177
|
+
fs.chmodSync(file, 0o600)
|
|
178
|
+
permission = 'chmod-600'
|
|
179
|
+
} catch {
|
|
180
|
+
permission = 'chmod-failed'
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return { secret, source: 'generated', file, permission }
|
|
184
|
+
} catch (error) {
|
|
185
|
+
// 目录只读 / 磁盘满:仍然能签发与校验(本进程内),只是无法跨进程互认。
|
|
186
|
+
return {
|
|
187
|
+
secret,
|
|
188
|
+
source: 'memory',
|
|
189
|
+
file,
|
|
190
|
+
permission: 'write-failed',
|
|
191
|
+
error: String((error && error.message) || error),
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-in-hand",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.2",
|
|
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 && node scripts/check-ticket-hmac.mjs",
|
|
37
37
|
"demo": "node scripts/make-demo.mjs",
|
|
38
38
|
"icons": "node scripts/make-icons.mjs"
|
|
39
39
|
},
|