feat: support MCP elicitation and rebuild release
This commit is contained in:
@@ -117,6 +117,7 @@ ${runCommand}
|
||||
## 目录说明
|
||||
|
||||
- \`public/\`:前端静态资源
|
||||
- \`bin/gitea-mcp\`:Gitea Workflow 使用的官方 MCP 二进制
|
||||
- \`config/\`:运行时配置,首次启动会写入登录和通知配置
|
||||
- \`sessions/\`:会话数据
|
||||
- \`logs/\`:运行日志
|
||||
@@ -128,6 +129,8 @@ ${runCommand}
|
||||
|
||||
function copyRuntimeAssets(projectRoot, releaseDir, target, binaryName) {
|
||||
copyDirIfExists(path.join(projectRoot, 'public'), path.join(releaseDir, 'public'));
|
||||
// Gitea MCP 是 cc-web 工作流运行时的一部分,随源码/单文件发布目录一起交付。
|
||||
copyDirIfExists(path.join(projectRoot, 'bin'), path.join(releaseDir, 'bin'));
|
||||
|
||||
for (const file of ['.env.example', 'README.md', 'README.en.md', 'CHANGELOG.md', 'package.json']) {
|
||||
copyFileIfExists(path.join(projectRoot, file), path.join(releaseDir, file));
|
||||
|
||||
86
scripts/gitea-mcp-probe-unit.js
Normal file
86
scripts/gitea-mcp-probe-unit.js
Normal file
@@ -0,0 +1,86 @@
|
||||
'use strict';
|
||||
|
||||
/** gitea-mcp stdio 预检离线回归。 */
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const process = require('node:process');
|
||||
const {
|
||||
DEFAULT_TIMEOUT_MS,
|
||||
MAX_TIMEOUT_MS,
|
||||
probeGiteaMcp,
|
||||
} = require('../lib/gitea-mcp-probe');
|
||||
|
||||
const fixture = path.join(__dirname, '..', 'fixtures', 'gitea-workflow', 'mock-gitea-mcp.js');
|
||||
|
||||
async function expectCode(promise, code) {
|
||||
await assert.rejects(promise, (error) => {
|
||||
assert.equal(error.code, code);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
assert.equal(DEFAULT_TIMEOUT_MS, 8000);
|
||||
assert.equal(MAX_TIMEOUT_MS, 30000);
|
||||
|
||||
const success = await probeGiteaMcp({
|
||||
command: process.execPath,
|
||||
args: [fixture],
|
||||
env: { GITEA_MCP_FIXTURE_MODE: 'success', GITEA_ACCESS_TOKEN: 'fixture-token' },
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
assert.equal(success.ok, true);
|
||||
assert.equal(success.protocolVersion, '2024-11-05');
|
||||
|
||||
await expectCode(probeGiteaMcp({
|
||||
command: process.execPath,
|
||||
args: [fixture],
|
||||
env: { GITEA_MCP_FIXTURE_MODE: 'error' },
|
||||
timeoutMs: 1000,
|
||||
}), 'gitea_mcp_handshake_failed');
|
||||
|
||||
await expectCode(probeGiteaMcp({
|
||||
command: process.execPath,
|
||||
args: [fixture],
|
||||
env: { GITEA_MCP_FIXTURE_MODE: 'exit' },
|
||||
timeoutMs: 1000,
|
||||
}), 'gitea_mcp_start_failed');
|
||||
|
||||
await expectCode(probeGiteaMcp({
|
||||
command: process.execPath,
|
||||
args: [fixture],
|
||||
env: { GITEA_MCP_FIXTURE_MODE: 'timeout' },
|
||||
timeoutMs: 250,
|
||||
}), 'gitea_mcp_handshake_timeout');
|
||||
|
||||
await expectCode(probeGiteaMcp({
|
||||
command: '/definitely/missing/gitea-mcp',
|
||||
timeoutMs: 1000,
|
||||
}), 'gitea_mcp_spawn_failed');
|
||||
|
||||
// 协议守门:失败仍交给统一的队列终态回执,线程级注入和“禁止自动安装”不回退。
|
||||
const serverSource = fs.readFileSync(path.join(__dirname, '..', 'server.js'), 'utf8');
|
||||
const probeSource = fs.readFileSync(path.join(__dirname, '..', 'lib', 'gitea-mcp-probe.js'), 'utf8');
|
||||
const buildSource = fs.readFileSync(path.join(__dirname, '..', 'scripts', 'build-single-exe.js'), 'utf8');
|
||||
assert.match(serverSource, /await probeGiteaMcp\(\{/);
|
||||
assert.match(serverSource, /code: 'gitea_mcp_not_found'/);
|
||||
assert.match(serverSource, /path\.join\(APP_DIR, 'bin', bundledName\)/);
|
||||
assert.match(probeSource, /gitea_mcp_start_failed/);
|
||||
assert.match(probeSource, /gitea_mcp_handshake_failed/);
|
||||
assert.match(probeSource, /gitea_mcp_handshake_timeout/);
|
||||
assert.match(serverSource, /failed: \(task\) => `任务失败:/);
|
||||
assert.match(serverSource, /failed_reply: \(task\) => `回帖失败:/);
|
||||
assert.match(serverSource, /statusComments/);
|
||||
assert.match(serverSource, /config\['mcp_servers\.gitea'\]/);
|
||||
assert.match(buildSource, /copyDirIfExists\(path\.join\(projectRoot, 'bin'\), path\.join\(releaseDir, 'bin'\)\)/);
|
||||
assert.doesNotMatch(serverSource, /npm\s+(?:install| i)\b|curl\s+[^\n]*(?:gitea-mcp|github)/i);
|
||||
|
||||
console.log('Gitea MCP probe unit checks passed.');
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error.stack || error.message || error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
319
scripts/gitea-webhook-regression.js
Normal file
319
scripts/gitea-webhook-regression.js
Normal 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`);
|
||||
}
|
||||
243
scripts/gitea-webhook-workspace-unit.js
Normal file
243
scripts/gitea-webhook-workspace-unit.js
Normal file
@@ -0,0 +1,243 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const crypto = require('node:crypto');
|
||||
const { EventEmitter } = require('node:events');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const {
|
||||
DeliveryStore,
|
||||
createGiteaWebhookReceiver,
|
||||
createGiteaWebhookRoute,
|
||||
normalizeWebhookEvent,
|
||||
parseBotMention,
|
||||
verifyWebhookSignature,
|
||||
} = require('../lib/gitea-webhook');
|
||||
const { buildWorkflowMarker } = require('../lib/gitea-workflow-codex');
|
||||
const {
|
||||
BlockedWorkspaceError,
|
||||
createGiteaWorkspaceManager,
|
||||
createRepositoryLockManager,
|
||||
normalizeRepository,
|
||||
withTempGitCredentials,
|
||||
} = require('../lib/gitea-workspace');
|
||||
|
||||
function sign(body, secret) {
|
||||
return `sha256=${crypto.createHmac('sha256', secret).update(body).digest('hex')}`;
|
||||
}
|
||||
|
||||
function payloadFixture(overrides = {}) {
|
||||
return {
|
||||
action: 'created',
|
||||
repository: {
|
||||
owner: { login: 'alice' },
|
||||
name: 'demo',
|
||||
full_name: 'alice/demo',
|
||||
clone_url: 'https://gitea.example/alice/demo.git',
|
||||
default_branch: 'main',
|
||||
},
|
||||
issue: { number: 7, title: '修复问题' },
|
||||
comment: {
|
||||
id: 19,
|
||||
body: '@ccweb-bot 请检查这个问题',
|
||||
user: { id: 11, login: 'alice' },
|
||||
},
|
||||
sender: { id: 11, login: 'alice' },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const secret = 'webhook-secret';
|
||||
const body = Buffer.from(JSON.stringify(payloadFixture()));
|
||||
assert.equal(verifyWebhookSignature(body, sign(body, secret), secret), true);
|
||||
assert.equal(verifyWebhookSignature(body, sign(body, secret).slice(7), secret), true);
|
||||
assert.equal(verifyWebhookSignature(Buffer.from(`${body}\n`), sign(body, secret), secret), false);
|
||||
assert.equal(parseBotMention('@ccweb-bot 做事').instruction, '做事');
|
||||
assert.equal(parseBotMention('提及 @ccweb-bot-extra'), null);
|
||||
|
||||
const normalized = normalizeWebhookEvent({
|
||||
payload: payloadFixture(),
|
||||
headers: { 'x-gitea-event': 'issue_comment', 'x-gitea-delivery': 'd-1' },
|
||||
instanceId: 'default',
|
||||
botIdentity: { login: 'ccweb-bot' },
|
||||
});
|
||||
assert.equal(normalized.ignored, false);
|
||||
assert.equal(normalized.repository.fullName, 'alice/demo');
|
||||
assert.equal(normalized.resource.key, 'issue:7');
|
||||
assert.equal(normalized.mention.instruction, '请检查这个问题');
|
||||
assert.equal(normalizeWebhookEvent({
|
||||
payload: payloadFixture({ comment: { body: '@ccweb-bot 状态', user: { login: 'ccweb-bot' } }, sender: { login: 'ccweb-bot' } }),
|
||||
headers: { 'x-gitea-event': 'issue_comment' },
|
||||
botIdentity: { login: 'ccweb-bot' },
|
||||
}).reason, 'bot_self_comment');
|
||||
assert.equal(normalizeWebhookEvent({
|
||||
payload: payloadFixture({ comment: { body: '普通评论', user: { login: 'alice' } } }),
|
||||
headers: { 'x-gitea-event': 'issue_comment' },
|
||||
botIdentity: { login: 'ccweb-bot' },
|
||||
}).reason, 'missing_mention');
|
||||
assert.equal(normalizeWebhookEvent({
|
||||
payload: payloadFixture({ comment: { body: `@ccweb-bot 状态\n${buildWorkflowMarker({ taskId: 't', turnId: 'u', resourceKey: 'r' })}`, user: {} }, sender: {} }),
|
||||
headers: { 'x-gitea-event': 'issue_comment' },
|
||||
botIdentity: { login: 'ccweb-bot' },
|
||||
}).reason, 'workflow_marker_comment');
|
||||
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccweb-gitea-webhook-unit-'));
|
||||
try {
|
||||
const deliveryFile = path.join(tempRoot, 'deliveries.json');
|
||||
const store = new DeliveryStore({ filePath: deliveryFile });
|
||||
const tasks = [];
|
||||
const receiver = createGiteaWebhookReceiver({
|
||||
secret,
|
||||
instanceId: 'default',
|
||||
deliveryStore: store,
|
||||
onTask: async (task) => tasks.push(task),
|
||||
});
|
||||
const accepted = await receiver.processWebhook({
|
||||
headers: { 'x-gitea-event': 'issue_comment', 'x-gitea-delivery': 'd-1', 'x-gitea-signature': sign(body, secret) },
|
||||
rawBody: body,
|
||||
});
|
||||
assert.equal(accepted.statusCode, 202);
|
||||
assert.match(accepted.payload.taskId, /^gitea-task-/);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(tasks.length, 1);
|
||||
const duplicate = await receiver.processWebhook({
|
||||
headers: { 'x-gitea-event': 'issue_comment', 'x-gitea-delivery': 'd-1', 'x-gitea-signature': sign(body, secret) },
|
||||
rawBody: body,
|
||||
});
|
||||
assert.equal(duplicate.statusCode, 200);
|
||||
assert.equal(duplicate.payload.duplicate, true);
|
||||
const invalid = await receiver.processWebhook({
|
||||
headers: { 'x-gitea-event': 'issue_comment', 'x-gitea-delivery': 'd-2', 'x-gitea-signature': 'sha256=bad' },
|
||||
rawBody: body,
|
||||
});
|
||||
assert.equal(invalid.statusCode, 401);
|
||||
assert.equal(store.has('default:d-2'), false);
|
||||
const ignored = await receiver.processWebhook({
|
||||
headers: { 'x-gitea-event': 'push', 'x-gitea-delivery': 'd-3', 'x-gitea-signature': sign(body, secret) },
|
||||
rawBody: body,
|
||||
});
|
||||
assert.equal(ignored.statusCode, 200);
|
||||
assert.equal(ignored.payload.ignored, true);
|
||||
const reloaded = new DeliveryStore({ filePath: deliveryFile });
|
||||
assert.equal(reloaded.has('default:d-1'), true);
|
||||
|
||||
const route = createGiteaWebhookRoute(receiver);
|
||||
const request = new EventEmitter();
|
||||
request.method = 'POST';
|
||||
request.url = '/api/gitea/webhook';
|
||||
request.headers = { 'x-gitea-event': 'issue_comment', 'x-gitea-delivery': 'd-route', 'x-gitea-signature': sign(body, secret) };
|
||||
const responseDone = new Promise((resolve) => {
|
||||
const response = {
|
||||
headersSent: false,
|
||||
writeHead(statusCode) { this.statusCode = statusCode; this.headersSent = true; },
|
||||
end(value) { this.body = JSON.parse(value); resolve(this); },
|
||||
};
|
||||
assert.equal(route(request, response, new URL('http://localhost/api/gitea/webhook')), true);
|
||||
request.emit('data', body);
|
||||
request.emit('end');
|
||||
});
|
||||
const response = await responseDone;
|
||||
assert.equal(response.statusCode, 202);
|
||||
assert.equal(response.body.state, 'queued');
|
||||
assert.equal(route({ method: 'GET', url: '/api/gitea/webhook', headers: {} }, {}, '/api/gitea/webhook'), false);
|
||||
|
||||
receiver.updateConfig({ botLogin: 'renamed-bot' });
|
||||
const renamedBody = Buffer.from(JSON.stringify(payloadFixture({
|
||||
comment: { id: 20, body: '@renamed-bot 继续处理', user: { id: 11, login: 'alice' } },
|
||||
})));
|
||||
const renamed = await receiver.processWebhook({
|
||||
headers: { 'x-gitea-event': 'issue_comment', 'x-gitea-delivery': 'd-renamed', 'x-gitea-signature': sign(renamedBody, secret) },
|
||||
rawBody: renamedBody,
|
||||
});
|
||||
assert.equal(renamed.statusCode, 202);
|
||||
assert.equal(renamed.payload.status, 'accepted');
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccweb-gitea-workspace-unit-'));
|
||||
try {
|
||||
assert.throws(() => normalizeRepository({ owner: '../bad', name: 'demo', cloneUrl: 'https://gitea.example/a/demo.git' }), /owner/);
|
||||
assert.throws(() => normalizeRepository({ owner: 'alice', name: 'demo', cloneUrl: 'http://gitea.example/a/demo.git' }), /HTTPS/);
|
||||
const calls = [];
|
||||
const manager = createGiteaWorkspaceManager({
|
||||
workspaceRoot,
|
||||
instanceId: 'default',
|
||||
botUsername: 'ccweb-bot',
|
||||
gitRunner: async ({ args, cwd, env }) => {
|
||||
calls.push({ args, cwd, env });
|
||||
if (args[0] === 'clone') {
|
||||
fs.mkdirSync(path.join(args[args.length - 1], '.git'), { recursive: true });
|
||||
}
|
||||
if (args[0] === 'status') return { code: 0, stdout: '', stderr: '' };
|
||||
return { code: 0, stdout: '', stderr: '' };
|
||||
},
|
||||
});
|
||||
const repository = { owner: 'alice', name: 'demo', cloneUrl: 'https://gitea.example/alice/demo.git', defaultBranch: 'main' };
|
||||
const first = await manager.ensureRepository(repository, { token: 'token-value' });
|
||||
assert.equal(first.status, 'ready');
|
||||
assert.equal(calls[0].args[0], 'clone');
|
||||
assert.deepEqual(calls[0].args.slice(0, 7), ['clone', '--origin', 'origin', '--depth', '1', '--single-branch', '--branch']);
|
||||
assert.equal(calls[0].args[7], 'main');
|
||||
assert.equal(calls[0].args.includes('token-value'), false);
|
||||
assert.equal(calls[0].env.GIT_ASKPASS.endsWith('askpass.sh'), true);
|
||||
await manager.ensureRepository(repository, { token: 'token-value' });
|
||||
assert.deepEqual(calls.slice(1).map((item) => item.args[0]), ['status', 'fetch', 'checkout', 'merge']);
|
||||
|
||||
const dirtyManager = createGiteaWorkspaceManager({
|
||||
workspaceRoot: path.join(workspaceRoot, 'dirty'),
|
||||
instanceId: 'default',
|
||||
gitRunner: async ({ args }) => {
|
||||
if (args[0] === 'clone') return { code: 0, stdout: '', stderr: '' };
|
||||
if (args[0] === 'status') return { code: 0, stdout: ' M tracked.txt\n', stderr: '' };
|
||||
return { code: 0, stdout: '', stderr: '' };
|
||||
},
|
||||
});
|
||||
const dirtyPath = dirtyManager.workspacePath(repository);
|
||||
fs.mkdirSync(path.join(dirtyPath, '.git'), { recursive: true });
|
||||
await assert.rejects(() => dirtyManager.ensureRepository(repository, { token: 'token-value' }), (error) => {
|
||||
assert(error instanceof BlockedWorkspaceError);
|
||||
assert.equal(error.code, 'blocked_workspace');
|
||||
return true;
|
||||
});
|
||||
const dirtyContinuation = await dirtyManager.ensureRepository(repository, {
|
||||
token: 'token-value', allowDirty: true, dirtySessionKey: 'default:alice/demo:issue:7',
|
||||
});
|
||||
assert.equal(dirtyContinuation.status, 'dirty_ready');
|
||||
assert.equal(dirtyContinuation.dirtySessionKey, 'default:alice/demo:issue:7');
|
||||
} finally {
|
||||
fs.rmSync(workspaceRoot, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const lockRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccweb-gitea-lock-unit-'));
|
||||
try {
|
||||
const locks = createRepositoryLockManager({ lockRoot, retryMs: 5, waitTimeoutMs: 1000 });
|
||||
const order = [];
|
||||
await Promise.all([
|
||||
locks.withLock('same-repo', async () => {
|
||||
order.push('first-start');
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
order.push('first-end');
|
||||
}),
|
||||
locks.withLock('same-repo', async () => order.push('second')),
|
||||
]);
|
||||
assert.deepEqual(order, ['first-start', 'first-end', 'second']);
|
||||
} finally {
|
||||
fs.rmSync(lockRoot, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
let seenEnv;
|
||||
await withTempGitCredentials({ token: 'secret-token', username: 'bot' }, async (env) => {
|
||||
seenEnv = env;
|
||||
});
|
||||
assert.equal(seenEnv.CCWEB_GIT_TOKEN, 'secret-token');
|
||||
assert.equal(fs.existsSync(seenEnv.GIT_ASKPASS), false, '临时凭据脚本必须在命令完成后清理');
|
||||
console.log('Gitea webhook/workspace unit checks passed');
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error.stack || error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
153
scripts/gitea-workflow-codex-unit.js
Normal file
153
scripts/gitea-workflow-codex-unit.js
Normal file
@@ -0,0 +1,153 @@
|
||||
'use strict';
|
||||
|
||||
/** Gitea Workflow Codex/App 协议适配器离线单测。 */
|
||||
const assert = require('node:assert/strict');
|
||||
const {
|
||||
buildGiteaMcpServerConfig,
|
||||
buildGiteaThreadConfig,
|
||||
buildGiteaMcpConfig,
|
||||
extractTurnId,
|
||||
extractThreadId,
|
||||
buildWorkflowMarker,
|
||||
parseWorkflowMarker,
|
||||
encodeMarker,
|
||||
isBotComment,
|
||||
createGiteaWorkflowCodex,
|
||||
WORKFLOW_STATES,
|
||||
} = require('../lib/gitea-workflow-codex');
|
||||
|
||||
async function main() {
|
||||
const mcp = buildGiteaMcpServerConfig({
|
||||
host: 'https://gitea.example/',
|
||||
accessToken: 'secret-token',
|
||||
});
|
||||
assert.deepEqual(mcp.args.slice(0, 4), ['-t', 'stdio', '-H', 'https://gitea.example']);
|
||||
assert.equal(mcp.env.GITEA_ACCESS_TOKEN, 'secret-token');
|
||||
assert.equal(mcp.env.GITEA_HOST, 'https://gitea.example');
|
||||
assert.equal(buildGiteaMcpConfig({ gitea: { host: 'https://gitea.example', token: 't' } }).type, 'stdio');
|
||||
|
||||
const thread = buildGiteaThreadConfig({
|
||||
cwd: '/tmp/gitea-workspace',
|
||||
host: 'https://gitea.example',
|
||||
accessToken: 'secret-token',
|
||||
});
|
||||
assert.equal(thread.config['mcp_servers.gitea'].type, 'stdio');
|
||||
assert.equal(thread.config['mcp_servers.gitea'].env.GITEA_ACCESS_TOKEN, 'secret-token');
|
||||
assert.equal(Object.hasOwn(thread.config, 'model'), false);
|
||||
|
||||
assert.equal(extractTurnId({ params: { turn: { id: 'turn-1' } } }), 'turn-1');
|
||||
assert.equal(extractThreadId({ params: { thread: { id: 'thread-1' } } }), 'thread-1');
|
||||
const marker = buildWorkflowMarker({ taskId: 'task-1', turnId: 'turn-1', resourceKey: 'default:a/r:issue:1' });
|
||||
assert.deepEqual(parseWorkflowMarker(`正文\n${marker}`), {
|
||||
version: '1', taskId: 'task-1', turnId: 'turn-1', resourceKey: 'default:a/r:issue:1', kind: 'final',
|
||||
});
|
||||
assert.equal(parseWorkflowMarker('<!-- ccweb-gitea v="1" taskId="x" -->'), null);
|
||||
assert.equal(encodeMarker({ taskId: 'task-1', turnId: 'turn-1', resourceKey: 'r', state: 'fallback' }).includes('kind="fallback"'), true);
|
||||
|
||||
const bot = { id: '42', login: 'ccweb-bot' };
|
||||
assert.equal(isBotComment({ user: { id: 42, login: 'renamed-bot' } }, bot), true);
|
||||
assert.equal(isBotComment({ user: { login: 'CCWEB-BOT' } }, bot), true);
|
||||
assert.equal(isBotComment({ user: { login: 'alice' } }, bot), false);
|
||||
assert.equal(
|
||||
createGiteaWorkflowCodex({ botIdentity: bot }).matchWorkflowReply(
|
||||
{ body: `已完成\n${buildWorkflowMarker({ taskId: 'task-1', turnId: 'turn-old', resourceKey: 'r' })}`, user: bot },
|
||||
{ taskId: 'task-1', turnId: 'turn-new', turnIds: ['turn-new', 'turn-old'], resourceKey: 'r' },
|
||||
),
|
||||
true,
|
||||
);
|
||||
|
||||
let nowTick = 0;
|
||||
const retryCalls = [];
|
||||
const restCalls = [];
|
||||
let verifyCalls = 0;
|
||||
const adapter = createGiteaWorkflowCodex({
|
||||
botIdentity: bot,
|
||||
now: () => new Date(Date.UTC(2026, 7, 24, 0, 0, nowTick++)),
|
||||
verifyReply: async ({ source, expected }) => {
|
||||
verifyCalls += 1;
|
||||
if (source === 'mcp' && verifyCalls === 1) return { status: 'missing' };
|
||||
if (source === 'mcp' && verifyCalls === 2) return {
|
||||
comments: [{
|
||||
id: 99,
|
||||
body: `已完成\n${buildWorkflowMarker(expected)}`,
|
||||
user: { id: 42, login: 'ccweb-bot' },
|
||||
}],
|
||||
};
|
||||
return { status: 'missing' };
|
||||
},
|
||||
startTurn: async ({ threadId, kind, hidden, prompt }) => {
|
||||
retryCalls.push({ threadId, kind, hidden, prompt });
|
||||
return { turnId: 'turn-retry-1' };
|
||||
},
|
||||
sendRestReply: async (input) => {
|
||||
restCalls.push(input);
|
||||
return { confirmed: true, commentId: 100 };
|
||||
},
|
||||
});
|
||||
adapter.createTask({ taskId: 'task-1', sessionKey: 'default:r:issue:1', resourceKey: 'default:r:issue:1' });
|
||||
adapter.attachThread('task-1', 'thread-1');
|
||||
adapter.handleTurnStarted({ taskId: 'task-1', threadId: 'thread-1', turnId: 'turn-1' });
|
||||
const retry = await adapter.handleTurnCompleted({ taskId: 'task-1', turnId: 'turn-1', finalText: '最终答案' });
|
||||
assert.equal(retry.event, 'reply_retry_started');
|
||||
assert.equal(retryCalls.length, 1);
|
||||
assert.equal(retryCalls[0].threadId, 'thread-1');
|
||||
assert.equal(retryCalls[0].kind, 'reply_retry');
|
||||
assert.equal(retryCalls[0].hidden, true);
|
||||
const confirmed = await adapter.handleTurnCompleted({ taskId: 'task-1', turnId: 'turn-retry-1', finalText: '最终答案' });
|
||||
assert.equal(confirmed.event, 'reply_confirmed');
|
||||
assert.equal(confirmed.source, 'mcp');
|
||||
assert.equal(restCalls.length, 0);
|
||||
|
||||
let unknownRetry = 0;
|
||||
let unknownRest = 0;
|
||||
const unknown = createGiteaWorkflowCodex({
|
||||
verifyReply: async () => ({ status: 'unknown' }),
|
||||
startTurn: async () => { unknownRetry += 1; return { turnId: 'should-not-run' }; },
|
||||
sendRestReply: async () => { unknownRest += 1; return { confirmed: true }; },
|
||||
});
|
||||
unknown.createTask({ taskId: 'task-unknown', sessionKey: 's', resourceKey: 'r' });
|
||||
unknown.handleTurnStarted({ taskId: 'task-unknown', threadId: 'thread-u', turnId: 'turn-u' });
|
||||
const unknownResult = await unknown.handleTurnCompleted({ taskId: 'task-unknown', turnId: 'turn-u', finalText: 'x' });
|
||||
assert.equal(unknownResult.event, 'reply_verification_unknown');
|
||||
assert.equal(unknownRetry, 0);
|
||||
assert.equal(unknownRest, 0);
|
||||
assert.equal(unknown.getTask('task-unknown').state, WORKFLOW_STATES.FAILED_REPLY);
|
||||
|
||||
const continuation = adapter.queueWaitingUserComment({
|
||||
taskId: 'task-1',
|
||||
nextTaskId: 'task-2',
|
||||
comment: { id: 7, body: '@ccweb-bot 请继续', user: { id: 9, login: 'alice' } },
|
||||
});
|
||||
assert.equal(continuation.threadId, 'thread-1');
|
||||
assert.equal(adapter.getTask('task-2').parentTaskId, 'task-1');
|
||||
|
||||
const waitingAdapter = createGiteaWorkflowCodex({ botIdentity: bot });
|
||||
waitingAdapter.createTask({ taskId: 'task-wait', sessionKey: 's-wait', resourceKey: 'r-wait', threadId: 'thread-wait' });
|
||||
waitingAdapter.handleTurnStarted({ taskId: 'task-wait', threadId: 'thread-wait', turnId: 'turn-wait' });
|
||||
const waiting = waitingAdapter.handleTurnEvent({
|
||||
taskId: 'task-wait',
|
||||
notification: { method: 'item/tool/requestUserInput', params: { turnId: 'turn-wait', questions: [{ id: 'q1' }] } },
|
||||
});
|
||||
assert.equal(waiting.event, 'waiting_user');
|
||||
assert.equal(waitingAdapter.getTask('task-wait').state, WORKFLOW_STATES.WAITING_USER);
|
||||
const waitingContinuation = waitingAdapter.queueWaitingUserComment({
|
||||
taskId: 'task-wait', nextTaskId: 'task-wait-2',
|
||||
comment: { id: 10, body: '@ccweb-bot 答案是 main', user: { id: 9, login: 'alice' } },
|
||||
});
|
||||
assert.equal(waitingContinuation.threadId, 'thread-wait');
|
||||
assert.equal(adapter.queueWaitingUserComment({
|
||||
taskId: 'task-1', nextTaskId: 'task-bot',
|
||||
comment: { id: 8, body: 'bot', user: { id: 42, login: 'ccweb-bot' } },
|
||||
}).ignored, true);
|
||||
assert.equal(adapter.queueWaitingUserComment({
|
||||
taskId: 'task-1', nextTaskId: 'task-marker',
|
||||
comment: { id: 9, body: `重复回执 ${buildWorkflowMarker({ taskId: 'task-1', turnId: 'turn-1', resourceKey: 'default:r:issue:1' })}`, user: { id: 9, login: 'alice' } },
|
||||
}).ignored, true);
|
||||
|
||||
console.log('Gitea Workflow Codex unit checks passed.');
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error.stack || error.message || error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
176
scripts/gitea-workflow-core-unit.js
Normal file
176
scripts/gitea-workflow-core-unit.js
Normal file
@@ -0,0 +1,176 @@
|
||||
'use strict';
|
||||
|
||||
/** Gitea Workflow 核心领域模块单元测试,可直接执行:node scripts/gitea-workflow-core-unit.js */
|
||||
|
||||
const assert = require('assert');
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const domain = require('../lib/gitea-workflow-domain');
|
||||
const { createGiteaWorkflowStore } = require('../lib/gitea-workflow-store');
|
||||
const { createGiteaWorkflowQueue } = require('../lib/gitea-workflow-queue');
|
||||
const { createGiteaWorkflowService, buildGiteaMcpConfig } = require('../lib/gitea-workflow-service');
|
||||
|
||||
function wait(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }
|
||||
function sign(body, secret) { return crypto.createHmac('sha256', secret).update(body).digest('hex'); }
|
||||
function tempState() { return path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'ccweb-gitea-core-')), 'state.json'); }
|
||||
function payload(body, number = 7, login = 'alice') {
|
||||
return {
|
||||
action: 'created',
|
||||
repository: {
|
||||
owner: { login: 'acme' }, name: 'demo', full_name: 'acme/demo',
|
||||
clone_url: 'https://gitea.example/acme/demo.git', default_branch: 'main',
|
||||
},
|
||||
issue: { number, title: '测试 Issue' },
|
||||
comment: { id: `${number}-comment`, body, user: { login } },
|
||||
sender: { login },
|
||||
};
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const statePath = tempState();
|
||||
const secret = 'unit-secret';
|
||||
const results = [];
|
||||
const check = (name, fn) => Promise.resolve().then(fn).then(() => results.push(`PASS ${name}`));
|
||||
|
||||
await check('配置、标识与敏感字段脱敏', () => {
|
||||
const config = domain.normalizeConfig({ instanceId: 'default', gitea: { token: 'top-secret', webhookSecret: secret } });
|
||||
assert.equal(config.maxConcurrency, undefined);
|
||||
assert.equal(domain.sessionKeyFor({ owner: 'acme', repo: 'demo', kind: 'issue', number: 7 }), 'default:acme/demo:issue:7');
|
||||
assert.equal(domain.resourceKeyFor({ owner: 'acme', repo: 'demo', kind: 'issue', number: 7 }), 'default:acme/demo:issue:7');
|
||||
const publicConfig = domain.publicConfig(config);
|
||||
assert.notEqual(publicConfig.gitea.token, 'top-secret');
|
||||
assert.match(publicConfig.gitea.token, /^sha256:/);
|
||||
});
|
||||
|
||||
await check('状态机阻止非法迁移并允许正常迁移', () => {
|
||||
const task = domain.createTaskRecord({ taskId: 't-state', state: 'received' });
|
||||
const queued = domain.transitionTask(task, 'queued');
|
||||
assert.equal(queued.state, 'queued');
|
||||
assert.throws(() => domain.transitionTask(queued, 'succeeded'), /不可从/);
|
||||
assert.equal(domain.transitionTask(queued, 'preparing').state, 'preparing');
|
||||
});
|
||||
|
||||
await check('Webhook 验签、mention/Bot 过滤、delivery 去重与任务入队', async () => {
|
||||
const service = createGiteaWorkflowService({
|
||||
statePath,
|
||||
config: { gitea: { webhookSecret: secret, host: 'https://gitea.example', token: 'bot-token' } },
|
||||
runner: async () => ({ state: 'waiting_user' }),
|
||||
});
|
||||
const body = JSON.stringify(payload('@ccweb-bot 请检查这个问题'));
|
||||
const headers = { 'x-gitea-signature': sign(body, secret), 'x-gitea-delivery': 'delivery-1', 'x-gitea-event': 'issue_comment' };
|
||||
const accepted = await service.ingestWebhook({ rawBody: body, headers });
|
||||
assert.equal(accepted.statusCode, 202);
|
||||
assert.ok(accepted.task.taskId);
|
||||
const duplicate = await service.ingestWebhook({ rawBody: body, headers });
|
||||
assert.equal(duplicate.payload.duplicate, true);
|
||||
const botBody = JSON.stringify(payload('@ccweb-bot 自己回帖', 8, 'ccweb-bot'));
|
||||
const ignored = await service.ingestWebhook({ rawBody: botBody, headers: { ...headers, 'x-gitea-delivery': 'delivery-bot', 'x-gitea-signature': sign(botBody, secret) } });
|
||||
assert.equal(ignored.payload.ignored, true);
|
||||
const invalid = await service.ingestWebhook({ rawBody: body, headers: { ...headers, 'x-gitea-delivery': 'delivery-invalid', 'x-gitea-signature': '00' } });
|
||||
assert.equal(invalid.statusCode, 401);
|
||||
const internalService = createGiteaWorkflowService({
|
||||
statePath: tempState(),
|
||||
config: { gitea: { host: 'https://gitea.example', token: 'bot-token' } },
|
||||
runner: async () => ({ state: 'waiting_user' }),
|
||||
});
|
||||
const internalBody = JSON.stringify(payload('@ccweb-bot 内部回调无需额外 Secret'));
|
||||
const internal = await internalService.ingestWebhook({
|
||||
rawBody: internalBody,
|
||||
headers: { 'x-gitea-delivery': 'delivery-internal', 'x-gitea-event': 'issue_comment' },
|
||||
});
|
||||
assert.equal(internal.statusCode, 202);
|
||||
await wait(20);
|
||||
assert.equal(service.getTask(accepted.task.taskId).state, 'waiting_user');
|
||||
assert.equal(service.listTasks().length, 1);
|
||||
});
|
||||
|
||||
await check('JSON 持久化与重启恢复 running/queued', () => {
|
||||
const store = createGiteaWorkflowStore({ filePath: statePath });
|
||||
store.createTask({ taskId: 'restart-running', state: 'running', repoKey: 'default:acme/demo' });
|
||||
store.createTask({ taskId: 'restart-queued', state: 'queued', repoKey: 'default:acme/demo2' });
|
||||
const restored = createGiteaWorkflowStore({ filePath: statePath });
|
||||
const recovered = restored.recover({ maxRestartRetries: 1 });
|
||||
assert.equal(recovered.find((item) => item.taskId === 'restart-running').state, 'retry_wait');
|
||||
assert.equal(recovered.find((item) => item.taskId === 'restart-running').errorCode, 'interrupted_by_restart');
|
||||
assert.equal(restored.getTask('restart-queued').state, 'queued');
|
||||
assert.throws(() => restored.transitionTask('restart-queued', 'preparing', { expectedVersion: 999 }), /版本已变化/);
|
||||
});
|
||||
|
||||
await check('同仓库串行、跨仓库并行', async () => {
|
||||
const store = createGiteaWorkflowStore({ filePath: tempState() });
|
||||
const queue = createGiteaWorkflowQueue({ store, autoRecover: false });
|
||||
const started = [];
|
||||
const released = new Set();
|
||||
const runner = async (task) => {
|
||||
started.push(task.taskId);
|
||||
while (!released.has(task.taskId)) await wait(5);
|
||||
return { state: 'succeeded' };
|
||||
};
|
||||
const tasks = [
|
||||
store.createTask({ taskId: 'repo-a-1', state: 'received', repoKey: 'repo-a' }),
|
||||
store.createTask({ taskId: 'repo-a-2', state: 'received', repoKey: 'repo-a' }),
|
||||
store.createTask({ taskId: 'repo-b-1', state: 'received', repoKey: 'repo-b' }),
|
||||
];
|
||||
const waits = tasks.map((task) => queue.enqueue(task.taskId, runner));
|
||||
await wait(30);
|
||||
assert.equal(started.includes('repo-a-1'), true);
|
||||
assert.equal(started.includes('repo-b-1'), true);
|
||||
assert.equal(started.includes('repo-a-2'), false);
|
||||
released.add('repo-a-1');
|
||||
await wait(30);
|
||||
assert.equal(started.includes('repo-a-2'), true);
|
||||
released.add('repo-a-2');
|
||||
released.add('repo-b-1');
|
||||
await Promise.all(waits);
|
||||
});
|
||||
|
||||
await check('线程级 gitea-mcp 配置不把 token 放入进程级配置', () => {
|
||||
const mcp = buildGiteaMcpConfig({ gitea: { host: 'https://gitea.example', token: 'abc' } });
|
||||
assert.equal(mcp.type, 'stdio');
|
||||
assert.deepEqual(mcp.args.slice(0, 2), ['-t', 'stdio']);
|
||||
assert.equal(mcp.env.GITEA_TOKEN, 'abc');
|
||||
assert.equal(mcp.command, 'gitea-mcp');
|
||||
const service = createGiteaWorkflowService({ config: { gitea: { host: 'https://gitea.example', token: 'abc' }, codex: { model: 'configured-model', reasoningEffort: 'medium' } }, autoRecover: false });
|
||||
const thread = service.buildThreadConfig({ cwd: '/tmp/workspace' });
|
||||
assert.equal(thread.collaborationMode.settings.model, 'configured-model');
|
||||
assert.equal(thread.collaborationMode.settings.reasoning_effort, 'medium');
|
||||
assert.equal(thread.model, undefined);
|
||||
assert.equal(thread.effort, undefined);
|
||||
});
|
||||
|
||||
await check('已规范化 Webhook 任务可通过 server 挂接契约入队', async () => {
|
||||
const service = createGiteaWorkflowService({ statePath: tempState(), autoRecover: false });
|
||||
service.setRunner(async () => ({ state: 'waiting_user' }));
|
||||
const event = {
|
||||
instanceId: 'default', deliveryId: 'normalized-1', eventName: 'issue_comment',
|
||||
repository: { owner: 'acme', name: 'normalized', fullName: 'acme/normalized', cloneUrl: 'https://gitea.example/acme/normalized.git' },
|
||||
resource: { kind: 'issue', number: 3, key: 'issue:3' },
|
||||
comment: { id: 'comment-3', body: '@ccweb-bot 继续', author: { login: 'alice' } },
|
||||
mention: { instruction: '继续' },
|
||||
};
|
||||
const accepted = service.enqueueNormalizedTask({ taskId: 'normalized-task', deliveryKey: 'default:normalized-1', deliveryId: 'normalized-1', event });
|
||||
assert.equal(accepted.ok, true);
|
||||
assert.equal(accepted.duplicate, false);
|
||||
const duplicate = service.enqueueNormalizedTask({ taskId: 'normalized-task', deliveryKey: 'default:normalized-1', deliveryId: 'normalized-1', event });
|
||||
assert.equal(duplicate.duplicate, true);
|
||||
await wait(30);
|
||||
assert.equal(service.getTask('normalized-task').state, 'waiting_user');
|
||||
assert.ok(service.store.getTask('normalized-task').metadata.event.repository);
|
||||
const disabled = service.disableRepository({ repoKey: 'default:acme/normalized', actor: 'alice', reason: '维护' });
|
||||
assert.equal(disabled.ok, true);
|
||||
const rejected = service.enqueueNormalizedTask({ taskId: 'normalized-task-2', deliveryKey: 'default:normalized-2', deliveryId: 'normalized-2', event: { ...event, deliveryId: 'normalized-2' } });
|
||||
assert.equal(rejected.ok, false);
|
||||
assert.equal(rejected.code, 'repository_disabled');
|
||||
});
|
||||
|
||||
console.log(results.join('\n'));
|
||||
console.log(`全部通过:${results.length} 项`);
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
console.error(`FAIL ${error.stack || error}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
126
scripts/gitea-workflow-management-unit.js
Normal file
126
scripts/gitea-workflow-management-unit.js
Normal file
@@ -0,0 +1,126 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { Readable } = require('stream');
|
||||
const {
|
||||
createGiteaWorkflowManagement,
|
||||
handleGiteaWorkflowManagementApi,
|
||||
} = require('../lib/gitea-workflow-management');
|
||||
|
||||
function request(method, body = null) {
|
||||
const stream = Readable.from(body == null ? [] : [Buffer.from(JSON.stringify(body))]);
|
||||
stream.method = method;
|
||||
return stream;
|
||||
}
|
||||
|
||||
function response() {
|
||||
return {
|
||||
statusCode: 0,
|
||||
headersSent: false,
|
||||
headers: {},
|
||||
writeHead(code, headers) { this.statusCode = code; this.headers = headers; },
|
||||
end(value) { this.body = String(value || ''); this.headersSent = true; },
|
||||
};
|
||||
}
|
||||
|
||||
function url(value) { return new URL(`http://localhost${value}`); }
|
||||
|
||||
async function call(management, method, pathname, body, authenticated = true) {
|
||||
const res = response();
|
||||
await handleGiteaWorkflowManagementApi(request(method, body), res, url(pathname), management, {
|
||||
authenticate: () => authenticated,
|
||||
});
|
||||
return { status: res.statusCode, body: JSON.parse(res.body || '{}') };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'public', 'index.html'), 'utf8');
|
||||
const frontend = fs.readFileSync(path.join(__dirname, '..', 'public', 'gitea-workflow.js'), 'utf8');
|
||||
assert(html.includes('gitea-workflow-open') && html.includes('gitea-workflow-panel'), '页面入口未挂接');
|
||||
assert(html.includes('gitea-workflow.js?v=__CC_WEB_GITEA_WORKFLOW_ASSET_VERSION__'), 'Gitea 管理脚本应使用独立缓存版本');
|
||||
assert(html.includes('gitea-workflow.css?v=__CC_WEB_GITEA_WORKFLOW_ASSET_VERSION__'), 'Gitea 管理样式应使用独立缓存版本');
|
||||
for (const marker of ['仓库与工作区', '任务队列与 Turn', '运行日志', '操作记录', '暂停全局', '中止 Turn']) {
|
||||
assert(frontend.includes(marker), `前端缺少 ${marker} 展示/控制`);
|
||||
}
|
||||
for (const marker of ['gitea-workflow-actor', 'gitea-workflow-reason', 'gitea-config-webhook-secret', 'gitea-config-concurrency', 'operatorReason', 'controlFields', '填写本次运维操作原因', '凭据仅写入 0600 运行目录,不回显;保存后重启 cc-web 才会应用到 Webhook/MCP。', '全局并发上限', 'Webhook Secret']) {
|
||||
assert(!frontend.includes(marker), `前端不应再暴露 ${marker}`);
|
||||
}
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccweb-gitea-workflow-'));
|
||||
const statePath = path.join(tempDir, 'management.json');
|
||||
const readOnly = createGiteaWorkflowManagement({ statePath });
|
||||
const overview = await call(readOnly, 'GET', '/api/gitea-workflow/overview');
|
||||
assert.strictEqual(overview.status, 200);
|
||||
assert.strictEqual(overview.body.ok, true);
|
||||
assert.strictEqual(overview.body.available, false);
|
||||
assert.strictEqual(overview.body.summary.queued, 0);
|
||||
const config = await call(readOnly, 'GET', '/api/gitea-workflow/config');
|
||||
assert.strictEqual(config.status, 200);
|
||||
assert.strictEqual(config.body.secrets.webhookSecret, undefined);
|
||||
const invalidHost = await call(readOnly, 'PUT', '/api/gitea-workflow/config', {
|
||||
host: 'https://user:password@gitea.example', actor: 'alice', reason: 'invalid host test',
|
||||
});
|
||||
assert.strictEqual(invalidHost.status, 400);
|
||||
assert.strictEqual(invalidHost.body.code, 'invalid_gitea_host');
|
||||
const missingReason = await call(readOnly, 'POST', '/api/gitea-workflow/control/pause', { actor: 'alice' });
|
||||
assert.strictEqual(missingReason.status, 400);
|
||||
assert.strictEqual(missingReason.body.code, 'reason_required');
|
||||
const unauthorized = await call(readOnly, 'GET', '/api/gitea-workflow/overview', null, false);
|
||||
assert.strictEqual(unauthorized.status, 401);
|
||||
const unavailable = await call(readOnly, 'POST', '/api/gitea-workflow/control/pause', { actor: 'alice', reason: '维护' });
|
||||
assert.strictEqual(unavailable.status, 503);
|
||||
assert.strictEqual(unavailable.body.code, 'workflow_service_unavailable');
|
||||
|
||||
const calls = [];
|
||||
const service = {
|
||||
getOverview() { return { control: { globalPaused: false }, summary: {} }; },
|
||||
listRepositories() { return [{ repoKey: 'default:acme/widget', owner: 'acme', name: 'widget', status: 'active', dirty: true, dirtyReason: '未提交修改' }]; },
|
||||
listTasks() { return [
|
||||
{ taskId: 'task-1', repoKey: 'default:acme/widget', state: 'queued', turnId: null },
|
||||
{ taskId: 'task-blocked', repoKey: 'default:acme/widget', state: 'blocked_workspace', errorMessage: '目录有未提交修改' },
|
||||
]; },
|
||||
listAudit() { return []; },
|
||||
listLogs() { return [{ level: 'info', message: 'queued' }]; },
|
||||
pause(input) { calls.push(['pause', input]); return { globalPaused: true }; },
|
||||
resume(input) { calls.push(['resume', input]); return { globalPaused: false }; },
|
||||
disableRepository(input) { calls.push(['disable', input]); return { status: 'disabled' }; },
|
||||
enableRepository(input) { calls.push(['enable', input]); return { status: 'active' }; },
|
||||
cancelTask(input) { calls.push(['cancel', input]); return { state: 'cancelled' }; },
|
||||
abortTask(input) { calls.push(['abort', input]); return { state: 'aborted' }; },
|
||||
};
|
||||
const management = createGiteaWorkflowManagement({ statePath, workflowService: service });
|
||||
const listed = await call(management, 'GET', '/api/gitea-workflow/tasks?state=queued');
|
||||
assert.strictEqual(listed.body.total, 1);
|
||||
assert.strictEqual(listed.body.items[0].taskId, 'task-1');
|
||||
const overviewWithDerivedFields = await call(management, 'GET', '/api/gitea-workflow/overview');
|
||||
assert.strictEqual(overviewWithDerivedFields.body.summary.maxConcurrency, undefined);
|
||||
assert.strictEqual(overviewWithDerivedFields.body.repositories[0].queuedCount, 1);
|
||||
assert.strictEqual(overviewWithDerivedFields.body.repositories[0].dirty, true);
|
||||
assert.match(overviewWithDerivedFields.body.repositories[0].dirtyReason, /未提交修改/);
|
||||
assert(overviewWithDerivedFields.body.logs.some((entry) => entry.message === 'queued'));
|
||||
for (const [route, expected] of [
|
||||
['/control/pause', 'pause'], ['/control/resume', 'resume'],
|
||||
['/repos/default%3Aacme%2Fwidget/disable', 'disable'], ['/repos/default%3Aacme%2Fwidget/enable', 'enable'],
|
||||
['/tasks/task-1/cancel', 'cancel'], ['/tasks/task-1/abort', 'abort'],
|
||||
]) {
|
||||
const result = await call(management, 'POST', `/api/gitea-workflow${route}`, { actor: 'alice', reason: `test ${expected}`, requestId: expected });
|
||||
assert.strictEqual(result.status, 200, expected);
|
||||
assert.strictEqual(result.body.ok, true, expected);
|
||||
}
|
||||
assert.strictEqual(calls.length, 6);
|
||||
const duplicate = await call(management, 'POST', '/api/gitea-workflow/control/pause', { actor: 'alice', reason: 'test pause', requestId: 'pause' });
|
||||
assert.strictEqual(duplicate.status, 200);
|
||||
assert.strictEqual(duplicate.body.idempotent, true);
|
||||
assert.strictEqual(calls.length, 6, '重复 requestId 不应再次调用核心服务');
|
||||
const audits = await call(management, 'GET', '/api/gitea-workflow/audit');
|
||||
assert.strictEqual(audits.body.total, 6);
|
||||
assert(audits.body.items.every((entry) => entry.actor === 'alice' && entry.reason.startsWith('test')));
|
||||
|
||||
const persisted = JSON.parse(fs.readFileSync(statePath, 'utf8'));
|
||||
assert.strictEqual(persisted.audits.length, 6);
|
||||
console.log('gitea-workflow-management-unit: ok');
|
||||
}
|
||||
|
||||
main().catch((error) => { console.error(error); process.exitCode = 1; });
|
||||
84
scripts/gitea-workflow-ui-unit.js
Normal file
84
scripts/gitea-workflow-ui-unit.js
Normal file
@@ -0,0 +1,84 @@
|
||||
'use strict';
|
||||
|
||||
/** Gitea 会话展示契约回归:不启动 server,只校验数据协议和脱敏链路。 */
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { normalizeWebhookEvent } = require('../lib/gitea-webhook');
|
||||
const { buildWorkflowPrompt } = require('../lib/gitea-workflow-codex');
|
||||
|
||||
const serverSource = fs.readFileSync(path.join(__dirname, '..', 'server.js'), 'utf8');
|
||||
const appSource = fs.readFileSync(path.join(__dirname, '..', 'public', 'app.js'), 'utf8');
|
||||
const styleSource = fs.readFileSync(path.join(__dirname, '..', 'public', 'style.css'), 'utf8');
|
||||
|
||||
function fixturePayload() {
|
||||
return {
|
||||
repository: {
|
||||
owner: { login: 'shiyue' },
|
||||
name: 'kan',
|
||||
full_name: 'shiyue/kan',
|
||||
clone_url: 'https://gitea.example/shiyue/kan.git',
|
||||
},
|
||||
issue: { number: 1, title: '这里是测试bot' },
|
||||
comment: {
|
||||
id: 9,
|
||||
body: '@codexbot 再测试一次',
|
||||
user: { login: 'alice' },
|
||||
},
|
||||
sender: { login: 'alice' },
|
||||
};
|
||||
}
|
||||
|
||||
function run() {
|
||||
const event = normalizeWebhookEvent({
|
||||
payload: fixturePayload(),
|
||||
headers: { 'x-gitea-event': 'issue_comment' },
|
||||
botIdentity: { login: 'codexbot' },
|
||||
});
|
||||
assert.equal(event.ignored, false);
|
||||
assert.equal(event.resource.title, '这里是测试bot');
|
||||
assert.equal(event.mention.instruction, '再测试一次');
|
||||
|
||||
const prompt = buildWorkflowPrompt({
|
||||
taskId: 'task-ui',
|
||||
turnId: 'turn-ui',
|
||||
resourceKey: 'default:shiyue/kan:issue:1',
|
||||
workspacePath: '/home/hdzx/giteabot/shiyue/kan',
|
||||
instruction: event.mention.instruction,
|
||||
});
|
||||
assert.match(prompt, /工作区:\/home\/hdzx\/giteabot\/shiyue\/kan/);
|
||||
assert.match(prompt, /用户指令:\n再测试一次/);
|
||||
assert.match(prompt, /final 隐藏标记/);
|
||||
|
||||
// Codex 仍接收 runtimeText;历史用户消息使用 displayText,避免内部 Prompt 进入用户气泡。
|
||||
assert.match(serverSource, /const displayText = giteaDisplayMessage\(task, event\)/);
|
||||
assert.match(serverSource, /text: displayText, mode: 'yolo', agent: 'codexapp'/);
|
||||
assert.match(serverSource, /runtimeText: prompt/);
|
||||
assert.match(serverSource, /content: displayTextValue/);
|
||||
assert.match(serverSource, /const giteaSource = normalizeGiteaSource\(options\.giteaSource\)/);
|
||||
assert.match(serverSource, /persistedUserMessage\.giteaSource = giteaSource/);
|
||||
|
||||
// 标题优先 Issue/PR resource.title,旧资源键仅作兜底。
|
||||
assert.match(serverSource, /title: issueTitle \|\| legacyTitle/);
|
||||
assert.match(serverSource, /session\.title === legacyTitle/);
|
||||
assert.match(serverSource, /let sessionCreated = false/);
|
||||
assert.match(serverSource, /if \(sessionCreated && !presentationChanged\) broadcastSessionList\(\)/);
|
||||
|
||||
// 前端既显示来源标记,也会把旧完整 Prompt 脱敏为简短来源消息。
|
||||
assert.match(appSource, /来自 gitea 消息:/);
|
||||
assert.match(appSource, /gitea-message-label/);
|
||||
assert.match(appSource, /session-item-source-badge/);
|
||||
assert.match(appSource, /function getGiteaVisibleMessageContent/);
|
||||
assert.match(appSource, /已收到工单请求,请读取工单上下文/);
|
||||
assert.match(styleSource, /\.msg\.gitea-message \.msg-bubble/);
|
||||
assert.match(styleSource, /\.session-item-source-badge/);
|
||||
|
||||
console.log('Gitea Workflow UI unit checks passed');
|
||||
}
|
||||
|
||||
try {
|
||||
run();
|
||||
} catch (error) {
|
||||
console.error(error.stack || error.message || error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -826,6 +826,44 @@ function completeApprovalTurn(thread, turnId) {
|
||||
});
|
||||
}
|
||||
|
||||
function completeElicitationTurn(thread, turnId, text) {
|
||||
const isUrl = /elicitation\s+url/i.test(text);
|
||||
const isOpenAiForm = /elicitation\s+openai/i.test(text);
|
||||
const params = isUrl
|
||||
? {
|
||||
threadId: thread.id,
|
||||
turnId,
|
||||
serverName: 'fixture-mcp',
|
||||
mode: 'url',
|
||||
message: '请完成测试授权后返回 cc-web。',
|
||||
url: 'https://example.com/mcp-elicitation-fixture',
|
||||
elicitationId: 'fixture-elicitation-url',
|
||||
_meta: { fixture: true },
|
||||
}
|
||||
: {
|
||||
threadId: thread.id,
|
||||
turnId,
|
||||
serverName: 'fixture-mcp',
|
||||
mode: isOpenAiForm ? 'openai/form' : 'form',
|
||||
message: '请填写 MCP 测试信息。',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
displayName: { type: 'string', title: '显示名称', description: '用于回归断言。' },
|
||||
count: { type: 'integer', title: '数量', minimum: 1, default: 2 },
|
||||
enabled: { type: 'boolean', title: '启用', default: true },
|
||||
tags: { type: 'array', title: '标签', items: { type: 'string', enum: ['alpha', 'beta'] } },
|
||||
},
|
||||
required: ['displayName'],
|
||||
},
|
||||
_meta: { fixture: true },
|
||||
};
|
||||
requestClient('mcpServer/elicitation/request', params, (message) => {
|
||||
const result = message.result || {};
|
||||
completeTurn(thread, turnId, `elicitation result: ${JSON.stringify(result)}`);
|
||||
});
|
||||
}
|
||||
|
||||
function completeEmptyReasoningTurn(thread, turnId, text) {
|
||||
send({
|
||||
method: 'item/started',
|
||||
@@ -983,6 +1021,11 @@ function startTurn(params) {
|
||||
return { turn: { id: turnId, status: 'running', items: [] } };
|
||||
}
|
||||
|
||||
if (/elicitation/i.test(text)) {
|
||||
completeElicitationTurn(thread, turnId, text);
|
||||
return { turn: { id: turnId, status: 'running', items: [] } };
|
||||
}
|
||||
|
||||
const delay = /recover/i.test(text) ? 5000 : /slow/i.test(text) ? 900 : 80;
|
||||
if (/recover/i.test(text)) {
|
||||
send({
|
||||
|
||||
@@ -2627,6 +2627,10 @@ function assertFrontendPrimaryCodexAppUiContract() {
|
||||
serverSource.includes("const VALID_AGENTS = new Set(['claude', 'codex', 'codexapp']);"),
|
||||
'Server explicit agent support should remain available for API/MCP paths'
|
||||
);
|
||||
assert(serverSource.includes("case 'mcpServer/elicitation/request':"), 'Server should route MCP elicitation requests through the interactive response path');
|
||||
assert(serverSource.includes('pendingCodexAppElicitations') && serverSource.includes('10 * 60 * 1000') && serverSource.includes('resolvePendingCodexAppElicitationsForSession'), 'MCP elicitation should keep a bounded pending lifecycle with timeout and session cleanup');
|
||||
assert(source.includes("case 'codex_app_elicitation_request':"), 'Frontend should render MCP elicitation requests');
|
||||
assert(source.includes("type: 'codex_app_elicitation_response'"), 'Frontend should send structured MCP elicitation responses');
|
||||
}
|
||||
|
||||
function assertSetTitleMcpContract() {
|
||||
@@ -2668,6 +2672,7 @@ function assertSessionItemTooltipContract() {
|
||||
function assertSessionProjectSnapshotContract() {
|
||||
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
|
||||
const mergeSource = extractFunctionSource(frontendSource, 'mergeSessionListSnapshot');
|
||||
const normalizeGiteaSourceSource = extractFunctionSource(frontendSource, 'normalizeGiteaSource');
|
||||
const api = new Function(`
|
||||
let sessions = [];
|
||||
function normalizeAgent(agent) {
|
||||
@@ -2680,6 +2685,7 @@ function assertSessionProjectSnapshotContract() {
|
||||
function compareSessionUpdatedDesc(a, b) {
|
||||
return new Date(b.updated || 0) - new Date(a.updated || 0);
|
||||
}
|
||||
${normalizeGiteaSourceSource}
|
||||
${mergeSource}
|
||||
return {
|
||||
mergeSessionListSnapshot,
|
||||
@@ -7991,6 +7997,43 @@ async function main() {
|
||||
assert(/guided answer: A/.test(guidedDelta.text || ''), 'Codex App should continue after guided input response');
|
||||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
|
||||
|
||||
ws.send(JSON.stringify({ type: 'message', text: 'codexapp elicitation prompt', sessionId: codexAppSession.sessionId, mode: 'default', agent: 'codexapp' }));
|
||||
const elicitationRequest = await nextMessage(messages, ws, (msg) => msg.type === 'codex_app_elicitation_request' && msg.sessionId === codexAppSession.sessionId);
|
||||
assert(elicitationRequest.mode === 'form', 'Codex App should forward MCP form elicitation requests');
|
||||
assert(elicitationRequest.serverName === 'fixture-mcp', 'MCP elicitation should preserve server name');
|
||||
assert(elicitationRequest.requestedSchema?.properties?.displayName, 'MCP elicitation should forward requested schema');
|
||||
assert(!messages.some((msg) => msg.type === 'system_message' && /mcpServer\/elicitation\/request/.test(msg.message || '') && /保守策略拒绝/.test(msg.message || '')), 'MCP elicitation requests should not be rejected by the generic unsupported-request path');
|
||||
ws.send(JSON.stringify({
|
||||
type: 'codex_app_elicitation_response',
|
||||
action: 'accept',
|
||||
sessionId: codexAppSession.sessionId,
|
||||
requestId: elicitationRequest.requestId,
|
||||
content: { displayName: '回归用户', count: 3, enabled: true, tags: ['alpha'] },
|
||||
}));
|
||||
const elicitationSubmitted = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.sessionId === codexAppSession.sessionId && /已提交 MCP elicitation/.test(msg.message || ''));
|
||||
assert(/已提交 MCP elicitation/.test(elicitationSubmitted.message || ''), 'MCP elicitation acceptance should show confirmation hint');
|
||||
const elicitationDelta = await nextMessage(messages, ws, (msg) => msg.type === 'text_delta' && msg.sessionId === codexAppSession.sessionId && /回归用户/.test(msg.text || ''));
|
||||
assert(/回归用户/.test(elicitationDelta.text || ''), 'Codex App should continue after MCP elicitation response');
|
||||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
|
||||
|
||||
ws.send(JSON.stringify({ type: 'message', text: 'codexapp elicitation openai', sessionId: codexAppSession.sessionId, mode: 'default', agent: 'codexapp' }));
|
||||
const openAiElicitationRequest = await nextMessage(messages, ws, (msg) => msg.type === 'codex_app_elicitation_request' && msg.sessionId === codexAppSession.sessionId);
|
||||
assert(openAiElicitationRequest.mode === 'openai/form', 'Codex App should forward openai/form elicitation requests');
|
||||
ws.send(JSON.stringify({ type: 'codex_app_elicitation_response', action: 'decline', sessionId: codexAppSession.sessionId, requestId: openAiElicitationRequest.requestId }));
|
||||
await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.sessionId === codexAppSession.sessionId && /已拒绝 MCP elicitation/.test(msg.message || ''));
|
||||
const openAiElicitationDelta = await nextMessage(messages, ws, (msg) => msg.type === 'text_delta' && msg.sessionId === codexAppSession.sessionId && /"action":"decline"/.test(msg.text || ''));
|
||||
assert(/"action":"decline"/.test(openAiElicitationDelta.text || ''), 'MCP openai/form elicitation should support decline');
|
||||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
|
||||
|
||||
ws.send(JSON.stringify({ type: 'message', text: 'codexapp elicitation url', sessionId: codexAppSession.sessionId, mode: 'default', agent: 'codexapp' }));
|
||||
const urlElicitationRequest = await nextMessage(messages, ws, (msg) => msg.type === 'codex_app_elicitation_request' && msg.sessionId === codexAppSession.sessionId);
|
||||
assert(urlElicitationRequest.mode === 'url' && /example\.com/.test(urlElicitationRequest.url || ''), 'Codex App should forward URL elicitation requests');
|
||||
ws.send(JSON.stringify({ type: 'codex_app_elicitation_response', action: 'cancel', sessionId: codexAppSession.sessionId, requestId: urlElicitationRequest.requestId }));
|
||||
await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.sessionId === codexAppSession.sessionId && /已取消 MCP elicitation/.test(msg.message || ''));
|
||||
const urlElicitationDelta = await nextMessage(messages, ws, (msg) => msg.type === 'text_delta' && msg.sessionId === codexAppSession.sessionId && /"action":"cancel"/.test(msg.text || ''));
|
||||
assert(/"action":"cancel"/.test(urlElicitationDelta.text || ''), 'MCP URL elicitation should support cancel');
|
||||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
|
||||
|
||||
ws.send(JSON.stringify({ type: 'message', text: 'codexapp approval prompt', sessionId: codexAppSession.sessionId, mode: 'default', agent: 'codexapp' }));
|
||||
const approvalRequest = await nextMessage(messages, ws, (msg) => msg.type === 'codex_app_approval_request' && msg.sessionId === codexAppSession.sessionId);
|
||||
assert(approvalRequest.method === 'item/commandExecution/requestApproval', 'Codex App should forward command approval requests');
|
||||
|
||||
Reference in New Issue
Block a user