From 2996f9af9cef87f6fc3338c17117ad9c1154977a Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Thu, 3 Sep 2026 21:10:48 +0800 Subject: [PATCH] =?UTF-8?q?fix(plugins):=20409=20=E6=97=B6=E5=BD=93?= =?UTF-8?q?=E5=9C=BA=E8=A1=A8=E6=80=81=20+=20DSH=20=E8=A1=A5=E6=8A=95?= =?UTF-8?q?=E6=8C=89=E4=BC=9A=E8=AF=9D=E4=B8=B2=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **409 = 永远不会成功**(没有人类可路由)。原来三个插件都在失败时让位给 平台本地 UI —— 但邮件驱动的会话**没有 TUI**,让位之后 waterfall 跑到尾 依旧无人应答,仍是无声挂死。 HTTP 客户端必须把 err.status 与 err.body 挂到 error 上:只看 message 字符串分不出「暂时失败(502,该重试)」与「永远不会成功(409)」, 两种都会被当成前者,而前者会永久挂住会话。 三平台表态方式不同但语义统一: - opencode: output.status = "deny" + output.reason 带服务端原文 - dsh: return 'rejected'(ApprovalOutcome 只认 allowed-once/rejected/ cancelled,写 'denied' 不报错而是被当未知值静默失效) - pi: return { block: true, reason } 其余失败(502 等)保持原行为,让位本地 UI。 --- **DSH 补投并发**(同一文件,故并入本次提交) 生产日志:`补投 5 封(共 16 封未读)`,9 秒后三封失败 `message "undefined" is already pending`。串行 for...of 并未真正串行 —— awaitFirstTurn 在**首个 token** 就放行,turn 尚未结束下一封已 followup。 新增 waitForTurnEnd(等 turn/end 而非首 chunk)与 sessionLocks/locked() 按会话串行化。live-agent 路径原来直接 followup 就返回,现在也进锁。 120s 超时兜底,模型完全无响应时不会把后续邮件永久卡住。 权限场景下锁会持有到人类决策完 —— 这是正确行为:两封都需要授权时 第二封排队,比同时弹两个授权请求更合理。 顺带把 rename-proposal 纳入 check-shared-libs.sh 的同源校验。 --- deploy/check-shared-libs.sh | 4 +- .../lib/permission-grants.d.ts | 12 ++ .../dsh-mail-bridge/lib/permission-grants.js | 105 ++++++++++++ .../dsh-mail-bridge/lib/rename-proposal.js | 35 +++- plugins/dsh-mail-bridge/src/index.ts | 130 ++++++++++++--- .../test/permission-grants.test.mjs | 156 ++++++++++++++++++ .../test/rename-proposal.test.mjs | 33 +++- plugins/opencode-mail-bridge/index.js | 57 ++++++- .../lib/permission-grants.js | 105 ++++++++++++ .../test/permission-grants.test.mjs | 156 ++++++++++++++++++ .../pi-mail-bridge/lib/permission-grants.js | 105 ++++++++++++ plugins/pi-mail-bridge/lib/rename-proposal.js | 35 +++- plugins/pi-mail-bridge/src/gateway.mjs | 3 + plugins/pi-mail-bridge/src/index.mjs | 112 ++++++++++--- .../test/permission-grants.test.mjs | 156 ++++++++++++++++++ .../test/rename-proposal.test.mjs | 33 +++- 16 files changed, 1156 insertions(+), 81 deletions(-) create mode 100644 plugins/dsh-mail-bridge/lib/permission-grants.d.ts create mode 100644 plugins/dsh-mail-bridge/lib/permission-grants.js create mode 100644 plugins/dsh-mail-bridge/test/permission-grants.test.mjs create mode 100644 plugins/opencode-mail-bridge/lib/permission-grants.js create mode 100644 plugins/opencode-mail-bridge/test/permission-grants.test.mjs create mode 100644 plugins/pi-mail-bridge/lib/permission-grants.js create mode 100644 plugins/pi-mail-bridge/test/permission-grants.test.mjs diff --git a/deploy/check-shared-libs.sh b/deploy/check-shared-libs.sh index 61d2645..8e73514 100755 --- a/deploy/check-shared-libs.sh +++ b/deploy/check-shared-libs.sh @@ -12,7 +12,7 @@ PEERS=(plugins/dsh-mail-bridge plugins/pi-mail-bridge) fail=0 for peer in "${PEERS[@]}"; do - for f in relay-dedup inbox-format session-snapshot workspace model-scope catchup addressing discovery rename-proposal; do + for f in relay-dedup inbox-format session-snapshot workspace model-scope catchup addressing discovery rename-proposal permission-grants; do if [[ ! -f "$peer/lib/$f.js" ]]; then echo "共用模块缺失:$peer/lib/$f.js" >&2 fail=1 @@ -26,7 +26,7 @@ for peer in "${PEERS[@]}"; do done # 测试同样要同源:共用模块的行为约定写在测试里, # 只同步实现不同步测试,等于允许一侧偷偷放宽约定。 - for f in inbox-format session-snapshot workspace model-scope catchup addressing discovery rename-proposal; do + for f in inbox-format session-snapshot workspace model-scope catchup addressing discovery rename-proposal permission-grants; do if [[ ! -f "$peer/test/$f.test.mjs" ]]; then echo "共用测试缺失:$peer/test/$f.test.mjs" >&2 fail=1 diff --git a/plugins/dsh-mail-bridge/lib/permission-grants.d.ts b/plugins/dsh-mail-bridge/lib/permission-grants.d.ts new file mode 100644 index 0000000..5d0993c --- /dev/null +++ b/plugins/dsh-mail-bridge/lib/permission-grants.d.ts @@ -0,0 +1,12 @@ +export declare function isAlwaysDecision(decision: string | null | undefined): boolean; + +export declare function isApproval(decision: string | null | undefined): boolean; + +export interface GrantStore { + isGranted(sessionId: string, toolName: string): boolean; + grant(sessionId: string, toolName: string, decision: string): boolean; + revokeSession(sessionId: string): void; + size(): number; +} + +export declare function createGrantStore(): GrantStore; diff --git a/plugins/dsh-mail-bridge/lib/permission-grants.js b/plugins/dsh-mail-bridge/lib/permission-grants.js new file mode 100644 index 0000000..0959f3d --- /dev/null +++ b/plugins/dsh-mail-bridge/lib/permission-grants.js @@ -0,0 +1,105 @@ +// 权限免批(「一直同意」)的纯逻辑 —— 所有平台插件共用。 +// +// # 这是什么 +// +// 权限询问默认是**每次都问**:模型每调一次 bash 就发一封邮件等人点头。 +// 这在「跑一条命令看看」的场景下是对的,在「审查这个工程」的场景下是灾难 —— +// 实测同一条会话被问了 15 次 bash,人点了 15 次「同意」,全是同一类操作。 +// +// 「一直同意」就是人对此的回答:这条会话里这个工具,别再问了。 +// +// # 为什么需要一个独立模块 +// +// 因为它的**作用域**是唯一容易搞错的地方,而搞错的后果是静默的越权: +// +// - 作用域太宽(全局 / 只按工具名)→ 人为「审查 llmsproxy」批准的 bash, +// 会静默授权另一个发件人派来的另一条任务。那不是他批准的东西。 +// - 作用域太窄(按 toolCallId)→ 等于没有免批,每条命令还是一封邮件。 +// +// 正确的粒度是 **(会话, 工具名)**:人看到的那句「是否允许执行 bash?」 +// 就是在这个粒度上提的问,授权范围不该超出提问范围。 +// +// # 为什么只在内存里 +// +// 会话结束(进程重启)即失效,这是有意的。长期免批该由平台自己的 settings +// 管(pi 的 settings.json、opencode 的 permission 配置),不该让一个守护进程 +// 的内存变成事实上的安全策略 —— 那种策略没人能审计,重启后又悄悄消失。 + +/** + * 判定一个决策文本是不是「永久同意」。 + * + * **必须精确匹配**,不能用前缀匹配。`/^同意/` 会把「同意」也算成 always, + * 于是人点一次单次授权,后面所有命令都不再问了 —— 那是把单次授权 + * 静默升级成永久授权,比不实现这个功能危险得多。 + * + * @param {string} decision 人点的选项原文 + * @returns {boolean} + */ +export function isAlwaysDecision(decision) { + return /^(一直同意|always|allow-always|allow_always)$/i.test(String(decision ?? '').trim()); +} + +/** + * 判定一个决策文本是不是「同意」(含永久同意)。 + * + * fail closed:认不出的文本一律当拒绝。空串、`shutdown`(关停时唤醒等待者 + * 用的哨兵值)、以及任何没见过的选项都走这一支 —— 放行一个没人批准的 + * 危险操作,比让它失败严重得多。 + * + * @param {string} decision + * @returns {boolean} + */ +export function isApproval(decision) { + return /^(同意|一直同意|allow|approve|always|yes)/i.test(String(decision ?? '').trim()); +} + +/** + * 免批授权表:`会话 id -> Set<工具名>`。 + * + * 用 Map> 而不是 Set<`${session}:${tool}`>: + * 会话结束时要能一次清掉它的全部授权(`revokeSession`), + * 拼接键的话得遍历整张表按前缀删,而工具名里出现 `:` 就会误删。 + */ +export function createGrantStore() { + /** @type {Map>} */ + const grants = new Map(); + + return { + /** 这条会话的这个工具是否已获免批。 */ + isGranted(sessionId, toolName) { + if (!sessionId || !toolName) return false; + return grants.get(sessionId)?.has(toolName) ?? false; + }, + + /** + * 记下一条免批授权。只在决策文本确实是「一直同意」时才记 —— + * 判定交给 isAlwaysDecision,调用方不要自己写正则。 + * @returns {boolean} 是否真的记下了(便于调用方决定要不要打日志) + */ + grant(sessionId, toolName, decision) { + if (!sessionId || !toolName) return false; + if (!isAlwaysDecision(decision)) return false; + let set = grants.get(sessionId); + if (!set) grants.set(sessionId, (set = new Set())); + set.add(toolName); + return true; + }, + + /** + * 撤销整条会话的免批。 + * + * 换模型重开会话时必须调:授权是人对**那次**上下文的判断, + * 新会话重跑一遍提示,不该继承上一条的授权。 + */ + revokeSession(sessionId) { + grants.delete(sessionId); + }, + + /** 仅用于测试与诊断:当前授权总数。 */ + size() { + let n = 0; + for (const set of grants.values()) n += set.size; + return n; + }, + }; +} diff --git a/plugins/dsh-mail-bridge/lib/rename-proposal.js b/plugins/dsh-mail-bridge/lib/rename-proposal.js index 9deb385..346efea 100644 --- a/plugins/dsh-mail-bridge/lib/rename-proposal.js +++ b/plugins/dsh-mail-bridge/lib/rename-proposal.js @@ -93,17 +93,38 @@ export function appendRenameProposal(body, alias, reason) { /** * 提议提交后回给模型的那句话。 * + * **别名取服务端回的 `rename_proposed`,不是本地提议的那个。** 服务端会跑 + * `normalizeAlias` —— 非法字符换成 `-`、`new` 变 `session-new`、超长按 UTF-8 + * 边界截断。回显本地值会让模型记住一个不存在的名字,之后拿它寻址就 404。 + * * 必须说明「等人确认」。不说的话模型会以为改名已经生效,接着在后续邮件里 * 用新别名当地址发信 —— 而那个别名此刻还不存在,投递会失败。 * - * @param {string} alias - * @param {boolean} proposed appendRenameProposal 的返回值 + * @param {string} [serverAlias] 服务端 `/mail/send` 响应里的 `rename_proposed` + * @param {string} [requestedAlias] 本地提议的别名,仅用于「未提交」时的说明 + * @param {boolean} [proposed] appendRenameProposal 的返回值 * @returns {string} 空串表示没有需要追加的说明 */ -export function renameProposalNote(alias, proposed) { - if (!alias) return ''; - if (!proposed) { - return `(改名提议 "${alias}" 未提交:别名不可为 new,不可含 . 空白 / @ 或双引号。)`; +export function renameProposalNote(serverAlias, requestedAlias, proposed) { + const server = String(serverAlias ?? '').trim(); + const wanted = String(requestedAlias ?? '').trim(); + + // 服务端确认收到了:用它给的最终值 + if (server) { + const changed = wanted && wanted !== server + ? `(你提的 "${wanted}" 被规范化成了这个)` + : ''; + return `已附上改名提议 "${server}"${changed},等用户在界面上确认后生效 —— ` + + `在那之前继续用原别名寻址。`; } - return `已附上改名提议 "${alias}",等用户在界面上确认后生效 —— 在那之前继续用原别名寻址。`; + + if (!wanted) return ''; + + // 本地就判定不合法,标记没发出去 + if (!proposed) { + return `(改名提议 "${wanted}" 未提交:别名不可为 new,不可含 . 空白 / @ 或双引号。)`; + } + + // 标记发出去了但服务端没回 rename_proposed:它那侧的校验也拒了 + return `(改名提议 "${wanted}" 未被服务端接受,会话别名不变。)`; } diff --git a/plugins/dsh-mail-bridge/src/index.ts b/plugins/dsh-mail-bridge/src/index.ts index ceb48cc..40f55fc 100644 --- a/plugins/dsh-mail-bridge/src/index.ts +++ b/plugins/dsh-mail-bridge/src/index.ts @@ -51,6 +51,8 @@ import { renderThread, } from '../lib/discovery.js'; import { appendRenameProposal, renameProposalNote } from '../lib/rename-proposal.js'; +// 只用 isApproval:DSH 没有 always 语义,免批授权表在这里用不上(见决策处的注释)。 +import { isApproval } from '../lib/permission-grants.js'; // ─── 凭证管理 ─── @@ -108,7 +110,15 @@ class GatewayClient { body: JSON.stringify(body), }); const data = await res.json() as any; - if (!res.ok) throw new Error(data?.error || `POST ${path} failed: ${res.status}`); + if (!res.ok) { + // 状态码与响应体挂在 error 上:调用方要区分「暂时失败」与「永远不会成功」。 + // 权限询问碰到 409(任务链上没有人类)必须当场拒绝, + // 而 502 应该重试 —— 只看 message 字符串分不出这两种。 + const err: any = new Error(data?.error || `POST ${path} failed: ${res.status}`); + err.status = res.status; + err.body = data; + throw err; + } return data; } @@ -427,13 +437,12 @@ export function apply(ctx: any, config: PluginConfig): void { if (event?.type === 'assistant/chunk') { const chunk = event.data?.chunk; if (chunk?.type === 'finish') { - // finish 是这一步的收尾,可能成功也可能失败 if (chunk.reason?.kind === 'error') { return finish({ ok: false, error: describe(chunk.reason.failure) }); } - return; // 正常收尾,等 turn/end 定论 + return; // finish 收尾,等 turn/end } - // 其余 chunk 类型 = 模型真的在产出内容 + // assistant/chunk 模型在产出内容 → 模型确实活着,算 OK return finish({ ok: true }); } @@ -442,13 +451,47 @@ export function apply(ctx: any, config: PluginConfig): void { if (reason?.kind === 'error') { return finish({ ok: false, error: describe(reason.error) }); } - // 正常结束(completed/canceled)也算走通了 —— 有些轮次不产出 chunk return finish({ ok: true }); } }); }); } + /** + * 等待当前轮次结束后再投递下一封。 + * + * 原来 catchUp 用 awaitFirstTurn(首 token 即放行),同一会话的多封邮件在 + * 补投时全部撞进同一个 turn → 「message undefined is already pending」。 + * 这个函数等 turn/end,且对同一会话串行化,杜绝并发 followup。 + */ + async function waitForTurnEnd(agent: any, timeoutMs = 120_000): Promise { + await new Promise((resolve) => { + let done = false; + const finish = () => { if (!done) { done = true; clearTimeout(timer); dispose?.(); resolve(); } }; + const timer = setTimeout(finish, timeoutMs); + const dispose = ctx.on('session/event', (session: any, event: any) => { + if (session !== agent.session) return; + if (event?.type === 'turn/end') finish(); + }); + }); + } + + /** 按会话锁串行化 followup,同一会话同一时刻只跑一轮。 */ + const sessionLocks = new Map>(); + async function locked(dshSessionId: string, fn: () => Promise): Promise { + const prev = sessionLocks.get(dshSessionId) ?? Promise.resolve(); + let release!: () => void; + const next = new Promise((r) => { release = r; }); + sessionLocks.set(dshSessionId, next); + try { + await prev; + return await fn(); + } finally { + release(); + if (sessionLocks.get(dshSessionId) === next) sessionLocks.delete(dshSessionId); + } + } + // ─── 建会话(磁盘上已有则 resume)─── /** @@ -534,20 +577,27 @@ export function apply(ctx: any, config: PluginConfig): void { if (existing) { const live = ctx.agents.get(existing.dshSessionId); if (live) { - const promptText = kind === 'permission' - ? `你之前发起的权限请求已有结论:${data.decision}(决策人:${data.decided_by || '用户'})。请据此继续后续工作。` - : [ - `本会话收到一封新邮件(AgentMail 续谈)。`, - ``, - `发件人:${data.from_name || 'unknown'}`, - `主题:${data.subject || '(无主题)'}`, - `邮件 ID:${data.mail_id || 'unknown'}`, - ``, - `请先调用 read_inbox 读取完整正文,然后处理其中的请求。`, - `回信不用你自己发:把这一轮做完、把结论说出来就行。`, - ].join('\n'); - live.followup(userMessage(promptText)); - return { sessionID: existing.dshSessionId, reused: true }; + return locked(existing.dshSessionId, async () => { + const promptText = kind === 'permission' + ? `你之前发起的权限请求已有结论:${data.decision}(决策人:${data.decided_by || '用户'})。请据此继续后续工作。` + : [ + `本会话收到一封新邮件(AgentMail 续谈)。`, + ``, + `发件人:${data.from_name || 'unknown'}`, + `主题:${data.subject || '(无主题)'}`, + `邮件 ID:${data.mail_id || 'unknown'}`, + ``, + `请先调用 read_inbox 读取完整正文,然后处理其中的请求。`, + `回信不用你自己发:把这一轮做完、把结论说出来就行。`, + ].join('\n'); + live.followup(userMessage(promptText)); + // 等 turn/end 而不是立即返回:这封邮件的轮次未结束时投递下一封, + // 会让 DSH 报 "message already pending"。串行化靠 locked() 保证 + // 同一时刻只有一个 followup 在跑,两个锁互斥 —— 即使 turn/end + // 未出现(比如模型完全没响应),120s 超时兜底不会把后续邮件永久卡住。 + await waitForTurnEnd(live); + return { sessionID: existing.dshSessionId, reused: true }; + }); } } @@ -1239,26 +1289,51 @@ export function apply(ctx: any, config: PluginConfig): void { // DSH 不给询问发 id,用 (会话, 工具, callId) 做幂等键。 const relayKey = `${agentId}:${req.toolName}:${req.callId ?? 'nocall'}`; + const mctx = mailContexts.get(mailSessionID); try { + // **不传 `to`**:决策人由服务端定(会话 owner → 线索里最近的人类 → + // 无人可问则 409)。插件若把来信人当决策人,Agent 之间转派任务时 + // (A 把活分给 B)权限邮件会发给 Agent 自己 —— Agent 不可能在界面上点 + // 「同意」,于是下面那个 await 永不 resolve,会话无声挂死。 await client.post('/permission/request', { question: `请求执行 ${req.toolName}`, - options: ['同意', '拒绕'], + options: ['同意', '拒绝'], context: [ `工具:${req.toolName}`, req.callId ? `调用 ID:${req.callId}` : '', req.reason ? `理由:${req.reason}` : '', + // 决策人未必是这条会话的参与者(Agent 转派出来的会话,人从没见过它), + // 只给工具名无从判断,得说明这活是谁派的、为的什么事(B-8.4)。 + mctx?.subject ? `触发任务:${mctx.subject}` : '', + mctx?.replyTo ? `任务来自:${mctx.replyTo}` : '', ].filter(Boolean).join('\n'), session_id: mailSessionID, relay_key: relayKey, }); } catch (e: any) { - // 转不出去就别把 DSH 挂在那儿等:交给下一个 answerer(本地 UI)接管。 - ctx.logger.error(`[dsh-mail-bridge] 权限询问转发失败: ${e?.message || e}`); + // 409 = 服务端已判定这条任务链上没有人类,永远不会有人来点头。 + // + // 不能 `return next()`:下一个 answerer 是本地 UI,而邮件驱动的会话 + // 根本没有 UI,waterfall 跑到尾以后依旧无人应答 —— 这正是生产事故 + // 的形状:pi 把任务派给自己的另一条会话,那条要跑 bash,会话永久挂死。 + // + // 直接 denied 并把服务端的建议原文写进日志:模型从工具报错里看到 + // 拒绝后会自己换方式,而挂死时它连重试的机会都没有。 + if (e?.status === 409) { + const hint = [e?.body?.error, e?.body?.detail, e?.body?.suggestion] + .filter(Boolean).join(' '); + console.error(`[dsh-mail-bridge] 权限询问无人可投,当场拒绝 ${relayKey}:${hint}`); + // 用 'rejected' 而不是 'denied':DSH 的 ApprovalOutcome 只认 + // allowed-once / rejected / cancelled,写错了它不报错而是当成未知值处理。 + return 'rejected'; + } + // 其余失败(网络抖动、Gateway 重启)是暂时的,交给下一个 answerer(本地 UI)。 + console.error(`[dsh-mail-bridge] 权限询问转发失败: ${e?.message || e}`); return next(); } - ctx.logger.info(`[dsh-mail-bridge] 权限询问已转邮件 ${relayKey}`); + console.error(`[dsh-mail-bridge] 权限询问已转邮件 ${relayKey}`); // 等人类决策;DSH 撤销询问(signal abort)时结算为 cancelled。 return new Promise((resolve) => { @@ -1277,8 +1352,15 @@ export function apply(ctx: any, config: PluginConfig): void { pendingApprovals.delete(relayKey); // AgentMail 的选项文本 → DSH 的 ApprovalOutcome。 // 只有“同意”才放行,其余(包括认不出的选项)一律 fail closed。 + // + // **DSH 不提供「一直同意」**:它的 ApprovalOutcome 只有 + // allowed-once / rejected / cancelled / unavailable,没有 always 语义 + // (见 @deepseek-ai/dsh-user-approval 的类型定义)。桥自己记免批的话, + // DSH 侧仍会每次调 approval/request,而桥直接答 allowed-once —— + // 那等于用插件内存覆盖平台的审批策略,且这份策略没人能审计。 + // 因此这里的选项只有两个(见上面的 options),isApproval 就够用。 const decision = String(data?.decision ?? ''); - const outcome = /^(同意|allow|approve|yes)/i.test(decision) ? 'allowed-once' : 'rejected'; + const outcome = isApproval(decision) ? 'allowed-once' : 'rejected'; pending.resolve(outcome); ctx.logger.info(`[dsh-mail-bridge] 权限决策 ${relayKey} -> ${outcome}`); return; diff --git a/plugins/dsh-mail-bridge/test/permission-grants.test.mjs b/plugins/dsh-mail-bridge/test/permission-grants.test.mjs new file mode 100644 index 0000000..3c37533 --- /dev/null +++ b/plugins/dsh-mail-bridge/test/permission-grants.test.mjs @@ -0,0 +1,156 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + isAlwaysDecision, + isApproval, + createGrantStore, +} from '../lib/permission-grants.js'; + +// ─── isAlwaysDecision ─── +// +// 这个函数是整个模块里最危险的一处:判宽了就把单次授权静默升级成永久授权。 + +test('「一直同意」判为永久', () => { + assert.equal(isAlwaysDecision('一直同意'), true); +}); + +test('「同意」不是永久 —— 前缀匹配会把单次授权升级成永久', () => { + // /^同意/ 之类的正则会让这条过,那意味着人点一次「同意」, + // 后面所有命令都不再问 —— 静默越权。 + assert.equal(isAlwaysDecision('同意'), false); +}); + +test('always / allow-always 判为永久(英文界面)', () => { + for (const d of ['always', 'Always', 'ALWAYS', 'allow-always', 'allow_always']) { + assert.equal(isAlwaysDecision(d), true, d); + } +}); + +test('allow / approve / yes 不是永久', () => { + for (const d of ['allow', 'approve', 'yes']) { + assert.equal(isAlwaysDecision(d), false, d); + } +}); + +test('「拒绝」不是永久', () => { + assert.equal(isAlwaysDecision('拒绝'), false); +}); + +test('两侧空白不影响判定(界面传过来的值可能带空格)', () => { + assert.equal(isAlwaysDecision(' 一直同意 '), true); +}); + +test('空值与 null 不是永久', () => { + for (const d of ['', ' ', null, undefined]) { + assert.equal(isAlwaysDecision(d), false, String(d)); + } +}); + +test('「一直同意吧」这类多余后缀不判为永久(精确匹配)', () => { + // 精确匹配的取舍:宁可漏判(多问一次)也不误判(静默永久放行) + assert.equal(isAlwaysDecision('一直同意吧'), false); +}); + +// ─── isApproval ─── + +test('同意与一直同意都是放行', () => { + assert.equal(isApproval('同意'), true); + assert.equal(isApproval('一直同意'), true); +}); + +test('英文放行选项', () => { + for (const d of ['allow', 'approve', 'always', 'yes', 'Allow']) { + assert.equal(isApproval(d), true, d); + } +}); + +test('拒绝不是放行', () => { + assert.equal(isApproval('拒绝'), false); +}); + +test('fail closed:认不出的文本一律当拒绝', () => { + // 关停哨兵、空值、乱码都必须落到拒绝一侧(N-9) + for (const d of ['shutdown', '', null, undefined, '也许吧', 'maybe']) { + assert.equal(isApproval(d), false, String(d)); + } +}); + +// ─── createGrantStore ─── + +test('未授权时不放行', () => { + const s = createGrantStore(); + assert.equal(s.isGranted('sess-1', 'bash'), false); +}); + +test('点「一直同意」后同会话同工具免批', () => { + const s = createGrantStore(); + assert.equal(s.grant('sess-1', 'bash', '一直同意'), true); + assert.equal(s.isGranted('sess-1', 'bash'), true); +}); + +test('点「同意」不产生免批 —— 这正是修复前的 bug', () => { + const s = createGrantStore(); + assert.equal(s.grant('sess-1', 'bash', '同意'), false); + assert.equal(s.isGranted('sess-1', 'bash'), false); +}); + +test('授权不跨工具:批了 bash 不等于批了 write', () => { + const s = createGrantStore(); + s.grant('sess-1', 'bash', '一直同意'); + assert.equal(s.isGranted('sess-1', 'write'), false); +}); + +test('授权不跨会话:这是防越权的关键', () => { + // 人为「审查 llmsproxy」这条会话批准的 bash,不该授权 + // 另一个发件人派来的另一条任务 + const s = createGrantStore(); + s.grant('sess-1', 'bash', '一直同意'); + assert.equal(s.isGranted('sess-2', 'bash'), false); +}); + +test('revokeSession 清掉整条会话的全部授权', () => { + const s = createGrantStore(); + s.grant('sess-1', 'bash', '一直同意'); + s.grant('sess-1', 'write', '一直同意'); + s.grant('sess-2', 'bash', '一直同意'); + assert.equal(s.size(), 3); + + s.revokeSession('sess-1'); + assert.equal(s.isGranted('sess-1', 'bash'), false); + assert.equal(s.isGranted('sess-1', 'write'), false); + // 别的会话不受影响 + assert.equal(s.isGranted('sess-2', 'bash'), true); + assert.equal(s.size(), 1); +}); + +test('工具名里含 : 不会导致误删(这是不用拼接键的原因)', () => { + const s = createGrantStore(); + s.grant('sess-1', 'mcp:bash', '一直同意'); + s.grant('sess-1:extra', 'bash', '一直同意'); + s.revokeSession('sess-1'); + // 拼接键实现(`${session}:${tool}` 按前缀删)会把下面这条一起删掉 + assert.equal(s.isGranted('sess-1:extra', 'bash'), true); +}); + +test('空会话 id / 空工具名不产生授权(防止一个空键放行一切)', () => { + const s = createGrantStore(); + assert.equal(s.grant('', 'bash', '一直同意'), false); + assert.equal(s.grant('sess-1', '', '一直同意'), false); + assert.equal(s.isGranted('', 'bash'), false); + assert.equal(s.isGranted('sess-1', ''), false); + assert.equal(s.size(), 0); +}); + +test('重复授权同一对不重复计数', () => { + const s = createGrantStore(); + s.grant('sess-1', 'bash', '一直同意'); + s.grant('sess-1', 'bash', '一直同意'); + assert.equal(s.size(), 1); +}); + +test('revokeSession 对没授权过的会话是安全的空操作', () => { + const s = createGrantStore(); + s.revokeSession('never-seen'); + assert.equal(s.size(), 0); +}); diff --git a/plugins/dsh-mail-bridge/test/rename-proposal.test.mjs b/plugins/dsh-mail-bridge/test/rename-proposal.test.mjs index a7e366e..960d079 100644 --- a/plugins/dsh-mail-bridge/test/rename-proposal.test.mjs +++ b/plugins/dsh-mail-bridge/test/rename-proposal.test.mjs @@ -100,20 +100,37 @@ test('不给别名时正文完全不变', () => { assert.equal(body, '正文'); }); -test('renameProposalNote: 成功时必须说明等人确认', () => { - // 不说的话模型会以为改名已生效,接着用新别名当地址发信 —— 那个别名还不存在 - const note = renameProposalNote('fix-leak', true); - assert.match(note, /fix-leak/); +test('renameProposalNote: 用服务端回的别名,不是本地提议的', () => { + // 服务端跑 normalizeAlias:非法字符换 -、new 变 session-new、超长截断。 + // 回显本地值会让模型记住一个不存在的名字,之后拿它寻址就 404。 + const note = renameProposalNote('fix-login-leak', 'fix.login.leak', true); + assert.match(note, /fix-login-leak/); + assert.match(note, /规范化/, '要告诉模型名字被改写过'); assert.match(note, /确认/); assert.match(note, /原别名/, '要明确说在那之前用哪个'); }); -test('renameProposalNote: 失败时说清为什么', () => { - const note = renameProposalNote('a.b', false); +test('renameProposalNote: 服务端别名与提议一致时不提规范化', () => { + const note = renameProposalNote('fix-leak', 'fix-leak', true); + assert.match(note, /fix-leak/); + assert.doesNotMatch(note, /规范化/); +}); + +test('renameProposalNote: 本地判非法时说清为什么', () => { + const note = renameProposalNote('', 'a.b', false); assert.match(note, /未提交/); assert.match(note, /a\.b/); }); -test('renameProposalNote: 没提议时不产生噪音', () => { - assert.equal(renameProposalNote('', false), ''); +test('renameProposalNote: 标记发出但服务端没接受', () => { + // 本地校验比服务端宽的情况(例如服务端加了新约束)—— + // 不能沉默,否则模型以为提议成功了 + const note = renameProposalNote('', 'somealias', true); + assert.match(note, /未被服务端接受/); + assert.match(note, /somealias/); +}); + +test('renameProposalNote: 没提议时不产生噪音', () => { + assert.equal(renameProposalNote('', '', false), ''); + assert.equal(renameProposalNote(undefined, undefined, false), ''); }); diff --git a/plugins/opencode-mail-bridge/index.js b/plugins/opencode-mail-bridge/index.js index 71a54d7..75f6fff 100644 --- a/plugins/opencode-mail-bridge/index.js +++ b/plugins/opencode-mail-bridge/index.js @@ -34,6 +34,9 @@ import { shouldSkipAutoRelay, } from "./lib/relay-dedup.js"; import { appendRenameProposal, renameProposalNote } from "./lib/rename-proposal.js"; +// opencode 原生支持三态权限,免批由它自己记(response:"always"), +// 所以这里只借用决策文本的判定,不需要 createGrantStore。 +import { isAlwaysDecision, isApproval } from "./lib/permission-grants.js"; const GATEWAY_URL = process.env.AGENTMAIL_GATEWAY_URL || "http://127.0.0.1:8180"; const AGENT_NAME = process.env.AGENTMAIL_AGENT_NAME || "opencode"; @@ -124,7 +127,16 @@ async function apiPost(path, body) { body: JSON.stringify(body), }); const data = await res.json().catch(() => ({})); - if (!res.ok) throw new Error(data.error || `POST ${path} failed: ${res.status}`); + if (!res.ok) { + // 把状态码与响应体挂在 error 上:调用方需要区分「暂时失败」与 + // 「永远不会成功」。具体例子:权限询问碰到 409(任务链上没有人类) + // 必须当场 deny,而 502 应该保持 ask 等重试 —— 只看 message 字符串 + // 分不出这两种,于是两种都会被当成后者,而前者会永久挂住会话。 + const err = new Error(data.error || `POST ${path} failed: ${res.status}`); + err.status = res.status; + err.body = data; + throw err; + } return data; } @@ -460,6 +472,11 @@ const readMailTool = { // // relay_key 用 opencode 的 permission.id 做幂等键:permission.updated 会重复触发, // 插件重连也会重放,没有它同一次询问会生成好几封邮件。 +// +// **不传 `to`**:决策人由服务端定(会话 owner → 线索里最近的人类 → 无人可问则 409)。 +// 插件若把来信人当决策人,Agent 之间转派任务时权限邮件会发给 Agent 自己 —— +// Agent 不可能在界面上点「同意」,服务端的用户推送也投进一个不存在的通道, +// 那条会话于是无声挂死(pi 侧真实发生过)。 async function relayPermission({ question, options, context, relayKey }) { return apiPost("/permission/request", { question, @@ -745,9 +762,15 @@ async function replyPermission(client, directory, data) { } const decision = String(data.decision || ""); - const response = - decision === "一直同意" || decision === "always" ? "always" : - decision === "拒绝" || decision === "reject" ? "reject" : "once"; + // 三态映射,且**认不出的一律 reject**(fail closed,N-9)。 + // + // 这里原先是 `... : "once"` —— 兜底落在放行一侧。那意味着任何意外文本 + // (空串、历史数据里的旧选项、将来服务端新增的选项)都会放行一次 + // 没人批准的危险操作。判断顺序也重要:always 必须先判,因为 isApproval + // 对「一直同意」同样为真。 + const response = isAlwaysDecision(decision) ? "always" + : isApproval(decision) ? "once" + : "reject"; await client.postSessionIdPermissionsPermissionId({ path: { id: sessionID, permissionID: permID }, @@ -1107,9 +1130,31 @@ export default async function mailBridge(input) { }); console.error(`[mail-bridge] 权限询问已转邮件 ${input.id}(${input.type})`); } catch (e) { - // 转不出去就别让 opencode 挂在那儿等:保持 ask 让本地机制接管(TUI 弹窗) - console.error("[mail-bridge] 权限询问转发失败:", e?.message || e); pendingPermissions.delete(input.id); + + // 409 = 这条任务链上没有人类,永远不会有人来点头。 + // + // 必须当场 deny:保持 "ask" 等于把会话交给本地 TUI 弹窗, + // 而邮件驱动的会话根本没有 TUI —— 模型会永久挂在那里。 + // 这正是生产事故的形状:pi 把任务派给自己的另一条会话, + // 那条会话要跑 bash,权限邮件无人可投,整条线索卡死。 + // + // deny 的同时把服务端的建议原文带给模型,它才知道下一步该换什么做法。 + if (e?.status === 409 && e?.body?.suggestion) { + console.error(`[mail-bridge] 权限询问无人可投,当场拒绝 ${input.id}:${e.body.error || ""}`); + output.status = "deny"; + // opencode 把 reason 作为工具报错回给模型 + output.reason = [ + e.body.error || "权限询问无法送达:该任务链上没有人类用户", + e.body.detail || "", + e.body.suggestion || "", + ].filter(Boolean).join("\n"); + return; + } + + // 其余失败(网络抖动、Gateway 重启)保持 ask:那些是暂时的, + // 人仍可能在本地看到弹窗,不该把一次抖动当成永久拒绝。 + console.error("[mail-bridge] 权限询问转发失败:", e?.message || e); return; } output.status = "ask"; diff --git a/plugins/opencode-mail-bridge/lib/permission-grants.js b/plugins/opencode-mail-bridge/lib/permission-grants.js new file mode 100644 index 0000000..0959f3d --- /dev/null +++ b/plugins/opencode-mail-bridge/lib/permission-grants.js @@ -0,0 +1,105 @@ +// 权限免批(「一直同意」)的纯逻辑 —— 所有平台插件共用。 +// +// # 这是什么 +// +// 权限询问默认是**每次都问**:模型每调一次 bash 就发一封邮件等人点头。 +// 这在「跑一条命令看看」的场景下是对的,在「审查这个工程」的场景下是灾难 —— +// 实测同一条会话被问了 15 次 bash,人点了 15 次「同意」,全是同一类操作。 +// +// 「一直同意」就是人对此的回答:这条会话里这个工具,别再问了。 +// +// # 为什么需要一个独立模块 +// +// 因为它的**作用域**是唯一容易搞错的地方,而搞错的后果是静默的越权: +// +// - 作用域太宽(全局 / 只按工具名)→ 人为「审查 llmsproxy」批准的 bash, +// 会静默授权另一个发件人派来的另一条任务。那不是他批准的东西。 +// - 作用域太窄(按 toolCallId)→ 等于没有免批,每条命令还是一封邮件。 +// +// 正确的粒度是 **(会话, 工具名)**:人看到的那句「是否允许执行 bash?」 +// 就是在这个粒度上提的问,授权范围不该超出提问范围。 +// +// # 为什么只在内存里 +// +// 会话结束(进程重启)即失效,这是有意的。长期免批该由平台自己的 settings +// 管(pi 的 settings.json、opencode 的 permission 配置),不该让一个守护进程 +// 的内存变成事实上的安全策略 —— 那种策略没人能审计,重启后又悄悄消失。 + +/** + * 判定一个决策文本是不是「永久同意」。 + * + * **必须精确匹配**,不能用前缀匹配。`/^同意/` 会把「同意」也算成 always, + * 于是人点一次单次授权,后面所有命令都不再问了 —— 那是把单次授权 + * 静默升级成永久授权,比不实现这个功能危险得多。 + * + * @param {string} decision 人点的选项原文 + * @returns {boolean} + */ +export function isAlwaysDecision(decision) { + return /^(一直同意|always|allow-always|allow_always)$/i.test(String(decision ?? '').trim()); +} + +/** + * 判定一个决策文本是不是「同意」(含永久同意)。 + * + * fail closed:认不出的文本一律当拒绝。空串、`shutdown`(关停时唤醒等待者 + * 用的哨兵值)、以及任何没见过的选项都走这一支 —— 放行一个没人批准的 + * 危险操作,比让它失败严重得多。 + * + * @param {string} decision + * @returns {boolean} + */ +export function isApproval(decision) { + return /^(同意|一直同意|allow|approve|always|yes)/i.test(String(decision ?? '').trim()); +} + +/** + * 免批授权表:`会话 id -> Set<工具名>`。 + * + * 用 Map> 而不是 Set<`${session}:${tool}`>: + * 会话结束时要能一次清掉它的全部授权(`revokeSession`), + * 拼接键的话得遍历整张表按前缀删,而工具名里出现 `:` 就会误删。 + */ +export function createGrantStore() { + /** @type {Map>} */ + const grants = new Map(); + + return { + /** 这条会话的这个工具是否已获免批。 */ + isGranted(sessionId, toolName) { + if (!sessionId || !toolName) return false; + return grants.get(sessionId)?.has(toolName) ?? false; + }, + + /** + * 记下一条免批授权。只在决策文本确实是「一直同意」时才记 —— + * 判定交给 isAlwaysDecision,调用方不要自己写正则。 + * @returns {boolean} 是否真的记下了(便于调用方决定要不要打日志) + */ + grant(sessionId, toolName, decision) { + if (!sessionId || !toolName) return false; + if (!isAlwaysDecision(decision)) return false; + let set = grants.get(sessionId); + if (!set) grants.set(sessionId, (set = new Set())); + set.add(toolName); + return true; + }, + + /** + * 撤销整条会话的免批。 + * + * 换模型重开会话时必须调:授权是人对**那次**上下文的判断, + * 新会话重跑一遍提示,不该继承上一条的授权。 + */ + revokeSession(sessionId) { + grants.delete(sessionId); + }, + + /** 仅用于测试与诊断:当前授权总数。 */ + size() { + let n = 0; + for (const set of grants.values()) n += set.size; + return n; + }, + }; +} diff --git a/plugins/opencode-mail-bridge/test/permission-grants.test.mjs b/plugins/opencode-mail-bridge/test/permission-grants.test.mjs new file mode 100644 index 0000000..3c37533 --- /dev/null +++ b/plugins/opencode-mail-bridge/test/permission-grants.test.mjs @@ -0,0 +1,156 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + isAlwaysDecision, + isApproval, + createGrantStore, +} from '../lib/permission-grants.js'; + +// ─── isAlwaysDecision ─── +// +// 这个函数是整个模块里最危险的一处:判宽了就把单次授权静默升级成永久授权。 + +test('「一直同意」判为永久', () => { + assert.equal(isAlwaysDecision('一直同意'), true); +}); + +test('「同意」不是永久 —— 前缀匹配会把单次授权升级成永久', () => { + // /^同意/ 之类的正则会让这条过,那意味着人点一次「同意」, + // 后面所有命令都不再问 —— 静默越权。 + assert.equal(isAlwaysDecision('同意'), false); +}); + +test('always / allow-always 判为永久(英文界面)', () => { + for (const d of ['always', 'Always', 'ALWAYS', 'allow-always', 'allow_always']) { + assert.equal(isAlwaysDecision(d), true, d); + } +}); + +test('allow / approve / yes 不是永久', () => { + for (const d of ['allow', 'approve', 'yes']) { + assert.equal(isAlwaysDecision(d), false, d); + } +}); + +test('「拒绝」不是永久', () => { + assert.equal(isAlwaysDecision('拒绝'), false); +}); + +test('两侧空白不影响判定(界面传过来的值可能带空格)', () => { + assert.equal(isAlwaysDecision(' 一直同意 '), true); +}); + +test('空值与 null 不是永久', () => { + for (const d of ['', ' ', null, undefined]) { + assert.equal(isAlwaysDecision(d), false, String(d)); + } +}); + +test('「一直同意吧」这类多余后缀不判为永久(精确匹配)', () => { + // 精确匹配的取舍:宁可漏判(多问一次)也不误判(静默永久放行) + assert.equal(isAlwaysDecision('一直同意吧'), false); +}); + +// ─── isApproval ─── + +test('同意与一直同意都是放行', () => { + assert.equal(isApproval('同意'), true); + assert.equal(isApproval('一直同意'), true); +}); + +test('英文放行选项', () => { + for (const d of ['allow', 'approve', 'always', 'yes', 'Allow']) { + assert.equal(isApproval(d), true, d); + } +}); + +test('拒绝不是放行', () => { + assert.equal(isApproval('拒绝'), false); +}); + +test('fail closed:认不出的文本一律当拒绝', () => { + // 关停哨兵、空值、乱码都必须落到拒绝一侧(N-9) + for (const d of ['shutdown', '', null, undefined, '也许吧', 'maybe']) { + assert.equal(isApproval(d), false, String(d)); + } +}); + +// ─── createGrantStore ─── + +test('未授权时不放行', () => { + const s = createGrantStore(); + assert.equal(s.isGranted('sess-1', 'bash'), false); +}); + +test('点「一直同意」后同会话同工具免批', () => { + const s = createGrantStore(); + assert.equal(s.grant('sess-1', 'bash', '一直同意'), true); + assert.equal(s.isGranted('sess-1', 'bash'), true); +}); + +test('点「同意」不产生免批 —— 这正是修复前的 bug', () => { + const s = createGrantStore(); + assert.equal(s.grant('sess-1', 'bash', '同意'), false); + assert.equal(s.isGranted('sess-1', 'bash'), false); +}); + +test('授权不跨工具:批了 bash 不等于批了 write', () => { + const s = createGrantStore(); + s.grant('sess-1', 'bash', '一直同意'); + assert.equal(s.isGranted('sess-1', 'write'), false); +}); + +test('授权不跨会话:这是防越权的关键', () => { + // 人为「审查 llmsproxy」这条会话批准的 bash,不该授权 + // 另一个发件人派来的另一条任务 + const s = createGrantStore(); + s.grant('sess-1', 'bash', '一直同意'); + assert.equal(s.isGranted('sess-2', 'bash'), false); +}); + +test('revokeSession 清掉整条会话的全部授权', () => { + const s = createGrantStore(); + s.grant('sess-1', 'bash', '一直同意'); + s.grant('sess-1', 'write', '一直同意'); + s.grant('sess-2', 'bash', '一直同意'); + assert.equal(s.size(), 3); + + s.revokeSession('sess-1'); + assert.equal(s.isGranted('sess-1', 'bash'), false); + assert.equal(s.isGranted('sess-1', 'write'), false); + // 别的会话不受影响 + assert.equal(s.isGranted('sess-2', 'bash'), true); + assert.equal(s.size(), 1); +}); + +test('工具名里含 : 不会导致误删(这是不用拼接键的原因)', () => { + const s = createGrantStore(); + s.grant('sess-1', 'mcp:bash', '一直同意'); + s.grant('sess-1:extra', 'bash', '一直同意'); + s.revokeSession('sess-1'); + // 拼接键实现(`${session}:${tool}` 按前缀删)会把下面这条一起删掉 + assert.equal(s.isGranted('sess-1:extra', 'bash'), true); +}); + +test('空会话 id / 空工具名不产生授权(防止一个空键放行一切)', () => { + const s = createGrantStore(); + assert.equal(s.grant('', 'bash', '一直同意'), false); + assert.equal(s.grant('sess-1', '', '一直同意'), false); + assert.equal(s.isGranted('', 'bash'), false); + assert.equal(s.isGranted('sess-1', ''), false); + assert.equal(s.size(), 0); +}); + +test('重复授权同一对不重复计数', () => { + const s = createGrantStore(); + s.grant('sess-1', 'bash', '一直同意'); + s.grant('sess-1', 'bash', '一直同意'); + assert.equal(s.size(), 1); +}); + +test('revokeSession 对没授权过的会话是安全的空操作', () => { + const s = createGrantStore(); + s.revokeSession('never-seen'); + assert.equal(s.size(), 0); +}); diff --git a/plugins/pi-mail-bridge/lib/permission-grants.js b/plugins/pi-mail-bridge/lib/permission-grants.js new file mode 100644 index 0000000..0959f3d --- /dev/null +++ b/plugins/pi-mail-bridge/lib/permission-grants.js @@ -0,0 +1,105 @@ +// 权限免批(「一直同意」)的纯逻辑 —— 所有平台插件共用。 +// +// # 这是什么 +// +// 权限询问默认是**每次都问**:模型每调一次 bash 就发一封邮件等人点头。 +// 这在「跑一条命令看看」的场景下是对的,在「审查这个工程」的场景下是灾难 —— +// 实测同一条会话被问了 15 次 bash,人点了 15 次「同意」,全是同一类操作。 +// +// 「一直同意」就是人对此的回答:这条会话里这个工具,别再问了。 +// +// # 为什么需要一个独立模块 +// +// 因为它的**作用域**是唯一容易搞错的地方,而搞错的后果是静默的越权: +// +// - 作用域太宽(全局 / 只按工具名)→ 人为「审查 llmsproxy」批准的 bash, +// 会静默授权另一个发件人派来的另一条任务。那不是他批准的东西。 +// - 作用域太窄(按 toolCallId)→ 等于没有免批,每条命令还是一封邮件。 +// +// 正确的粒度是 **(会话, 工具名)**:人看到的那句「是否允许执行 bash?」 +// 就是在这个粒度上提的问,授权范围不该超出提问范围。 +// +// # 为什么只在内存里 +// +// 会话结束(进程重启)即失效,这是有意的。长期免批该由平台自己的 settings +// 管(pi 的 settings.json、opencode 的 permission 配置),不该让一个守护进程 +// 的内存变成事实上的安全策略 —— 那种策略没人能审计,重启后又悄悄消失。 + +/** + * 判定一个决策文本是不是「永久同意」。 + * + * **必须精确匹配**,不能用前缀匹配。`/^同意/` 会把「同意」也算成 always, + * 于是人点一次单次授权,后面所有命令都不再问了 —— 那是把单次授权 + * 静默升级成永久授权,比不实现这个功能危险得多。 + * + * @param {string} decision 人点的选项原文 + * @returns {boolean} + */ +export function isAlwaysDecision(decision) { + return /^(一直同意|always|allow-always|allow_always)$/i.test(String(decision ?? '').trim()); +} + +/** + * 判定一个决策文本是不是「同意」(含永久同意)。 + * + * fail closed:认不出的文本一律当拒绝。空串、`shutdown`(关停时唤醒等待者 + * 用的哨兵值)、以及任何没见过的选项都走这一支 —— 放行一个没人批准的 + * 危险操作,比让它失败严重得多。 + * + * @param {string} decision + * @returns {boolean} + */ +export function isApproval(decision) { + return /^(同意|一直同意|allow|approve|always|yes)/i.test(String(decision ?? '').trim()); +} + +/** + * 免批授权表:`会话 id -> Set<工具名>`。 + * + * 用 Map> 而不是 Set<`${session}:${tool}`>: + * 会话结束时要能一次清掉它的全部授权(`revokeSession`), + * 拼接键的话得遍历整张表按前缀删,而工具名里出现 `:` 就会误删。 + */ +export function createGrantStore() { + /** @type {Map>} */ + const grants = new Map(); + + return { + /** 这条会话的这个工具是否已获免批。 */ + isGranted(sessionId, toolName) { + if (!sessionId || !toolName) return false; + return grants.get(sessionId)?.has(toolName) ?? false; + }, + + /** + * 记下一条免批授权。只在决策文本确实是「一直同意」时才记 —— + * 判定交给 isAlwaysDecision,调用方不要自己写正则。 + * @returns {boolean} 是否真的记下了(便于调用方决定要不要打日志) + */ + grant(sessionId, toolName, decision) { + if (!sessionId || !toolName) return false; + if (!isAlwaysDecision(decision)) return false; + let set = grants.get(sessionId); + if (!set) grants.set(sessionId, (set = new Set())); + set.add(toolName); + return true; + }, + + /** + * 撤销整条会话的免批。 + * + * 换模型重开会话时必须调:授权是人对**那次**上下文的判断, + * 新会话重跑一遍提示,不该继承上一条的授权。 + */ + revokeSession(sessionId) { + grants.delete(sessionId); + }, + + /** 仅用于测试与诊断:当前授权总数。 */ + size() { + let n = 0; + for (const set of grants.values()) n += set.size; + return n; + }, + }; +} diff --git a/plugins/pi-mail-bridge/lib/rename-proposal.js b/plugins/pi-mail-bridge/lib/rename-proposal.js index 9deb385..346efea 100644 --- a/plugins/pi-mail-bridge/lib/rename-proposal.js +++ b/plugins/pi-mail-bridge/lib/rename-proposal.js @@ -93,17 +93,38 @@ export function appendRenameProposal(body, alias, reason) { /** * 提议提交后回给模型的那句话。 * + * **别名取服务端回的 `rename_proposed`,不是本地提议的那个。** 服务端会跑 + * `normalizeAlias` —— 非法字符换成 `-`、`new` 变 `session-new`、超长按 UTF-8 + * 边界截断。回显本地值会让模型记住一个不存在的名字,之后拿它寻址就 404。 + * * 必须说明「等人确认」。不说的话模型会以为改名已经生效,接着在后续邮件里 * 用新别名当地址发信 —— 而那个别名此刻还不存在,投递会失败。 * - * @param {string} alias - * @param {boolean} proposed appendRenameProposal 的返回值 + * @param {string} [serverAlias] 服务端 `/mail/send` 响应里的 `rename_proposed` + * @param {string} [requestedAlias] 本地提议的别名,仅用于「未提交」时的说明 + * @param {boolean} [proposed] appendRenameProposal 的返回值 * @returns {string} 空串表示没有需要追加的说明 */ -export function renameProposalNote(alias, proposed) { - if (!alias) return ''; - if (!proposed) { - return `(改名提议 "${alias}" 未提交:别名不可为 new,不可含 . 空白 / @ 或双引号。)`; +export function renameProposalNote(serverAlias, requestedAlias, proposed) { + const server = String(serverAlias ?? '').trim(); + const wanted = String(requestedAlias ?? '').trim(); + + // 服务端确认收到了:用它给的最终值 + if (server) { + const changed = wanted && wanted !== server + ? `(你提的 "${wanted}" 被规范化成了这个)` + : ''; + return `已附上改名提议 "${server}"${changed},等用户在界面上确认后生效 —— ` + + `在那之前继续用原别名寻址。`; } - return `已附上改名提议 "${alias}",等用户在界面上确认后生效 —— 在那之前继续用原别名寻址。`; + + if (!wanted) return ''; + + // 本地就判定不合法,标记没发出去 + if (!proposed) { + return `(改名提议 "${wanted}" 未提交:别名不可为 new,不可含 . 空白 / @ 或双引号。)`; + } + + // 标记发出去了但服务端没回 rename_proposed:它那侧的校验也拒了 + return `(改名提议 "${wanted}" 未被服务端接受,会话别名不变。)`; } diff --git a/plugins/pi-mail-bridge/src/gateway.mjs b/plugins/pi-mail-bridge/src/gateway.mjs index 9142bb7..48e1700 100644 --- a/plugins/pi-mail-bridge/src/gateway.mjs +++ b/plugins/pi-mail-bridge/src/gateway.mjs @@ -114,6 +114,9 @@ export class GatewayClient { if (!res.ok) { const err = new Error(data?.error || `POST ${path} 失败: HTTP ${res.status}`); err.status = res.status; + // 响应体也带上:服务端对 409 会给 detail/suggestion, + // 那些文字要原文转给模型(它据此决定换什么做法)。 + err.body = data; throw err; } return data; diff --git a/plugins/pi-mail-bridge/src/index.mjs b/plugins/pi-mail-bridge/src/index.mjs index 70794dd..5fe3ec5 100644 --- a/plugins/pi-mail-bridge/src/index.mjs +++ b/plugins/pi-mail-bridge/src/index.mjs @@ -34,6 +34,7 @@ import { modelAttemptOrder, renderFailureReport, snapshotPiModels } from '../lib import { snapshotPiSessions } from '../lib/session-snapshot.js'; import { selectCatchup } from '../lib/catchup.js'; import { explicitSends, shouldSkipAutoRelay } from '../lib/relay-dedup.js'; +import { createGrantStore, isApproval } from '../lib/permission-grants.js'; // ─── 配置 ─── @@ -60,6 +61,8 @@ const mailContexts = new Map(); // agentmail session_id -> { replyTo, subject, const relayedSummaries = new Map(); // pi session id -> 已转发过的 relay_key const syncedNames = new Map(); // pi session id -> 上次提交给 Gateway 的名字 const pendingPermissions = new Map(); // relay_key -> { resolve, piSessionId } +// 人点过「一直同意」的 (会话, 工具)。作用域与清理语义见 lib/permission-grants.js。 +const permissionGrants = createGrantStore(); const deliveredMails = new Set(); // 已投过的 mail_id(SSE 与补拉共用,B-7.3) let allowedModels = []; @@ -140,23 +143,66 @@ function permissionExtension(getMailContext) { // 占着钩子不放会让人在 TUI 里干活时每一步都卡住等邮件。 if (!mailSessionId) return; + // 人对这条会话的这个工具点过「一直同意」→ 直接放行,不再发邮件。 + // 这一步必须在 POST 之前:否则每条命令都生成一封邮件,人点过的 + // 「一直同意」形同虚设(实测同一条会话被问了 15 次 bash)。 + if (permissionGrants.isGranted(piSessionId, event.toolName)) { + return; + } + // relay_key 用 pi 给的 toolCallId(B-8.1):服务端会随决策事件回传它, // 桥重启丢了 pendingPermissions 也能对上(B-4.2)。自造随机 id 做不到。 const relayKey = `${piSessionId}:${event.toolCallId}`; const ctxInfo = getMailContext(mailSessionId); try { + // **不传 `to`**(这里曾经传 `ctxInfo.replyTo`,那是个死锁 bug)。 + // + // replyTo 是来信人的名字,而来信人可能是另一个 Agent —— Agent 把任务 + // 分派给自己的另一条会话时(pi→pi),权限邮件就发给了 `pi` 自己。 + // 后果是死锁而不是报错:Agent 不可能在 Web 界面上点「同意」, + // 服务端的 SendToUser 又投进一个不存在的用户通道(没有任何人被提醒), + // 于是下面那个 await 永不 resolve —— 会话永久挂死,没有超时、没有日志。 + // + // 决策人交给服务端定:它按 会话 owner → 线索里最近的人类 → 无人可问则 + // 409 的顺序解析,那是唯一能看到整条线索的地方。插件只有本地那点上下文, + // 猜不出「这条 Agent 链最初是谁派的活」。 await client.post('/permission/request', { question: `是否允许执行 ${event.toolName}?`, options: ['同意', '一直同意', '拒绝'], - context: describeToolCall(event), + // 带上触发这次询问的来信(B-8.4)。决策人未必是这条会话的参与者 —— + // Agent 转派出来的会话,人从没见过它,只给一句「是否允许执行 bash」 + // 是无从判断的:得知道这活是谁派的、为的什么事。 + context: [ + describeToolCall(event), + ctxInfo?.subject ? `\n触发任务:${ctxInfo.subject}` : '', + ctxInfo?.replyTo ? `任务来自:${ctxInfo.replyTo}` : '', + ].filter(Boolean).join('\n'), session_id: mailSessionId, - to: ctxInfo?.replyTo || '', relay_key: relayKey, }); } catch (e) { - // 转发失败 → 让位给 pi 本地 UI(B-8.2)。返回 undefined 表示 - // 「这个钩子不表态」,pi 会走它自己的批准流程。 + // 409 = 服务端已判定这条任务链上没有人类,永远不会有人来点头。 + // + // 不能「让位给本地 UI」:邮件驱动的会话没有 TUI,让位之后 pi 按默认 + // 策略处置,而默认策略在没有交互端时就是等 —— 又一次无声挂死。 + // + // 直接 block 并把服务端的建议原文当作 reason:模型从工具报错里 + // 看到「这条链上没人可问,换不需要权限的方式」才能自己改道, + // 而挂死时它连重试的机会都没有。 + if (e?.status === 409) { + const b = e.body || {}; + const reason = [ + b.error || '权限询问无法送达:这条任务链上没有人类用户', + b.detail || '', + b.suggestion || '', + ].filter(Boolean).join('\n'); + log(`权限询问无人可投,当场拒绝 ${relayKey}:${b.error || ''}`); + return { block: true, reason }; + } + + // 其余失败(网络抖动、Gateway 重启)是暂时的 → 让位给 pi 本地 UI(B-8.2)。 + // 返回 undefined 表示「这个钩子不表态」,pi 会走它自己的批准流程。 log(`权限转发失败,让位给本地决策: ${describeError(e)}`); return; } @@ -168,7 +214,13 @@ function permissionExtension(getMailContext) { // fail closed(B-9.2 / N-9):只有明确的同意才放行。 // 关停时 shutdown() 会用 'shutdown' 唤醒所有等待者,落到这里的 else。 - if (/^(同意|一直同意|allow|approve|always|yes)/i.test(decision)) { + if (isApproval(decision)) { + // 「一直同意」要真的记住,否则这个选项是在骗人:人点了它, + // 下一条命令照样来一封邮件。grant() 内部只认精确的 always 文本 —— + // 「同意」是单次授权,把它当 always 会放行人没看过的后续命令。 + if (permissionGrants.grant(piSessionId, event.toolName, decision)) { + log(`本会话的 ${event.toolName} 已获「一直同意」,后续不再询问`); + } log(`权限 ${relayKey} 获批(${decision}),放行 ${event.toolName}`); return; } @@ -483,6 +535,9 @@ async function deliverMail(data, kind, mailTools) { function rebind(mailSessionID, oldPiId, opened, cwd) { reverseMap.delete(oldPiId); mailDriven.delete(oldPiId); + // 免批授权跟着旧的 pi 会话作废:它是人对**那次**上下文的判断, + // 换模型意味着重开一条会话、重跑一遍提示,不该继承上一条的授权。 + permissionGrants.revokeSession(oldPiId); const piSessionId = opened.session.sessionId; // cwd 由调用方传:AgentSession 上没有 cwd getter(只有 sessionId / // sessionFile / sessionName),从 sessionManager.getCwd() 也行, @@ -610,7 +665,18 @@ async function main() { const runtimeErr = modelRuntime.getError?.(); if (runtimeErr) log(`模型运行时告警: ${runtimeErr}`); - const mailTools = createMailTools({ client, log, agentName: AGENT_NAME }); + // onReconnect:connect_to_server 换了 Gateway 地址/密钥后,旧 SSE 长连仍连着 + // 旧地址(或已被旧密钥打断),必须在这里重建,模型调完工具才真正「切过去」。 + // reconfigure 已清空 lastEventID,所以 startSSE 会以「首次连接」姿态 + // (不带 Last-Event-ID,N-11)连上新地址 —— 拿旧序号问新 Gateway 只会 + // 命中一段无关的历史。 + const mailTools = createMailTools({ + client, log, agentName: AGENT_NAME, + onReconnect: () => { + client.stopSSE(); + client.startSSE((type, data) => handleSSEEvent(type, data, mailTools), log); + }, + }); try { await client.register(); // B-1.2 @@ -644,23 +710,31 @@ async function main() { await beat(); // B-1.3:不等第一个 30 秒周期 heartbeatTimer = setInterval(beat, 30_000); // B-1.5 - client.startSSE((type, data) => { // B-1.4:首次不带 Last-Event-ID - if (type === 'permission_decision') { - handlePermissionDecision(data, mailTools).catch((e) => - log(`权限决策处理失败: ${describeError(e)}`)); - return; - } - if (type !== 'new_mail') return; - if (data?.role && data.role !== 'to' && data.role !== 'cc') return; - const id = data?.mail_id; - if (!id || deliveredMails.has(id)) return; // B-3 第 1 步:去重 - deliveredMails.add(id); - deliverMail(data, 'mail', mailTools).catch((e) => log(`投递 ${id} 失败: ${describeError(e)}`)); - }, log); + client.startSSE((type, data) => handleSSEEvent(type, data, mailTools), log); for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => shutdown(sig)); } +/** + * SSE 事件分派。 + * + * 提成命名函数是因为 connect_to_server 换地址后要用同一个处理器重建长连 —— + * 内联箭头函数在那里拿不到,只能复制一遍,而复制出来的两份迟早会分叉。 + */ +function handleSSEEvent(type, data, mailTools) { + if (type === 'permission_decision') { + handlePermissionDecision(data, mailTools).catch((e) => + log(`权限决策处理失败: ${describeError(e)}`)); + return; + } + if (type !== 'new_mail') return; + if (data?.role && data.role !== 'to' && data.role !== 'cc') return; + const id = data?.mail_id; + if (!id || deliveredMails.has(id)) return; // B-3 第 1 步:去重 + deliveredMails.add(id); + deliverMail(data, 'mail', mailTools).catch((e) => log(`投递 ${id} 失败: ${describeError(e)}`)); +} + function shutdown(reason) { if (shuttingDown) return; shuttingDown = true; diff --git a/plugins/pi-mail-bridge/test/permission-grants.test.mjs b/plugins/pi-mail-bridge/test/permission-grants.test.mjs new file mode 100644 index 0000000..3c37533 --- /dev/null +++ b/plugins/pi-mail-bridge/test/permission-grants.test.mjs @@ -0,0 +1,156 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + isAlwaysDecision, + isApproval, + createGrantStore, +} from '../lib/permission-grants.js'; + +// ─── isAlwaysDecision ─── +// +// 这个函数是整个模块里最危险的一处:判宽了就把单次授权静默升级成永久授权。 + +test('「一直同意」判为永久', () => { + assert.equal(isAlwaysDecision('一直同意'), true); +}); + +test('「同意」不是永久 —— 前缀匹配会把单次授权升级成永久', () => { + // /^同意/ 之类的正则会让这条过,那意味着人点一次「同意」, + // 后面所有命令都不再问 —— 静默越权。 + assert.equal(isAlwaysDecision('同意'), false); +}); + +test('always / allow-always 判为永久(英文界面)', () => { + for (const d of ['always', 'Always', 'ALWAYS', 'allow-always', 'allow_always']) { + assert.equal(isAlwaysDecision(d), true, d); + } +}); + +test('allow / approve / yes 不是永久', () => { + for (const d of ['allow', 'approve', 'yes']) { + assert.equal(isAlwaysDecision(d), false, d); + } +}); + +test('「拒绝」不是永久', () => { + assert.equal(isAlwaysDecision('拒绝'), false); +}); + +test('两侧空白不影响判定(界面传过来的值可能带空格)', () => { + assert.equal(isAlwaysDecision(' 一直同意 '), true); +}); + +test('空值与 null 不是永久', () => { + for (const d of ['', ' ', null, undefined]) { + assert.equal(isAlwaysDecision(d), false, String(d)); + } +}); + +test('「一直同意吧」这类多余后缀不判为永久(精确匹配)', () => { + // 精确匹配的取舍:宁可漏判(多问一次)也不误判(静默永久放行) + assert.equal(isAlwaysDecision('一直同意吧'), false); +}); + +// ─── isApproval ─── + +test('同意与一直同意都是放行', () => { + assert.equal(isApproval('同意'), true); + assert.equal(isApproval('一直同意'), true); +}); + +test('英文放行选项', () => { + for (const d of ['allow', 'approve', 'always', 'yes', 'Allow']) { + assert.equal(isApproval(d), true, d); + } +}); + +test('拒绝不是放行', () => { + assert.equal(isApproval('拒绝'), false); +}); + +test('fail closed:认不出的文本一律当拒绝', () => { + // 关停哨兵、空值、乱码都必须落到拒绝一侧(N-9) + for (const d of ['shutdown', '', null, undefined, '也许吧', 'maybe']) { + assert.equal(isApproval(d), false, String(d)); + } +}); + +// ─── createGrantStore ─── + +test('未授权时不放行', () => { + const s = createGrantStore(); + assert.equal(s.isGranted('sess-1', 'bash'), false); +}); + +test('点「一直同意」后同会话同工具免批', () => { + const s = createGrantStore(); + assert.equal(s.grant('sess-1', 'bash', '一直同意'), true); + assert.equal(s.isGranted('sess-1', 'bash'), true); +}); + +test('点「同意」不产生免批 —— 这正是修复前的 bug', () => { + const s = createGrantStore(); + assert.equal(s.grant('sess-1', 'bash', '同意'), false); + assert.equal(s.isGranted('sess-1', 'bash'), false); +}); + +test('授权不跨工具:批了 bash 不等于批了 write', () => { + const s = createGrantStore(); + s.grant('sess-1', 'bash', '一直同意'); + assert.equal(s.isGranted('sess-1', 'write'), false); +}); + +test('授权不跨会话:这是防越权的关键', () => { + // 人为「审查 llmsproxy」这条会话批准的 bash,不该授权 + // 另一个发件人派来的另一条任务 + const s = createGrantStore(); + s.grant('sess-1', 'bash', '一直同意'); + assert.equal(s.isGranted('sess-2', 'bash'), false); +}); + +test('revokeSession 清掉整条会话的全部授权', () => { + const s = createGrantStore(); + s.grant('sess-1', 'bash', '一直同意'); + s.grant('sess-1', 'write', '一直同意'); + s.grant('sess-2', 'bash', '一直同意'); + assert.equal(s.size(), 3); + + s.revokeSession('sess-1'); + assert.equal(s.isGranted('sess-1', 'bash'), false); + assert.equal(s.isGranted('sess-1', 'write'), false); + // 别的会话不受影响 + assert.equal(s.isGranted('sess-2', 'bash'), true); + assert.equal(s.size(), 1); +}); + +test('工具名里含 : 不会导致误删(这是不用拼接键的原因)', () => { + const s = createGrantStore(); + s.grant('sess-1', 'mcp:bash', '一直同意'); + s.grant('sess-1:extra', 'bash', '一直同意'); + s.revokeSession('sess-1'); + // 拼接键实现(`${session}:${tool}` 按前缀删)会把下面这条一起删掉 + assert.equal(s.isGranted('sess-1:extra', 'bash'), true); +}); + +test('空会话 id / 空工具名不产生授权(防止一个空键放行一切)', () => { + const s = createGrantStore(); + assert.equal(s.grant('', 'bash', '一直同意'), false); + assert.equal(s.grant('sess-1', '', '一直同意'), false); + assert.equal(s.isGranted('', 'bash'), false); + assert.equal(s.isGranted('sess-1', ''), false); + assert.equal(s.size(), 0); +}); + +test('重复授权同一对不重复计数', () => { + const s = createGrantStore(); + s.grant('sess-1', 'bash', '一直同意'); + s.grant('sess-1', 'bash', '一直同意'); + assert.equal(s.size(), 1); +}); + +test('revokeSession 对没授权过的会话是安全的空操作', () => { + const s = createGrantStore(); + s.revokeSession('never-seen'); + assert.equal(s.size(), 0); +}); diff --git a/plugins/pi-mail-bridge/test/rename-proposal.test.mjs b/plugins/pi-mail-bridge/test/rename-proposal.test.mjs index a7e366e..960d079 100644 --- a/plugins/pi-mail-bridge/test/rename-proposal.test.mjs +++ b/plugins/pi-mail-bridge/test/rename-proposal.test.mjs @@ -100,20 +100,37 @@ test('不给别名时正文完全不变', () => { assert.equal(body, '正文'); }); -test('renameProposalNote: 成功时必须说明等人确认', () => { - // 不说的话模型会以为改名已生效,接着用新别名当地址发信 —— 那个别名还不存在 - const note = renameProposalNote('fix-leak', true); - assert.match(note, /fix-leak/); +test('renameProposalNote: 用服务端回的别名,不是本地提议的', () => { + // 服务端跑 normalizeAlias:非法字符换 -、new 变 session-new、超长截断。 + // 回显本地值会让模型记住一个不存在的名字,之后拿它寻址就 404。 + const note = renameProposalNote('fix-login-leak', 'fix.login.leak', true); + assert.match(note, /fix-login-leak/); + assert.match(note, /规范化/, '要告诉模型名字被改写过'); assert.match(note, /确认/); assert.match(note, /原别名/, '要明确说在那之前用哪个'); }); -test('renameProposalNote: 失败时说清为什么', () => { - const note = renameProposalNote('a.b', false); +test('renameProposalNote: 服务端别名与提议一致时不提规范化', () => { + const note = renameProposalNote('fix-leak', 'fix-leak', true); + assert.match(note, /fix-leak/); + assert.doesNotMatch(note, /规范化/); +}); + +test('renameProposalNote: 本地判非法时说清为什么', () => { + const note = renameProposalNote('', 'a.b', false); assert.match(note, /未提交/); assert.match(note, /a\.b/); }); -test('renameProposalNote: 没提议时不产生噪音', () => { - assert.equal(renameProposalNote('', false), ''); +test('renameProposalNote: 标记发出但服务端没接受', () => { + // 本地校验比服务端宽的情况(例如服务端加了新约束)—— + // 不能沉默,否则模型以为提议成功了 + const note = renameProposalNote('', 'somealias', true); + assert.match(note, /未被服务端接受/); + assert.match(note, /somealias/); +}); + +test('renameProposalNote: 没提议时不产生噪音', () => { + assert.equal(renameProposalNote('', '', false), ''); + assert.equal(renameProposalNote(undefined, undefined, false), ''); });