feat: support MCP elicitation and rebuild release

This commit is contained in:
shiyue
2026-08-24 17:39:41 +08:00
parent dd233a40e8
commit bd20a79d4b
69 changed files with 8978 additions and 13 deletions

View File

@@ -0,0 +1,319 @@
#!/usr/bin/env node
/*
* Gitea Webhook 工作流的离线协议回归。
* 该脚本不加载 server.js不访问网络也不写业务 Store实现落地时可把同一
* 组 fixtures 接入 HTTP/Store/MCP 集成测试。
*/
const assert = require('node:assert/strict');
const crypto = require('node:crypto');
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.resolve(__dirname, '..');
const FIXTURE_DIR = path.join(ROOT, 'fixtures', 'gitea-workflow');
const TEST_SECRET = 'fixture-only-webhook-secret';
const BOT_LOGIN = 'ccweb-bot';
function loadFixture(name) {
return JSON.parse(fs.readFileSync(path.join(FIXTURE_DIR, name), 'utf8'));
}
function rawBody(payload) {
return JSON.stringify(payload);
}
function signBody(body, secret = TEST_SECRET) {
return crypto.createHmac('sha256', secret).update(body).digest('hex');
}
function verifySignature(body, header, secret = TEST_SECRET) {
const expected = Buffer.from(signBody(body, secret), 'hex');
const suppliedText = String(header || '').replace(/^sha256=/i, '');
const supplied = /^[0-9a-f]{64}$/i.test(suppliedText)
? Buffer.from(suppliedText, 'hex')
: Buffer.alloc(expected.length);
return supplied.length === expected.length && crypto.timingSafeEqual(supplied, expected);
}
function normalizeEvent(fixture) {
const headers = Object.fromEntries(Object.entries(fixture.headers || {})
.map(([key, value]) => [key.toLowerCase(), value]));
const type = headers['x-gitea-event'];
if (!['issue_comment', 'pull_request_comment'].includes(type)) {
return {accepted: false, reason: 'unsupported_event'};
}
const payload = fixture.payload || {};
const repository = payload.repository || {};
const comment = payload.comment || {};
const sender = payload.sender || comment.user || {};
const pullRequest = payload.pull_request;
const issue = payload.issue;
const resource = type === 'pull_request_comment' || pullRequest
? {
kind: 'pull_request',
number: Number(pullRequest && pullRequest.number),
base: pullRequest && pullRequest.base && pullRequest.base.ref,
head: pullRequest && pullRequest.head && pullRequest.head.ref,
}
: {kind: 'issue', number: Number(issue && issue.number)};
return {
accepted: true,
instanceId: 'default',
deliveryId: headers['x-gitea-delivery'],
eventType: type,
repo: {
owner: String(repository.owner || ''),
name: String(repository.name || ''),
cloneUrl: repository.clone_url || null,
defaultBranch: repository.default_branch || null,
},
resource,
comment: {
id: Number(comment.id),
body: String(comment.body || ''),
createdAt: comment.created_at || null,
},
actor: {login: String(sender.login || ''), isBot: Boolean(sender.is_bot || sender.type === 'Bot')},
};
}
function filterEvent(event, {botLogin = BOT_LOGIN, repositoryStatus = 'active'} = {}) {
if (!event.accepted) return event;
if (event.actor.login.toLowerCase() === botLogin.toLowerCase() || event.actor.isBot) {
return {...event, accepted: false, reason: 'bot_self_comment'};
}
if (repositoryStatus !== 'active') {
return {...event, accepted: false, reason: 'repository_disabled'};
}
if (!/(^|[\s(])@ccweb-bot\b/i.test(event.comment.body)) {
return {...event, accepted: false, reason: 'missing_mention'};
}
const instruction = event.comment.body.replace(/(^|[\s(])@ccweb-bot\b/i, '$1').trim();
return {...event, instruction};
}
class DeliveryQueue {
constructor() {
this.deliveries = new Map();
this.tasks = [];
}
accept(event) {
const deliveryKey = `${event.instanceId}:${event.deliveryId}`;
if (this.deliveries.has(deliveryKey)) {
return {...this.deliveries.get(deliveryKey), status: 'duplicate'};
}
const record = {deliveryKey, status: event.accepted ? 'accepted' : 'ignored'};
this.deliveries.set(deliveryKey, record);
if (!event.accepted) return record;
const task = {
taskId: `task-${this.tasks.length + 1}`,
deliveryKey,
repoKey: `${event.instanceId}:${event.repo.owner}/${event.repo.name}`,
resourceKey: `${event.instanceId}:${event.repo.owner}/${event.repo.name}:${event.resource.kind}:${event.resource.number}`,
state: 'queued',
};
this.tasks.push(task);
record.taskId = task.taskId;
return record;
}
}
function recoverTasks(snapshot) {
return snapshot.tasks.map((task) => {
if (['succeeded', 'failed', 'cancelled', 'aborted', 'waiting_user'].includes(task.state)) {
return {...task};
}
if (task.state === 'running' || task.state === 'preparing' || task.state === 'aborting') {
return task.attempt >= 1
? {...task, state: 'failed', errorCode: 'restart_retry_exhausted'}
: {...task, state: 'retry_wait', attempt: task.attempt + 1, errorCode: 'interrupted_by_restart'};
}
return {...task};
});
}
function runReplyCompensation(fixture) {
const events = [];
if (fixture.mcpAccepted && !fixture.mcpCommentVisible) {
events.push('reply_retry');
if (!fixture.retryCommentVisible && fixture.restFallbackVisible) {
events.push('rest_fallback');
return {state: 'succeeded_with_rest_fallback', events};
}
if (fixture.retryCommentVisible) return {state: 'succeeded', events};
return {state: 'failed_reply', events};
}
return {state: fixture.mcpCommentVisible ? 'succeeded' : 'failed_reply', events};
}
function buildThreadConfig(fixture) {
return {
cwd: fixture.cwd,
mcp_servers: {
gitea: {
command: fixture.mcpServer.command,
args: fixture.mcpServer.args,
env: fixture.mcpServer.env,
},
},
collaborationMode: fixture.collaborationMode,
};
}
function isWorkspacePathSafe(candidate, root) {
const resolvedRoot = path.resolve(root);
const resolvedCandidate = path.resolve(candidate);
return resolvedCandidate === resolvedRoot || resolvedCandidate.startsWith(`${resolvedRoot}${path.sep}`);
}
const tests = [];
function test(name, fn) {
tests.push({name, fn});
}
test('合法 Issue/PR 评论规范化并提取指令', () => {
const issue = loadFixture('valid-issue-comment.json');
const issueEvent = filterEvent(normalizeEvent(issue));
assert.equal(issueEvent.accepted, true);
assert.equal(issueEvent.resource.kind, 'issue');
assert.equal(issueEvent.instruction, issue.expected.instruction);
const pr = loadFixture('valid-pr-comment.json');
const prEvent = filterEvent(normalizeEvent(pr));
assert.equal(prEvent.accepted, true);
assert.equal(prEvent.resource.kind, 'pull_request');
assert.equal(prEvent.resource.base, pr.expected.base);
assert.equal(prEvent.resource.head, pr.expected.head);
});
test('HMAC 验签使用 raw body错误签名不产生任务', () => {
const fixture = loadFixture('valid-issue-comment.json');
const body = rawBody(fixture.payload);
const signature = signBody(body);
assert.equal(verifySignature(body, signature), true);
assert.equal(verifySignature(`${body} `, signature), false);
assert.equal(verifySignature(body, ''), false);
const queue = new DeliveryQueue();
assert.equal(verifySignature(body, '00'.repeat(32)), false);
assert.equal(queue.tasks.length, 0);
});
test('事件过滤阻止 Bot 自评论、无 mention、非评论和停用仓库', () => {
const bot = filterEvent(normalizeEvent(loadFixture('bot-self-comment.json')));
assert.equal(bot.accepted, false);
assert.equal(bot.reason, 'bot_self_comment');
for (const fixture of loadFixture('ignored-events.json')) {
const result = filterEvent(normalizeEvent(fixture));
assert.equal(result.accepted, false, fixture.name);
assert.equal(result.reason, fixture.expected.reason, fixture.name);
}
const valid = filterEvent(normalizeEvent(loadFixture('valid-issue-comment.json')), {repositoryStatus: 'disabled'});
assert.equal(valid.reason, 'repository_disabled');
});
test('deliveryKey 幂等,重复 Webhook 不增加 task', () => {
const fixture = loadFixture('valid-issue-comment.json');
const event = filterEvent(normalizeEvent(fixture));
const queue = new DeliveryQueue();
const first = queue.accept(event);
const second = queue.accept(event);
assert.equal(first.status, 'accepted');
assert.equal(second.status, 'duplicate');
assert.equal(queue.tasks.length, 1);
assert.equal(first.taskId, second.taskId);
});
test('重启恢复只重试中断任务一次,终态和 waiting_user 不重跑', () => {
const snapshot = loadFixture('queue-recovery.json');
const recovered = recoverTasks(snapshot);
const byId = new Map(recovered.map((task) => [task.taskId, task]));
assert.equal(byId.get('task-queued').state, 'queued');
assert.equal(byId.get('task-running').state, 'retry_wait');
assert.equal(byId.get('task-running').errorCode, 'interrupted_by_restart');
assert.equal(byId.get('task-running').attempt, 1);
assert.equal(byId.get('task-exhausted').state, 'failed');
assert.equal(byId.get('task-waiting').state, 'waiting_user');
assert.equal(byId.get('task-terminal').state, 'succeeded');
});
test('队列恢复遵守同仓库锁', () => {
const active = [
{repoKey: 'default:acme/widget', state: 'running'},
{repoKey: 'default:acme/other', state: 'preparing'},
];
assert.equal(active.filter((task) => task.repoKey === 'default:acme/widget' && ['preparing', 'running', 'aborting'].includes(task.state)).length, 1);
assert.equal(active.filter((task) => task.repoKey === 'default:acme/other' && ['preparing', 'running', 'aborting'].includes(task.state)).length, 1);
const queued = {repoKey: 'default:acme/widget', state: 'queued'};
assert.equal(queued.state, 'queued');
});
test('回帖缺失只补发一次,失败后 REST 兜底并区分终态', () => {
const result = runReplyCompensation(loadFixture('reply-failure.json'));
assert.equal(result.state, 'succeeded_with_rest_fallback');
assert.deepEqual(result.events, ['reply_retry', 'rest_fallback']);
assert.equal(result.events.filter((event) => event === 'reply_retry').length, 1);
});
test('回执策略只发起始已收到,成功不再发送中间状态', () => {
const source = fs.readFileSync(path.join(ROOT, 'server.js'), 'utf8');
assert.match(source, /postGiteaWorkflowStatus\(task, '已收到'\)/);
for (const status of ['已接收并排队', '运行中', '等待用户补充信息', '已完成,最终回帖已确认', '已完成REST 兜底回帖)', '回帖状态未知']) {
const callPattern = new RegExp(
`postGiteaWorkflowStatus\\([^\\n;]*['"]${status}['"]`,
);
assert.equal(callPattern.test(source), false, `不应发送中间状态:${status}`);
}
const settleStart = source.indexOf('async function settleGiteaWorkflowTurn');
const runnerStart = source.indexOf('giteaWorkflowService.setRunner');
const settleSource = source.slice(settleStart, runnerStart);
assert.doesNotMatch(settleSource, /postGiteaWorkflowStatus\(/, 'MCP 成功、等待用户和回帖重试过程不应再发状态评论');
assert.match(source, /commandExistsForMcp\(giteaMcpCommand\)/, '启动前必须检查 gitea-mcp 命令是否存在');
assert.match(source, /code: 'gitea_mcp_not_found'/, '缺失 MCP 命令必须使用稳定错误码快速失败');
assert.match(source, /failed_reply: \(task\) => `回帖失败:/);
});
test('MCP 线程配置按线程注入且不重复传顶层 model/effort', () => {
const config = buildThreadConfig(loadFixture('mcp-thread-config.json'));
assert.equal(config.mcp_servers.gitea.command, 'gitea-mcp');
assert.deepEqual(config.mcp_servers.gitea.args.slice(0, 2), ['-t', 'stdio']);
assert.equal(config.mcp_servers.gitea.env.GITEA_ACCESS_TOKEN.startsWith('secret-ref:'), true);
assert.equal(config.model, undefined);
assert.equal(config.effort, undefined);
assert.equal(config.collaborationMode.settings.model, 'configured-model');
assert.equal(config.collaborationMode.settings.reasoning_effort, 'medium');
});
test('安全边界:路径、凭据和不可信正文不越权', () => {
const fixtureText = fs.readdirSync(FIXTURE_DIR)
.map((name) => fs.readFileSync(path.join(FIXTURE_DIR, name), 'utf8'))
.join('\n');
assert.equal(fixtureText.includes('fixture-only-webhook-secret'), false);
assert.equal(fixtureText.includes('Authorization:'), false);
assert.equal(fixtureText.includes('..\\'), false);
const config = buildThreadConfig(loadFixture('mcp-thread-config.json'));
assert.equal(config.cwd.startsWith('/var/lib/ccweb/workspaces/'), true);
assert.equal(config.mcp_servers.gitea.env.GITEA_ACCESS_TOKEN.includes('fixture-only'), false);
assert.equal(isWorkspacePathSafe(config.cwd, '/var/lib/ccweb/workspaces'), true);
assert.equal(isWorkspacePathSafe('/var/lib/ccweb/workspaces/../secrets', '/var/lib/ccweb/workspaces'), false);
assert.equal(isWorkspacePathSafe('/tmp/ccweb-workspace', '/var/lib/ccweb/workspaces'), false);
});
let failures = 0;
for (const current of tests) {
try {
current.fn();
process.stdout.write(`PASS ${current.name}\n`);
} catch (error) {
failures += 1;
process.stderr.write(`FAIL ${current.name}: ${error.message}\n`);
}
}
if (failures > 0) {
process.stderr.write(`\n${failures}/${tests.length} 场景失败\n`);
process.exitCode = 1;
} else {
process.stdout.write(`\nGitea Webhook regression passed: ${tests.length} 场景\n`);
}