掌上 DSH(dsh-in-hand)
English · 简体中文
把电脑上的 DSH 会话搬到手机:不用装 App,打开一个网页就能接着聊、看进度、收文件。
形态就是一个网页:手机浏览器打开即可。同一个 Wi-Fi 下走局域网直连,人在外面走经中继的公网入口,两条路在页面右上角随时切。
> 用着还行的话,点个 ⭐ 就够了——插件市场的「搜插件」按 GitHub star 排序,star 是这个插件能被人翻到的唯一杠杆。
| 手机上打开就是这样一个页面。 | 桌面端侧边栏点「掌上 DSH」进来,面板里能把地址发到手机。 |
30 秒上手
- 装插件(在跑 DSH 的那台电脑上):
dsh plugin --profile web add dsh-in-hand - 重启 DSH:桌面客户端关掉再打开(命令行装的用
dsh web)—— 插件行来自 profile 的 bundle 图,不重启不生效。 - 手机打开:同一 Wi-Fi 用中继的局域网地址
http://<电脑的内网 IP>:3099/taskwatch,人在外面用中继的 HTTPS 域名;首次进配对页输一次 6 位码。
为什么用它
- 不用公网 IP:电脑在 NAT 后面也能连上,路由器不用做端口映射。
- 能接着聊:还是桌面上那批会话,手机上回一句就继续,逐字看着它跑。
- 能批一次:手机在线时,待批准卡片直接给「批准一次 / 拒绝」;超时或手机切走就自动退回桌面(v1.8.0)。
- 拍一张直接发:输入框旁的相机键唤起系统相机,拍完就在输入区看到缩略图、大小和取消;发送前在手机上压到长边 1600、保留拍摄方向,原图不出手机(仍走本机收件箱,不进云端,v1.8.9)。
- 等待和交付物都在这一页:哪件事在等你、进度到哪,展开就能看;生成好的图和 PDF 直接打开。
从只读监控长成手机客户端
它起初只是一份只读的任务监控页——让你在手机上瞄一眼 DSH 有几个任务在跑、有没有事在等你;后来接上了 DSH 自己的会话运行时,长成了手机浏览器里的 DSH 客户端:左上角会话列表、右上角新建对话、输入框上方是当前会话的实时指标(轮次 / 步数 / token/s / Token 消耗 / 缓存命中率 / 上下文占用),可以直接对话、逐字跟随输出、随时打断;再往后,手机上还能一次性批准 / 拒绝一次工具调用、回答选项式提问。
没有 App 可装:不需要原生代码、不需要打包、不需要上架。手机上进一次「添加到主屏幕」,之后它就是一个图标;页面按请求读盘、壳由 Service Worker 换版,所以代码更新后下一次打开就会跟上。
打开就是概览(v1.8.2)
以前打开是一段对话——你得先翻一眼才知道自己为什么被叫来。现在打开是概览:第一行就是「在等你 N 件」,下面第一张卡能直接点「批准一次」;再往下是「在跑」的三条(跑到第几步、跑了多久),一行后台任务 / 子代理 / 今日轮次,最后是「继续上次会话」和「全部会话」。对话退到第二层,点进去才看正文,左上角有明确的「返回首页」,系统返回手势也回得去(地址是 #session/,刷新不丢)。
两件事刻意没做:没有新写一套审批逻辑(待办卡复用 v1.8.0 的按钮、二次确认与倒计时,只是多了一个放置位置),断网时不清空首页(继续显示上次读到的状态,底下补一条说明,而不是假装"什么都没有在等你")。
并行视图:一屏看清、失败永远在最上面(v1.8.10)
首页「在跑」只有三条,开四五个会话就得一页页翻,失败的那条尤其容易被埋掉。v1.8.10 加了整屏的并行视图(首页「在跑」右上角「全部」,或抽屉里的「并行视图」),排序写死为一句话:
> 需要你(待批准 / 待回答) → 失败 → 运行中 → 已完成最近
同组按最后活动时间倒序。这条排序不是"红的最显眼",而是分组本身:即使把颜色全去掉,失败仍然在运行中与空闲之上,滑不到它才奇怪。每行只留四件事——标题(最多两行)、状态徽标、一句最新动作(工具名 · 第几步)、已运行 / 结束时长;失败行多一句 ≤60 字的原因和「查看」。有子代理的会话下面折一行「子代理 N」,展开就能看到它们各自的状态,不另开一层页面。
更新沿用现有的 5 秒轮询与 SSE:只按会话 id 复用行、改变化了的那几个字段,不整屏重建;页面不可见时时长计时与轮询一起暂停。390 宽首屏能看到 5 行,无横向溢出,标题两行截断。
手机端一次性审批(v1.8.0)
以前手机只能看「有 N 件事在等你」,真正批准还得回桌面。现在 approval/request 是 waterfall,插件可以认领它:手机页在线时,待批准卡片会多出「批准一次」和「拒绝」两个按钮。批准会弹一次「确认允许一次?」,拒绝不弹;倒计时结束或页面切走就自动退回桌面。
四条失败语义是这次的核心,功能本身反而简单:
- 没有手机在线 → 立刻
next()。判据只看手机页最近 15 秒有没有轮询或审批 SSE;桌面 GUI 也打/taskwatch/data,但不带view=phone,故意不算在线,免得桌面开着面板却让审批空等。 - 超时 →
next()。approvalTimeoutMs默认 120000,允许 10000–600000;越界取边界值并在启动时 warn。 req.signalabort → 立刻交还,因为桌面端已经先处理了。- 只暴露
allowed-once/rejected,不做 always-allow;手机决定只成功一次,重复提交 409,过期或已交还 410。
> 这张卡只在「手机在线且这次请求可认领」时出现;离线、超时、桌面先处理都会回到原来的「请在桌面端批准」。
手机端回答选项式提问与计划审批(v1.8.3)
P0 的第一类是「放不放行」,第二类是「选哪一个」。v1.8.3 把 user-questions/request
也接上了:它和 approval/request 一样是 waterfall,手机页在线时插件认领它,把题目
变成一张可点的卡。
- 单题:选项渲染成整行按钮(
label一行、description第二行小字),点一下即选中,
但仍要再点「提交回答」—— 防的是误触直接交卷。 - 多选:
multiSelect:true时按钮可以多选,底部同一个「提交回答」。 - 多题:逐题纵排,答过的题不再显示「待回答」,没答完提交键保持禁用。
- 计划审批:
intent.kind === 'plan-review'时标题带「计划审批」徽标,intent.approve
对应的那个选项用主色强调(它不是"更推荐",而是这次提问里唯一的放行项)。 - 自定义回答:每题都有一个默认收起的输入框,展开后可以直接写一句话(≤2000 字)。
提交时作为custom字段发给宿主。 - 一次性与倒计时:与审批卡同一套
mm:ss倒计时;到 0 显示「已退回桌面」。提交成功后
卡片收起显示「已提交」;宿主回409/410时显示「已由桌面处理」;网络失败则保留
已选项与自定义草稿、放开按钮让用户重试。
失败语义逐条照抄审批,没有第二套机制:没有手机在线 → 立刻 next();超时(同一个
approvalTimeoutMs,默认 120000)→ next() 并审计 by:'timeout';request.signal
abort → 立刻交还;任何内部异常 → try/catch → next()。审计同样只记
{at, session, kind:'question', title, outcome, by, selectedCount, custom} ——
不记答案原文。
这张卡在三个地方都能答,且是同一份结构:首页待办区、任务详情抽屉、
会话页输入框上方的问答条(只显示当前会话的提问)。
A phone client for DeepSeek Harness with no app to install: it reuses the desktop GUI's design tokens and adds a live metrics row above the composer.
设备列表与撤销(v1.8.7)
抽屉里多了一个「设备」:列出最近用过这个入口的手机与电脑,每行只有摘要名(系统 · 浏览器)、
最近活跃、从局域网还是公网进来,以及一个「撤销」。当前这台带「这台设备」标记;列表里的
id 是 12 位摘要,不是设备 cookie 原值。
撤销当前这台要过一道二次确认,说清后果;确认后本端立即翻到「此设备已被撤销,请重新
配对」,下一次请求由宿主回 401:
- 宿主给每个浏览器发一个 HttpOnly 的设备 cookie,页面读不到;不记录 IP、不记录完整 UA,
最多 50 台、30 天不活跃自动清。 - 撤销别的设备不需要确认(一次点击即可),撤销自己才需要 —— 防的是手滑把自己踢下线。
- 这不是账号体系:本机仍然不鉴权,它解决的是「手机丢了 / 借人了,把这一台踢掉」。
高危动作二次确认(v1.8.8)
手机能批准之后,最危险的那一类不该和下一条普通改文案走同一个动作。v1.8.8 把判定放在
宿主侧:approval/request 到达时,assessRisk({ toolName, reason, detail }) 用
highRiskPatterns 扫一遍宿主手里那几段可见说明,命中返回 'high',页面只收到
pending[].risk。页面里没有任何按工具名或命令猜风险的正则 —— 它只负责画红色
「高危」徽标与第二步。
点「批准一次」不会直接发请求,而是在卡片内部展开确认层:列出工具与原因,写明
「确认执行?此操作不可撤销」,只有「确认执行」(实心危险色)才真的
POST /taskwatch/approve。展开期间卡片其余按钮禁用;「取消」、Esc、点遮罩都只收层、
不发请求。拒绝路径不加确认,直接发。
匹配对象是 toolName + reason + displayReason —— 不是完整命令:我们本来就不存命令
全文(审计也不记),这是一道按可见信息加的门槛,不是命令级沙箱。默认模式 19 条:
| 类别 | 默认模式 |
|---|---|
| 命令类 | rm -rf、Remove-Item、del /f、format、mkfs、shutdown、Stop-Process、taskkill /F、DROP TABLE、TRUNCATE、git push --force、npm publish、git tag -d、git reset --hard |
| 路径类 | .ssh、.dsh、C:\Windows、/etc、.git(作为被操作对象时;.gitignore 不算 .git) |
highRiskPatterns 可以增删或整组替换(给了就替换默认值;最多 64 条、每条 ≤120 字符,
越界会被裁剪并在启动时 warn)。riskConfirm:false 只保留徽标、点击直接提交 ——
关掉就失去这层保护。
拍一张直接发(v1.8.9)
拍屏幕、白板、错误码,比打字快一个数量级。相机键背后就是
`:手机上进系统相机,
桌面上退化成选图;没有getUserMedia`,也就没有权限弹窗和前后摄像头的状态机。
选中后立刻显示缩略图、大小和取消(×)。压缩在客户端做完再上传:长边压到 1600、
JPEG 质量 0.82;仍超过 2.5 MB 就降到 0.7 再试一次,还超就明说「图片太大」,不悄悄
上传一张 8 MB 的图去等超时。方向靠 createImageBitmap(..., { imageOrientation:'from-image' }),
竖拍的照片不会横躺(EXIF Orientation=6 在 scripts/check-camera.mjs 里有断言)。
上传走的是现有附件那条路:同一个 /taskwatch/chat/upload、同一套进度与失败重试,
没有新增接口、存储或后端目录。照片不进云端 —— 它先落在本机的收件箱里,发给哪个会话
由你在这一页决定。附件与相机两个键都是 44px,390 宽实测输入框仍有 210px,页面不横向溢出。
> 真机未验证的部分写在这里:iOS Safari 上 capture 的入口形态、HEIC 照片的输入,
> 以及弱网下 2.5 MB 图的等待体感,都只在 Chrome 里模拟过。
界面细节
真实会话里,连续的工具调用会折叠成一行「N 个步骤」,失败的那一步永不隐藏;助手回复支持轻量 markdown(标题、列表、行内代码、代码块),过宽的代码块提示可以横滑。
会话抽屉、状态卡片、输入框等全部走客户端同一套 design token,亮暗两套都是真值。抽屉里的字号三档就在会话列表上方:
> 演示页由 docs/demo.html 渲染——那个文件是 scripts/make-demo.mjs 从 lib/page.html 生成的(npm run demo 重新生成),你可以直接用浏览器打开它,点着看,不需要装任何东西,也不会发出网络请求。契约测试会校验它与当前页面构建结果一致,防止它悄悄停在旧界面上。
上面首页与审批这些图是 v1.8.2 发布时用 npm run shots 重拍的当前界面(真 Chrome + CDP、DPR2、390 宽;另有一张 360 宽),数据来自演示会话,图上保留「演示数据 · 非真实状态」的标记。首页 4 张(待办 / 深色 / 无待办 / 360)与「返回首页」1 张是那一版新加的;审批 4 张的审批状态是注入的,未在真实手机上点过。设备 2 张由 scripts/shoot-devices.mjs 在 v1.8.7 重拍;高危 2 张由 npm run shots:risk 在 v1.8.8 重拍(高清原图在 _ui-review/round16/,仓库里放的是同一张图)。相机 3 张由 npm run shots:camera 在 v1.8.9 重拍,数据是注入的演示会话与一张现画的报错屏(高清原图在 _ui-review/round17/)。历史版本截图仍留在 docs/screenshots/ 里,清单与用途见 docs/screenshots/README.md。
手机上怎么打开(不用装 App)
- 在跑 DSH 的电脑上启用本插件,让它监听一个本地端口(见下文各章节)。
- 电脑侧起一个反向隧道 / 中继,把那个端口映射到一个 HTTPS 地址。
PWA 的 Service Worker 只在 HTTPS(或 localhost) 下工作——纯 HTTP 能看页面,
但装不到手机桌面,也没有离线壳。 - 手机上打开那个 HTTPS 地址,首次用一次性配对码换一个长期令牌,之后自动续期。
加成一个伪 App(主屏幕图标)
浏览器地址栏里的那一页,加到主屏幕之后就变成一个有图标、独立全屏的入口:点开不再有地址栏,切换手势、状态栏样式都按 App 走。技术上它是 PWA,不是原生 App,也不经过任何应用商店。
- iOS Safari:分享 ⤴ → 添加到主屏幕 → 添加。图标来自页面的
apple-touch-icon,名字来自apple-mobile-web-app-title(现在是「掌上 DSH」)。 - Android Chrome:右上角 ⋮ → 安装应用(旧版菜单叫「添加到主屏幕」)。v1.7.9 起站点有了 web app manifest,
display: standalone、start_url/scope都是/taskwatch,装完是独立窗口,不是带地址栏的快捷方式。图标复用页面同一个/taskwatch/icon-192.png。
两条硬前提,先说清楚免得白试:
- 必须是 HTTPS(或 localhost)。局域网那条
http://<电脑的内网 IP>:3099/taskwatch页面照常能用,但装不成桌面图标、也没有离线壳 —— 想装就走公网中继那条 HTTPS 入口。 - iOS 的 PWA 没有后台推送,也没有真正的后台刷新。退出后轮询就停了,重新点开才会继续;这一点和原生 App 不一样。
> 反代与隧道属于部署细节,本仓库只负责 DSH 这一侧:域名 + nginx + ssh -R 可以,
> Tailscale、Cloudflare Tunnel、ngrok 之类同样可以(它们都能免费给出 HTTPS)。
> 自己的专属入口链接里带访问令牌,等于完整控制权,绝不要提交到本仓库。
多台机器 / 多个入口(v1.8.12)
家里一台、公司一台、会议室那台测试机——以前每换一台就要重新输地址、在手机里再配一次。现在手机页里有一份入口清单:抽屉里的「入口 · 当前名」点开就是切换器,每行一个名字 + 地址,点「打开」就在新页面跳到那台机器的 /taskwatch。
- 切换只换地址:认证靠每个源各自的 cookie(
tw/twd),浏览器只会把 cookie 发给发下它的那个地址。所以切过去那台机器自己就认你,不需要在手机里再存一份令牌。 - 手机上不存令牌:清单只有
{label, url}两项,写在localStorage['tw:entries'];不存 token、cookie、配对码。拿到这份清单最多知道几个内网地址。 - 最多 8 条,同一个源只留一条;地址会被规范化成「协议 + host[:port]」——路径、
?、#丢掉,javascript:/data:/file:一律拒绝。 - 首次进入自动加一条「本机」(当前地址);已经删空的清单不会又被自动加回来。
- 当前项只做一条细线 + 极淡底色,不抢眼;390 宽、明暗两套、命中区 44px。切换不清 cookie,也不做跨源取数(浏览器同源策略不允许,也不该绕)。
为什么需要它
DSH 的 Web GUI 没有内置鉴权,而默认 preset 往往是 danger-full-access。把整个 GUI
暴露到公网,等于把一台能执行任意命令的机器的入口挂出去。
这个插件的做法是不暴露 GUI,另开一组窄接口:
- 读:把状态渲染成一份手机看得懂的页面,只放行这一条路径就够。
- 写:
/taskwatch/chat/*把 DSH 自己的会话运行时接到手机上 —— 直接对话、
逐字收回复、随时停止、切换会话与模型、调整排队顺序。写入口只有 6 条,全部列在下面。
采集面完全通过 ctx.get(...) 软读取服务,任何一个服务不存在都只会让对应的那一块
留空,不会让插件挂载失败。对话那一组同理:拿不到 sessionController 时只让这一个
功能返回明确错误,不影响只读页面。
采集面
| 区块 | 内容 |
|---|---|
| 需要你介入 | 等待中的 approval/request 与 user-questions/request |
| 会话 | 运行/空闲、最后活动时间、该会话在跑的后台任务数、preset、是否仅日志 |
| 会话详情 | 点开后:目标、待介入事项、属于它的后台任务与子代理、最近对话 |
| 目标 | phase、轮次 started/max、activation、阻塞原因 |
| 后台任务 | id、label(多行命令已折成单行)、kind、status、生命周期时长、属主会话 |
| 子代理树 | 层级、深度、父会话、activity、mode |
| 工作流 | 当前 phase、日志条数、已运行时长 |
| 采集诊断 | 采集过程中单项失败的原因(不静默吞掉) |
HTTP 路由
读(GET):
| 路径 | 类型 | 说明 |
|---|---|---|
/taskwatch |
HTML | 手机页:以对话为主,顶部状态条点开是任务详情 |
/taskwatch/data |
JSON | 全部状态的快照,带 ETag,未变化回 304。pending[] 里两种待办靠 kind 区分:审批(approval)与选项式提问(question,带 questions[] / options[] / multiSelect / intent) |
/taskwatch/session?id=&limit= |
JSON | 单个会话的最近对话(limit 上限 60,默认 20) |
/taskwatch/file?id= · ?path= · ?sessionId= · ?info=1 |
二进制 / JSON | 交付文件:附件走 id;绝对路径走白名单根,带 sessionId 时改走「本会话引用过的绝对路径」(单个文件 ≤50 MB);info=1 只回元数据 |
/taskwatch/chat/sessions |
JSON | 可对话的会话列表(不含子代理会话) |
/taskwatch/chat/workspaces |
JSON | 新建对话可选的工作区(现有会话的 cwd + 固定候选 + 环境变量追加;只列真实存在的目录) |
/taskwatch/chat/models |
JSON | 可选模型目录 |
/taskwatch/chat/titles?ids= |
JSON | 批量补会话标题(最多 20 个,宿主缓存 10 分钟) |
/taskwatch/chat/page?... |
JSON | 往更早翻会话历史 |
/taskwatch/chat/stream?sessionId= |
SSE | 跟随一个会话:开场快照 + 增量事件 + 逐字输出 |
/taskwatch/chat/queue?sessionId= |
JSON | 该会话未领取的消息:items(下一轮队列,可重排)与 steering(已插到本回合下一步) |
/taskwatch/decisions |
JSON | 当前手机端可决定的审批清单(内存里的活动 decision;approveFromPhone:false 时为空) |
/taskwatch/devices |
JSON | 设备账本(v1.8.7):每台设备只有可读摘要名、首次/最近活跃、入口、配对次数、是否已撤销,外加 current 标记;不回设备 cookie 原值、IP 或完整 UA |
/taskwatch/approvals/stream |
SSE | 审批事件流:approval 事件推送 pending / settled;页面可见时保持,隐藏时关闭 |
/taskwatch/entries |
JSON | 双入口清单:{entries:[{kind:'remote'\|'lan',label,url,current}]};没配 publicUrl 时只有局域网一条 |
/taskwatch/manifest.webmanifest |
JSON | PWA manifest |
/taskwatch/sw.js |
JS | service worker |
/taskwatch/icon-192.png · /taskwatch/icon-512.png |
PNG | 图标 |
写(POST)—— 对话入口、一条换路票据、审批 / 回答各一条,外加一条只改内存账本的设备撤销:
| 路径 | 请求体 | 说明 |
|---|---|---|
/taskwatch/chat/upload |
原始二进制(sessionId/kind/name/mime 查询参数) |
导入文件或图片;图片由宿主校验,文件按会话签发 receipt |
/taskwatch/chat/send |
{sessionId,text,attachments?,mode?,now?,interrupt?,timeZone?} |
给会话发文字和已上传附件;now:true 走宿主原生 steer 插队(见下) |
/taskwatch/chat/cancel |
{sessionId} |
停止当前这一轮 |
/taskwatch/chat/create |
{} 或 {workspace} |
新建会话;workspace 必须命中 /taskwatch/chat/workspaces 列出的路径 |
/taskwatch/chat/model |
{sessionId,provider,model} |
切换该会话使用的模型 |
/taskwatch/chat/queue/reorder |
{sessionId,ids:[...]} |
重排下一轮队列;ids 必须是当前队列的完整 id 列表 |
/taskwatch/ticket |
{to:'remote'\|'lan'} |
签发 60 秒的 v2 HMAC 换路票据(v2...),宿主与中继用同一密钥互认;只在内存里记已用的 nonce,不碰会话 / 文件 / 配置。没配 publicUrl 时远端请求回 409 no-public-entry |
/taskwatch/approve |
{id, decision:'allow'\|'reject'} |
手机端一次性审批:只把一次已认领的 approval/request resolve 成 allowed-once / rejected。成功 200;参数错 400;未知 id 404;重复提交 409;过期 / 已交还 410。不做 always-allow |
/taskwatch/answer |
{id, answers:[{id, selected:string[], custom?}]} |
手机端回答选项式提问:把一次已认领的 user-questions/request resolve 成 {answers}。必须覆盖请求里所有题目、selected 只能取该题 options[].label、单选≤1 项、custom≤2000 字;不合规 400 并带 field(如 answers[1].selected)。未知 id 404;重复提交 409;过期 / 已交还 410 |
/taskwatch/devices/revoke |
{deviceId} |
撤销一台设备(v1.8.7):把账本记录标成 revoked 并清空它的配对记录;被撤销设备的下一次请求 401({ok:false, code:'device-revoked', revoked:true})。deviceId 是列表里的 12 位摘要,不是 cookie 原值。撤销自己同样 200(self:true),随后本端也 401 |
全部注册在 ctx.webServer 上,不代理 DSH 自身的 /api,也不转发任意路径。
写入口就只有上面这些对话入口 + 1 条换路票据 + 1 条一次性审批 + 1 条回答 + 1 条设备撤销 —— 契约测试会正面清点路由表,
新增路由必须在只读 / 对话 / 非对话写三张白名单里显式归类,漏一个就让构建失败
(早先那条断言写的是"源码里不出现 method === 'POST'"。加了 5 个写路由之后它
照样通过,是条假绿灯:它证明的是"没这么写",不是"没有这个能力")。
排队、重排与「立即发送」的真实语义
宿主本来就有两条投递通道:mode:'queue'(进 nextTurn,等下一轮)与
mode:'steer'(进 nextStep,在本回合下一个步骤边界被领取)。手机端:
- 排队 = 默认的
queue;GET /taskwatch/chat/queue读nextTurn的顺序,
POST /taskwatch/chat/queue/reorder用同一批 Message 整体重新splice,
不伪造消息、不改内容。 - 立即发送 =
send带now:true,即宿主原生steer。它进入 steering lane,
排在本轮所有nextTurn排队消息之前,但不会中止正在执行的工具调用或模型
请求 —— 消息在下一个 step 边界才被模型看到;会话空闲时会立刻唤醒并起一轮。
同一 lane 里更早的 steering 消息仍按先来后到排在它前面。 - 硬打断 = 再带
interrupt:true:先cancel({keepInbox:true})(队列保留),
等 agent 真正 idle(最多 2 秒)再 steer;成了就先于保留的nextTurn队列执行
(cancel 前已有的 steering 仍按原顺序先跑)。2 秒内停不下来返回
409 interrupt-timeout,消息不发出、队列不动。 - 不会做的事:不把队列顺序存在插件内存里(那会和宿主真实队列漂移),
也不支持把steering消息重新排进nextTurn。
手机能选工作区,但选不了任意目录
「在手机上新开一个对话」得能指定在哪个目录开工,否则只能落在部署默认值上。但
sessionController.create() 收的是绝对路径(cwd / workspaceId)—— 直接透给公网
客户端,等于把宿主的磁盘交出去。所以拆成两件事:
- 选择权给手机:
GET /taskwatch/chat/workspaces回候选清单,页面只负责挑; - 决定权留服务端:
POST /taskwatch/chat/create拿客户端发来的字符串去清单里
逐字比对,命中才写进cwd,否则直接报错。
清单三个来源:现有会话的 cwd(你最近在哪儿干活,天然是你自己认过的目录)、
环境变量 DSH_TASKWATCH_WORKSPACES(; 分隔,默认 D:\Projects,装到别处改它)、
固定候选。不存在的目录用 statSync 过滤掉 —— 手机上少一个点进去才报错的入口。
对话路由为什么这么薄
手机端要的「像 ChatGPT 一样对话」,DSH 内部本来就有 —— sessionController
提供 prompt / follow / cancel / list / create / page / modelCatalog /
selectModel。所以这个插件一行 LLM 调用、一行流式解析都没写,只是把这几个方法
转成 HTTP。三个实测出来的坑记在这里:
follow()返回的本来就是为传输设计的 wire 帧(SessionWireEvent.data是
JsonValue),可以安全地逐帧JSON.stringify;不要去碰会话活对象。- SSE 响应必须显式带
x-accel-buffering: no,否则 nginx 会把流式缓冲成一次性吐出。 tool/result帧的callId不在顶层,在data.message.source.callId。
按顶层读会一直拿到undefined,表现是每来一个工具结果就多插一张错卡。- 会话标题只能从事件日志的
session/title帧里取 ——SessionSummary里没有
title 字段。分页语义也是实测的:throughSeq超过会话 cursor 会报
past cursor N,而 N 正是需要的值,于是用错误信息一步重试。
交付文件为什么按内容识别,而不是按扩展名
手机端要能直接看到交付的图和 PDF(而不是只收到一个文件名),所以加了一条只读的
/taskwatch/file。它的安全边界抽在 lib/files.js 里,配 75 项边界测试:目录穿越、
同前缀兄弟目录、白名单外路径、realpath 复查(防符号链接逃逸)、.svg/.html/.js
一律拒绝。
v1.8.1 起,带 sessionId 的路径请求可以读本会话消息正文里引用过的绝对路径
(这是经用户 2026-10-29 授权的放宽,边界见下面「安全说明」);不带 sessionId 的
旧调用行为完全不变,仍然只认那两个根目录。
一个真实验收抓到的教训值得写下来:宿主附件库是内容寻址的,模型产出的图落在
~/.dsh/attachments/v1/objects/ —— 名字就是哈希、根本没有扩展名。
最初只按扩展名判类型,于是最常用的那条路("把刚生成的那张图发我")被判成
unsupported-type 直接 403。而演示页一切正常,因为它用的是 data URI 和有扩展名的
路径 —— 这类「演示绿、真机红」的差异,只有端到端跑一次才会露出来。
现在:有扩展名走快路;没有扩展名就读前 16 字节按魔数识别(PNG / JPEG / GIF / WebP /
PDF / ZIP / MP4)。这是按内容识别,不是"按内容放行" —— html / svg / js 没有魔数,
仍然走不通,白名单没有被放宽。
手机页的动效为什么这么克制
第 10 节「纸墨微动」:静态是一张纸,动起来像墨。位移 ≤3px、时长 120–320ms、
曲线统一"起步快、末段收",每个动效都携带信息 —— 在跑 = 一圈墨环在转、
完成 = 勾自己画出来、失败 = 顿一下、图 = 骨架纸纹后显影、新消息 = 浮标弹一下。
不放光、不放烟花、不为动而动。
两条硬约束写进了契约测试(不靠肉眼):
- 入场动画必须由 JS 按"状态刚刚变化"加的类门控。挂在会被整段重渲染的节点上,
每 3 秒轮询就重放一次,观感从"灵动"变"抽搐"。 - "隐藏"只能挂在 JS 之后加的类上。先藏内容、等 JS 显示是这类动效最常见的自伤:
JS 一旦不跑,图片就永远看不见。
缩小动效时也不是简单全关:墨环变成静态描边、骨架图直接可见 —— 动效可以没有,
状态语义不能丢。
手机页为什么这样排
要解决的是「小白点进来也看得懂」。所以页面不做仪表盘:顶部一行说人话的状态
(点开才是原来那张数据表),中间是对话,底部是输入框,数据表格收进抽屉。
两个具体决定:
- 工具调用翻译成人话。
pwsh→ 「运行命令」、grep→ 「搜索内容」、subagent→
「派发子任务」。用户不需要知道pwsh是什么;卡片上还给一行命令/路径摘要,状态用
进行中 / 完成 / 失败三态。这是"看得懂进度"最关键的一处。 - 逐字输出只改一个节点。整页重建会让长会话每来一个字都重排一次。
对话排版与子代理二级页(v1.7.6)
手机只有 360–390px 宽,对话里最容易坏的是三样东西:表格、长代码、长 URL。v1.7.6
把表格渲染成真表格并放进可横滑的 .tablewrap,代码块自己横滑,长 token 在分隔符后
断行;页面级 scrollWidth 在 390 和 360 下都等于视口宽。助手正文、流式回复和工具卡
统一 100% 宽、左对齐;用户消息仍靠右。
子代理不再混在主对话里:工具条出现「子代理 N」,点进去是独立列表,再点开只读快照;
返回回到原来的根会话。没有子代理时入口不出现。
长代码块提示「可横滑」,自己滚,不把页面撑宽:
子代理不再混在主对话里:工具条出现「子代理 N」,点进去是独立列表,再点开只读快照;
返回回到原来的根会话。没有子代理时入口不出现。
360 宽下最难的表格与超长 URL 也不横向溢出:
截图是 docs/demo.html 的真实渲染(Chrome headless + CDP,DPR2,390 / 360 宽),
数据来自演示会话。回归自检:npm run check 里的 scripts/check-mobile.mjs 会在
390 / 360 两个宽度下跑 window.twSelfCheck()、切会话残留断言与左基线断言。
v1.7.7:坏图不再留空框、图标不再依赖字体
图片加载失败(宿主文件路由默认只服务 ~/.dsh/attachments / ~/.dsh/outbox;v1.8.1 起
本会话引用过的绝对路径也可读,其它路径仍是 403;外域 URL 也可能不可达)时,v1.7.6 会铺
一张 63px 的整宽 ERR 卡片,视觉上仍是一块把正文截断的占位框。v1.7.7 换成 36px 单行提示条,
并且页面级保证 DOM 里不留破图 img。v1.8.1 起提示条会区分「该文件未被本会话引用」与
「文件超过 50 MB」,文件卡片第二行同样按 403 / 413 分开写,并保留可点的原路径。
会话抽屉里「子代理 N」入口的图标改成内联 SVG(不再用字符,真机上曾出现「廿」这种回退字形)。
> 这一版的「改前 / 改后」对比图仍留在 docs/screenshots/v1.7.7-broken-before.png /
> v1.7.7-broken-after.png / v1.7.7-subagents-icon.png,只作历史留存,README 不再引用;
> 当前界面看 docs/screenshots/current/。
v1.7.8:长会话不再整屏闪、附件信封不再露标签
图片的骨架流光用 inset:0 绝对定位,却忘了给 .md-imgwrap 定位,包含块一路退到视口。
长会话里总有还没进入视口的懒加载图,它们永远带着 .loading,于是整屏多出一条 1.35s
无限循环的亮带(用户原话「反复闪屏」),translateX(55%) 还把页面从 390 撑到 601。现在
流光被关进图片盒、不吃点击,离屏图也不再挂任何动画:
- 同一条会话的
scrollWidth从 601 → 390,静止 1.4s 内像素变化 2.55% → 0.1%。 - 同一轮的第二个修复:DSH 给模型看的
…image…
信封,以前在手机上原样显示成 XML(某会话第一条消息整条都是它),现在渲染成图片卡,
拿不到图时仍是那条 36px 提示条。
> 这一版的「改前 / 改后」对比图 docs/screenshots/v1.7.8-flash-before.png /
> v1.7.8-flash-after.png 只作历史留存,README 不再引用。
页面为什么不做整页重建
页面每 3 秒轮询一次,但数据没变就完全不碰 DOM,只刷新右上角时间戳。首版是
root.innerHTML = h 全量重建,结果是每 3 秒把页面拆一次:展开的东西 3 秒后被抹掉,
滚动位置被打断,看起来像「不刷新」和「点不动」——那是同一个原因。
这里有个容易漏掉的坑:比较序列化结果时不能把 generatedAt 算进去。 它是每次采集
都不同的 Date.now(),带着它比较,签名就永远在变,「数据没变就不碰 DOM」实际上是
一句空话,页面照样每 3 秒整页重建。所以客户端算签名、服务端算 ETag 时都先把这个
字段归一化,两边保持同一套判据。
而「有东西在跑的时候」仍然要重画:画面上的时长要一直往上走。所以静止时就真的不碰
DOM,有运行中的会话、后台任务、子代理或工作流时才重画。
展开状态与对话缓存存在 JS 对象里,跨轮询保留;点击用事件委托挂在常驻容器上,
所以内层 DOM 重建也不会丢监听。对话按需懒加载,点开一次即缓存。
轮询在服务端和手机上各收一次口
页面每 5 秒轮询,而这个页面的主要用途是手机——最常见的状态是「揣在兜里、屏幕已熄」。
不处理的话,一个常驻后台的标签页会永远每 3 秒发一次请求。所以两头都做了收敛:
- 客户端:监听
visibilitychange,页面隐藏时clearInterval停止轮询,回到前台立刻
补一次请求。手机上省的是流量和电。 - 服务端:
snapshot()一次要问一圈服务(每个 agent 的 jobs、每个会话的
eventAt/title/goal、每个 agent 的listDescendants)。现在同一份采集在
2 秒 TTL 内被复用,并发请求共用同一个 Promise 而不是各算各的——手机、桌面面板和
多个标签页同时开着时,这一层把开销从「每请求一次全量采集」压成「最多每 2 秒一次」。 - 带宽:
/taskwatch/data返回ETag并在未变化时回304,手机上只交换一个头而不是
整份 JSON。
最后一条有个必须成对的地方:页面取数用的是 cache:'no-cache',不能用 no-store。
no-store 会让浏览器根本不保存响应,也就永远不会带 If-None-Match 回来,服务端那段
304 会变成死代码。这两处因此都在契约测试里钉住了。
网络抖动时会发生什么(v1.8.4)
- 页面在后台就不刷屏:
visibilitychange一隐藏就停掉轮询、暂停所有重连排期,锁屏十分钟不会攒出一串请求。 - 断线后按退避重连:快照和两条事件流共用 1s → 2s → 5s → 10s → 30s(每档 ±20% 抖动、30s 封顶)的退避,同一时刻只允许一个
/taskwatch/data在飞。 - 恢复后自动续上:
online、回到前台(以及 iOS 的 bfcachepageshow)都会立刻取一次新快照并重开事件流;超过 3 个轮询周期没成功,页脚显示「连接已断开 · 正在重试(N 秒前成功)」并给一键重试,旧内容不会被清空。
采集失败时不能伪装成正常
这是监控工具最危险的失败姿态,所以单独说清。
宿主采集异常时 /taskwatch/data 回的是 HTTP 200 + { "error": ... }。如果客户端
把这份载荷当数据用,画出来就是一块「全部为 0」的板子:0 个会话、0 个后台任务、
「当前没有等待中的审批或提问」。它看起来像「真的没有任务」,而不是「没读到」。
所以两半都显式处理 error 字段,把它当错误而不是数据:
- 手机页:
tick()先查d.error,命中就只在页脚写「采集失败:…」并保留上一份
可用快照,绝不拿它去重画。 - GUI 面板:把
snap.error视为错误,继续渲染上一份可用快照,并在顶部挂一条
「数据可能已陈旧(快照时间 HH:MM:SS):…」的横幅。一次网络抖动同样只产生这条横幅,
而不是把整块面板清成空白 —— 监控面板的价值恰恰在于「断开时还能看到最后一帧」。
同一类问题的另一面是轮询的乱序:并发请求下,较早发出的响应完全可能晚回来。两半
都用一个自增序号只接受最新一次的结果,避免旧数据覆盖新数据。
/taskwatch/session 的两条限制
- 只能读活着的会话。
ctx.sessions是内存存储,get(id)返回undefined时接口
会明确报错「这个会话不在内存里(已落盘但未加载的会话读不到)」,而不是返回一个
空的messages—— 后者会让人误以为那个会话真的没说话。 - 只读不写。 没有发送消息、批准审批、打断运行的入口。要做到那些,必须把 DSH 的
Web GUI 暴露出去,那是另一个量级的风险,本插件刻意不做。
消息只取叶子字段(role、text、工具名、时间),不把活的会话日志对象序列化出去。
GUI 面板
lib/client.js 注册一个侧边栏面板(sidebar.panellist),数据取自同源的
/taskwatch/data,不需要任何 host RPC。样式全部挂在插件自己的 fiber 上,
卸载即还原。
面板右上角有一个指向手机页的链接,方便直接把 /taskwatch 复制到手机上打开。
面板和手机页在轮询上是对称的:窗口不可见时 clearInterval 停止轮询,回到前台立刻
补一次请求。桌面这一半容易被忽略,但它的典型用法恰恰是「GUI 最小化、拿手机看」。
安装
dsh plugin --profile web add dsh-in-hand
装完需要重启 DSH:桌面客户端关掉再打开,命令行装的用 dsh web。插件行来自
profile 的 bundle 图,而 client 侧模块元数据
按 specifier 缓存且没有失效路径,所以增删插件一律要重启才生效。
环境:Node ≥ 20(见 engines.node)。package.json 里的 engines.dsh 声明为
>=0.2.1-rc.2 <0.3.0,这是本包端到端跑过的范围(Windows 上的 DSH 桌面版、nightly 通道)。
更低的版本(含 0.1.x 稳定线)没有端到端验证过,装上去可能直接报错——这是声明与实测一致
的结果,不是故意收紧的门槛。
本包没有构建步骤:lib/ 是提交进仓库的、可直接运行的产物,客户端 bundle 就是
一个调用 window.__ModuleLoader__.load({ id, factory }) 的普通脚本。从源码安装不需要
任何构建授权(allowBuilds)。
本地开发时也可以直接指向工作副本:
dsh plugin --profile web add <你的绝对路径>/dsh-in-hand
补丁里引用插件有两种写法,注意区别:bundle 自带的 cordis.patch.yml 用包名
(由 profile 的 node_modules 解析),而 profile 自带补丁里若写路径,必须是绝对路径
(相对路径是相对该补丁文件解析的,跨盘会失败)。
DSH Web 端口固定为 3080(部署前提)
中继的上游默认就是 http://127.0.0.1:3080(server.mjs 里的 RELAY_UPSTREAM),
所以 DSH 的 Web 端口必须是 3080,否则 /taskwatch/data 只会回 502,手机页显示
「对话功能不可用」。桌面版默认不是这样:端口由 app.asar 决定,而那里把 --port 0
写死了(OS 每次启动随机挑一个空闲端口),并且 没有 DSH_PORT / DSH_WEB_PORT
这类环境变量入口、配置文件里也没有端口字段 —— 环境变量与 CLI 参数这条路在桌面版上
都是堵死的。
唯一「不改安装目录、升级不丢」的入口,是用户补丁层覆盖 web-app 自带的 webserver 行:
# ~/.dsh/profiles/desktop/cordis.patch.yml
- id: webserver
name: "@deepseek-ai/dsh-host-webserver"
config:
host: "127.0.0.1"
port: 3080 # 原来是 !!js ctx.webStartup.port ?? 3080,桌面版传进来的是 0
tls: !!js ctx.webStartup.tls
compression: gzip
compressionLevel: 1
compressionThresholdBytes: 1024
三个要点:
- 补丁层是整段替换
config,不是深合并。少写一个字段(上面那四个除port和
host之外的字段)等于把它删掉,所以必须原样写回。 - 这个 profile 的补丁文件是 live 生效的:保存后 DSH 立刻在新端口重新监听,
已经打开的窗口会断线 —— 重开一次客户端即可,会话不丢。 - 改端口时三处必须一起改:上面这行
port、~/.dsh/autostart/services-watchdog.ps1
里给中继设的RELAY_UPSTREAM、以及本文档这一节。中继的UPSTREAM是启动时读取的
模块常量,改环境变量不会热生效,必须重启一次中继。 - 3080 若被别的程序占着,桌面版会 bind 失败、Web 界面起不来;先
curl http://127.0.0.1:3080/
看是谁在应答。
在外网 / 手机上访问(需要你自己叠一层)
> 想省掉手写 nginx,用这条三步部署 → docs/deploy/README.md
本插件自己不做鉴权,只监听回环。 它提供的是「可被安全转发的只读源」,转发这一层
由你自己的隧道或反向代理负责。一个已验证可用的组合是:
手机 / 外网
└─ HTTPS <你的域名> 反向代理:TLS 终止
└─ 隧道回到本机 ssh -R,或任何等价隧道
└─ 本机只读中继 校验应用层共享令牌
├─ /taskwatch、/taskwatch/data、/taskwatch/session → DSH 的回环端口
└─ manifest / sw.js / 图标 → 中继本地文件
配套的只读中继是刻意做成另一件事的,不属于本插件:它是几十行的零依赖 Node 脚本,
把白名单里的固定路径转发到 DSH 的回环端口,其余一律 404。想自己写的话,要点就四条:
- 白名单硬编码,不要让上游路径可配置 —— 否则它就成了一个任意代理。
- 只允许 GET/HEAD,不接受请求体,不转发
/api。 - 自带一层共享令牌,定长比较(
timingSafeEqual),并且不要把令牌转发给上游。 - 转发时要带上查询串。这一点踩过坑:漏掉它时
/taskwatch/session会返回
HTTP 200 但内容永远是「缺少 id 参数」——状态码看着完全正常,只是数据永远为空。
PWA 外壳由中继本地提供(而不是转发上游),这样它不随上游插件的版本变化而失效;
页面文件与插件共用同一份,避免两边漂移。
局域网直连(同一 Wi-Fi)
上面那条路要域名、要 nginx、要一条 ssh -R —— 适合"人在外面也想看"。如果手机和电脑
就在同一个屋子里,还有一条更短的路:让中继直接监听内网地址,手机连同一个 Wi-Fi
直连它。
怎么开(中继侧开关)
中继默认只监听回环(127.0.0.1),连局域网都到不了,只有隧道能进。开局域网模式就是
一个开关:在中继目录里放一个空的 .lan 标记文件,或者给它设环境变量 RELAY_LAN=1,
它就从 127.0.0.1 改为绑定 0.0.0.0;删掉标记文件即关闭(这个开关是中继启动时
读的,改完要重启它才生效)。中继自己的
/taskwatch/lan 页面(不需要令牌,也不含任何凭据)会列出当前是否已开启、本机可用
的内网地址和连接步骤。
手机怎么进
- 在跑中继的电脑上打开
http://127.0.0.1:3099/taskwatch/pair,页面上会显示一个
6 位配对码(只有从本机访问才显示)。 - 手机连同一个 Wi-Fi,打开
http://192.168.1.20:3099/taskwatch(把192.168.1.20
换成电脑的内网地址,以中继/taskwatch/lan列出的为准)。它会进配对页。 - 输入那 6 位码即可。配对码 2 分钟有效、最多试 5 次、只能用一次;换到的 cookie
有效期 30 天,并且每次带着它访问都会滑动续期。
页面右上角那个双入口胶囊也可以在这条路上用(见下一节)。
这条路的硬限制
http://不是安全上下文。 装不成 PWA、没有 service worker 离线外壳,摄像头 /
麦克风、剪贴板 API 也用不了。页面本身照常能用 —— 但"添加到主屏幕当一个 App 用"
这件事只在 HTTPS 那条路上成立。- IP 会随 DHCP 变。 路由器重新分配地址之后,手机上存的那条
http://192.168.x.x:3099就失效了;以中继/taskwatch/lan当前列出的地址为准。 - 开启客户端隔离(AP isolation)的 Wi-Fi 连不上。 那种设置下同一 Wi-Fi 的设备之间
互相不可见,手机到不了电脑 —— 这是路由器的行为,不是这条路能绕开的。 - 出了这个 Wi-Fi 就连不上。 要外网访问就用上面的隧道那条路。
安全提醒
拿到这条地址 + 配对码,等于拿到你机器上的一个终端。 手机端能驱动 agent,而 DSH
默认是 danger-full-access。所以:只在自己家、朋友家这类你信得过的网络里开;公司 /
公共网络的 Wi-Fi 不要常开;不用的时候就删掉 .lan 标记文件,让它退回只监听回环。
配对码这一层是刻意收紧的:它一次性,并且只在本机 127.0.0.1 的页面上显示 ——
能操作这台电脑的人,才能授权一台手机。
连接原理(实现路径)
两条路
远端(人在外面):
手机
└─ HTTPS https://dsh.example.com (示例域名,不是真实部署)
└─ nginx(服务器) 反向代理:TLS 终止
└─ SSH 反向隧道 ssh -R 本机主动连出,不需要公网 IP 与端口映射
└─ 本机中继 127.0.0.1:3099 校验中继令牌,按白名单转发
└─ DSH 上游 127.0.0.1:3080 /taskwatch/*(读 + /taskwatch/chat/* 写)
局域网(同一个 Wi-Fi):
手机
└─ HTTP http://192.168.1.20:3099/taskwatch 直连中继(中继绑定 0.0.0.0)
└─ 同一套 6 位配对码 → 30 天 cookie
└─ DSH 上游 127.0.0.1:3080
为什么用 SSH 反向隧道
家里的机器通常在 NAT 后面:没有固定公网 IP,也没开入站端口。反向隧道是本机主动连出
到服务器,服务器再顺着这条已经建立的连接把请求送回来 —— 所以不需要公网 IP,也不需要在
路由器上做端口映射。服务器那一侧只多一个 nginx 反代负责 TLS 终止。
令牌和 6 位配对码各解决什么
两段路,两道锁,解的不是同一个问题:
- 中继令牌(中继侧的
RELAY_TOKEN,或它自己生成在.token里的值)管的是
「服务器 → 本机中继」这一段:证明这个请求是从被授权的隧道入口进来的。比较是定长
的(timingSafeEqual),并且令牌不会被转发给上游。 - 6 位配对码管的是「手机 → 中继」这一段:手机第一次访问时用它换一张 30 天、会
滑动续期的HttpOnlycookie。配对码 2 分钟有效、最多试 5 次、只能用一次,并且只在
127.0.0.1的页面上显示。
为什么中继看不出手机在哪个网络
隧道那条路上,请求在中继眼里来源都是 127.0.0.1 —— 它看到的对端是隧道,不是手机。
所以中继分不清手机是在家里、在 4G,还是在同一个 Wi-Fi 里;同理,HTTPS 页面也没法探测
http:// 内网地址(浏览器按混合内容拦掉,IP 主机连"可升级"都不算)。
这就是手机页右上角那个双入口胶囊只能"事后切"、不能"事先测"的原因:
- 胶囊显示当前走的是哪条路(远端 / 局域网)。点开是一张菜单,列出两条入口:
当前那条标「当前」,另一条标「切换」—— 点另一条 = 签一张票 + 跳一次。 - 没有任何自动跳转(v1.7.5 起)。页面加载时只读一次入口清单、只渲染胶囊,
不允许因为入口判断而导航;也没有定时体检。自动模式被删除的原因写在下面。 - 两道保险:跳转前在
sessionStorage['tw-route-jump']写时间戳,8 秒内重复点击
只给静态提示、不签票也不跳;URL 带?t=落地时立刻history.replaceState
清掉参数,并把入口锁成当前项(sessionStorage['tw-route-lock'])。
> 为什么不再"自动切":v1.6.0 – v1.7.4 的自动模式用「这条路刚才通不通」猜方向,
> 而 apply() 在初始加载与每 12 秒的体检后都会重新判断,判断出「该换」就改地址。
> 两条路的信号一旦互相矛盾(一条体检失败、另一条请求报错),页面就会在两个入口之间
> 无限往返,手机看到的是地址栏来回变、整页反复刷新。猜错的代价远大于省下的一次
> 点击,所以自动只保留在人的脑子里:菜单列出两条路,当前是哪条、另一条在哪,由用户点。
> GET /taskwatch/upstream-health 仍在中继侧保留,但页面不再请求它。
> 入口菜单的旧截图 docs/route-entries-390.png 只作历史留存,README 不再引用;
> 当前界面见 docs/screenshots/current/。
换路为什么不重输配对码
换路等于换了一个源(https://… ↔ http://192.168.x.x:3099),而浏览器的 cookie 是
按源隔离的 —— 在远端那条路上换到的 cookie,到了局域网这条路等于不存在。
所以页面点切换时,先向当前这条路的宿主(中继,或插件宿主本身)要一张凭据:
POST /taskwatch/ticket(中继上要带令牌)→ 返回另一条路的 URL,末尾带?t=…;- 手机跳过去,对面兑换这张票并给那条路种下 cookie,于是不用重新输那 6 位配对码;
- 中继与插件宿主跑在同一台机器、同一个用户下,用同一个密钥验签,所以宿主签的票
打到公网(域名后面是中继)也能被中继认,反之亦然。
v1.7.1 起,前两条插件宿主也自己实现了(同一套语义):GET /taskwatch/entries
回答「现在有哪几条路」,POST /taskwatch/ticket 给一张换路票据。
所以从宿主页面(例如 http://127.0.0.1:52676/taskwatch)点「去远端」不再拿到 405 / 404。
GET /taskwatch/upstream-health 仍只有中继实现,但 v1.7.5 起页面不再请求它:
自动体检切换已删除,来源也不可靠 —— 中继经隧道看到的来源永远是 127.0.0.1。
票据格式(v1.7.2)
t = 'v2.' + + '.' + + '.' +
HMAC = HMAC-SHA256(secret, 'v2|' + expMs + '|' + nonce)
nonce = crypto.randomBytes(16) # base64url 后 22 个字符
校验顺序:格式 → timingSafeEqual 比 HMAC → expMs > now → expMs - now <= 180000
(60 秒 TTL + 时钟偏差)。签名不对 / 过期 / 格式不符一律当作「不是本票」;只有不是
v2. 形状的字符串才回落到原来的进程内 Map(老页面、老票仍然能用)。票据有效期内
两边各自维护一个已用 nonce 的 Set(带 TTL 清理)做进程内 best-effort 去重 ——
这不是严格一次性,详见下面「安全边界」。
公网地址只来自插件配置项 publicUrl(也支持环境变量 DSH_TASKWATCH_PUBLIC_URL),
局域网入口可以选配 lanUrl(也支持 DSH_TASKWATCH_LAN_URL),代码里不写死任何域名:
- insert:
- id: dsh-in-hand
name: dsh-in-hand
config:
publicUrl: https://dsh.example.com
lanUrl: http://192.168.1.5:3099 # 指向中继的局域网入口(有令牌门)
approveFromPhone: true # 默认 true;false 时完全退回只观测
approvalTimeoutMs: 120000 # 默认 120000,允许 10000–600000
riskConfirm: true # 默认 true;false 时高危只显示徽标、点击直接提交
# highRiskPatterns: # 默认 19 条(见「安全说明」);给了就替换默认清单
# - rm -rf
# - docker rm
配了 lanUrl 就把 entries 的局域网条目换成它,不再自动探测内网 IP;没配保持现状。
lanUrl 接受 http://192.168.1.5:3099,也接受入口的完整地址
http://192.168.1.5:3099/taskwatch(只吃掉这一个后缀,entries / ticket 还会自己再拼
/taskwatch);其它路径一律按未配置处理,并在启动时打一条告警(v1.7.3 之前是静默丢弃)。
插件自己的路由没有令牌门(授权由中继那层负责),所以建议把 lanUrl 指到有鉴权的
中继,而不是直接指回插件宿主。
approveFromPhone(默认 true)决定手机端能不能认领审批与选项式提问;设成 false
就完全回到 v1.7.9 的只观测行为(两种待办都只显示,不出现操作按钮)。
approvalTimeoutMs 默认 120000,允许 10000–600000;
小于下限按 10000、大于上限按 600000,并在启动时打一条 warn —— 越界是「取边界值」,
不是悄悄回默认值。
riskConfirm(默认 true)决定高危动作在手机上是否要两步确认;关掉后卡片仍显示
红色「高危」徽标与后果说明,但点「批准一次」直接提交,不再展开确认层。
highRiskPatterns 是判定用的字符串数组:给了就替换默认清单(不是追加),
不写则用默认 19 条;最多 64 条、每条 ≤120 字符,越界会裁剪并 warn。匹配大小写不敏感,
路径类模式只在「作为被操作对象」时命中(.gitignore 不算 .git)。判定与下发的字段
(risk: 'high' | 'normal')都在宿主侧完成,页面不参与判定。
票据接口的请求体字段就是 to:页面发 {"to":"remote"} / {"to":"lan"},
服务端读 body.to(缺省 / 未知值都按 lan 处理,不会误签远端票)。这条契约由
scripts/check-contract.mjs 钉住,改页面或改服务端时必须同步。
没配 publicUrl 时只报局域网入口;ticket 的远端请求明确回 409 no-public-entry。
宿主这层仍然不做令牌校验(401 由中继负责),但票据是写路由,所以和中继的写路由同一套
门禁:POST + application/json + 同源(Origin / Sec-Fetch-Site)校验。
页面侧遇到这两条接口的 404 / 405 会进入静态降级:不重试、不弹错,胶囊按
「只有一个入口」隐藏;设置 / 任务详情里留一行说明与一个手动「去远端」链接
(上次成功的地址记在 localStorage['tw-remote-url'])。其它错误只在入口菜单里
显示一行