按用户裁定「yolo_own_tools」实现:平台让开(--mode yolo),它自带的一切
「能动机器」的工具被 --disallowed-tools 拿掉,执行类动作改由我们自己的
run_command / write_file 承担,而门禁就在这两个工具里 —— 逐次向发件人请示。
## 为什么必须走这条路(实测,不是推断)
MCP 工具的 needsApproval 在产物里**硬编码为 true**(与 annotations 无关),
而 build/edit 档的判定最后一条是「需要审批 → ask」;headless 没有审批客户端
可问 ⇒ **每个 MCP 工具都被拒**(连 read_inbox 都调不动)。
我们本想让平台把询问转给钩子,但 PermissionRequest 在本版本(3.10.2 / CLI 0.16.5)
**不可靠**:有时压根不注册,触发时也无条件在 ~5ms 内失败、命令从未被 spawn
(用「钩子写 marker 文件」的副作用验证)。
于是选择只剩两个:「平台问、但问不到人 → 全拒」与「平台不问、我们自己问」。
后者才既可用又可审计。代价(平台不再提供第二道防线)写进了 README 的残余风险。
## 新增
- `lib/approval.mjs`:授权往返的唯一实现(钩子与工具共用,否则必然漂移)。
三条不可动摇的规矩:只有明确同意才放行(判据是共用库的前缀白名单,
不是「不等于拒绝」);永久失败(409/4xx)当场拒绝并把服务端建议带给模型;
暂时失败看有没有本地界面 —— 判据用**调用方传的 sessionId**(单一事实来源,
不再另读环境变量)。自己开 SSE 等决定,先建连再发请求。
- `lib/action-tools.mjs`:`run_command` / `write_file`。输出上限、超时上限、
默认 cwd=工作区;拒绝时**抛错**(MCP 层转 isError)而不是返回「已处理」——
opencode 上「工具失败但报成功」导致模型连试 6 次后放弃整个任务的教训。
平台保护目录(网关数据库/插件代码/服务单元/密钥目录)**无论谁批准都不写**,
且判定在门禁之前(不消耗人的注意力)——防的是自我强化:邮件驱动的 Agent
可能被来信诱导去改自己的插件代码,改完下一轮就换了一套规则。
- `REVIEWED_DENYLIST`(32 项):逐条按「不拿掉会怎样」分类。名单来自 CLI 产物里
模型可见工具名的**权威注册表**(aIn 那个 28 项数组)+ 另一份更宽的候选集并集,
**不采信模型自述**(基线里它用某个没点名的方式真的创建了文件)。
最容易被漏掉的是 `js` / `mcp__node_repl__js`:它挂在 MCP 上、
产物里自述「can run arbitrary JavaScript with full Node privileges, like Bash」。
- 提示词的能力说明(分档):告诉模型自带工具被禁、动手要用哪两个工具、
会被请示;并明确「被拒是业务结果,不要重试、不要绕道」。
## 修掉三个真缺陷(都是实测撞出来的)
1. **幂等键按「会话+工具」取 → 同会话第二次调用被静默吞掉**。
网关对重复 relay_key 返回 **HTTP 200** `{status:"duplicate_relay"}` 并提前返回:
不建请求、不发邮件、**永远不会有人来决策**。于是工具干等 → 被 MCP 调用超时
砍掉 → 模型回报「30 秒内未获批准」。从状态码到措辞全看不出问题,归因还完全
错了(像是人没理它)。改为**按调用唯一**(保留会话/工具前缀便于反查),
并把 duplicate_relay 当成可读的拒绝(fail fast,不再干等)。
2. **授权窗口被 MCP 调用超时截断**。ZCode 对 MCP 工具调用有超时(默认量级 30 秒),
而门禁要等人。已在插件清单声明 `mcpServers.agentmail.timeoutMs=600000`
(实测生效:40 秒的命令没被砍,墙钟 50 秒通过),并让门禁**自己**把等待夹到
timeoutMs - 余量之下(`resolveWaitMs`)——被客户端杀掉时连理由都发不出去,
所以必须由我们自己先 settle。
3. **`--allowed-tools` 在 help 里写着但解析器不认**(`Unknown option`)。
留着会拼出一条永远跑不起来的命令行,现在 `buildRunArgs` 直接抛错并指出
替代方案。我在这里误判过一次:先看到「文件没创建」就以为白名单生效,
其实进程只是没退到 usage。判据缺了「进程真的执行了」这一环。
## 自报改成如实
detectModeEnforcement 以前拿「钩子已注册」当 native 的凭据 —— yolo 下钩子
根本不会触发,那等于替一个不存在的能力背书。现在先看**我们那条链**是否就绪
(yolo + 禁用清单里真的有 Bash/js),就绪才报 native,并在理由里点明谁在把关
(实测输出:「执行类动作只能经我们自己的门禁…平台自带危险工具已禁用 32 项」)。
## 验证
- 单测 376/376(新增 47 条)。重点在反向对照:一句「拒绝/deny/空串/平台自己的
shutdown 哨兵都不放行」之外,还验了「别人的决策不能拿来用(relay_key 配对)」、
「超时必须真的拒绝」、「同一会话两次调用必须用不同的幂等键」、
「重复请求要当场拒绝而不是干等」;执行工具的每条拒绝场景都配一个**文件系统断言**
(「抛错了」不等于「副作用没发生」),保护目录还验了 `..`/`./` 绕不过去。
- 真模型端到端(`/root/e2e-zcode-gate/run.py`,13/13):
批 → 命令真执行(文件内容=标记);拒 → 命令真没执行(文件不存在)
且回信把成因说成「人拒绝」而**不是**「超时」;同会话第三次调用仍能产生新请求
并在获批后执行。判据本身也修了两处(授权请求邮件里带标记会被误当成回信;
备注在通过项旁边显示会误导)。
- 部署:`deploy/redeploy-plugin.sh zcode` 快照切换 + 握手自检;
驱动单元改为跑快照(生产不跑仓库工作区),env 与清单超时的关系写进注释。
- 顺手清掉一个遗留驱动进程(跑的是仓库路径的旧代码、连着网关 SSE、会抢邮件)。
## 判据纪律(本轮又踩到、已写进代码注释)
「文件没被创建」不能区分「被拦住了」与「进程根本没跑」;
「未获批准」不能区分「人拒绝」与「窗口被截断」;
「工具报错」不能区分「命令失败」与「工具坏了」。
每一处都改成了验到**具体成因**。
355 lines
16 KiB
JavaScript
355 lines
16 KiB
JavaScript
/**
|
||
* 授权往返(lib/approval.mjs)的测试。
|
||
*
|
||
* 这是全项目最该被测死的一块:它决定「什么算同意」。所以每一组都配了
|
||
* **反向对照** —— 不是只验「同意时放行了」,而要同时验「别的任何东西都不放行」。
|
||
*
|
||
* 时序靠注入的假 SSE 控制:真网关的 SSE 是扇出的,假实现只需要保留
|
||
* `onEvent` 回调并在合适的时候投喂事件,就能精确复现「先建连、再发请求、
|
||
* 决策在请求之后到达」以及几个边界(决策在请求之前就到了 / 一直没到 /
|
||
* 来的是别人的决策)。
|
||
*/
|
||
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { requestApproval, tierOf, hasLocalUi } from '../lib/approval.mjs';
|
||
import { createGrantStore } from '../lib/permission-grants.js';
|
||
|
||
/** 可控的假 SSE:把 onEvent 抓住,测试自己决定何时投喂什么。 */
|
||
function makeSSE() {
|
||
const s = { onEvent: null, connected: false, stopped: false };
|
||
const factory = ({ onEvent }) => {
|
||
s.onEvent = onEvent;
|
||
// 真实现在建连后立刻下发 connected;这里用 microtask 复现「不等它也能跑」。
|
||
queueMicrotask(() => {
|
||
s.connected = true;
|
||
onEvent('connected', {});
|
||
});
|
||
return { stop: () => { s.stopped = true; } };
|
||
};
|
||
return { factory, s };
|
||
}
|
||
|
||
/** 记录请求体;可选在请求成功后投喂一条决定。
|
||
* `onRequest` 的**返回值会被当作 HTTP 响应体**返回给被测代码 ——
|
||
* 这一点至关重要:网关的幂等命中是一个 200 + `{status:"duplicate_relay"}`,
|
||
* 判据就看它。之前这里硬编码 `return {}`,把响应体丢了,于是「重复请求」
|
||
* 那条测试变成干等到超时,而失败信息看起来像被测代码的 bug。
|
||
*/
|
||
function makeClient({ onRequest } = {}) {
|
||
const state = { requests: [] };
|
||
const client = {
|
||
baseURL: 'http://gw.test',
|
||
authHeaders: () => ({ 'X-Agent-Secret': 's' }),
|
||
async post(path, body) {
|
||
state.requests.push({ path, body });
|
||
if (onRequest) {
|
||
const res = await onRequest(state, body);
|
||
return res === undefined ? {} : res;
|
||
}
|
||
return {};
|
||
}
|
||
};
|
||
return { client, state };
|
||
}
|
||
|
||
const base = env => ({
|
||
toolName: 'run_command',
|
||
question: '要执行一条命令',
|
||
context: 'echo hi',
|
||
sessionId: 'sess-1',
|
||
log: () => {},
|
||
env,
|
||
...env
|
||
});
|
||
|
||
test('★ plan 档直接拒绝执行类工具,且根本不发请求', async () => {
|
||
const { factory } = makeSSE();
|
||
const { client, state } = makeClient();
|
||
const r = await requestApproval({
|
||
...base({ tier: 'plan', createSSE: factory })
|
||
});
|
||
assert.equal(r.allowed, false);
|
||
assert.equal(r.via, 'tier');
|
||
assert.match(r.reason, /plan 档/);
|
||
// 反向对照:不该在「注定拒绝」的档位上去打扰人。
|
||
assert.equal(state.requests.length, 0, 'plan 档不该发出授权请求');
|
||
});
|
||
|
||
test('★ full 档直接放行', async () => {
|
||
const { client } = makeClient();
|
||
const r = await requestApproval({ toolName: 'run_command', tier: 'full', sessionId: 's1' });
|
||
assert.equal(r.allowed, true);
|
||
assert.equal(r.via, 'tier');
|
||
});
|
||
|
||
test('★ 「一直同意」命中时不发请求(钩子与工具共用同一张表)', async () => {
|
||
const grants = createGrantStore();
|
||
grants.grant('sess-1', 'run_command', '一直同意');
|
||
const { client, state } = makeClient();
|
||
const r = await requestApproval({ ...base({}), client, tier: 'workspace', grants });
|
||
assert.equal(r.allowed, true);
|
||
assert.equal(r.via, 'grant');
|
||
assert.equal(state.requests.length, 0);
|
||
});
|
||
|
||
test('★ 人同意 → 放行,且请求里带上了 relay_key 与选项', async () => {
|
||
const { factory, s } = makeSSE();
|
||
const { client, state } = makeClient({
|
||
onRequest: async st => {
|
||
// 真网关是「先受理、后有人决策」,所以决策必须晚于请求。
|
||
queueMicrotask(() => s.onEvent('permission_decision', { relay_key: st.requests[0].body.relay_key, decision: '同意', decided_by: 'gui-lab' }));
|
||
}
|
||
});
|
||
const r = await requestApproval({ ...base({}), client, tier: 'workspace', createSSE: factory, waitMs: 1000 });
|
||
assert.equal(r.allowed, true);
|
||
assert.equal(r.via, 'human');
|
||
assert.equal(r.decidedBy, 'gui-lab');
|
||
const sent = state.requests[0].body;
|
||
assert.equal(sent.session_id, 'sess-1');
|
||
assert.ok(sent.relay_key, '请求必须带 relay_key(决定回执怎么配对)');
|
||
assert.deepEqual(sent.options, ['同意', '一直同意', '拒绝']);
|
||
assert.equal(s.stopped, true, 'SSE 必须被关掉(否则短命进程不退出)');
|
||
});
|
||
|
||
test('★ 「一直同意」放行并落进授权表;「同意」不落', async () => {
|
||
for (const [decision, shouldPersist] of [
|
||
['一直同意', true],
|
||
['同意', false]
|
||
]) {
|
||
const { factory, s } = makeSSE();
|
||
const grants = createGrantStore();
|
||
const { client } = makeClient({
|
||
onRequest: async st => {
|
||
queueMicrotask(() => s.onEvent('permission_decision', { relay_key: st.requests[0].body.relay_key, decision }));
|
||
}
|
||
});
|
||
const r = await requestApproval({ ...base({}), client, tier: 'workspace', grants, createSSE: factory, waitMs: 1000 });
|
||
assert.equal(r.allowed, true, decision);
|
||
assert.equal(
|
||
grants.isGranted('sess-1', 'run_command'),
|
||
shouldPersist,
|
||
`${decision} 的落表行为不对`
|
||
);
|
||
}
|
||
});
|
||
|
||
test('★ 人拒绝 → 不放行,且原因里带上决策人', async () => {
|
||
const { factory, s } = makeSSE();
|
||
const { client } = makeClient({
|
||
onRequest: async st => {
|
||
queueMicrotask(() =>
|
||
s.onEvent('permission_decision', {
|
||
relay_key: st.requests[0].body.relay_key,
|
||
decision: '拒绝',
|
||
decided_by: 'gui-lab',
|
||
note: '这条命令会删数据'
|
||
})
|
||
);
|
||
}
|
||
});
|
||
const r = await requestApproval({ ...base({}), client, tier: 'workspace', createSSE: factory, waitMs: 1000 });
|
||
assert.equal(r.allowed, false);
|
||
assert.equal(r.via, 'human');
|
||
assert.match(r.reason, /拒绝/);
|
||
assert.match(r.reason, /gui-lab/);
|
||
assert.match(r.reason, /会删数据/);
|
||
});
|
||
|
||
test('★ 反向对照:一切「不是明确同意」的文本都不放行', async () => {
|
||
// 判据是「在放行白名单里」,不是「不等于拒绝」。所以拒绝、看不懂的东西、
|
||
// 平台自己的 shutdown 哨兵、空串都不能放行。
|
||
//
|
||
// 注意白名单本身是共用库的前缀匹配(`^同意|一直同意|allow|approve|always|yes`,
|
||
// 四个桥共用同一份)。所以「不同意」不放行(前缀不是同意),而「同意吧」放行 ——
|
||
// 后者是刻意接受的:决策文本来自界面按钮,前缀匹配是为了容错,不是为了放宽。
|
||
// 这里把两类都钉住,避免哪天有人把前缀匹配改成 includes 而无人发现
|
||
// (那会让「我不同意」变成同意)。
|
||
for (const decision of ['', 'maybe', 'ok?', 'shutdown', 'deny', '拒绝', '不同意', '否', 'no', undefined, null]) {
|
||
const { factory, s } = makeSSE();
|
||
const { client } = makeClient({
|
||
onRequest: async st => {
|
||
queueMicrotask(() => s.onEvent('permission_decision', { relay_key: st.requests[0].body.relay_key, decision }));
|
||
}
|
||
});
|
||
const r = await requestApproval({ ...base({}), client, tier: 'workspace', createSSE: factory, waitMs: 300 });
|
||
assert.equal(r.allowed, false, `decision=${JSON.stringify(decision)} 不该放行`);
|
||
}
|
||
|
||
// 反向对照的对照:确实在白名单里的必须放行,否则上面全绿可能只是因为门槛坏死了。
|
||
for (const decision of ['同意', '一直同意', 'allow', 'yes']) {
|
||
const { factory, s } = makeSSE();
|
||
const { client } = makeClient({
|
||
onRequest: async st => {
|
||
queueMicrotask(() => s.onEvent('permission_decision', { relay_key: st.requests[0].body.relay_key, decision }));
|
||
}
|
||
});
|
||
const r = await requestApproval({ ...base({}), client, tier: 'workspace', createSSE: factory, waitMs: 300 });
|
||
assert.equal(r.allowed, true, `decision=${JSON.stringify(decision)} 应当放行`);
|
||
}
|
||
});
|
||
|
||
test('★ 超时 → 拒绝(不能靠「没消息就是好消息」)', async () => {
|
||
const { factory } = makeSSE();
|
||
const { client } = makeClient(); // 从不投喂决策
|
||
const t0 = Date.now();
|
||
const r = await requestApproval({ ...base({}), client, tier: 'workspace', createSSE: factory, waitMs: 120 });
|
||
assert.equal(r.allowed, false);
|
||
assert.equal(r.via, 'timeout');
|
||
assert.match(r.reason, /超时/);
|
||
assert.ok(Date.now() - t0 >= 100, '必须真的等过,而不是立刻返回');
|
||
});
|
||
|
||
test('★ 别人的决策不能拿来用(relay_key 配对)', async () => {
|
||
// 同一个 Agent 可能同时有多个调用在等(模型并行发起两个动作)。
|
||
// 若不按 relay_key 过滤,B 的同意会放行 A。
|
||
const { factory, s } = makeSSE();
|
||
const { client } = makeClient({
|
||
onRequest: async st => {
|
||
const mine = st.requests[0].body.relay_key;
|
||
queueMicrotask(() => {
|
||
s.onEvent('permission_decision', { relay_key: `${mine}-other`, decision: '同意' });
|
||
setTimeout(() => s.onEvent('permission_decision', { relay_key: mine, decision: '拒绝' }), 30);
|
||
});
|
||
}
|
||
});
|
||
const r = await requestApproval({ ...base({}), client, tier: 'workspace', createSSE: factory, waitMs: 1000 });
|
||
assert.equal(r.allowed, false, '拿到别人的「同意」就是越权放行');
|
||
assert.match(r.reason, /拒绝/);
|
||
});
|
||
|
||
test('★ 永久失败(409 无人可问)当场拒绝,并把服务端建议带给模型', async () => {
|
||
const { factory } = makeSSE();
|
||
const err = Object.assign(new Error('409'), {
|
||
status: 409,
|
||
body: { error: '本线索内找不到可决策的人类', suggestion: '请让发件人把档位改成 full' }
|
||
});
|
||
const { client } = makeClient({
|
||
onRequest: async () => {
|
||
throw err;
|
||
}
|
||
});
|
||
const r = await requestApproval({ ...base({}), client, tier: 'workspace', createSSE: factory, waitMs: 1000 });
|
||
assert.equal(r.allowed, false);
|
||
assert.equal(r.via, 'permanent-failure');
|
||
assert.match(r.reason, /找不到可决策的人类/);
|
||
assert.match(r.reason, /改成 full/, '服务端的建议必须原样带给模型,否则它只能盲试');
|
||
});
|
||
|
||
test('★ 暂时失败:没有本地界面时必须拒绝(fail closed)', async () => {
|
||
const { factory } = makeSSE();
|
||
const { client } = makeClient({
|
||
onRequest: async () => {
|
||
throw new Error('ECONNREFUSED');
|
||
}
|
||
});
|
||
const r = await requestApproval({ ...base({}), client, tier: 'workspace', createSSE: factory, waitMs: 1000 });
|
||
assert.equal(r.allowed, false);
|
||
assert.equal(r.via, 'transport');
|
||
assert.match(r.reason, /没有本地界面/);
|
||
});
|
||
|
||
test('★ 暂时失败:有本地界面时明确说明没有放行', async () => {
|
||
// 桌面模式下平台自己还有流程,所以这里不放行是安全的 —— 但**不能说**放行了。
|
||
const { factory } = makeSSE();
|
||
const { client } = makeClient({
|
||
onRequest: async () => {
|
||
throw new Error('ECONNREFUSED');
|
||
}
|
||
});
|
||
const r = await requestApproval({ toolName: 'Bash', tier: 'workspace', client, createSSE: factory, waitMs: 1000 });
|
||
assert.equal(r.allowed, false);
|
||
assert.match(r.reason, /授权询问失败/);
|
||
});
|
||
|
||
test('★ 「有没有本地界面」由调用方传的 sessionId 判定(单一事实来源)', async () => {
|
||
// 反向对照:同一个暂时失败,在「有会话」与「没会话」下必须给出不同的拒绝理由。
|
||
// 这里刻意把 process.env.AGENTMAIL_SESSION_ID 设成反的,验证模块**不看它** ——
|
||
// 两个事实来源不一致时,谁也说不清到底算有界面还是没界面。
|
||
const prev = process.env.AGENTMAIL_SESSION_ID;
|
||
process.env.AGENTMAIL_SESSION_ID = '来自进程环境的干扰值';
|
||
try {
|
||
const mk = () => {
|
||
const { factory } = makeSSE();
|
||
const { client } = makeClient({ onRequest: async () => { throw new Error('boom'); } });
|
||
return { factory, client };
|
||
};
|
||
const a = mk();
|
||
const withSession = await requestApproval({
|
||
...base({}), client: a.client, tier: 'workspace', createSSE: a.factory, waitMs: 500
|
||
});
|
||
assert.match(withSession.reason, /没有本地界面/, '带会话 = 邮件驱动,必须 fail closed');
|
||
|
||
const b = mk();
|
||
const noSession = await requestApproval({
|
||
toolName: 'Bash', tier: 'workspace', client: b.client, createSSE: b.factory, waitMs: 500
|
||
});
|
||
assert.doesNotMatch(noSession.reason, /没有本地界面/, '不带会话 = 有界面,不该说成没界面');
|
||
} finally {
|
||
if (prev === undefined) delete process.env.AGENTMAIL_SESSION_ID;
|
||
else process.env.AGENTMAIL_SESSION_ID = prev;
|
||
}
|
||
});
|
||
|
||
test('tierOf / hasLocalUi 的判据', () => {
|
||
assert.equal(tierOf({}), 'workspace');
|
||
assert.equal(tierOf({ AGENTMAIL_PERMISSION_MODE: 'full' }), 'full');
|
||
// 认不出来的值 → workspace(共用库的约定),不是「免问」
|
||
assert.equal(tierOf({ AGENTMAIL_PERMISSION_MODE: 'FULL' }), 'workspace');
|
||
// 有会话 id = 邮件驱动 = 没有本地界面
|
||
assert.equal(hasLocalUi({}), true);
|
||
assert.equal(hasLocalUi({ AGENTMAIL_SESSION_ID: 'sess-1' }), false);
|
||
assert.equal(hasLocalUi({ AGENTMAIL_SESSION_ID: ' ' }), true, '空白串不算会话');
|
||
});
|
||
|
||
// ─── 幂等键必须按「这一次调用」唯一 ─────────────────────────────────────
|
||
// 一个实测缺陷,失败方式极隐蔽:键取成「会话+工具」之后,同一会话里**第二次**
|
||
// run_command 被网关判成重复请求 → HTTP 200 duplicate_relay → 请求**没发出去**、
|
||
// 永远没人来决策 → 工具干等到被 MCP 调用超时砍掉 → 模型回报「30 秒内未获批准」。
|
||
// 从状态码到措辞全都看不出问题,归因还完全错了(像是人没理它)。
|
||
|
||
test('★ 同一会话同一工具的两次调用必须用不同的幂等键', async () => {
|
||
const keys = [];
|
||
for (let i = 0; i < 2; i++) {
|
||
const { factory, s } = makeSSE();
|
||
const { client } = makeClient({
|
||
onRequest: async st => {
|
||
keys.push(st.requests[0].body.relay_key);
|
||
queueMicrotask(() => s.onEvent('permission_decision', { relay_key: st.requests[0].body.relay_key, decision: '同意' }));
|
||
}
|
||
});
|
||
const r = await requestApproval({ ...base({}), client, tier: 'workspace', createSSE: factory, waitMs: 500 });
|
||
assert.equal(r.allowed, true);
|
||
}
|
||
assert.equal(keys.length, 2);
|
||
assert.notEqual(keys[0], keys[1], '两次调用的键相同 ⇒ 第二次会被网关当重复丢弃');
|
||
// 键里仍保留会话与工具,便于事后从邮件反查(但唯一性来自随机尾)
|
||
assert.match(keys[0], /sess-1/);
|
||
assert.match(keys[0], /run_command/);
|
||
});
|
||
|
||
test('★ 网关判为重复请求时当场拒绝(不能干等到被超时砍掉)', async () => {
|
||
const { factory } = makeSSE();
|
||
const { client } = makeClient({
|
||
onRequest: async () => ({ status: 'duplicate_relay', detail: '该权限询问已转发过,本次调用未产生新邮件' })
|
||
});
|
||
const t0 = Date.now();
|
||
const r = await requestApproval({ ...base({}), client, tier: 'workspace', createSSE: factory, waitMs: 60000 });
|
||
const dt = Date.now() - t0;
|
||
assert.equal(r.allowed, false);
|
||
assert.equal(r.via, 'duplicate-relay');
|
||
assert.match(r.reason, /重复/);
|
||
assert.match(r.reason, /没有人会看到这次询问/);
|
||
assert.ok(dt < 5000, `必须立刻返回,实际等了 ${dt}ms(说明它在干等一个永远不会来的决策)`);
|
||
});
|
||
|
||
test('★ 反向对照:正常的 200(非 duplicate_relay)仍要等决策', async () => {
|
||
// 否则上面那条可能只是因为「任何 200 都被当成重复」。
|
||
const { factory } = makeSSE();
|
||
const { client } = makeClient({
|
||
onRequest: async () => ({ status: 'pending' })
|
||
});
|
||
const r = await requestApproval({ ...base({}), client, tier: 'workspace', createSSE: factory, waitMs: 150 });
|
||
assert.equal(r.via, 'timeout', '非重复的正常请求应该等,然后超时');
|
||
});
|