diff --git a/plugins/webhook-lab/.gitignore b/plugins/webhook-lab/.gitignore
new file mode 100644
index 00000000..1eae0cf6
--- /dev/null
+++ b/plugins/webhook-lab/.gitignore
@@ -0,0 +1,2 @@
+dist/
+node_modules/
diff --git a/plugins/webhook-lab/CHANGELOG.md b/plugins/webhook-lab/CHANGELOG.md
new file mode 100644
index 00000000..f3c90ff1
--- /dev/null
+++ b/plugins/webhook-lab/CHANGELOG.md
@@ -0,0 +1,11 @@
+# 更新日志
+
+## 0.1.0
+
+- 提供有界的本地 Webhook 收件箱。
+- 为 ZTools 2.4+ 增加有界原生 MCP 工具,用于本地 HMAC 计算和脱敏负载预览;旧版宿主可平滑降级。
+- 在文本与嵌套 JSON 预览中脱敏常见 GitHub、OpenAI、AWS 凭据及 PEM 私钥。
+- 通过无原型对象、描述符安全复制、确定性冲突后缀和保留键别名清洗动态 JSON 键。
+- 根目录宿主清单直接指向可加载的源码 UI/preload 入口,同时仅将生成的 `dist/plugin.json` 用于发布。
+- 对未压缩 `dist` 执行递归 14.5 MB 大小门禁并报告精确体积。
+- 将人工界面、运行状态和面向人的错误提示统一为简体中文。
diff --git a/plugins/webhook-lab/README.md b/plugins/webhook-lab/README.md
new file mode 100644
index 00000000..179031fb
--- /dev/null
+++ b/plugins/webhook-lab/README.md
@@ -0,0 +1,15 @@
+# Webhook 实验室
+
+一个有界的本地 Webhook 接收器。它只监听带随机路由令牌的回环地址,最多保留 200 条小型事件,并可在不向外发送内容的前提下预览和校验负载。首个版本暂不提供重放功能。
+
+在 Windows 上,复制出的示例可直接在 PowerShell 中执行:命令使用带单引号参数的 `curl.exe`;若本地监听 URL 含单引号,则会被拒绝。
+
+Node 测试覆盖跨平台命令与生命周期契约,回环服务器已在开发用 macOS 设备完成冒烟测试。Windows、macOS、Linux 的真实 ZTools 宿主加载仍未验证;Windows PowerShell 执行和 Linux 运行行为目前仅完成契约测试。
+
+根目录 `plugin.json` 直接指向 `src/main/index.html`、`src/preload/index.cjs` 和 `logo.svg`,因此 ZTools 开发模式不依赖 `development` 覆盖即可加载界面与 preload。`npm run build` 会将 `dist/plugin.json` 重写为可独立发布的入口。`verify-dist` 递归统计 `dist` 内所有未压缩文件,打印精确字节数,并执行 14.5 MB(14,500,000 字节)安全门禁。
+
+## Agent / MCP 使用
+
+ZTools 2.4 及以上版本可将 `hmac` 和 `preview_payload` 分别作为 `webhook_lab_hmac`、`webhook_lab_preview_payload` 提供给 Agent。它们都是纯本地计算:不能启动、列出、停止或重放监听流量。`hmac` 仅接受 SHA-256 或 SHA-512,将 UTF-8 正文限制为 256 KiB、密钥限制为 8192 字节,并且只返回摘要和非敏感元数据。`preview_payload` 复用人工界面的负载解析器与 preload 脱敏器,将 UTF-8 正文限制为 256 KiB、内容类型限制为 256 字节、序列化响应限制为 64 KiB。
+
+ZTools MCP 传输允许的请求体最大为 1 MiB,且不会替各工具执行 JSON Schema 校验或限制响应,因此 preload 会独立拒绝未知字段、恶意或自定义原型、访问器、错误类型及越界输入。动态 JSON 键和值使用同一套最终凭据清洗器:带凭据含义的键、Bearer/JWT 值、GitHub 令牌、OpenAI `sk-` 令牌、AWS 访问密钥 ID 和带标签的密钥,以及 PEM 私钥块,都会在预览离开能力桥前被脱敏。脱敏键冲突会添加确定性后缀;`__proto__`、`constructor` 和 `prototype` 则会复制到无原型对象中的安全保留键别名。本功能只提供尽力而为的安全预览,不能证明负载绝对不含秘密;新型、无标签或业务自定义凭据仍需人工检查。旧版宿主没有 `registerTool` 时仍保留人工界面。Windows、macOS、Linux 真机 ZTools 宿主加载仍待验证。
diff --git a/plugins/webhook-lab/logo.svg b/plugins/webhook-lab/logo.svg
new file mode 100644
index 00000000..bd32d310
--- /dev/null
+++ b/plugins/webhook-lab/logo.svg
@@ -0,0 +1 @@
+
diff --git a/plugins/webhook-lab/package-lock.json b/plugins/webhook-lab/package-lock.json
new file mode 100644
index 00000000..925b54bb
--- /dev/null
+++ b/plugins/webhook-lab/package-lock.json
@@ -0,0 +1 @@
+{"name":"webhook-lab","version":"0.1.0","lockfileVersion":3,"requires":true,"packages":{"":{"name":"webhook-lab","version":"0.1.0","engines":{"node":">=16"}}}}
diff --git a/plugins/webhook-lab/package.json b/plugins/webhook-lab/package.json
new file mode 100644
index 00000000..89101ffa
--- /dev/null
+++ b/plugins/webhook-lab/package.json
@@ -0,0 +1 @@
+{"name":"webhook-lab","version":"0.1.0","private":true,"scripts":{"test":"node --test","build":"npm test && node scripts/build.mjs && node scripts/verify-dist.mjs","verify-dist":"node scripts/verify-dist.mjs"},"engines":{"node":">=16"}}
diff --git a/plugins/webhook-lab/plugin.json b/plugins/webhook-lab/plugin.json
new file mode 100644
index 00000000..488eda90
--- /dev/null
+++ b/plugins/webhook-lab/plugin.json
@@ -0,0 +1,82 @@
+{
+ "name": "webhook-lab",
+ "title": "Webhook 实验室",
+ "version": "0.1.0",
+ "description": "仅在本机运行的有界 Webhook 收件箱与签名工作台。",
+ "author": "harris",
+ "platform": [
+ "darwin",
+ "win32",
+ "linux"
+ ],
+ "categories": [
+ "development",
+ "network"
+ ],
+ "main": "src/main/index.html",
+ "preload": "src/preload/index.cjs",
+ "logo": "logo.svg",
+ "features": [
+ {
+ "code": "webhook-lab",
+ "icon": "logo.svg",
+ "explain": "接收并检查本地 Webhook 请求",
+ "cmds": [
+ "Webhook 实验室",
+ "Webhook 调试"
+ ]
+ }
+ ],
+ "tools": {
+ "hmac": {
+ "title": "计算 Webhook HMAC",
+ "description": "纯本地计算 SHA-256 或 SHA-512 HMAC;不会启动监听器,也不会返回正文或密钥。",
+ "inputSchema": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "body": {
+ "type": "string",
+ "maxLength": 262144
+ },
+ "secret": {
+ "type": "string",
+ "maxLength": 8192
+ },
+ "algorithm": {
+ "type": "string",
+ "enum": [
+ "sha256",
+ "sha512"
+ ]
+ }
+ },
+ "required": [
+ "body",
+ "secret"
+ ]
+ }
+ },
+ "preview_payload": {
+ "title": "安全预览 Webhook 负载",
+ "description": "复用本地负载预览与凭据脱敏逻辑,返回不超过 64 KiB 的只读结果。",
+ "inputSchema": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "body": {
+ "type": "string",
+ "maxLength": 262144
+ },
+ "contentType": {
+ "type": "string",
+ "maxLength": 256
+ }
+ },
+ "required": [
+ "body"
+ ]
+ }
+ }
+ }
+}
diff --git a/plugins/webhook-lab/scripts/build.mjs b/plugins/webhook-lab/scripts/build.mjs
new file mode 100644
index 00000000..6b8fa819
--- /dev/null
+++ b/plugins/webhook-lab/scripts/build.mjs
@@ -0,0 +1 @@
+import{cp,mkdir,readFile,rm,writeFile}from'node:fs/promises';import path from'node:path';import{fileURLToPath}from'node:url';const root=path.dirname(path.dirname(fileURLToPath(import.meta.url))),d=path.join(root,'dist');await rm(d,{recursive:true,force:true});await mkdir(d,{recursive:true});await cp(path.join(root,'src'),d,{recursive:true});await cp(path.join(root,'logo.svg'),path.join(d,'logo.svg'));const m=JSON.parse(await readFile(path.join(root,'plugin.json')));delete m.development;m.main='main/index.html';m.preload='preload/index.cjs';m.logo='logo.svg';await writeFile(path.join(d,'plugin.json'),JSON.stringify(m,null,2));
diff --git a/plugins/webhook-lab/scripts/dist-size.mjs b/plugins/webhook-lab/scripts/dist-size.mjs
new file mode 100644
index 00000000..30964eda
--- /dev/null
+++ b/plugins/webhook-lab/scripts/dist-size.mjs
@@ -0,0 +1,39 @@
+import { lstat, readdir } from 'node:fs/promises';
+import path from 'node:path';
+
+export const DIST_SIZE_LIMIT_BYTES = 14_500_000;
+
+export function assertWithinDistSizeLimit(bytes, limit = DIST_SIZE_LIMIT_BYTES) {
+ if (!Number.isSafeInteger(bytes) || bytes < 0) throw new TypeError('dist byte count must be a non-negative safe integer');
+ if (!Number.isSafeInteger(limit) || limit < 0) throw new TypeError('dist size limit must be a non-negative safe integer');
+ if (bytes > limit) throw new Error(`dist is ${bytes} bytes and exceeds the 14.5 MB safety limit (${limit} bytes)`);
+ return bytes;
+}
+
+export async function directoryBytes(directory, options = {}) {
+ const {
+ baseDirectory = directory,
+ readEntries = readdir,
+ inspectEntry = lstat
+ } = options;
+ let total = 0;
+
+ for (const entry of await readEntries(directory, { withFileTypes: true })) {
+ const entryPath = path.join(directory, entry.name);
+ const metadata = await inspectEntry(entryPath);
+ const relative = path.relative(baseDirectory, entryPath) || entry.name;
+
+ if (metadata.isSymbolicLink()) throw new Error(`Unsupported dist symbolic link: ${relative}`);
+ if (metadata.isDirectory()) {
+ total += await directoryBytes(entryPath, { baseDirectory, readEntries, inspectEntry });
+ } else if (metadata.isFile()) {
+ if (!Number.isSafeInteger(metadata.size) || metadata.size < 0) throw new Error(`Invalid dist file size: ${relative}`);
+ total += metadata.size;
+ if (!Number.isSafeInteger(total)) throw new Error('dist byte count exceeds the safe integer range');
+ } else {
+ throw new Error(`Unsupported dist special file: ${relative}`);
+ }
+ }
+
+ return total;
+}
diff --git a/plugins/webhook-lab/scripts/verify-dist.mjs b/plugins/webhook-lab/scripts/verify-dist.mjs
new file mode 100644
index 00000000..696c1ee2
--- /dev/null
+++ b/plugins/webhook-lab/scripts/verify-dist.mjs
@@ -0,0 +1,36 @@
+import { access, readFile } from 'node:fs/promises';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { assertWithinDistSizeLimit, directoryBytes } from './dist-size.mjs';
+
+const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
+const dist = path.join(root, 'dist');
+
+for (const file of ['plugin.json', 'main/index.html', 'preload/index.cjs', 'core/server.cjs', 'logo.svg']) {
+ await access(path.join(dist, file));
+}
+
+const manifest = JSON.parse(await readFile(path.join(dist, 'plugin.json')));
+if (manifest.development) throw new Error('development leaked');
+if (manifest.main !== 'main/index.html' || manifest.preload !== 'preload/index.cjs' || manifest.logo !== 'logo.svg') {
+ throw new Error('dist manifest does not use self-contained release entries');
+}
+
+const expected = ['hmac', 'preview_payload'];
+if (JSON.stringify(Object.keys(manifest.tools || {}).sort()) !== JSON.stringify(expected)) {
+ throw new Error('MCP tool declarations are missing or unexpected');
+}
+for (const name of expected) {
+ const schema = manifest.tools[name]?.inputSchema;
+ if (!schema || schema.type !== 'object' || schema.additionalProperties !== false) {
+ throw new Error(`MCP tool ${name} is not strict`);
+ }
+}
+
+const source = await readFile(path.join(root, 'src', 'preload', 'index.cjs'), 'utf8');
+const built = await readFile(path.join(dist, 'preload', 'index.cjs'), 'utf8');
+if (source !== built) throw new Error('dist preload is stale');
+
+const bytes = await directoryBytes(dist);
+assertWithinDistSizeLimit(bytes);
+console.log(`webhook-lab dist verified: ${bytes} bytes (14.5 MB safety limit)`);
diff --git a/plugins/webhook-lab/src/core/server.cjs b/plugins/webhook-lab/src/core/server.cjs
new file mode 100644
index 00000000..b9ef8e4c
--- /dev/null
+++ b/plugins/webhook-lab/src/core/server.cjs
@@ -0,0 +1,119 @@
+const http = require('http');
+const crypto = require('crypto');
+const MAX_BODY = 2 * 1024 * 1024, MAX_PREVIEW = 64 * 1024, MAX_HISTORY = 200, MAX_HISTORY_BYTES = 4 * 1024 * 1024, MAX_HEADERS = 100, MAX_REQUESTS = 10000, MAX_CONNECTIONS = 64;
+const METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD']);
+const MAX_JSON_DEPTH = 48, MAX_JSON_NODES = 2000;
+
+function routeToken() { return crypto.randomBytes(18).toString('base64url'); }
+function hostFor() { return '127.0.0.1'; }
+function hmac(body, secret, algorithm = 'sha256') {
+ if (!['sha256', 'sha512'].includes(algorithm)) throw Error('不支持的 HMAC 算法');
+ if (Buffer.byteLength(String(body)) > MAX_BODY || Buffer.byteLength(String(secret)) > 8192) throw Error('HMAC 输入过大');
+ return crypto.createHmac(algorithm, String(secret)).update(String(body)).digest('hex');
+}
+function curlFor(url, platform = process.platform) {
+ if (!/^http:\/\/127\.0\.0\.1:\d+\/[^']*$/.test(url)) throw Error('仅允许不包含单引号的本地监听 URL');
+ if (platform === 'win32') return `curl.exe -X POST '${url}' -H 'content-type: application/json' -d '{\"event\":\"test\"}'`;
+ return `curl -X POST '${url}' -H 'content-type: application/json' -d '{"event":"test"}'`;
+}
+function preview(body, contentType) {
+ const clipped = body.subarray(0, MAX_PREVIEW), text = clipped.toString('utf8');
+ let value = text, kind = 'text';
+ if (/application\/json/i.test(contentType)) {
+ try {
+ const parsed = JSON.parse(text);
+ if (!withinJsonLimit(parsed)) return { kind: 'text', value: '[preview omitted: JSON nesting limit exceeded]', truncated: true };
+ value = parsed; kind = 'json';
+ } catch {}
+ }
+ else if (/application\/x-www-form-urlencoded/i.test(contentType)) { value = Object.fromEntries(new URLSearchParams(text)); kind = 'form'; }
+ return { kind, value, truncated: body.length > MAX_PREVIEW };
+}
+function withinJsonLimit(root) {
+ const queue = [[root, 0]]; let nodes = 0;
+ while (queue.length) {
+ const [value, depth] = queue.pop();
+ if (++nodes > MAX_JSON_NODES || depth > MAX_JSON_DEPTH) return false;
+ if (value && typeof value === 'object') for (const next of Object.values(value)) queue.push([next, depth + 1]);
+ }
+ return true;
+}
+
+class WebhookServer {
+ constructor(options = {}) {
+ this.options = { port: 0, token: routeToken(), requestTimeoutMs: 15000, maxRequests: MAX_REQUESTS, maxConnections: MAX_CONNECTIONS, ...options };
+ this.events = []; this.historyBytes = 0; this.requestCount = 0; this.sockets = new Set();
+ this.server = null; this.starting = null; this.stopping = null;
+ }
+ async start() {
+ if (this.stopping) await this.stopping;
+ if (this.starting) return this.starting;
+ if (this.server?.listening) return this.address();
+ // Do this before assigning starting: stop waits on starting and would otherwise self-deadlock.
+ if (this.server) await this.stop();
+ this.starting = (async () => {
+ const server = http.createServer((request, response) => this._request(request, response));
+ server.maxHeadersCount = MAX_HEADERS;
+ server.requestTimeout = Math.min(60000, Math.max(1000, Number(this.options.requestTimeoutMs) || 15000));
+ server.headersTimeout = server.requestTimeout;
+ server.keepAliveTimeout = 5000;
+ server.on('connection', (socket) => {
+ if (this.sockets.size >= this.options.maxConnections) return socket.destroy();
+ this.sockets.add(socket);
+ socket.on('close', () => this.sockets.delete(socket));
+ });
+ await new Promise((resolve, reject) => {
+ server.once('error', reject);
+ server.listen(this.options.port, hostFor(), () => { server.off('error', reject); resolve(); });
+ });
+ this.server = server;
+ return this.address();
+ })();
+ try { return await this.starting; } finally { this.starting = null; }
+ }
+ address() {
+ const address = this.server?.address();
+ return address && typeof address === 'object' ? { host: address.address, port: address.port, path: `/${this.options.token}` } : null;
+ }
+ _reply(response, status, body) {
+ response.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'x-content-type-options': 'nosniff', 'cache-control': 'no-store', 'content-length': Buffer.byteLength(body) });
+ response.end(body);
+ }
+ _request(request, response) {
+ if (this.requestCount++ >= this.options.maxRequests) return this._reply(response, 429, '{"error":"request limit reached"}');
+ if (!METHODS.has(request.method)) return this._reply(response, 405, '{"error":"method not allowed"}');
+ const announced = Number(request.headers['content-length']);
+ if (!Number.isFinite(announced) && request.headers['content-length']) return this._reply(response, 400, '{"error":"invalid content length"}');
+ if (announced < 0 || announced > MAX_BODY) return this._reply(response, 413, '{"error":"body too large"}');
+ if (request.url !== `/${this.options.token}`) return this._reply(response, 404, '{"error":"unknown route"}');
+ let bytes = 0, done = false; const chunks = [];
+ const finish = (status, body) => { if (!done) { done = true; this._reply(response, status, body); } };
+ request.on('aborted', () => finish(400, '{"error":"request aborted"}'));
+ request.on('data', (chunk) => { bytes += chunk.length; if (bytes > MAX_BODY) { finish(413, '{"error":"body too large"}'); request.destroy(); } else chunks.push(chunk); });
+ request.on('end', () => {
+ if (done) return;
+ const body = Buffer.concat(chunks);
+ const event = { id: crypto.randomUUID?.() || crypto.randomBytes(8).toString('hex'), at: new Date().toISOString(), method: request.method, headers: request.headers, bytes, body: preview(body, request.headers['content-type'] || '') };
+ this.events.unshift(event); this.historyBytes += Math.min(bytes, MAX_PREVIEW);
+ while (this.events.length > MAX_HISTORY || this.historyBytes > MAX_HISTORY_BYTES) { const old = this.events.pop(); this.historyBytes -= Math.min(old.bytes, MAX_PREVIEW); }
+ finish(202, JSON.stringify({ accepted: true, id: event.id }));
+ });
+ request.on('error', () => finish(400, '{"error":"request error"}'));
+ }
+ async stop() {
+ if (this.stopping) return this.stopping;
+ this.stopping = (async () => {
+ if (this.starting) await this.starting.catch(() => {});
+ const server = this.server; this.server = null;
+ if (!server) return;
+ const oldSockets = [...this.sockets];
+ this.sockets.clear();
+ await new Promise((resolve) => { for (const socket of oldSockets) socket.destroy(); if (!server.listening) return resolve(); server.close(resolve); });
+ })();
+ try { return await this.stopping; }
+ finally { this.stopping = null; }
+ }
+ async restart(options = {}) { await this.stop(); this.options = { ...this.options, ...options }; return this.start(); }
+ clear() { this.events = []; this.historyBytes = 0; }
+}
+module.exports = { WebhookServer, MAX_BODY, MAX_HISTORY, MAX_HISTORY_BYTES, MAX_PREVIEW, MAX_REQUESTS, MAX_CONNECTIONS, hostFor, hmac, curlFor, preview, routeToken };
diff --git a/plugins/webhook-lab/src/main/app.js b/plugins/webhook-lab/src/main/app.js
new file mode 100644
index 00000000..50f0f1c9
--- /dev/null
+++ b/plugins/webhook-lab/src/main/app.js
@@ -0,0 +1 @@
+(function(){const $=s=>document.querySelector(s),events=$('#events');let timer,url='';const visibleTokens=[['[preview omitted: JSON nesting limit exceeded]','【JSON 预览已省略:嵌套层级超限】'],['[preview omitted: redacted output exceeds 64 KiB]','【预览已省略:脱敏后的输出超过 64 KiB】'],['[redacted-private-key]','【私钥已脱敏】'],['[redacted]','【已脱敏】'],['[reserved-key]','【保留字段】'],['[truncated]','【已截断】']];function node(tag,text){const x=document.createElement(tag);x.textContent=String(text);return x;}function visibleError(error,fallback){const message=String(error?.message||'');return /[\u3400-\u9fff]/.test(message)?message:fallback;}function humanize(value){let text=String(value??'');for(const [machine,human]of visibleTokens)text=text.split(machine).join(human);return text;}function bodyLabel(body){if(body?.kind==='json'){try{return humanize(JSON.stringify(body.value)).slice(0,280);}catch{return '【JSON 负载无法显示】';}}return humanize(body?.value).slice(0,280);}function draw(){const list=window.webhookLab?.events?.()||[];events.replaceChildren();if(!list.length){events.append(node('article','正在等待携带签名的请求…'));return;}for(const e of list){const row=node('article','');row.append(node('b',e.method),document.createTextNode(` · ${e.bytes} B · ${e.at}`),document.createElement('br'),node('code',bodyLabel(e.body)));events.append(row);}}function stopTimer(){if(timer){clearInterval(timer);timer=null;}}async function stop(){await window.webhookLab?.stop?.();stopTimer();url='';$('#secret').value='';$('#digest').textContent='';$('#url').textContent='监听器已停止';draw();}$('#start').onclick=async()=>{try{const x=await window.webhookLab?.start?.({});url=x?`http://${x.host}:${x.port}${x.path}`:'';$('#url').textContent=url||'ZTools 能力桥不可用';draw();stopTimer();timer=setInterval(draw,700);}catch(e){$('#url').textContent=visibleError(e,'启动监听失败,请稍后重试。');}};$('#stop').onclick=stop;$('#copy-url').onclick=()=>url&&window.webhookLab?.copyText?.(url);$('#copy-curl').onclick=()=>url&&window.webhookLab?.copyText?.(window.webhookLab?.curl?.(url)||'');$('#sign').onclick=()=>{try{$('#digest').textContent=window.webhookLab?.hmac?.($('#payload').value,$('#secret').value)||'ZTools 能力桥不可用';}catch(e){$('#digest').textContent=visibleError(e,'签名计算失败,请检查输入。');}};window.addEventListener('pagehide',stop);}());
diff --git a/plugins/webhook-lab/src/main/index.html b/plugins/webhook-lab/src/main/index.html
new file mode 100644
index 00000000..2968bf46
--- /dev/null
+++ b/plugins/webhook-lab/src/main/index.html
@@ -0,0 +1 @@
+
Webhook 实验室本地事件收件箱
Webhook 实验室
监听器已停止仅监听本机回环地址(127.0.0.1);v0.1 不支持局域网绑定。
diff --git a/plugins/webhook-lab/src/main/style.css b/plugins/webhook-lab/src/main/style.css
new file mode 100644
index 00000000..7df34bf8
--- /dev/null
+++ b/plugins/webhook-lab/src/main/style.css
@@ -0,0 +1 @@
+:root{background:#180c2b;color:#f9eaff;font-family:ui-sans-serif,system-ui,sans-serif}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 84% 4%,#5d1c59,transparent 28%),#180c2b}main{max-width:980px;margin:auto;padding:clamp(22px,6vw,72px)}header{display:flex;align-items:center;gap:14px;border-bottom:1px solid #5a2d72;padding-bottom:23px}header button{margin-left:auto}.pulse{width:19px;height:19px;border-radius:50%;background:#ff5b9f;box-shadow:0 0 0 8px #ff5b9f22}h1,h2{margin:4px 0}h1,h2,small,.endpoint p{overflow-wrap:break-word;text-wrap:pretty}.endpoint{padding:28px 0}.endpoint code{font:18px ui-monospace,monospace;color:#aeefff;overflow-wrap:anywhere}.endpoint p{color:#d0a7d8}.events{display:grid;gap:10px;min-height:120px}.events article{border-left:4px solid #aeefff;background:#28153c;padding:15px;font:13px ui-monospace,monospace;overflow-wrap:anywhere}.sign{margin-top:38px;display:grid;gap:10px;max-width:620px}input,textarea{min-width:0;background:#10071d;border:1px solid #704785;color:#fff;padding:12px;font:14px ui-monospace,monospace}textarea{min-height:100px}button{border:0;border-radius:8px;background:#aeefff;color:#220e31;padding:11px 15px;font-weight:800;cursor:pointer;width:max-content;max-width:100%;white-space:normal;overflow-wrap:break-word}output{font:13px ui-monospace,monospace;color:#ffb8d5;overflow-wrap:anywhere}button:focus-visible,input:focus-visible,textarea:focus-visible{outline:3px solid white;outline-offset:3px}@media(max-width:560px){header{align-items:flex-start;flex-wrap:wrap}header button{margin-left:0}}@media(prefers-reduced-motion:reduce){*{animation:none!important}}
diff --git a/plugins/webhook-lab/src/preload/index.cjs b/plugins/webhook-lab/src/preload/index.cjs
new file mode 100644
index 00000000..fb09a59f
--- /dev/null
+++ b/plugins/webhook-lab/src/preload/index.cjs
@@ -0,0 +1,211 @@
+/* ZTools preload: CommonJS, intentionally readable and dependency-light. */
+'use strict';
+
+const { WebhookServer, hmac, curlFor, preview } = require('../core/server.cjs');
+let owner = null;
+const TOOL_NAMES = Object.freeze({ hmac: 'hmac', preview: 'preview_payload' });
+const MCP_BODY_BYTES = 256 * 1024;
+const MCP_SECRET_BYTES = 8192;
+const MCP_CONTENT_TYPE_BYTES = 256;
+const MCP_RESPONSE_BYTES = 64 * 1024;
+const FORBIDDEN_KEYS = new Set(['__proto__', 'prototype', 'constructor']);
+const registeredHosts = new WeakSet();
+const lifecycleHosts = new WeakSet();
+const SENSITIVE = /authorization|cookie|token|secret|api[-_]?key|password|signature|credential|(^|[-_])sig(nature)?($|[-_])/i;
+const KNOWN_TOKEN = /(?:github_pat_[A-Za-z0-9_]{10,}|gh[pousr]_[A-Za-z0-9]{16,}|sk-(?:proj-)?[A-Za-z0-9_-]{16,}|(?:AKIA|ASIA)[A-Z0-9]{16})/g;
+const PRIVATE_KEY = /-----BEGIN ([A-Z0-9 ]*PRIVATE KEY)-----[\s\S]*?(?:-----END \1-----|$)/g;
+const RESERVED_OUTPUT_KEY = '[reserved-key]';
+
+function invalid(message) {
+ return Object.assign(new TypeError(message), { code: 'INVALID_TOOL_INPUT' });
+}
+
+function validateObject(value, allowed, label = '工具输入') {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw invalid(`${label}必须是对象。`);
+ const prototype = Object.getPrototypeOf(value);
+ if (prototype !== Object.prototype && prototype !== null) throw invalid(`${label}使用了不支持的原型。`);
+ for (const key of Reflect.ownKeys(value)) {
+ if (typeof key !== 'string' || FORBIDDEN_KEYS.has(key) || !allowed.has(key)) throw invalid(`${label}包含不支持的字段。`);
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
+ if (!descriptor || !Object.hasOwn(descriptor, 'value')) throw invalid(`${label}只能包含数据字段。`);
+ }
+}
+
+function utf8(value, field, maxBytes, required = true) {
+ if (value === undefined && !required) return '';
+ if (typeof value !== 'string') throw invalid(`${field} 必须是字符串。`);
+ if (Buffer.byteLength(value, 'utf8') > maxBytes) throw invalid(`${field} 超过 UTF-8 字节限制。`);
+ return value;
+}
+
+function own(value, key) {
+ return Object.hasOwn(value, key) ? value[key] : undefined;
+}
+
+function validateHmacInput(input) {
+ validateObject(input, new Set(['body', 'secret', 'algorithm']));
+ const algorithmValue = own(input, 'algorithm');
+ const algorithm = algorithmValue === undefined ? 'sha256' : algorithmValue;
+ if (algorithm !== 'sha256' && algorithm !== 'sha512') throw invalid('algorithm 必须是 sha256 或 sha512。');
+ return {
+ body: utf8(own(input, 'body'), 'body', MCP_BODY_BYTES),
+ secret: utf8(own(input, 'secret'), 'secret', MCP_SECRET_BYTES),
+ algorithm
+ };
+}
+
+function validatePreviewInput(input) {
+ validateObject(input, new Set(['body', 'contentType']));
+ return {
+ body: utf8(own(input, 'body'), 'body', MCP_BODY_BYTES),
+ contentType: utf8(own(input, 'contentType'), 'contentType', MCP_CONTENT_TYPE_BYTES, false)
+ };
+}
+
+function safeOptions(input = {}) {
+ const port = Number(input.port), options = {};
+ if (Number.isInteger(port) && port >= 0 && port <= 65535) options.port = port;
+ return options;
+}
+function assign(target, key, value) { Object.defineProperty(target, key, { value, enumerable: true, configurable: true, writable: true }); }
+function redactString(value) {
+ return value
+ .replace(PRIVATE_KEY, '[redacted-private-key]')
+ .replace(KNOWN_TOKEN, '[redacted]')
+ .replace(/\b(?:bearer|basic)\s+[A-Za-z0-9._~+/=-]{6,}/gi, '[redacted]')
+ .replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, '[redacted]')
+ .replace(/(^|[^A-Za-z0-9])(token|secret|password|api[_ -]?key|authorization|signature|credential|aws[_ -]?secret[_ -]?access[_ -]?key)\s*([=:])\s*[^\s,;]+/gi, '$1$2$3[redacted]')
+ .replace(/([?&](?:token|secret|password|api[_-]?key|authorization|signature|credential|aws[_-]?secret[_-]?access[_-]?key)=)[^\s]*/gi, '$1[redacted]')
+ .slice(0, 4096);
+}
+function sanitizedOutputKey(key) {
+ const sanitized = redactString(key);
+ return FORBIDDEN_KEYS.has(sanitized) ? RESERVED_OUTPUT_KEY : sanitized;
+}
+function allocateOutputKey(key, allocated) {
+ const base = sanitizedOutputKey(key);
+ let candidate = base;
+ for (let suffix = 2; allocated.has(candidate); suffix += 1) candidate = `${base}#${suffix}`;
+ allocated.add(candidate);
+ return candidate;
+}
+function redact(value) {
+ const root = { value: null }, queue = [{ source: value, target: root, key: 'value', depth: 0, sensitive: false }];
+ let nodes = 0;
+ while (queue.length) {
+ const item = queue.pop();
+ const inheritedSensitive = item.sensitive;
+ if (inheritedSensitive) { assign(item.target, item.key, '[redacted]'); continue; }
+ if (item.source === null || typeof item.source !== 'object') { assign(item.target, item.key, typeof item.source === 'string' ? redactString(item.source) : item.source); continue; }
+ if (++nodes > 2000 || item.depth > 48) { assign(item.target, item.key, '[truncated]'); continue; }
+ const nameDescriptor = Object.getOwnPropertyDescriptor(item.source, 'name');
+ const sourceName = nameDescriptor && Object.hasOwn(nameDescriptor, 'value') ? nameDescriptor.value : undefined;
+ const namedSecret = typeof sourceName === 'string' && SENSITIVE.test(sourceName);
+ const copy = Array.isArray(item.source) ? [] : Object.create(null);
+ assign(item.target, item.key, copy);
+ const allocated = new Set();
+ for (const key of Object.keys(item.source)) {
+ const descriptor = Object.getOwnPropertyDescriptor(item.source, key);
+ const outputKey = allocateOutputKey(key, allocated);
+ if (!descriptor || !Object.hasOwn(descriptor, 'value')) {
+ assign(copy, outputKey, '[redacted]');
+ continue;
+ }
+ queue.push({ source: descriptor.value, target: copy, key: outputKey, depth: item.depth + 1, sensitive: namedSecret || SENSITIVE.test(key) });
+ }
+ }
+ return root.value;
+}
+
+function encodedBytes(value) {
+ return Buffer.byteLength(JSON.stringify(value), 'utf8');
+}
+
+function hmacForMcp(input) {
+ const value = validateHmacInput(input);
+ return {
+ algorithm: value.algorithm,
+ digest: hmac(value.body, value.secret, value.algorithm),
+ bodyBytes: Buffer.byteLength(value.body, 'utf8')
+ };
+}
+
+function previewForMcp(input) {
+ const value = validatePreviewInput(input);
+ const body = Buffer.from(value.body, 'utf8');
+ const result = redact(preview(body, value.contentType));
+ const response = {
+ kind: result && ['json', 'form', 'text'].includes(result.kind) ? result.kind : 'text',
+ value: result?.value,
+ truncated: Boolean(result?.truncated),
+ bodyBytes: body.length,
+ outputLimitBytes: MCP_RESPONSE_BYTES
+ };
+ if (encodedBytes(response) <= MCP_RESPONSE_BYTES) return response;
+ return {
+ kind: response.kind,
+ value: '[preview omitted: redacted output exceeds 64 KiB]',
+ truncated: true,
+ bodyBytes: body.length,
+ outputLimitBytes: MCP_RESPONSE_BYTES
+ };
+}
+
+function safeEvents() { return redact((owner?.events || []).slice(0, 200)); }
+function registerLifecycle(ztools) {
+ if (!ztools || (typeof ztools !== 'object' && typeof ztools !== 'function') || lifecycleHosts.has(ztools)) return false;
+ const stop = async () => { const current = owner; owner = null; current?.clear(); await current?.stop(); };
+ try {
+ if (typeof ztools.onPluginOut === 'function') ztools.onPluginOut(stop);
+ else if (typeof ztools.onPluginExit === 'function') ztools.onPluginExit(stop);
+ } catch {}
+ lifecycleHosts.add(ztools);
+ return true;
+}
+function registerTools(target) {
+ const ztools = target?.ztools;
+ if (!ztools || typeof ztools.registerTool !== 'function' || registeredHosts.has(ztools)) return Object.freeze([]);
+ const registered = [];
+ for (const [name, handler] of [[TOOL_NAMES.hmac, hmacForMcp], [TOOL_NAMES.preview, previewForMcp]]) {
+ try { ztools.registerTool.call(ztools, name, handler); registered.push(name); } catch {}
+ }
+ registeredHosts.add(ztools);
+ return Object.freeze(registered);
+}
+function bridge(ztools) {
+ registerLifecycle(ztools);
+ return Object.freeze({
+ start: async (options) => { const next = safeOptions(options); if (!owner) owner = new WebhookServer(next); else if (Object.hasOwn(next, 'port') && owner.options.port !== next.port) await owner.restart(next); return owner.start(); },
+ stop: async () => { const current = owner; owner = null; await current?.stop(); },
+ events: safeEvents,
+ hmac: (body, secret, algorithm) => hmac(body, secret, algorithm),
+ curl: (url) => curlFor(url, process.platform),
+ copyText: (text) => ztools?.copyText?.(String(text))
+ });
+}
+function attachWebhookLab(target) {
+ if (!target || (typeof target !== 'object' && typeof target !== 'function')) throw new TypeError('需要一个类似 window 的挂载目标。');
+ const value = bridge(target.ztools || {});
+ Object.defineProperty(target, 'webhookLab', { value, enumerable: true, configurable: true, writable: true });
+ registerTools(target);
+ return value;
+}
+if (typeof globalThis !== 'undefined') attachWebhookLab(globalThis);
+module.exports = {
+ TOOL_NAMES,
+ MCP_BODY_BYTES,
+ MCP_SECRET_BYTES,
+ MCP_CONTENT_TYPE_BYTES,
+ MCP_RESPONSE_BYTES,
+ validateObject,
+ validateHmacInput,
+ validatePreviewInput,
+ redact,
+ hmacForMcp,
+ previewForMcp,
+ registerTools,
+ attachWebhookLab,
+ bridge,
+ __testOwner: () => owner,
+ __testSetOwner: (next) => { owner = next; }
+};
diff --git a/plugins/webhook-lab/test/dist-size.test.mjs b/plugins/webhook-lab/test/dist-size.test.mjs
new file mode 100644
index 00000000..d3353342
--- /dev/null
+++ b/plugins/webhook-lab/test/dist-size.test.mjs
@@ -0,0 +1,59 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import path from 'node:path';
+import {
+ DIST_SIZE_LIMIT_BYTES,
+ assertWithinDistSizeLimit,
+ directoryBytes
+} from '../scripts/dist-size.mjs';
+
+function metadata(kind, size = 0) {
+ return {
+ size,
+ isDirectory: () => kind === 'directory',
+ isFile: () => kind === 'file',
+ isSymbolicLink: () => kind === 'symlink'
+ };
+}
+
+function virtualDirectory(root, tree, entries) {
+ return directoryBytes(root, {
+ readEntries: async (directory) => (tree.get(directory) || []).map((name) => ({ name })),
+ inspectEntry: async (entryPath) => entries.get(entryPath),
+ baseDirectory: root
+ });
+}
+
+test('size gate allows exactly 14,500,000 bytes', () => {
+ assert.equal(assertWithinDistSizeLimit(DIST_SIZE_LIMIT_BYTES), 14_500_000);
+});
+
+test('size gate rejects 14,500,001 bytes without creating a large file', () => {
+ assert.throws(() => assertWithinDistSizeLimit(DIST_SIZE_LIMIT_BYTES + 1), /14\.5 MB safety limit/);
+});
+
+test('directory byte count includes nested regular files recursively', async () => {
+ const root = path.resolve('/virtual/webhook-dist');
+ const nested = path.join(root, 'nested');
+ const tree = new Map([[root, ['root.js', 'nested']], [nested, ['child.css']]]);
+ const entries = new Map([
+ [path.join(root, 'root.js'), metadata('file', 17)],
+ [nested, metadata('directory')],
+ [path.join(nested, 'child.css'), metadata('file', 23)]
+ ]);
+ assert.equal(await virtualDirectory(root, tree, entries), 40);
+});
+
+test('directory byte count rejects symbolic links and other special files', async () => {
+ for (const [name, kind, pattern] of [
+ ['linked.js', 'symlink', /symbolic link/],
+ ['socket', 'special', /special file/]
+ ]) {
+ const root = path.resolve(`/virtual/webhook-${kind}`);
+ const entryPath = path.join(root, name);
+ await assert.rejects(
+ virtualDirectory(root, new Map([[root, [name]]]), new Map([[entryPath, metadata(kind)]])),
+ pattern
+ );
+ }
+});
diff --git a/plugins/webhook-lab/test/host-contract.test.cjs b/plugins/webhook-lab/test/host-contract.test.cjs
new file mode 100644
index 00000000..b11cc0ad
--- /dev/null
+++ b/plugins/webhook-lab/test/host-contract.test.cjs
@@ -0,0 +1,42 @@
+'use strict';
+
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+
+const root = path.resolve(__dirname, '..');
+
+function resolveLocalEntry(relative) {
+ assert.equal(path.isAbsolute(relative), false);
+ const resolved = path.resolve(root, relative);
+ assert.ok(resolved.startsWith(`${root}${path.sep}`));
+ assert.ok(fs.statSync(resolved).isFile(), `${relative} must be a loadable file`);
+ return resolved;
+}
+
+test('host manifest directly loads source entries while dist remains a self-contained CI artifact', () => {
+ const manifest = JSON.parse(fs.readFileSync(path.join(root, 'plugin.json'), 'utf8'));
+ assert.equal(Object.hasOwn(manifest, 'development'), false);
+ assert.deepEqual(
+ { main: manifest.main, preload: manifest.preload, logo: manifest.logo },
+ { main: 'src/main/index.html', preload: 'src/preload/index.cjs', logo: 'logo.svg' }
+ );
+
+ const main = resolveLocalEntry(manifest.main);
+ resolveLocalEntry(manifest.preload);
+ resolveLocalEntry(manifest.logo);
+ const html = fs.readFileSync(main, 'utf8');
+ assert.match(html, //);
+ assert.match(html, /Webhook 实验室/);
+ assert.doesNotMatch(html, />Open listener<|>Stop<|>Copy URL<|>Signal stream);
+ for (const asset of ['./style.css', './app.js']) {
+ assert.match(html, new RegExp(`(?:href|src)=["']${asset.replace('.', '\\.')}`));
+ assert.ok(fs.statSync(path.resolve(path.dirname(main), asset)).isFile());
+ }
+
+ const build = fs.readFileSync(path.join(root, 'scripts', 'build.mjs'), 'utf8');
+ assert.match(build, /delete m\.development/);
+ assert.match(build, /m\.main='main\/index\.html'/);
+ assert.match(build, /m\.preload='preload\/index\.cjs'/);
+});
diff --git a/plugins/webhook-lab/test/mcp.test.cjs b/plugins/webhook-lab/test/mcp.test.cjs
new file mode 100644
index 00000000..f6698d35
--- /dev/null
+++ b/plugins/webhook-lab/test/mcp.test.cjs
@@ -0,0 +1,182 @@
+'use strict';
+
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const crypto = require('node:crypto');
+const fs = require('node:fs');
+const path = require('node:path');
+const { spawnSync } = require('node:child_process');
+const preload = require('../src/preload/index.cjs');
+
+const root = path.resolve(__dirname, '..');
+const manifest = JSON.parse(fs.readFileSync(path.join(root, 'plugin.json'), 'utf8'));
+
+test('manifest declarations and real native handlers stay one-to-one', async () => {
+ const handlers = new Map();
+ const target = { ztools: { registerTool(name, handler) { handlers.set(name, handler); } } };
+ preload.attachWebhookLab(target);
+ assert.deepEqual([...handlers.keys()].sort(), Object.keys(manifest.tools).sort());
+ assert.deepEqual(Object.keys(manifest.tools).sort(), ['hmac', 'preview_payload']);
+ assert.ok([...handlers.values()].every((handler) => typeof handler === 'function'));
+ const result = await handlers.get('hmac')({ body: 'hello', secret: 'world' });
+ assert.equal(result.digest, crypto.createHmac('sha256', 'world').update('hello').digest('hex'));
+ assert.equal(typeof target.webhookLab.start, 'function');
+});
+
+test('top-level preload registration is synchronous', () => {
+ const file = require.resolve('../src/preload/index.cjs');
+ const script = `const names=[];globalThis.ztools={registerTool(name,handler){if(typeof handler!=='function')throw Error('bad handler');names.push(name)}};require(${JSON.stringify(file)});process.stdout.write(JSON.stringify(names.sort()))`;
+ const child = spawnSync(process.execPath, ['-e', script], { encoding: 'utf8' });
+ assert.equal(child.status, 0, child.stderr);
+ assert.deepEqual(JSON.parse(child.stdout), ['hmac', 'preview_payload']);
+});
+
+test('one registration failure neither blocks the other tool nor breaks the human UI', async () => {
+ const handlers = new Map();
+ const target = {
+ ztools: {
+ registerTool(name, handler) {
+ if (name === 'hmac') throw new Error('simulated host failure');
+ handlers.set(name, handler);
+ }
+ }
+ };
+ assert.doesNotThrow(() => preload.attachWebhookLab(target));
+ assert.equal(typeof target.webhookLab.events, 'function');
+ assert.deepEqual([...handlers.keys()], ['preview_payload']);
+ const result = await handlers.get('preview_payload')({ body: 'ok', contentType: 'text/plain' });
+ assert.equal(result.value, 'ok');
+});
+
+test('older hosts degrade to the unchanged human bridge', () => {
+ const copied = [];
+ const target = { ztools: { copyText(value) { copied.push(value); } } };
+ assert.doesNotThrow(() => preload.attachWebhookLab(target));
+ target.webhookLab.copyText('human');
+ assert.deepEqual(copied, ['human']);
+ assert.equal(typeof target.webhookLab.start, 'function');
+});
+
+test('HMAC tool is bounded, strict and never echoes body or secret', () => {
+ const result = preload.hmacForMcp({ body: 'payload', secret: 'top-secret', algorithm: 'sha512' });
+ assert.equal(result.algorithm, 'sha512');
+ assert.equal(result.digest.length, 128);
+ assert.equal(result.bodyBytes, 7);
+ assert.doesNotMatch(JSON.stringify(result), /payload|top-secret/);
+ assert.throws(() => preload.hmacForMcp({ body: 'x', secret: 's', algorithm: 'md5' }), { code: 'INVALID_TOOL_INPUT' });
+ assert.throws(() => preload.hmacForMcp({ body: 'x'.repeat(preload.MCP_BODY_BYTES + 1), secret: 's' }), { code: 'INVALID_TOOL_INPUT' });
+ assert.throws(() => preload.hmacForMcp({ body: '汉'.repeat(Math.floor(preload.MCP_BODY_BYTES / 3) + 1), secret: 's' }), { code: 'INVALID_TOOL_INPUT' });
+ assert.throws(() => preload.hmacForMcp({ body: 'x', secret: '密'.repeat(Math.floor(preload.MCP_SECRET_BYTES / 3) + 1) }), { code: 'INVALID_TOOL_INPUT' });
+});
+
+test('payload preview reuses parser and redaction without credential output', () => {
+ const body = JSON.stringify({ ok: true, authorization: 'Bearer abcdefghijklmnop', nested: { apiKey: 'never-return-me' }, url: 'https://example.test/?token=hidden' });
+ const result = preload.previewForMcp({ body, contentType: 'application/json' });
+ const serialized = JSON.stringify(result);
+ assert.equal(result.kind, 'json');
+ assert.match(serialized, /"ok":true/);
+ assert.match(serialized, /\[redacted\]/);
+ assert.doesNotMatch(serialized, /abcdefghijklmnop|never-return-me|token=hidden/);
+ assert.ok(Buffer.byteLength(serialized, 'utf8') <= preload.MCP_RESPONSE_BYTES);
+});
+
+test('registered payload handler sanitizes dynamic keys, reserved names and deterministic collisions', async () => {
+ const handlers = new Map();
+ preload.attachWebhookLab({ ztools: { registerTool(name, handler) { handlers.set(name, handler); } } });
+ const githubA = `ghp_${'A'.repeat(36)}`;
+ const githubB = `ghp_${'B'.repeat(36)}`;
+ const labeledCredential = 'metadata_token=must-not-leak';
+ const body = JSON.stringify(Object.fromEntries([
+ ['visible', { ok: true }],
+ [githubA, 'first'],
+ [githubB, 'second'],
+ ['[redacted]', 'literal'],
+ [labeledCredential, 'sensitive-value'],
+ ['__proto__', 'proto-value'],
+ ['constructor', 'constructor-value'],
+ ['prototype', 'prototype-value']
+ ]));
+
+ const result = await handlers.get('preview_payload')({ body, contentType: 'application/json' });
+ const output = result.value;
+ const serialized = JSON.stringify(result);
+ assert.equal(result.kind, 'json');
+ assert.equal(Object.getPrototypeOf(output), null);
+ assert.equal(Object.getPrototypeOf(output.visible), null);
+ assert.deepEqual(output.visible, Object.assign(Object.create(null), { ok: true }));
+ assert.equal(output['[redacted]'], 'first');
+ assert.equal(output['[redacted]#2'], 'second');
+ assert.equal(output['[redacted]#3'], 'literal');
+ assert.equal(output['metadata_token=[redacted]'], '[redacted]');
+ assert.equal(output['[reserved-key]'], 'proto-value');
+ assert.equal(output['[reserved-key]#2'], 'constructor-value');
+ assert.equal(output['[reserved-key]#3'], 'prototype-value');
+ assert.equal(Object.hasOwn(output, '__proto__'), false);
+ assert.equal(Object.hasOwn(output, 'constructor'), false);
+ assert.equal(Object.hasOwn(output, 'prototype'), false);
+ assert.doesNotMatch(serialized, new RegExp(`${githubA}|${githubB}|must-not-leak|sensitive-value`));
+ assert.ok(Buffer.byteLength(serialized, 'utf8') <= preload.MCP_RESPONSE_BYTES);
+});
+
+test('text and nested JSON previews redact known token prefixes and PEM private keys', () => {
+ const secrets = {
+ githubFine: `github_pat_${'A'.repeat(40)}`,
+ githubClassic: `ghp_${'B'.repeat(36)}`,
+ openAiProject: `sk-proj-${'C'.repeat(32)}`,
+ openAiLegacy: `sk-${'D'.repeat(32)}`,
+ awsAccess: `AKIA${'E'.repeat(16)}`,
+ awsSecret: 'aws_secret_access_key=abcdefghijklmnopqrstuvwxyz0123456789ABCD',
+ pem: `-----BEGIN PRIVATE KEY-----\n${'F'.repeat(64)}\n-----END PRIVATE KEY-----`
+ };
+ const rawValues = Object.values(secrets);
+ const textResult = preload.previewForMcp({ body: `visible\n${rawValues.join('\n')}`, contentType: 'text/plain' });
+ const jsonResult = preload.previewForMcp({ body: JSON.stringify({ visible: true, nested: { values: rawValues } }), contentType: 'application/json' });
+ for (const serialized of [JSON.stringify(textResult), JSON.stringify(jsonResult)]) {
+ assert.match(serialized, /\[redacted/);
+ for (const secret of rawValues) assert.equal(serialized.includes(secret), false);
+ assert.doesNotMatch(serialized, /github_pat_|ghp_|sk-proj-|\bsk-[A-Z]|AKIA[A-Z0-9]{16}|BEGIN PRIVATE KEY|abcdefghijklmnopqrstuvwxyz0123456789ABCD/);
+ }
+ assert.match(JSON.stringify(textResult), /\[redacted-private-key\]/);
+ assert.match(JSON.stringify(jsonResult), /"visible":true/);
+});
+
+test('payload preview enforces its 64 KiB serialized response boundary', () => {
+ const large = {};
+ let body = '{}';
+ for (let index = 0; ; index += 1) {
+ large[`field_${index}`] = 'v'.repeat(64);
+ const next = JSON.stringify(large);
+ if (Buffer.byteLength(next) > 65_520) { delete large[`field_${index}`]; break; }
+ body = next;
+ }
+ assert.ok(Buffer.byteLength(body) > 65_000);
+ const result = preload.previewForMcp({ body, contentType: 'application/json' });
+ assert.equal(result.truncated, true);
+ assert.equal(result.value, '[preview omitted: redacted output exceeds 64 KiB]');
+ assert.doesNotMatch(result.value, /[\u3400-\u9fff]/);
+ assert.ok(Buffer.byteLength(JSON.stringify(result), 'utf8') <= preload.MCP_RESPONSE_BYTES);
+});
+
+test('strict runtime validation rejects unknown fields, hostile prototypes and accessors', () => {
+ assert.throws(() => preload.hmacForMcp({ body: 'x', secret: 's', start: true }), { code: 'INVALID_TOOL_INPUT' });
+ assert.throws(() => preload.previewForMcp({ body: 'x', listener: 'start' }), { code: 'INVALID_TOOL_INPUT' });
+ assert.throws(() => preload.previewForMcp(JSON.parse('{"body":"x","__proto__":{}}')), { code: 'INVALID_TOOL_INPUT' });
+ assert.throws(() => preload.previewForMcp(Object.assign(Object.create({ inherited: true }), { body: 'x' })), { code: 'INVALID_TOOL_INPUT' });
+ const accessor = {};
+ Object.defineProperty(accessor, 'body', { enumerable: true, get() { throw new Error('must not execute'); } });
+ assert.throws(() => preload.previewForMcp(accessor), { code: 'INVALID_TOOL_INPUT' });
+ const symbol = { body: 'x' };
+ symbol[Symbol('hidden')] = true;
+ assert.throws(() => preload.previewForMcp(symbol), { code: 'INVALID_TOOL_INPUT' });
+ const safeNullPrototype = Object.assign(Object.create(null), { body: 'x', secret: 's' });
+ assert.equal(preload.hmacForMcp(safeNullPrototype).digest.length, 64);
+ assert.throws(() => preload.previewForMcp({ body: 'x', contentType: '汉'.repeat(86) }), { code: 'INVALID_TOOL_INPUT' });
+ const previousBody = Object.getOwnPropertyDescriptor(Object.prototype, 'body');
+ Object.defineProperty(Object.prototype, 'body', { value: 'inherited-body', configurable: true });
+ try {
+ assert.throws(() => preload.hmacForMcp({ secret: 's' }), { code: 'INVALID_TOOL_INPUT' });
+ } finally {
+ if (previousBody) Object.defineProperty(Object.prototype, 'body', previousBody);
+ else delete Object.prototype.body;
+ }
+});
diff --git a/plugins/webhook-lab/test/server.test.cjs b/plugins/webhook-lab/test/server.test.cjs
new file mode 100644
index 00000000..50709a9f
--- /dev/null
+++ b/plugins/webhook-lab/test/server.test.cjs
@@ -0,0 +1,162 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const { EventEmitter } = require('node:events');
+const http = require('node:http');
+const fs = require('node:fs');
+const { WebhookServer, MAX_BODY, MAX_HISTORY, MAX_HISTORY_BYTES, MAX_PREVIEW, hostFor, hmac, curlFor, preview } = require('../src/core/server.cjs');
+const preload = require('../src/preload/index.cjs');
+
+function invoke(server, { path = '/safe', body = '', method = 'POST', headers = {} } = {}) {
+ return new Promise((resolve) => {
+ const request = new EventEmitter();
+ Object.assign(request, { url: path, method, headers, destroy() { this.destroyed = true; } });
+ const response = { status: 0, body: '', writeHead(status) { this.status = status; }, end(bodyText) { this.body = bodyText; resolve(this); } };
+ server._request(request, response);
+ process.nextTick(() => { if (body) request.emit('data', Buffer.from(body)); request.emit('end'); });
+ });
+}
+
+test('bounded receiver and cleanup', async () => {
+ const server = new WebhookServer({ token: 'safe' });
+ const missing = await invoke(server, { path: '/bad' }); assert.equal(missing.status, 404); assert.equal(missing.body, '{"error":"unknown route"}');
+ assert.equal((await invoke(server, { body: '{"ok":1}', headers: { 'content-type': 'application/json' } })).status, 202);
+ let destroyed = false;
+ server.sockets.add({ destroy() { destroyed = true; } }); server.server = { listening: false };
+ await Promise.all([server.stop(), server.stop()]);
+ assert.equal(destroyed, true);
+});
+test('rejects method, content length and body', async () => {
+ const server = new WebhookServer({ token: 'safe' });
+ assert.equal((await invoke(server, { method: 'TRACE' })).status, 405);
+ assert.equal((await invoke(server, { headers: { 'content-length': String(MAX_BODY + 1) } })).status, 413);
+ assert.equal((await invoke(server, { body: 'x'.repeat(MAX_BODY + 1) })).status, 413);
+});
+test('concurrent starts share exactly one live server and stop closes it', async () => {
+ const original = http.createServer; let created = 0;
+ http.createServer = () => {
+ created++;
+ const fake = new EventEmitter(); fake.listening = false; fake.address = () => ({ address: '127.0.0.1', port: 45678 });
+ fake.listen = (_port, _host, callback) => { fake.listening = true; queueMicrotask(callback); return fake; };
+ fake.close = (callback) => { fake.listening = false; queueMicrotask(callback); };
+ return fake;
+ };
+ try {
+ const server = new WebhookServer({ token: 'safe' });
+ const [left, right] = await Promise.all([server.start(), server.start()]);
+ assert.equal(left.port, right.port); assert.equal(created, 1);
+ const live = server.server; await server.stop();
+ assert.equal(live.listening, false); assert.equal(server.server, null);
+ } finally { http.createServer = original; }
+});
+test('stop/start is linearized across a delayed close', async () => {
+ const original = http.createServer; let created = 0, closeCalls = 0, finishClose;
+ http.createServer = () => {
+ created++;
+ const fake = new EventEmitter(); fake.listening = false; fake.address = () => ({ address: '127.0.0.1', port: 45000 + created });
+ fake.listen = (_port, _host, callback) => { fake.listening = true; queueMicrotask(callback); return fake; };
+ fake.close = (callback) => { closeCalls++; finishClose = () => { fake.listening = false; callback(); }; };
+ return fake;
+ };
+ try {
+ const server = new WebhookServer({ token: 'safe' }); await server.start();
+ const oldSocket = { destroyed: false, destroy() { this.destroyed = true; } }; server.sockets.add(oldSocket);
+ const firstStop = server.stop(), secondStop = server.stop(), duringCloseStart = server.start();
+ await new Promise((resolve) => setImmediate(resolve));
+ assert.equal(closeCalls, 1); assert.equal(created, 1); assert.equal(oldSocket.destroyed, true);
+ finishClose(); await Promise.all([firstStop, secondStop, duringCloseStart]);
+ assert.equal(created, 2);
+ const newSocket = { destroy() {} }; server.sockets.add(newSocket);
+ assert.ok(server.sockets.has(newSocket)); const lastStop = server.stop(); finishClose(); await lastStop;
+ } finally { http.createServer = original; }
+});
+test('bridge start without a port does not restart its owner', async () => {
+ const bridge = preload.bridge({});
+ let stopped = false;
+ const server = { listening: true, address: () => ({ address: '127.0.0.1', port: 45678 }), close: (done) => { stopped = true; done(); } };
+ preload.__testSetOwner({ options: { port: 0, token: 'safe' }, server, start: WebhookServer.prototype.start, address: WebhookServer.prototype.address, stop: async () => { stopped = true; } });
+ const first = await bridge.start({});
+ const second = await bridge.start({});
+ assert.equal(first.port, second.port);
+ assert.equal(preload.__testOwner().server, server);
+ await bridge.stop();
+ assert.equal(stopped, true);
+});
+test('history honors 64 KiB previews, 4 MiB budget and clear on exit', () => {
+ const server = new WebhookServer();
+ server.events = Array.from({ length: MAX_HISTORY + 1 }, (_, index) => ({ bytes: MAX_PREVIEW, index }));
+ server.historyBytes = server.events.length * MAX_PREVIEW;
+ while (server.events.length > MAX_HISTORY || server.historyBytes > MAX_HISTORY_BYTES) { const old = server.events.pop(); server.historyBytes -= Math.min(old.bytes, MAX_PREVIEW); }
+ assert.ok(server.events.length <= MAX_HISTORY);
+ assert.ok(server.historyBytes <= MAX_HISTORY_BYTES);
+ server.clear();
+ assert.deepEqual(server.events, []); assert.equal(server.historyBytes, 0);
+});
+test('safe events redact header name/value and signature credentials', () => {
+ preload.__testSetOwner({ events: [{ headers: [{ name: 'Authorization', value: 'Bearer x' }, { name: 'X-Signature', value: 'x' }], credential: 'x', sig: 'x', body: { signature: 'x', safe: true } }] });
+ const event = preload.bridge({}).events()[0];
+ assert.equal(event.headers[0].value, '[redacted]'); assert.equal(event.headers[1].value, '[redacted]');
+ assert.equal(event.credential, '[redacted]'); assert.equal(event.sig, '[redacted]'); assert.equal(event.body.signature, '[redacted]');
+ preload.__testSetOwner(null);
+});
+test('safe event copies hostile keys without prototype pollution', () => {
+ preload.__testSetOwner({ events: [{ body: { value: JSON.parse('{"__proto__":{"polluted":true},"constructor":"safe","prototype":"safe"}') } }] });
+ const value = preload.bridge({}).events()[0].body.value;
+ assert.equal(Object.getPrototypeOf(value), null);
+ assert.equal(value['[reserved-key]'].polluted, true);
+ assert.equal(value['[reserved-key]#2'], 'safe');
+ assert.equal(value['[reserved-key]#3'], 'safe');
+ assert.equal(Object.hasOwn(value, '__proto__'), false);
+ assert.equal(Object.hasOwn(value, 'constructor'), false);
+ assert.equal(Object.hasOwn(value, 'prototype'), false);
+ assert.equal({}.polluted, undefined);
+ preload.__testSetOwner(null);
+});
+test('safe event copying never executes accessors', () => {
+ const payload = Object.create(null);
+ Object.defineProperty(payload, 'visible', { value: 'ok', enumerable: true });
+ Object.defineProperty(payload, 'derived', { enumerable: true, get() { throw new Error('must not execute'); } });
+ preload.__testSetOwner({ events: [{ body: { value: payload } }] });
+ const value = preload.bridge({}).events()[0].body.value;
+ assert.equal(Object.getPrototypeOf(value), null);
+ assert.equal(value.visible, 'ok');
+ assert.equal(value.derived, '[redacted]');
+ preload.__testSetOwner(null);
+});
+test('deep JSON is bounded before preview, bridge redaction, and renderer', () => {
+ const json = `${'{"x":'.repeat(5000)}{"token":"secret"}${'}'.repeat(5000)}`;
+ const deepPreview = preview(Buffer.from(json), 'application/json');
+ assert.deepEqual(deepPreview, { kind: 'text', value: '[preview omitted: JSON nesting limit exceeded]', truncated: true });
+ let value = { token: 'secret' }; for (let index = 0; index < 5000; index++) value = { child: value };
+ preload.__testSetOwner({ events: [{ body: { kind: 'json', value } }] });
+ const event = preload.bridge({}).events()[0];
+ let cursor = event.body.value; for (let index = 0; index < 60 && cursor && typeof cursor === 'object'; index++) cursor = cursor.child;
+ assert.equal(cursor, '[truncated]');
+ const source = fs.readFileSync(require.resolve('../src/main/app.js'), 'utf8');
+ assert.match(source, /humanize\(JSON\.stringify\(body\.value\)\)\.slice\(0,280\)/);
+ preload.__testSetOwner(null);
+});
+test('normal JSON stays renderable while secrets remain redacted', () => {
+ preload.__testSetOwner({ events: [{ body: { kind: 'json', value: { ok: true, token: 'do-not-leak' } } }] });
+ const visible = JSON.stringify(preload.bridge({}).events()[0].body.value);
+ assert.match(visible, /"ok":true/); assert.doesNotMatch(visible, /do-not-leak/); assert.match(visible, /\[redacted\]/);
+ preload.__testSetOwner(null);
+});
+test('text payload leaves receive final credential-pattern redaction', async () => {
+ const server = new WebhookServer({ token: 'safe' });
+ const privateKey = `-----BEGIN PRIVATE KEY-----\n${'A'.repeat(64)}\n-----END PRIVATE KEY-----`;
+ await invoke(server, { body: `note=visible token=AKIA_SUPER_SECRET_VALUE_123456 Authorization: Bearer abcdefghijklmnop https://x.test/?signature=hidden\n${privateKey}`, headers: { 'content-type': 'text/plain' } });
+ preload.__testSetOwner(server);
+ const text = preload.bridge({}).events()[0].body.value;
+ assert.match(text, /note=visible/);
+ assert.match(text, /\[redacted-private-key\]/);
+ assert.doesNotMatch(text, /AKIA_SUPER_SECRET_VALUE_123456|abcdefghijklmnop|signature=hidden|BEGIN PRIVATE KEY/);
+ preload.__testSetOwner(null);
+});
+test('renderer localizes every machine redaction token and cross-platform curl is explicit', () => {
+ const app = fs.readFileSync(require.resolve('../src/main/app.js'), 'utf8'); const html = fs.readFileSync(require.resolve('../src/main/index.html'), 'utf8');
+ assert.equal(app.includes('innerHTML'), false); assert.match(app, /\['\[redacted-private-key\]','【私钥已脱敏】'\]/); assert.match(app, /\['\[redacted\]','【已脱敏】'\]/); assert.match(app, /\['\[preview omitted: JSON nesting limit exceeded\]','【JSON 预览已省略:嵌套层级超限】'\]/); assert.match(app, /\['\[preview omitted: redacted output exceeds 64 KiB\]','【预览已省略:脱敏后的输出超过 64 KiB】'\]/); assert.match(app, /\$\('#secret'\)\.value=''/); assert.match(html, /id="secret" type="password"/);
+ assert.equal(hmac('x', 's').length, 64); assert.equal(hmac('x', 's', 'sha512').length, 128); assert.throws(() => hmac('x', 's', 'md5'));
+ for (const platform of ['win32', 'darwin', 'linux']) { assert.equal(hostFor('lan', platform), '127.0.0.1'); assert.match(curlFor('http://127.0.0.1:123/a', platform), /curl/); }
+ assert.match(curlFor('http://127.0.0.1:123/a', 'win32'), /curl\.exe.*'http:\/\/127\.0\.0\.1:123\/a'/);
+ assert.throws(() => curlFor("http://127.0.0.1:123/a'b", 'win32'));
+});