按用户裁定「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、会抢邮件)。
## 判据纪律(本轮又踩到、已写进代码注释)
「文件没被创建」不能区分「被拦住了」与「进程根本没跑」;
「未获批准」不能区分「人拒绝」与「窗口被截断」;
「工具报错」不能区分「命令失败」与「工具坏了」。
每一处都改成了验到**具体成因**。
330 lines
15 KiB
JavaScript
330 lines
15 KiB
JavaScript
/**
|
||
* 执行工具(lib/action-tools.mjs)的测试。
|
||
*
|
||
* 这些工具是**唯一**能动机器的路径(平台自带的 Bash/Write/Edit/js 已被
|
||
* `--disallowed-tools` 禁掉),所以每条测试都必须同时验两件事:
|
||
*
|
||
* 1. 结果对不对(执行了 / 返回了什么)
|
||
* 2. **在没获批准时,副作用真的没有发生**
|
||
*
|
||
* 第 2 条不能只看「抛错了」—— 抛错之后照样写文件是最糟的实现方式,
|
||
* 而只验抛错完全发现不了。所以拒绝场景一律配一个文件系统断言。
|
||
*/
|
||
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { mkdtemp, readFile, rm, stat, mkdir } from 'node:fs/promises';
|
||
import { existsSync } from 'node:fs';
|
||
import { tmpdir } from 'node:os';
|
||
import { join } from 'node:path';
|
||
import { buildActionTools } from '../lib/action-tools.mjs';
|
||
import { createGrantStore } from '../lib/permission-grants.js';
|
||
|
||
/** 假 SSE:立刻发 connected,测试自己投喂决策。 */
|
||
function makeSSE() {
|
||
const s = { onEvent: null, stopped: false };
|
||
return {
|
||
state: s,
|
||
factory: ({ onEvent }) => {
|
||
s.onEvent = onEvent;
|
||
queueMicrotask(() => onEvent('connected', {}));
|
||
return { stop: () => { s.stopped = true; } };
|
||
}
|
||
};
|
||
}
|
||
|
||
function makeClient({ decision, fail } = {}) {
|
||
const state = { requests: [] };
|
||
return {
|
||
state,
|
||
client: {
|
||
baseURL: 'http://gw.test',
|
||
authHeaders: () => ({}),
|
||
async post(path, body) {
|
||
state.requests.push({ path, body });
|
||
if (fail) throw fail;
|
||
return {};
|
||
}
|
||
}
|
||
};
|
||
}
|
||
|
||
/** 人都同意场景:请求受理后立刻投喂「同意」。 */
|
||
function approving({ decision = '同意' } = {}) {
|
||
const sse = makeSSE();
|
||
const c = makeClient();
|
||
const orig = c.client.post;
|
||
c.client.post = async (p, b) => {
|
||
await orig(p, b);
|
||
queueMicrotask(() => sse.state.onEvent('permission_decision', { relay_key: b.relay_key, decision }));
|
||
return {};
|
||
};
|
||
return { ...c, factory: sse.factory };
|
||
}
|
||
|
||
async function withTools(env, fn, opts = {}) {
|
||
const dir = await mkdtemp(join(tmpdir(), 'zc-act-'));
|
||
const c = opts.client || makeClient();
|
||
const tools = buildActionTools({
|
||
client: c.client,
|
||
env: { AGENTMAIL_SESSION_ID: 'sess-1', AGENTMAIL_WORKSPACE_ROOT: dir, ...env },
|
||
grants: opts.grants || null,
|
||
createSSE: opts.createSSE,
|
||
log: () => {}
|
||
});
|
||
const byName = new Map(tools.map(t => [t.name, t]));
|
||
try {
|
||
return await fn({ byName, dir, client: c });
|
||
} finally {
|
||
await rm(dir, { recursive: true, force: true });
|
||
}
|
||
}
|
||
|
||
// ─── 工具面本身 ─────────────────────────────────────────────────────────
|
||
|
||
test('★ 工具面只暴露两个执行工具,且都声明为 destructive', async () => {
|
||
await withTools({}, async ({ byName }) => {
|
||
assert.deepEqual([...byName.keys()].sort(), ['run_command', 'write_file']);
|
||
for (const [name, t] of byName) {
|
||
assert.equal(t.annotations.readOnlyHint, false, `${name} 不该声称只读`);
|
||
// destructiveHint 必须为真:plan 档下平台的判定是
|
||
// 「permissionName==="mcp" && !destructive → allow」,声明成非破坏性会让
|
||
// 这两个工具在只读档被平台放行 —— 那时我们的门禁也会拒,但平台那层
|
||
// 已经先把话说错了。
|
||
assert.equal(t.annotations.destructiveHint, true, `${name} 必须声明为破坏性`);
|
||
}
|
||
});
|
||
});
|
||
|
||
// ─── run_command ────────────────────────────────────────────────────────
|
||
|
||
test('★ 获批准后真的执行,并返回退出码与输出', async () => {
|
||
const c = approving();
|
||
await withTools({ AGENTMAIL_PERMISSION_MODE: 'workspace' }, async ({ byName }) => {
|
||
const out = await byName.get('run_command').run({ command: 'echo hello; echo err >&2' });
|
||
assert.match(out, /退出码:0/);
|
||
assert.match(out, /hello/);
|
||
assert.match(out, /err/);
|
||
}, { client: c, createSSE: c.factory });
|
||
});
|
||
|
||
test('★ 拒绝时抛错、且命令真的没执行', async () => {
|
||
await withTools({ AGENTMAIL_PERMISSION_MODE: 'plan' }, async ({ byName, dir }) => {
|
||
const marker = join(dir, 'should-not-exist.txt');
|
||
await assert.rejects(
|
||
() => byName.get('run_command').run({ command: `touch ${marker}` }),
|
||
/未获批准/
|
||
);
|
||
assert.equal(existsSync(marker), false, '被拒的命令仍然产生了副作用');
|
||
});
|
||
});
|
||
|
||
test('★ 命令非零退出不是工具失败:原样把退出码与 stderr 交给模型', async () => {
|
||
// 抛错会让模型以为工具坏了并重试;而 `grep` 没匹配到、测试失败、
|
||
// 编译报错都是**正常的命令结果**,模型靠 stderr 判断下一步。
|
||
const c = approving();
|
||
await withTools({ AGENTMAIL_PERMISSION_MODE: 'full' }, async ({ byName }) => {
|
||
const out = await byName.get('run_command').run({ command: 'echo boom >&2; exit 7' });
|
||
assert.match(out, /退出码:7/);
|
||
assert.match(out, /boom/);
|
||
}, { client: c, createSSE: c.factory });
|
||
});
|
||
|
||
test('★ 超时被当作命令结果报告(不能挂死整轮)', async () => {
|
||
const c = approving();
|
||
await withTools({ AGENTMAIL_PERMISSION_MODE: 'full' }, async ({ byName }) => {
|
||
const out = await byName.get('run_command').run({ command: 'sleep 5', timeout_ms: 300 });
|
||
assert.match(out, /退出码:(SIGTERM|null)/);
|
||
assert.match(out, /超时被终止/);
|
||
assert.match(out, /上限 300ms/);
|
||
}, { client: c, createSSE: c.factory });
|
||
});
|
||
|
||
test('★ 输出过长时截断并明确说明截断了多少', async () => {
|
||
const c = approving();
|
||
await withTools({ AGENTMAIL_PERMISSION_MODE: 'full' }, async ({ byName }) => {
|
||
const out = await byName.get('run_command').run({ command: `seq 1 20000` });
|
||
assert.match(out, /被截断,省略 \d+ 字符/);
|
||
assert.ok(out.length < 20000, '截断没生效');
|
||
}, { client: c, createSSE: c.factory });
|
||
});
|
||
|
||
test('★ 工作目录默认是本会话工作区,可用 cwd 覆盖', async () => {
|
||
const c = approving();
|
||
await withTools({ AGENTMAIL_PERMISSION_MODE: 'full' }, async ({ byName, dir }) => {
|
||
const out = await byName.get('run_command').run({ command: 'pwd' });
|
||
assert.match(out, new RegExp(dir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||
}, { client: c, createSSE: c.factory });
|
||
});
|
||
|
||
test('空命令被拒(不浪费一次人工审批)', async () => {
|
||
await withTools({ AGENTMAIL_PERMISSION_MODE: 'full' }, async ({ byName }) => {
|
||
await assert.rejects(() => byName.get('run_command').run({ command: ' ' }), /command 不能为空/);
|
||
});
|
||
});
|
||
|
||
// ─── write_file ─────────────────────────────────────────────────────────
|
||
|
||
test('★ 获批准后真的写入文件(含自动建父目录)', async () => {
|
||
const c = approving();
|
||
await withTools({ AGENTMAIL_PERMISSION_MODE: 'workspace' }, async ({ byName, dir }) => {
|
||
const target = join(dir, 'deep', 'nested', 'a.txt');
|
||
const out = await byName.get('write_file').run({ path: target, content: '内容' });
|
||
assert.match(out, /已写入/);
|
||
assert.equal(await readFile(target, 'utf8'), '内容');
|
||
}, { client: c, createSSE: c.factory });
|
||
});
|
||
|
||
test('★ 拒绝时抛错、且不创建文件也不创建目录', async () => {
|
||
await withTools({ AGENTMAIL_PERMISSION_MODE: 'plan' }, async ({ byName, dir }) => {
|
||
const target = join(dir, 'deep', 'x.txt');
|
||
await assert.rejects(() => byName.get('write_file').run({ path: target, content: 'x' }), /未获批准/);
|
||
assert.equal(existsSync(target), false, '被拒的写入仍然产生了文件');
|
||
assert.equal(existsSync(join(dir, 'deep')), false, '被拒的写入仍然创建了目录');
|
||
});
|
||
});
|
||
|
||
test('★ 保护目录:即使有人批准也拒,而且**根本不发审批请求**', async () => {
|
||
// 这不是不信任人,而是防自我强化:邮件驱动的 Agent 可能被来信诱导去改
|
||
// 网关数据库/服务单元/自己的插件代码,改完下一轮就换了一套规则。
|
||
// 所以这道判定必须在门禁**之前**,且不能消耗人的注意力。
|
||
const c = approving();
|
||
for (const target of [
|
||
'/opt/agentmail/data/agentmail.db',
|
||
'/opt/agentmail/plugins/zcode-mail-bridge/x.mjs',
|
||
'/etc/systemd/system/homeagent.service',
|
||
'/etc/agentmail/pi.env',
|
||
'/root/.agentmail-zcode/secret'
|
||
]) {
|
||
await withTools({ AGENTMAIL_PERMISSION_MODE: 'full' }, async ({ byName }) => {
|
||
await assert.rejects(
|
||
() => byName.get('write_file').run({ path: target, content: 'x' }),
|
||
/平台保护目录/,
|
||
`${target} 应该被保护`
|
||
);
|
||
}, { client: c, createSSE: c.factory });
|
||
}
|
||
// 反向对照:保护目录外真的写了(否则上面全绿可能只是因为全都写不进去)。
|
||
await withTools({ AGENTMAIL_PERMISSION_MODE: 'full' }, async ({ byName, dir }) => {
|
||
const p = join(dir, 'ok.txt');
|
||
await byName.get('write_file').run({ path: p, content: 'ok' });
|
||
assert.equal(await readFile(p, 'utf8'), 'ok');
|
||
}, { client: c, createSSE: c.factory });
|
||
});
|
||
|
||
test('★ 保护判定不能被路径花招绕过(大小写/相对路径/..)', async () => {
|
||
for (const target of [
|
||
'/opt/agentmail/data/../data/agentmail.db',
|
||
'/opt/agentmail/./data/x',
|
||
'/etc/systemd/system/../system/x.service'
|
||
]) {
|
||
await withTools({ AGENTMAIL_PERMISSION_MODE: 'full' }, async ({ byName }) => {
|
||
await assert.rejects(() => byName.get('write_file').run({ path: target, content: 'x' }), /平台保护目录/);
|
||
});
|
||
}
|
||
});
|
||
|
||
test('★ 相对路径按工作区解析(不能靠相对路径逃出工作区之外)', async () => {
|
||
await withTools({ AGENTMAIL_PERMISSION_MODE: 'full' }, async ({ byName, dir }) => {
|
||
const out = await byName.get('write_file').run({ path: 'sub/rel.txt', content: 'r' });
|
||
assert.match(out, new RegExp('sub/rel.txt'));
|
||
assert.equal(await readFile(join(dir, 'sub', 'rel.txt'), 'utf8'), 'r');
|
||
const st = await stat(join(dir, 'sub', 'rel.txt'));
|
||
assert.ok(st.isFile());
|
||
});
|
||
});
|
||
|
||
test('content 必须是字符串(否则会写出 "[object Object]")', async () => {
|
||
await withTools({ AGENTMAIL_PERMISSION_MODE: 'full' }, async ({ byName }) => {
|
||
await assert.rejects(() => byName.get('write_file').run({ path: 'x.txt', content: { a: 1 } }), /必须是字符串/);
|
||
await assert.rejects(() => byName.get('write_file').run({ content: 'x' }), /path 不能为空/);
|
||
});
|
||
});
|
||
|
||
// ─── 门禁接线 ───────────────────────────────────────────────────────────
|
||
|
||
test('★ 授权请求里带上了人真正需要看的信息(命令原文 / 用途 / 目标路径)', async () => {
|
||
const c = approving();
|
||
await withTools({ AGENTMAIL_PERMISSION_MODE: 'workspace' }, async ({ byName, client }) => {
|
||
await byName.get('run_command').run({ command: 'rm -rf /tmp/x', purpose: '清理临时文件' });
|
||
const body = client.state.requests.at(-1).body;
|
||
assert.equal(body.session_id, 'sess-1');
|
||
assert.match(body.question, /rm -rf \/tmp\/x/, '批准人必须看到命令原文');
|
||
assert.match(body.context, /清理临时文件/, '用途要带给批准人');
|
||
assert.match(body.relay_key, /sess-1/);
|
||
}, { client: c, createSSE: c.factory });
|
||
});
|
||
|
||
test('★ 「一直同意」命中时不再打扰人(同一会话同一工具)', async () => {
|
||
const grants = createGrantStore();
|
||
grants.grant('sess-1', 'run_command', '一直同意');
|
||
const c = makeClient();
|
||
await withTools({ AGENTMAIL_PERMISSION_MODE: 'workspace' }, async ({ byName }) => {
|
||
const out = await byName.get('run_command').run({ command: 'echo granted' });
|
||
assert.match(out, /granted/);
|
||
}, { client: c, grants });
|
||
assert.equal(c.state.requests.length, 0, '已有授权却仍然发了审批请求');
|
||
});
|
||
|
||
test('★ full 档不打扰人(发件人已声明全权)', async () => {
|
||
const c = makeClient();
|
||
await withTools({ AGENTMAIL_PERMISSION_MODE: 'full' }, async ({ byName }) => {
|
||
const out = await byName.get('run_command').run({ command: 'echo full' });
|
||
assert.match(out, /full/);
|
||
}, { client: c });
|
||
assert.equal(c.state.requests.length, 0);
|
||
});
|
||
|
||
test('★ 网关不可达时 fail closed(不执行、不写文件)', async () => {
|
||
const fail = Object.assign(new Error('ECONNREFUSED'), { status: 502 });
|
||
const c = makeClient({ fail });
|
||
const sse = makeSSE();
|
||
await withTools({ AGENTMAIL_PERMISSION_MODE: 'workspace' }, async ({ byName, dir }) => {
|
||
const marker = join(dir, 'nope.txt');
|
||
await assert.rejects(() => byName.get('run_command').run({ command: `touch ${marker}` }), /未获批准/);
|
||
assert.equal(existsSync(marker), false);
|
||
}, { client: c, createSSE: sse.factory });
|
||
});
|
||
|
||
// ─── 等待窗口必须容得下「人真的来点一下」────────────────────────────────
|
||
// 这一组来自一个实测缺陷:工具在等授权,客户端(ZCode)默认 30 秒就把这次
|
||
// MCP 调用掐了,模型于是回报「30 秒内未获批准」——看起来像人没理它,
|
||
// 实际是门禁的等待窗口被截断,而且**表现得完全正常**。
|
||
|
||
test('★ 授权等待被夹到 MCP 调用超时之下(并留下可发现的痕迹)', async () => {
|
||
const { resolveWaitMs, resolveMcpTimeoutMs } = await import('../lib/action-tools.mjs');
|
||
|
||
// 清单里声明的时间(本插件自己的清单,实测生效:40 秒的命令没被砍)
|
||
const declared = resolveMcpTimeoutMs();
|
||
assert.ok(declared && declared >= 60000, `清单应声明一个够长的 timeoutMs,实际 ${declared}`);
|
||
|
||
// 配置想等 90 分钟,但 MCP 只给 10 分钟 → 应夹到 10 分钟减余量
|
||
const capped = resolveWaitMs({ AGENTMAIL_PERMISSION_WAIT_MS: '5400000' }, 600000);
|
||
assert.ok(capped.waitMs < 600000, '必须小于 MCP 超时,否则调用会先被杀掉');
|
||
assert.ok(capped.waitMs >= 600000 - 120000, '也不该夹得过小(人需要时间点同意)');
|
||
assert.equal(capped.capped, true, '被夹小这件事必须能被发现(要写日志)');
|
||
|
||
// 边界:配置正好等于上限 → 不算被夹(它本来就 settle 得掉)
|
||
const onEdge = resolveWaitMs({ AGENTMAIL_PERMISSION_WAIT_MS: String(600000 - 30000) }, 600000);
|
||
assert.equal(onEdge.capped, false);
|
||
assert.equal(onEdge.waitMs, 570000);
|
||
|
||
// 反向对照:配置本来就比 MCP 超时小 → 原样使用,不报「被夹」
|
||
const fine = resolveWaitMs({ AGENTMAIL_PERMISSION_WAIT_MS: '120000' }, 600000);
|
||
assert.equal(fine.waitMs, 120000);
|
||
assert.equal(fine.capped, false);
|
||
|
||
// 反向对照:读不到清单时不猜,沿用配置(并在日志里说没校到)
|
||
const unknown = resolveWaitMs({ AGENTMAIL_PERMISSION_WAIT_MS: '540000' }, null);
|
||
assert.equal(unknown.waitMs, 540000);
|
||
assert.equal(unknown.capped, false);
|
||
});
|
||
|
||
test('★ 清单里的 timeoutMs 必须真的存在且够长(否则门禁没有可行窗口)', async () => {
|
||
const { resolveMcpTimeoutMs } = await import('../lib/action-tools.mjs');
|
||
const t = resolveMcpTimeoutMs();
|
||
assert.ok(t, '插件清单的 mcpServers.agentmail 必须有 timeoutMs');
|
||
// 默认 30 秒的 MCP 超时下,人根本来不及看到请求 —— 所以必须显式声明一个大的。
|
||
assert.ok(t > 300000, `timeoutMs=${t} 太短,人工审批窗口不够`);
|
||
});
|