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

386
lib/gitea-workspace.js Normal file
View File

@@ -0,0 +1,386 @@
'use strict';
const crypto = require('crypto');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawn } = require('child_process');
class WorkspaceError extends Error {
constructor(code, message, details) {
super(message);
this.name = 'WorkspaceError';
this.code = code;
if (details !== undefined) this.details = details;
}
}
class BlockedWorkspaceError extends WorkspaceError {
constructor(message, details) {
super('blocked_workspace', message || '工作区存在未提交修改,已阻止覆盖。', details);
this.name = 'BlockedWorkspaceError';
}
}
function atomicWriteJson(filePath, value) {
const target = path.resolve(filePath);
fs.mkdirSync(path.dirname(target), { recursive: true });
const tempPath = `${target}.${process.pid}.${crypto.randomBytes(5).toString('hex')}.tmp`;
fs.writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
try { fs.renameSync(tempPath, target); } finally {
try { if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath); } catch {}
}
}
function safeSegment(value, label) {
const normalized = String(value || '').trim();
if (!normalized || normalized === '.' || normalized === '..' || !/^[A-Za-z0-9._-]+$/.test(normalized)) {
throw new WorkspaceError('invalid_repository', `${label} 格式无效。`);
}
return normalized;
}
function normalizeRepository(repo) {
if (!repo || typeof repo !== 'object') throw new WorkspaceError('invalid_repository', '仓库信息无效。');
const owner = safeSegment(repo.owner, 'owner');
const name = safeSegment(repo.name, 'repo');
const cloneUrl = String(repo.cloneUrl || repo.clone_url || repo.cloneURL || repo.html_url || '').trim();
let parsed;
try { parsed = new URL(cloneUrl); } catch {
throw new WorkspaceError('invalid_clone_url', '仓库 clone URL 无效。');
}
if (parsed.protocol !== 'https:') {
throw new WorkspaceError('invalid_clone_url', '仅允许 HTTPS clone URL。');
}
parsed.username = '';
parsed.password = '';
return {
owner,
name,
fullName: `${owner}/${name}`,
cloneUrl: parsed.toString(),
defaultBranch: String(repo.defaultBranch || '').trim(),
private: repo.private === true,
};
}
// Gitea 某些版本在 Webhook 中返回 http clone_url即使实例的公开地址是
// https。仅在主机完全匹配且配置地址为 https 时升级协议,避免放宽到任意
// 不安全的远程地址。
function upgradeCloneUrlToConfiguredHttps(repo, allowedHost) {
if (!repo || !allowedHost) return repo;
const raw = String(repo.cloneUrl || repo.clone_url || repo.cloneURL || repo.html_url || '').trim();
try {
const clone = new URL(raw);
const configured = new URL(allowedHost);
if (clone.protocol === 'http:' && configured.protocol === 'https:' && clone.host === configured.host) {
clone.protocol = 'https:';
return { ...repo, cloneUrl: clone.toString() };
}
} catch {}
return repo;
}
function repoKey(instanceId, repo) {
const normalized = normalizeRepository(repo);
const instance = safeSegment(instanceId || 'default', 'instanceId');
return `${instance}:${normalized.fullName}`;
}
function createGitRunner(options = {}) {
if (typeof options.gitRunner === 'function') return options.gitRunner;
const gitCommand = String(options.gitCommand || 'git');
const maxOutputBytes = Number.isSafeInteger(options.maxOutputBytes) ? options.maxOutputBytes : 2 * 1024 * 1024;
return ({ args, cwd, env }) => new Promise((resolve, reject) => {
const child = spawn(gitCommand, args, { cwd, env, windowsHide: true });
const stdout = [];
const stderr = [];
let total = 0;
let rejected = false;
const collect = (target) => (chunk) => {
if (rejected) return;
total += chunk.length;
if (total > maxOutputBytes) {
rejected = true;
child.kill('SIGTERM');
reject(new WorkspaceError('git_output_too_large', 'Git 输出过大。'));
return;
}
target.push(chunk);
};
child.stdout.on('data', collect(stdout));
child.stderr.on('data', collect(stderr));
child.on('error', (error) => {
if (!rejected) reject(new WorkspaceError('git_spawn_failed', error.message));
});
child.on('close', (code, signal) => {
if (rejected) return;
const result = {
code: Number.isInteger(code) ? code : 1,
signal: signal || null,
stdout: Buffer.concat(stdout).toString('utf8'),
stderr: Buffer.concat(stderr).toString('utf8'),
};
if (result.code !== 0) {
const detail = String(result.stderr || result.stdout || '').trim().replace(/\s+/g, ' ').slice(-2000);
const suffix = detail ? `${detail}` : `(退出码 ${result.code}${result.signal ? `,信号 ${result.signal}` : ''}`;
const error = new WorkspaceError('git_failed', `Git 命令失败: ${args[0] || 'git'}${suffix}`, result);
reject(error);
return;
}
resolve(result);
});
});
}
async function withTempGitCredentials(options, fn) {
const token = String(options.token || '');
if (!token) throw new WorkspaceError('missing_git_token', '缺少 Gitea Bot Token。');
const username = String(options.username || 'ccweb-bot');
const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'ccweb-git-askpass-'));
const askpassPath = path.join(tempRoot, process.platform === 'win32' ? 'askpass.cmd' : 'askpass.sh');
// 凭据只存在子进程环境和临时脚本中,绝不写入 remote URL 或普通日志。
const script = process.platform === 'win32'
? [
'@echo off',
'echo %1 | findstr /I "user" >nul',
'if %errorlevel%==0 (echo %CCWEB_GIT_USERNAME%) else (echo %CCWEB_GIT_TOKEN%)',
'',
].join('\r\n')
: [
'#!/bin/sh',
'case "$(printf %s "${1:-}" | tr "[:upper:]" "[:lower:]")" in',
' *user*) printf %s "${CCWEB_GIT_USERNAME:-}" ;;',
' *) printf %s "${CCWEB_GIT_TOKEN:-}" ;;',
'esac',
'',
].join('\n');
await fs.promises.writeFile(askpassPath, script, { mode: 0o700 });
try {
const env = {
...process.env,
...(options.env || {}),
GIT_ASKPASS: askpassPath,
GIT_TERMINAL_PROMPT: '0',
CCWEB_GIT_USERNAME: username,
CCWEB_GIT_TOKEN: token,
};
return await fn(env);
} finally {
try { await fs.promises.rm(tempRoot, { recursive: true, force: true }); } catch {}
}
}
function createRepositoryLockManager(options = {}) {
const lockRoot = path.resolve(options.lockRoot || path.join(options.workspaceRoot || os.tmpdir(), '.locks'));
const staleLockMs = Number.isSafeInteger(options.staleLockMs) && options.staleLockMs > 0 ? options.staleLockMs : 10 * 60 * 1000;
const retryMs = Number.isSafeInteger(options.retryMs) && options.retryMs > 0 ? options.retryMs : 50;
const waitTimeoutMs = Number.isSafeInteger(options.waitTimeoutMs) && options.waitTimeoutMs > 0 ? options.waitTimeoutMs : 30_000;
const localLocks = new Map();
function lockPathFor(key) {
return path.join(lockRoot, `${crypto.createHash('sha256').update(String(key)).digest('hex')}.lock`);
}
async function acquire(key) {
const normalizedKey = String(key);
const previous = localLocks.get(normalizedKey) || Promise.resolve();
let releaseLocal;
const current = new Promise((resolve) => { releaseLocal = resolve; });
const chain = previous.then(() => current);
localLocks.set(normalizedKey, chain);
await previous;
fs.mkdirSync(lockRoot, { recursive: true });
const lockPath = lockPathFor(normalizedKey);
const startedAt = Date.now();
let handle;
while (!handle) {
try {
handle = await fs.promises.open(lockPath, 'wx', 0o600);
await handle.writeFile(JSON.stringify({ pid: process.pid, key: normalizedKey, createdAt: new Date().toISOString() }));
} catch (error) {
if (error.code !== 'EEXIST') {
releaseLocal();
throw new WorkspaceError('lock_failed', `无法创建仓库锁: ${error.message}`);
}
try {
const stat = await fs.promises.stat(lockPath);
if (Date.now() - stat.mtimeMs > staleLockMs) await fs.promises.unlink(lockPath);
} catch {}
if (Date.now() - startedAt >= waitTimeoutMs) {
releaseLocal();
throw new WorkspaceError('lock_timeout', '等待仓库锁超时。', { key: normalizedKey });
}
await new Promise((resolve) => setTimeout(resolve, retryMs));
}
}
let released = false;
return async () => {
if (released) return;
released = true;
try { await handle.close(); } catch {}
try { await fs.promises.unlink(lockPath); } catch (error) {
if (error.code !== 'ENOENT') throw error;
}
releaseLocal();
if (localLocks.get(normalizedKey) === chain) localLocks.delete(normalizedKey);
};
}
async function withLock(key, fn) {
const release = await acquire(key);
try { return await fn(); } finally { await release(); }
}
return { acquire, withLock, lockPathFor };
}
function createGiteaWorkspaceManager(options = {}) {
const workspaceRoot = path.resolve(options.workspaceRoot || process.env.CC_WEB_GITEA_WORKSPACE_ROOT || process.env.WORKSPACE_ROOT || path.join(process.cwd(), 'gitea-workspaces'));
const instanceId = String(options.instanceId || process.env.CC_WEB_GITEA_INSTANCE_ID || process.env.GITEA_INSTANCE_ID || 'default');
const defaultToken = String(options.token || process.env.CC_WEB_GITEA_BOT_TOKEN || process.env.GITEA_ACCESS_TOKEN || process.env.GITEA_BOT_TOKEN || '');
const recordsPath = path.resolve(options.recordsPath || path.join(workspaceRoot, 'repositories.json'));
const botUsername = String(options.botUsername || process.env.CC_WEB_GITEA_BOT_LOGIN || process.env.GITEA_BOT_LOGIN || 'ccweb-bot');
const allowedHost = String(options.allowedHost || process.env.CC_WEB_GITEA_HOST || process.env.GITEA_HOST || '').trim().replace(/\/+$/, '');
const gitRunner = createGitRunner(options);
const locks = options.lockManager || createRepositoryLockManager({ workspaceRoot, ...options });
let records = {};
try {
const parsed = JSON.parse(fs.readFileSync(recordsPath, 'utf8'));
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) records = parsed.repositories || parsed;
} catch {}
function workspacePath(repo) {
const normalized = normalizeRepository(repo);
return path.join(workspaceRoot, safeSegment(instanceId, 'instanceId'), normalized.owner, normalized.name);
}
function persistRecords() {
atomicWriteJson(recordsPath, { schemaVersion: 1, repositories: records });
}
async function git(repoPath, args, token, extraEnv = {}) {
return withTempGitCredentials({ token, username: botUsername, env: extraEnv }, (env) => gitRunner({ args, cwd: repoPath, env }));
}
async function inspect(repoPath, token) {
const result = await git(repoPath, ['status', '--porcelain', '--untracked-files=all'], token);
const output = String(result.stdout || '');
if (output.trim()) {
const conflict = output.split('\n').some((line) => /^(UU|AA|DD|AU|UA|DU|UD)/.test(line));
throw new BlockedWorkspaceError(conflict ? '工作区存在 Git 冲突,已阻止继续。' : undefined, { status: output });
}
return { clean: true };
}
async function ensureRepository(repo, options = {}) {
const normalized = normalizeRepository(upgradeCloneUrlToConfiguredHttps(repo, allowedHost));
if (allowedHost) {
let configured;
try { configured = new URL(allowedHost); } catch { throw new WorkspaceError('invalid_gitea_host', '配置的 Gitea 地址无效。'); }
const clone = new URL(normalized.cloneUrl);
if (configured.protocol !== clone.protocol || configured.host !== clone.host) {
throw new WorkspaceError('clone_host_mismatch', '仓库 clone 地址与配置的 Gitea 实例不一致。', {
configuredHost: configured.host,
cloneHost: clone.host,
});
}
}
const key = repoKey(instanceId, normalized);
const repoPath = workspacePath(normalized);
const token = String(options.token || defaultToken || '');
return locks.withLock(key, async () => {
const existing = records[key];
const now = new Date().toISOString();
if (!existing) {
records[key] = {
key,
instanceId,
owner: normalized.owner,
name: normalized.name,
fullName: normalized.fullName,
cloneUrl: normalized.cloneUrl,
workspacePath: repoPath,
status: 'preparing',
createdAt: now,
updatedAt: now,
};
persistRecords();
}
fs.mkdirSync(path.dirname(repoPath), { recursive: true });
const gitDir = path.join(repoPath, '.git');
if (!fs.existsSync(gitDir)) {
if (fs.existsSync(repoPath) && fs.readdirSync(repoPath).length > 0) {
throw new BlockedWorkspaceError('目标工作区目录非空且不是 Git 仓库。', { workspacePath: repoPath });
}
// 工作流只需要默认分支的当前代码。浅克隆显著降低首次接入的传输量,
// 避免 Gitea/反向代理在完整历史传输期间中断;后续 fetch 仍复用同一工作区。
const cloneArgs = ['clone', '--origin', 'origin', '--depth', '1', '--single-branch'];
if (normalized.defaultBranch) cloneArgs.push('--branch', normalized.defaultBranch);
cloneArgs.push(normalized.cloneUrl, repoPath);
await git(workspaceRoot, cloneArgs, token);
} else {
if (options.allowDirty === true) {
return {
...records[key],
key,
cloneUrl: normalized.cloneUrl,
workspacePath: repoPath,
status: 'dirty_ready',
dirty: true,
dirtyReason: records[key]?.dirtyReason || '沿用当前讨论的未提交修改。',
dirtySessionKey: options.dirtySessionKey || records[key]?.dirtySessionKey || null,
repository: normalized,
};
}
await inspect(repoPath, token);
await git(repoPath, ['fetch', '--prune', 'origin'], token);
if (normalized.defaultBranch) {
// 仅在工作区确认干净后快进到配置的基线分支,绝不 reset/覆盖用户修改。
await git(repoPath, ['checkout', normalized.defaultBranch], token);
await git(repoPath, ['merge', '--ff-only', `origin/${normalized.defaultBranch}`], token);
}
}
records[key] = {
...(records[key] || {}),
key,
cloneUrl: normalized.cloneUrl,
defaultBranch: normalized.defaultBranch || records[key]?.defaultBranch || '',
workspacePath: repoPath,
status: 'ready',
dirty: false,
dirtyReason: null,
dirtySessionKey: null,
updatedAt: new Date().toISOString(),
};
persistRecords();
return { ...records[key], repository: normalized };
});
}
return {
workspaceRoot,
recordsPath,
repoKey: (repo) => repoKey(instanceId, repo),
workspacePath,
ensureRepository,
ensureWorkspace: ensureRepository,
inspectWorkspace: inspect,
checkDirty: inspect,
lockManager: locks,
withRepositoryLock: locks.withLock,
getRecord: (repo) => records[repoKey(instanceId, repo)] || null,
};
}
module.exports = {
BlockedWorkspaceError,
WorkspaceError,
createWorkspaceManager: createGiteaWorkspaceManager,
createGiteaWorkspaceManager,
createGitRunner,
createRepositoryLockManager,
normalizeRepository,
repoKey,
withTempGitCredentials,
};