同一类问题在两个地方:出事的当下看不出来,表现是「信发出去了,然后再无音讯」。
## 1)pi 的续谈失败不回失败信(实测缺口)
模型侧 402(余额不足)时,**新会话**那条路会回一封「处理失败」,而**续谈**那条路
只写日志就 `throw` —— 发件人什么都收不到。邮件驱动的会话没有本地界面可以看,
没有这封信就等于静默挂死。复现条件很普通:往一条**已存在**的会话再发一封信。
修法与邻居一致:续谈失败也回失败信。但**不能复用**共用库的 `renderFailureReport`
——那段文案说「划定范围内的模型全部调用失败」并建议「调整可用模型范围」,
而续谈是**故意不降级**的(换模型=换会话=丢掉上下文,而上下文正是发件人指定
这条会话的原因)。照抄等于让人去调一个在这里无效的旋钮,他会去改配置,
然后发现依然失败。新增 `renderResumeFailure`:点明是续谈、附上游错误原文、
建议「确实要换模型就新建一条会话」。
**活体验证**(模型侧仍是 402,失败本身就是测试条件):发一封进 pi 的已有会话,
5 秒内收到失败信,内容含 402 原文且不再出现「调整模型范围」。
顺带把 pi 里 2 处没 clamp 的 relay_key 收敛(上一轮审计只看了权限键)。
## 2)duplicate_relay:只有 zcode 认,另三桥会等一个永远不会来的决策
网关对重复的 relay_key 回 **HTTP 200 `{status:"duplicate_relay"}` 并提前返回**:
不建请求、不发邮件、**永远不会有人来决策**。zcode 桥认它并当场失败,而
pi/opencode/dsh 把它当成功,接着等 `permission_decision` 事件 —— pi 那句
`await new Promise(...)` 连超时都没有。这是 zcode 上一轮那个缺陷的同类,
只是发生在另三个桥上。
- `lib/relay-key.js`(**共用**,四处逐字节同源)新增 `isDuplicateRelay` /
`DUPLICATE_RELAY_STATUS`:它长得像成功(200),所以必须单独认;对「发信」
那一侧重复就该当成功(幂等),但对「等一个决定」那一侧它与故障后果相同。
- pi / opencode / dsh 三桥在权限转发处接上判据并**当场拒绝**
(各自用自己的拒绝形状:`block: true` / `output.status = "deny"` / `'rejected'`)。
- zcode 里那份本地实现收敛到共用库(同一判据不该有两个定义)。
## 3)新增接线断言(带判据自检)
`test/permission-forward-wiring.test.mjs`(pi/opencode/dsh 三份同一内容):
纯函数测试对这类缺口天生无能为力(函数是对的,只是没人调用它),所以它读源码
验形态,钉住「判据在、落在权限转发这条路上、给出本桥形状的拒绝」。
三条自检都在写的过程中抓到了我自己的错:
- 第一次 `ROOT` 算错 → 过滤后 0 个桥、循环全不跑而「全绿」→ 加了
「找不到装着各桥的目录就判红」;
- 顺序判据写成「在文件里最早的 await 之前」,量到了别处的等待 → 三桥全红,
改成「必须在上报之后」;
- dsh 是**两段式**(`.then` 里抛、`catch` 的 `duplicateRelay` 分支里拒),
第一版抽取套错了分支 → 永远找不到 `return 'rejected'`。
扰动验证:把 pi 的判据禁用后该条变红,还原即绿(改动前后都核对了字节数)。
而 dsh 那条也暴露了:我把返回形状写成了 opencode 的 `{status:'deny'}`,
**`tsc` 没报错**(返回类型是宽联合),只有对着邻居读才发现 DSH 要的是
`'rejected'` 字符串 + `noteDenial`。
## 4)部署脚本:zcode 分支现在会重启驱动
`redeploy-plugin.sh` 的 zcode 分支只切软链(宿主是 ZCode 应用,不能重启它),
但**驱动是我们自己的 unit** —— 不重启它,进程里跑的还是切换前的代码。
这个由刚写的 `check-deploy-drift.mjs` 当场抓到(它比进程启动时刻与软链切换时刻),
而当时所有其它检查都是绿的。已补上重启并验证。
## 复查
四桥全量 413 / 321 / 370 / 380 全绿;共用库四方同源;部署漂移四项全通过;
四桥真发真收冒烟(dsh/opencode/zcode 正常回信;pi 因模型侧 402 回失败信 ——
这正是上面第 1 条要修的路径)。
另:写这段时踩到一个自伤 —— 用 `npx asar extract-file <asar> dist/index.html`
检查包内容时,它把文件**写进了 cwd**,正好覆盖掉 Vite 的源码模板
`client/electron/index.html`(下次构建会拿被污染的模板去构建)。已还原并重建,
产物哈希与之前一致。要看 asar 内容请用 `@electron/asar` 的 API(返回 Buffer),
别用这个 CLI 子命令。
300 lines
13 KiB
JavaScript
300 lines
13 KiB
JavaScript
/**
|
||
* 授权往返:把一次「要不要执行这个动作」的询问发给人类,等他的决定。
|
||
*
|
||
* # 为什么必须是独立模块
|
||
*
|
||
* 它现在有两个调用方,而且两者的失败后果完全不同:
|
||
*
|
||
* - `hooks/permission.mjs`:ZCode 桌面(交互)模式下的 PermissionRequest 钩子
|
||
* - `lib/action-tools.mjs`:headless 模式下我们自己的执行工具(run_command 等)
|
||
*
|
||
* 两份实现迟早会漂移,而漂移的地方恰恰是最不该出错的判定:「什么算同意」
|
||
* 「永久失败要不要 fail closed」「超时算不算拒绝」。所以判定复用共用库的
|
||
* `isApproval` / `isAlwaysDecision` / `isPermanentFailure`,流程只有这一份。
|
||
*
|
||
* # 三条不可动摇的规矩
|
||
*
|
||
* 1. **只有明确同意才放行**(共用库的 `isApproval`)。注意它实际的判据是
|
||
* **前缀匹配** `/^(同意|一直同意|allow|approve|always|yes)/i`(四个桥共用同一份,
|
||
* 所以这里不能另立一套)。前缀里的东西(如「同意吧」)算同意,
|
||
* 而看不懂的文本、空串、`拒绝`、`deny`、平台自己的 `shutdown` 哨兵一律当拒绝 ——
|
||
* 判据是「在放行白名单里」,不是「不等于拒绝」。
|
||
* 2. **永久失败当场拒绝**(409 无人可问、4xx 参数/权限错)。它们不会因为重试
|
||
* 而改变,重试只会把「权限系统坏了」这件事藏起来。
|
||
* 3. **暂时失败看有没有本地界面**:有(桌面模式)就退回平台自己的流程;
|
||
* 没有(headless 邮件驱动)必须拒绝 —— 退回等于守卫消失。
|
||
* 判据用的是调用方传进来的 `sessionId`(会话由邮件驱动 = 没有界面),
|
||
* **不再另读 `AGENTMAIL_SESSION_ID`**:两个事实来源迟早会不一致,
|
||
* 而它们不一致时到底算有界面还是没界面,谁都说不清。
|
||
*
|
||
* # 为什么自己开 SSE
|
||
*
|
||
* 网关的 SSE 是**扇出**的(`clients` 按唯一 id 存,`SendToAgent` 推给该 Agent
|
||
* 的所有客户端),所以一个短命的钩子进程或一次工具调用都能自己订阅、拿到
|
||
* 自己那条决定、然后退出。先建连再发请求 —— 反过来会有一个窗口:人恰好在
|
||
* 窗口内点了同意,而事件推给了当时还不存在的客户端,表现为「明明点了同意
|
||
* 却被拒」。
|
||
*/
|
||
|
||
import { createSSEClient } from './sse-client.js';
|
||
import { randomUUID } from 'node:crypto';
|
||
import { clampRelayKey, isPermanentFailure, isDuplicateRelay } from './relay-key.js';
|
||
import { isApproval, isAlwaysDecision } from './permission-grants.js';
|
||
import { normalizeMode, DEFAULT_MODE, MODE_FULL, MODE_PLAN } from './permission-mode.js';
|
||
/** 等待人工决策的默认上限。调用方应保证它**明显小于**自己的杀进程上限,
|
||
* 否则会在正要给出结论的瞬间被杀掉,而「不表态」与「来不及答」就分不开了。 */
|
||
export const DEFAULT_WAIT_MS = 540000;
|
||
|
||
/** 当前档位(来自驱动注入的环境变量)。 */
|
||
export function tierOf(env = process.env) {
|
||
return normalizeMode(env.AGENTMAIL_PERMISSION_MODE) || DEFAULT_MODE;
|
||
}
|
||
|
||
/**
|
||
* 「有没有本地界面可以让人就地决定」。
|
||
*
|
||
* 判据是驱动有没有注入会话 id:邮件驱动的会话由驱动起、没有界面;
|
||
* 人自己开着 ZCode 时有界面。这个区分决定了暂时失败该 fail closed 还是让位。
|
||
*/
|
||
export function hasLocalUi(env = process.env) {
|
||
return !String(env.AGENTMAIL_SESSION_ID || '').trim();
|
||
}
|
||
|
||
/** 等 SSE 建连完成(服务端在 AddClient 时立刻下发一个 connected 事件)。 */
|
||
function waitConnected(state, timeoutMs = 5000, log = () => {}) {
|
||
return new Promise(resolve => {
|
||
const timer = setTimeout(() => {
|
||
log('SSE 建连等待超时,仍然继续(可能错过极早到达的决策)');
|
||
resolve();
|
||
}, timeoutMs);
|
||
state.onConnected = () => {
|
||
clearTimeout(timer);
|
||
resolve();
|
||
};
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 幂等键必须**每次调用都不同**。
|
||
*
|
||
* 这里踩过一个真坑,而且失败方式极隐蔽:键取成 `会话 + 工具` 之后,
|
||
* 同一个会话里**第二次** `run_command` 就是个「重复请求」——网关按设计
|
||
* 返回 HTTP 200 `{status:"duplicate_relay", detail:"该权限询问已转发过,本次调用未产生新邮件"}`
|
||
* 并且**提前返回**:不建请求、不发邮件、永远不会有人来决策。
|
||
*
|
||
* 于是工具干等(实测被 MCP 的 30 秒调用超时砍掉),模型回报
|
||
* 「30 秒内未获批准」—— 看上去像人没理它,实际是**请求根本没出去**。
|
||
* 而 HTTP 还全是 200,从状态码上看不出任何异常。
|
||
*
|
||
* 所以键的语义是「**这一次调用**」(一次工具调用 = 一次询问),不是「这个会话的这个工具」。
|
||
* 重复请求的去重需求由「一直同意」表承担(那张表是按 会话+工具 生效的,那是对的语义)。
|
||
*/
|
||
export function relayKeyForCall({ seed, sessionId, toolName, nonce }) {
|
||
const head = seed || `${sessionId || 'zcode'}:${toolName}`;
|
||
const tail = nonce || randomUUID().slice(0, 8);
|
||
return clampRelayKey(`${head}:${tail}`);
|
||
}
|
||
|
||
// `isDuplicateRelay` 与 `DUPLICATE_RELAY_STATUS` 已挪到 **共用库** `lib/relay-key.js`:
|
||
// 四个桥都要认这个回包,各写一份必然分叉(而这个判据是「静默挂死」与
|
||
// 「当场拒绝」的分界)。这里只 import。
|
||
|
||
/**
|
||
* 询问人类。
|
||
*
|
||
* @param {object} opts
|
||
* @param {any} opts.client 网关客户端(要 authHeaders / post / baseURL)
|
||
* @param {string} opts.toolName 工具名(同时用作「一直同意」的授权粒度)
|
||
* @param {string} opts.question 给人看的问题
|
||
* @param {string} opts.context 给人看的上下文(命令内容/文件路径等)
|
||
* @param {string} [opts.sessionId] AgentMail 会话 id
|
||
* @param {string} [opts.relayKeySeed] 幂等键前缀(默认 session:tool);每次调用会**追加一个随机尾**,
|
||
* 见 relayKeyForCall 的注释
|
||
* @param {string} [opts.nonce] 仅测试用:固定随机尾以便断言
|
||
* @param {object} opts.grants createFileGrantStore 的实例(可省)
|
||
* @param {string} [opts.tier] 档位(默认从环境读)
|
||
* @param {number} [opts.waitMs]
|
||
* @param {Function} [opts.log]
|
||
* @param {Function} [opts.createSSE] 供测试注入
|
||
* @returns {Promise<{allowed:boolean, reason:string, decidedBy:string, via:string}>}
|
||
* via 说明结论来自哪一步:tier / grant / human / permanent-failure /
|
||
* timeout / transport —— 日志与回信要能看出「当时凭什么放行」。
|
||
*/
|
||
export async function requestApproval(opts) {
|
||
const {
|
||
client,
|
||
toolName,
|
||
question,
|
||
context = '',
|
||
sessionId = '',
|
||
relayKeySeed,
|
||
nonce,
|
||
grants = null,
|
||
tier = tierOf(),
|
||
waitMs = DEFAULT_WAIT_MS,
|
||
log = () => {},
|
||
createSSE = createSSEClient
|
||
} = opts;
|
||
|
||
// ① 档位:plan 档只允许读与查,没什么可问人的(该档语义就是「不动手」)。
|
||
if (tier === MODE_PLAN) {
|
||
return {
|
||
allowed: false,
|
||
via: 'tier',
|
||
decidedBy: '',
|
||
reason:
|
||
`plan 档下不允许执行 ${toolName}。本档只允许读与查,请把方案写在回信里。` +
|
||
`如需动手请让发件人把档位改成 workspace。`
|
||
};
|
||
}
|
||
|
||
// ② full 档:发件人已声明全权。这一档的核心语义就是免掉询问。
|
||
if (tier === MODE_FULL) {
|
||
return { allowed: true, via: 'tier', decidedBy: '', reason: `${tier} 档:全权,无需询问` };
|
||
}
|
||
|
||
const scope = sessionId || '';
|
||
// ③ 「一直同意」:钩子是短命进程,所以这张表由文件承载(见 grants-file.mjs)。
|
||
if (grants && grants.isGranted(scope, toolName)) {
|
||
return { allowed: true, via: 'grant', decidedBy: '', reason: `本会话的 ${toolName} 已获「一直同意」` };
|
||
}
|
||
|
||
const relayKey = relayKeyForCall({
|
||
seed: relayKeySeed,
|
||
sessionId: scope,
|
||
toolName,
|
||
nonce
|
||
});
|
||
|
||
// 有没有本地界面:由**调用方给的会话 id** 判定(单一事实来源)。
|
||
// 邮件驱动的会话一定带 sessionId;人自己开着 ZCode 时没有。
|
||
const mailDriven = scope.trim() !== '';
|
||
|
||
// ④ 先订阅再发请求(顺序不能反,见文件头注释)。
|
||
const state = { onConnected: null };
|
||
let waiter = null;
|
||
const early = [];
|
||
const sse = createSSE({
|
||
authHeaders: () => client.authHeaders(),
|
||
baseURL: client.baseURL,
|
||
path: '/api/v1/events/stream',
|
||
log,
|
||
onEvent: (evt, data) => {
|
||
if (evt === 'connected' && state.onConnected) state.onConnected();
|
||
if (evt !== 'permission_decision') return;
|
||
// 只认自己那条:同一 Agent 可能同时有多个调用在等(模型并行发起两个动作),
|
||
// 按 relay_key 配对才不会互相拿到对方的决定。
|
||
if (data?.relay_key && data.relay_key !== relayKey) return;
|
||
if (waiter) {
|
||
const w = waiter;
|
||
waiter = null;
|
||
w(data);
|
||
} else {
|
||
early.push(data);
|
||
}
|
||
}
|
||
});
|
||
|
||
try {
|
||
await waitConnected(state, 5000, log);
|
||
|
||
try {
|
||
const accepted = await client.post('/permission/request', {
|
||
question,
|
||
options: ['同意', '一直同意', '拒绝'],
|
||
context,
|
||
session_id: scope,
|
||
relay_key: relayKey
|
||
});
|
||
// 幂等命中 = 请求**没有**发出去,永远不会有决策事件。
|
||
// 不把它当成失败的话,调用方会一直等到被客户端杀掉,而错误信息是
|
||
// 「没有人批准」—— 归因完全错了。所以当场以可读的原因拒绝。
|
||
if (isDuplicateRelay(accepted)) {
|
||
log(`授权询问被网关判为重复(relay_key=${relayKey}),本次没有产生新请求`);
|
||
return {
|
||
allowed: false,
|
||
via: 'duplicate-relay',
|
||
decidedBy: '',
|
||
reason:
|
||
`授权请求被网关当作重复请求丢弃了(${accepted.detail || 'duplicate_relay'})。` +
|
||
`这意味着**没有人会看到这次询问**,因此不放行。` +
|
||
`请重新发起(键每次调用都不同),或改用不需要授权的方式。`
|
||
};
|
||
}
|
||
} catch (e) {
|
||
// 永久失败(409 无人可问 / 4xx)不会因重试而改变 → 当场拒绝,
|
||
// 让调用方从错误里看到原因并自己改道(挂死时连重试机会都没有)。
|
||
if (isPermanentFailure(e)) {
|
||
const b = e?.body && typeof e.body === 'object' ? e.body : {};
|
||
const reason = [b.error || `权限询问无法送达(HTTP ${e?.status})`, b.detail || '', b.suggestion || '']
|
||
.filter(Boolean)
|
||
.join('\n');
|
||
log(`权限询问永久失败,当场拒绝 ${relayKey}:${reason.split('\n')[0]}`);
|
||
return { allowed: false, via: 'permanent-failure', decidedBy: '', reason };
|
||
}
|
||
const detail = e?.message || String(e);
|
||
log(`权限询问暂时失败:${detail}`);
|
||
if (mailDriven) {
|
||
// 邮件驱动:没有本地界面兜底,退回本地决策等于守卫消失。
|
||
return {
|
||
allowed: false,
|
||
via: 'transport',
|
||
decidedBy: '',
|
||
reason:
|
||
`无法把 ${toolName} 的授权请求送达给人(${detail})。` +
|
||
`这条会话由邮件驱动、没有本地界面,因此不放行。` +
|
||
`请改用不需要授权的方式完成,或在回信里说明需要人工执行哪一步。`
|
||
};
|
||
}
|
||
return { allowed: false, via: 'transport', decidedBy: '', reason: `授权询问失败:${detail}` };
|
||
}
|
||
|
||
const decision =
|
||
early.shift() ??
|
||
(await new Promise(resolve => {
|
||
waiter = resolve;
|
||
setTimeout(() => {
|
||
if (waiter !== resolve) return;
|
||
waiter = null;
|
||
resolve(null);
|
||
}, waitMs);
|
||
}));
|
||
|
||
if (decision === null) {
|
||
return {
|
||
allowed: false,
|
||
via: 'timeout',
|
||
decidedBy: '',
|
||
reason: `等待授权超时(${Math.round(waitMs / 1000)} 秒内没有人决策),未执行 ${toolName}。`
|
||
};
|
||
}
|
||
|
||
const text = decision.decision ?? '';
|
||
if (isApproval(text)) {
|
||
if (isAlwaysDecision(text) && grants?.grant(scope, toolName, text)) {
|
||
log(`记下「一直同意」:会话 ${scope} 的 ${toolName} 后续免批`);
|
||
}
|
||
return {
|
||
allowed: true,
|
||
via: 'human',
|
||
decidedBy: decision.decided_by || '',
|
||
reason: `获批(${text})`
|
||
};
|
||
}
|
||
return {
|
||
allowed: false,
|
||
via: 'human',
|
||
decidedBy: decision.decided_by || '',
|
||
reason: [
|
||
`用户拒绝了这次 ${toolName} 调用。`,
|
||
decision.note ? `说明:${decision.note}` : '',
|
||
decision.decided_by ? `(由 ${decision.decided_by} 决定)` : ''
|
||
]
|
||
.filter(Boolean)
|
||
.join('\n')
|
||
};
|
||
} finally {
|
||
sse.stop();
|
||
}
|
||
}
|