DSH-ApproveByEmail-WhenApprovalNeeded
给 DSH 加两条邮件通道,都用同一个邮箱、同一套令牌机制:
审批(
approval/request):需要你批准敏感操作时发邮件,回「同意 / 拒绝」即可放行或驳回。提问(
user-questions/request):AI 给出选项让你选时也发邮件,回「1. 2」这样的行就能作答。给 DSH 用:插件挂在两个瀑布的最外层,是应答者而不是工具。
自己用:
node tools/probe-mail.mjs --send --watch 300一条命令验证整条链路。
1. 它做什么、不做什么
| 做 | 不做 |
|---|---|
| 有审批请求时发通知邮件(含工具名、请求原因、会话、令牌) | 不改任何工具的执行权限 |
| 轮询收件箱,认出「是你对某次请求的回复」并解析成同意 / 拒绝 | 不注册任何给模型用的工具,模型看不见它 |
默认是唯一通道:发邮件、等你回复(showApprovalCard / showQuestionCard 可把界面卡也打开) |
不写入审批策略(ask / never 还是 DSH 自己管) |
| 每次请求与结论写本地审计 JSONL | 不碰会话日志里的 approval/asked / approval/decided |
用的是 DSH 自己的审批接口(@deepseek-ai/dsh-user-approval 的 approval/request 瀑布事件),
不是绕过它——所以界面弹窗、审计、策略全都照常。
2. 工作原理
模型触发需要审批的操作
└─ ctx.approval.request() ← DSH 的审批服务(turn 内才有效)
└─ waterfall('approval/request') 监听顺序:
① 本插件(prepend,最外层)
├─ 发通知邮件 + 轮询收件箱
│ 你回复「同意 #令牌」→ allowed-once
│ 你回复「拒绝 #令牌」→ rejected
└─ 默认到此为止(不弹卡)
showApprovalCard: true 时才 next() ──→ ② 浏览器弹窗
③ 兜底:unavailable(fail-closed)
为什么必须 prepend:桌面端已经有浏览器弹窗这个「最终应答者」,它会把请求整个吃掉。
不抢在最外层,邮件就永远发不出去。这也是本插件唯一需要「抢先」的地方。
结论词表(DSH 只认这四个):allowed-once(唯一的放行)|rejected|cancelled|unavailable。
提问通道
模型调用 ask_user_question
└─ ctx.userQuestions.ask() ← 只在 turn 内有效
└─ waterfall('user-questions/request'):
① 本插件(prepend)
├─ 发提问邮件 + 轮询收件箱
│ 你回「1. 2」→ 返回 { answers: [{ id, selected: ['B 方案'] }] }
└─ 默认到此为止(不弹卡)
showQuestionCard: true 时才 next() ──→ ② 浏览器选项卡
③ 兜底:没有应答者 → 整个提问失败(fail-closed)
为什么提问也默认不弹卡(实测推翻了「投影会收卡」的说法): 我一开始以为选项卡跟着会话投影走、邮件答完会自动消失 —— 真机上是错的。源码确认了两层原因:
投影只跟踪 timed 版
ask_user_question(schema 里带timeout)。本机部署是阻塞式旧版 schema(顶层只有questions),tool/call那步就被if (!fold.timed) return fold跳过, 这道题从未进过投影的active。更关键的是客户端
reconcile的删除条件:for (const card of cards.values()) { if (card.pending.callId === void 0 || card.pending.review !== void 0 || rows.has(card.pending.key) || card.hasRequest() || card.pending.hasWaterfall()) continue; // 有活着的请求就永不删 card.remove(); }只要卡片背后还挂着那条转发过来的请求(
hasRequest()/hasWaterfall()),就永远不删, 投影怎么写都没用;而那条请求只有「用户自己点」或「客户端自己作答」才会结束。
结论:卡片一旦弹出,Host 侧没有任何办法撤回(审批卡、提问卡都一样)。
所以插件默认不转发、不弹卡,让邮件成为唯一通道 —— 没有卡,也就没有关不掉的卡。
代价是界面上不再出现选项按钮/审批按钮;要恢复就把 showQuestionCard / showApprovalCard 打开。
3. 装进 DSH
发布版用占位符:把
/path/to/...换成真实绝对路径,sender@example.com/approver@example.com换成你的邮箱。 授权码写在secrets.json(由secrets.example.json复制),它已在.gitignore里。
~/.dsh/profiles/desktop/cordis.patch.yml 的 insert 列表里加一项(完整片段见
examples/cordis.patch.snippet.yml):
- id: approval-email
name: "/path/to/DSH-ApproveByEmail-WhenApprovalNeeded/src/plugin.js"
config:
address: sender@example.com # 发信 + 收回复的邮箱账号
to: approver@example.com # 收通知的邮箱(回复会回到 address)
allowFrom: # 谁有资格审批(默认只认 address)
- approver@example.com
secretsFile: "/path/to/DSH-ApproveByEmail-WhenApprovalNeeded/secrets.json"
pollIntervalMs: 5000
waitMs: 0
# 界面卡片默认都不弹(弹了邮件答完也收不掉);想让界面也能点就打开下面两项,
# 代价是邮件答完后卡片留在屏幕上,需要你点一下。
showApprovalCard: false
showQuestionCard: false
装成 profile 级(全局),所以任意工作区的会话都生效;插件文件本身放在
/path/to/DSH-ApproveByEmail-WhenApprovalNeeded\。
改完必须重启 DSH(插件在 HOST 启动时装载)。重启后 HOST 日志里会出现:
[approval-email] 已挂载:sender@example.com(SMTP smtp.163.com:465,IMAP imap.163.com:993,轮询 5000ms,永不超时)
4. 配置项
| 字段 | 默认 | 说明 |
|---|---|---|
address |
必填 | 发信 + 收回复的邮箱账号(IMAP 轮询它的收件箱) |
to |
= address |
通知寄给谁;「用 A 发给 B」时写 B |
allowFrom |
[] |
谁有资格审批;默认只认 address,上面的用法里要写 B |
secretsFile |
— | 放授权码的 JSON({"authCode":"..."});也可直接写 authCode |
authCode |
— | 163 的客户端授权码,不是登录密码 |
user |
address |
登录名,一般就是邮箱地址 |
displayName |
DSH 审批 |
发件人显示名 |
subjectPrefix |
[DSH 审批] |
审批邮件的主题前缀 |
questionSubjectPrefix |
[DSH 提问] |
提问邮件的主题前缀 |
showApprovalCard |
false |
审批是否同时在界面弹卡。默认关:审批卡弹出后 Host 侧没有关闭通道,邮件批完会一直留在屏幕上。设成 true 就是旧的「并行抢答」(卡片可能残留) |
showQuestionCard |
false |
提问是否同时在界面弹选项卡。默认关,理由同上(源码已确认:卡片背后挂着转发请求时永不删除)。设成 true 恢复界面选项按钮,但邮件答完卡片会留着 |
smtp |
smtp.163.com:465 TLS |
{ host, port, secure, starttls } |
imap |
imap.163.com:993 TLS |
{ host, port, secure, mailbox } |
pollIntervalMs |
5000 |
收件箱轮询间隔 |
waitMs |
0 |
0 = 永不超时(按你的要求持续等待) |
notifyOnly |
false |
true = 只发通知,回复不作为放行依据 |
confirmByEmail |
false |
结论落地后再回一封「已处理」(默认关,免打扰) |
allowFrom |
[] |
额外允许的发件人;默认只认 address |
auditFile |
插件目录 audit/audit.jsonl |
本地审计 |
timeZone |
Asia/Shanghai |
邮件里展示时间的时区 |
两种工作模式
| 模式 | 配置 | 桌面 | 邮件答完后 |
|---|---|---|---|
| A 纯邮件(默认) | 两个开关都 false |
不弹卡,没有按钮 | 无卡可残留 |
| B 桌面 + 邮件 | 两个开关都 true |
卡片照常出现,可点 | 卡片留在屏幕上,点任意一项即关闭 |
B 模式里点哪一项都不改变已经记录的结果(邮件答案先到时,界面这次提交会被丢弃),
那个点击实际上只起「关闭卡片」的作用。想切到 B 就把 showApprovalCard / showQuestionCard
都设成 true,然后重启 DSH。
5. 邮件长什么样、怎么回
主题:[DSH 审批] #a1b2c3d4 需要你批准:bash
正文(纯文本 + HTML 双份):
DSH 需要你批准一次操作。
令牌:#a1b2c3d4
工具:bash
原因:grantWrite(/path/to/...)
调用:call_abc123
会话:sess_xxx
工作区:/path/to/...
时间:2026-11-05 15:40:12
【直接回复本邮件即可决定】
批准:回复正文写「同意 #a1b2c3d4」
拒绝:回复正文写「拒绝 #a1b2c3d4」
回复规则:
| 你的回复 | 结果 |
|---|---|
| 正文含「同意 / 批准 / 通过 / 允许 / 放行 / approve / yes / ok」且带令牌 | allowed-once |
| 正文含「拒绝 / 驳回 / 不同意 / deny / no」且带令牌 | rejected |
| 只有引用原文、没说结论 | 忽略,继续等 |
| 既说同意又说拒绝 | 忽略,继续等(怕误放行) |
| 没令牌 / 令牌过期 / 发件人不对 / 不是回复 | 忽略 |
识别只看你新写的那几句(引用区会被砍掉),所以通知模板里印着的「同意 #令牌 / 拒绝 #令牌」 两个示例不会被当成你的意思。
提问邮件长什么样、怎么回
主题:[DSH 提问] #a1b2c3d4 需要你选择:方案
DSH 需要你做一个选择。
令牌:#a1b2c3d4
【问题 1】方案
用哪个方案?
1) A 方案 — 改动小
2) B 方案 — 更彻底
【问题 2】要不要顺便补测试?
1) 要
2) 不要
【怎么回】直接回复本邮件,每行一题:
· 单选:`1. 2` = 第 1 题选第 2 个选项
· 多选:`1. 2,3`
· 自己写:`1. 自定义:你的文字`
· 也可以直接写选项原文:`1. 选项文字`
· 不想答某题就不写它(会记为跳过)
| 你的回复 | 结果 |
|---|---|
1. 2 |
第 1 题选第 2 个选项 |
1. 文档, 打包(多选题) |
两个都选 |
1. 自定义:先 A 后 B |
走「自定义」答案 |
1. A 方案 |
按选项原文匹配 |
只有一道题时直接回 2 |
也认 |
| 没写某一题 | 该题记为跳过(selected: []) |
| 一题都读不出来 | 忽略,继续等(界面上的卡还在) |
答完之后:工具调用结束、答案返回;因为默认不弹卡,界面上本来就没有需要收的按钮
(showQuestionCard: true 时卡片会留着,要你点一下才消失 —— 这就是默认关掉它的原因)。
6. 安全边界
- 只认一个发件人:
address(可用allowFrom加白名单)。其他地址的邮件一律忽略。 - 一次性令牌:每个请求随机生成,结论落地即失效,迟到的回复无人认领。
- 不是回复就不认:必须有
In-Reply-To/References,或主题带Re:/答复:等前缀; 我们自己发出去的通知带X-DSH-Approval: notify,投回同一个收件箱也不会被误当成回复。 - fail-closed:邮件没回、说不清、发信失败 —— 都不会放行;发信失败时该请求自动退回界面审批。
- 审计:
audit/audit.jsonl记录asked / decided / unreadable / notify-failed / settled。 - 这等于开了一条远程放行通道:邮箱被人拿到 = 本机被放行。请给邮箱开二次验证,授权码单独保管,
secrets.json不要进任何仓库、不要发群里。 - 授权码只写在
secrets.json,不进cordis.patch.yml;日志里只出现掩码。
7. 排错
| 现象 | 原因与处理 |
|---|---|
HOST 日志 配置不可用,插件未挂载 |
缺 authCode 或 address 不合法;插件会安全地不挂载,界面审批照常 |
日志 审批邮件发送失败(本请求改由界面审批) |
SMTP 不通(授权码错 / 网络 / 被限流);界面照旧可用 |
日志 收信失败(下轮自动重连重试) |
IMAP 断线或登录失败;插件会一直重连 |
SMTP … 535 Error: authentication failed |
授权码不对,或 163 没开 SMTP/IMAP 服务 |
IMAP … NO LOGIN Login error or password error |
同上(同一把授权码) |
| 邮件根本没到 | 先 node tools/probe-mail.mjs --send,看是发信还是收信的问题 |
| 回复了但 DSH 没反应 | node tools/probe-mail.mjs --watch 300 看它能不能认出你那封;认不出就把候选行发我 |
8. 验证状态
离线回归:50 项全过(node test/run-tests.mjs)。覆盖 MIME 编解码、
回复判定(同意 / 拒绝 / 不同意 / 引用隔离 / 令牌不符 / 双结论 / 主题判定)、
提问的邮件渲染与答案解析(题号、选项原文、自定义、多选、跳题、答非所问)、配置校验、
假 SMTP 发信、假 IMAP 增量收信(含 163 必需的 ID 命令、以及「服务端不给 UIDNEXT」的兜底)、
六条端到端(审批邮件先到、界面前点、发信失败不误放行、仅通知模式、提问邮件答完返回完整答案批次、
审批默认不弹卡),以及一组照抄实测邮件的用例(见下)。
真机验收 ①(命令行直连,2026-11-05,sender@example.com → approver@example.com):全链路跑通。
[SMTP] 已投递(637ms) 主题:[DSH 审批] #abcd1234 需要你批准:probe-mail
[IMAP] 登录并选中收件箱成功(含 163 要求的 ID 命令)
[IMAP] 开始盯收件箱 …
收到候选回复 UID 1624272441:令牌 abcd1234/结论 allow
收到你的回复:同意(会被放行)
真机验收 ②(重启 DSH 后跑真实审批):通过,且放行确实来自邮件。
触发一次真实的权限升级请求(往工作区外写文件),审批人只回邮件、没点界面弹窗,命令随即执行成功。
插件自己的审计日志(audit/audit.jsonl)留下了三段 —— 这是「插件确实挂在 HOST 里」的直接证据:
{"kind":"asked","token":"abcd1234","tool":"pwsh","reason":"escalate sandbox to danger-full-access: …","session":"session-8efee44d-…"}
{"kind":"decided","token":"abcd1234","uid":1624272442,"decision":"allow","outcome":"allowed-once","source":"email"}
{"kind":"settled","token":"abcd1234","outcome":"allowed-once","source":"race"}
source":"email" 是关键:请求先被本插件截住(prepend 生效)→ 通知邮件发出 →
60 秒后轮询到回复、令牌对上、判定 allowed-once → 操作随即执行,界面弹窗那条路一次都没用上。
真机暴露并修掉了三个只有真邮件才会出现的问题,每个都补了回归用例:
- 账号名写错一位就全线 535。
sender@example.com与sender@example.com只差一个k, 前者 SMTP 回535 authentication failed、IMAP 回NO LOGIN Login error or password error, 而两者用的是同一把授权码 —— 所以「协议三步都走完才拒」基本就是账号名或授权码的问题。 - 163 不返回 UIDNEXT:
SELECT里没有[UIDNEXT n],STATUS (UIDNEXT)也只回MESSAGES。 原来按UIDNEXT-1取水位线会退化成 0,导致重扫全邮箱。现在的三级兜底是SELECT 的 UIDNEXT → STATUS 的 UIDNEXT → UID SEARCH ALL 取现有最大 UID; 实测水位线取到1624272440(2 封历史邮件),新邮件才处理。 - 主题里的令牌被空格切开:163 会把长主题切成两个 RFC2047 编码词,实测收到的是
#40c10f 69。按 RFC 2047 §6.2,相邻编码词之间的空白必须丢掉;改之前令牌在主题里根本认不出来 (那一次是靠正文里的令牌兜住的)。同时也要保留「编码词 + 地址」之间那个有意义的空格,两件事都写了用例。 - 引用区标记是「---- 回复的原邮件 ----」(还带一张管道表格),不在原来的「原始邮件 / Original Message」
名单里,导致引用区整段漏进「你新写的话」——于是引用模板里的
「批准 → 写「同意 #令牌」」会被当成你的意思:一封「拒绝」会被判成「既同意又拒绝」而不生效。
补上该标记后,实测同一封信的可用文本从
"同意 #abcd1234\n\n\n\n---- 回复的原邮件 ----"变成干净的"同意 #abcd1234"。
9. 已知限制
- 邮件里没有具体命令与参数 —— DSH 审批接口只外传工具名、原因与调用 id(硬限制)。
- 只在 turn 内有效:你回得太晚(turn 已被中止)时回复作废,会话侧按
cancelled处理。 - 只有一次性放行:DSH 没有「永久允许 / 记住规则」。
- 界面卡片关不掉(审批卡、提问卡都一样,已用源码确认)。客户端只要还握着那条转发过来的请求,
reconcile就会跳过这张卡的删除;Host 侧没有任何撤回 API。所以两者默认都不弹卡 (showApprovalCard/showQuestionCard均为false),邮件成为唯一通道。 打开它们就回到「界面 + 邮件并行」,代价是邮件答完后卡片留在屏幕上,得点一下才消失。 - 需要重启 DSH 才加载。
- 收件箱是增量读的:插件启动/首次请求之前的旧邮件一概不看(水位线取
UIDNEXT, 服务端不给就退到UID SEARCH ALL的最大 UID)。 - 限时提问(
askTimed):超时后问题转为「continued」,此后答案走 Remote 的answer方法, 本插件只负责前台那次等待;超时后回复的邮件不会自动生效。
10. 文件
| 文件 | 作用 |
|---|---|
| src/plugin.js | cordis 入口:读配置、两个瀑布各挂一个 prepend 应答者、卸载时收尾 |
| src/answerer.mjs | 核心:与界面抢答、令牌待办表、轮询生命周期、审计(审批与提问共用) |
| src/config.mjs | 配置归一化(含 secrets 文件) |
| src/notify.mjs | 审批通知邮件文案(纯文本 + HTML) |
| src/decision.mjs | 审批的两道筛子:只看 header → 解正文出结论 |
| src/questions.mjs | 提问的邮件渲染(题号/选项)与回复解析(序号/原文/自定义/多选/跳题) |
| src/token.mjs | 一次性令牌生成与抽取 |
| src/mime.mjs | MIME 编解码(RFC2047 / base64 / QP / multipart / HTML→文本 / 引用隔离) |
| src/mail-smtp.mjs | 零依赖 SMTP(465 隐式 TLS / STARTTLS / AUTH LOGIN→PLAIN) |
| src/mail-imap.mjs | 零依赖 IMAP(含 163 必需的 ID 命令、UID 水位线、断线重连) |
| src/socket.mjs | 共用的 TCP/TLS 行读取器(按字节,字面量安全) |
| test/run-tests.mjs | 50 项离线回归 |
| test/servers.mjs | 假 SMTP / 假 IMAP 服务端 |
| tools/probe-mail.mjs | 真实邮箱连通性诊断 |
| examples/cordis.patch.snippet.yml | 装配片段 |
secrets.json |
授权码(唯一存放处,勿提交) |
No comments yet. Be the first to write one.