1801298dce
- 添加 Diff 按钮,点击可展开/折叠文件变更预览 - Server 端生成 diff 信息并传递到前端 - 默认折叠,用户可按需查看
283 lines
7.3 KiB
TypeScript
283 lines
7.3 KiB
TypeScript
/**
|
||
* Server 端权限处理器
|
||
* 通过 WebSocket 发送权限请求并等待客户端响应
|
||
*/
|
||
|
||
import { randomUUID } from 'crypto';
|
||
import { broadcastToSession } from '../ws.js';
|
||
import type {
|
||
PermissionRequestPayload,
|
||
PermissionDisplayContext,
|
||
ServerMessage,
|
||
DiffInfo,
|
||
DiffHunkInfo,
|
||
} from '../types.js';
|
||
import { inferPermissionType } from '@ai-assistant/core';
|
||
import type { PermissionDecision, PermissionContext, PermissionType } from '@ai-assistant/core';
|
||
|
||
// 等待中的权限请求
|
||
interface PendingRequest {
|
||
resolve: (decision: PermissionDecision) => void;
|
||
reject: (error: Error) => void;
|
||
timeout: ReturnType<typeof setTimeout>;
|
||
}
|
||
|
||
const pendingRequests = new Map<string, PendingRequest>();
|
||
|
||
// 会话级别的 auto-approve 配置
|
||
// key: sessionId, value: auto-approve 配置
|
||
const sessionAutoApprove = new Map<string, { file?: { write?: 'allow'; edit?: 'allow' } }>();
|
||
|
||
// 默认超时时间(60秒)
|
||
const PERMISSION_TIMEOUT = 60000;
|
||
|
||
/**
|
||
* 设置会话的 auto-approve 配置
|
||
*/
|
||
export function setSessionAutoApprove(
|
||
sessionId: string,
|
||
config: { file?: { write?: 'allow'; edit?: 'allow' } } | null
|
||
): void {
|
||
if (config) {
|
||
sessionAutoApprove.set(sessionId, config);
|
||
} else {
|
||
sessionAutoApprove.delete(sessionId);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取会话的 auto-approve 配置
|
||
*/
|
||
export function getSessionAutoApprove(sessionId: string): { file?: { write?: 'allow'; edit?: 'allow' } } | null {
|
||
return sessionAutoApprove.get(sessionId) ?? null;
|
||
}
|
||
|
||
/**
|
||
* 检查操作是否被 auto-approve
|
||
*/
|
||
function isAutoApproved(sessionId: string, ctx: PermissionContext): boolean {
|
||
const config = sessionAutoApprove.get(sessionId);
|
||
if (!config) return false;
|
||
|
||
// 优先使用结构化字段判断
|
||
if (ctx.permissionType === 'file') {
|
||
if (ctx.fileOperation === 'write' && config.file?.write === 'allow') {
|
||
return true;
|
||
}
|
||
if (ctx.fileOperation === 'edit' && config.file?.edit === 'allow') {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
// 向后兼容:解析 command 字符串
|
||
const command = ctx.command.toLowerCase();
|
||
if (command.startsWith('write ') && config.file?.write === 'allow') {
|
||
return true;
|
||
}
|
||
if (command.startsWith('edit ') && config.file?.edit === 'allow') {
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* 生成简单的行级别 diff 信息
|
||
* 用于在权限确认对话框中显示文件变更预览
|
||
*/
|
||
function generateDiffInfo(oldContent: string | undefined, newContent: string | undefined): DiffInfo | undefined {
|
||
// 如果没有内容,无法生成 diff
|
||
if (newContent === undefined) {
|
||
return undefined;
|
||
}
|
||
|
||
const isNew = !oldContent;
|
||
const oldLines = oldContent ? oldContent.split('\n') : [];
|
||
const newLines = newContent.split('\n');
|
||
|
||
// 简单的行级别 diff
|
||
const lines: DiffHunkInfo['lines'] = [];
|
||
let additions = 0;
|
||
let deletions = 0;
|
||
|
||
if (isNew) {
|
||
// 新文件:所有行都是添加
|
||
newLines.forEach((line, index) => {
|
||
lines.push({
|
||
type: 'add',
|
||
lineNumber: index + 1,
|
||
content: line,
|
||
});
|
||
additions++;
|
||
});
|
||
} else {
|
||
// 编辑:显示删除和添加
|
||
// 对于 edit_file(search-replace),oldContent 是被替换的部分,newContent 是替换后的部分
|
||
oldLines.forEach((line, index) => {
|
||
lines.push({
|
||
type: 'remove',
|
||
lineNumber: index + 1,
|
||
content: line,
|
||
});
|
||
deletions++;
|
||
});
|
||
newLines.forEach((line, index) => {
|
||
lines.push({
|
||
type: 'add',
|
||
lineNumber: index + 1,
|
||
content: line,
|
||
});
|
||
additions++;
|
||
});
|
||
}
|
||
|
||
// 构建单个 hunk
|
||
const hunk: DiffHunkInfo = {
|
||
oldStart: 1,
|
||
oldCount: oldLines.length,
|
||
newStart: 1,
|
||
newCount: newLines.length,
|
||
lines,
|
||
};
|
||
|
||
return {
|
||
isNew,
|
||
additions,
|
||
deletions,
|
||
hunks: [hunk],
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 构建权限请求显示上下文
|
||
* 将 Core 的完整 PermissionContext 转换为用于前端显示的精简格式
|
||
*/
|
||
function buildDisplayContext(ctx: PermissionContext): PermissionDisplayContext {
|
||
const permType = inferPermissionType(ctx);
|
||
|
||
switch (permType) {
|
||
case 'file':
|
||
return {
|
||
command: ctx.command,
|
||
operation: ctx.fileOperation || ctx.command.split(' ')[0],
|
||
path: ctx.filePath || ctx.command.split(' ').slice(1).join(' '),
|
||
patterns: ctx.patterns,
|
||
externalPaths: ctx.externalPaths,
|
||
};
|
||
|
||
case 'git':
|
||
return {
|
||
command: ctx.command,
|
||
gitOperation: ctx.gitOperation || ctx.command.replace(/^git\s+/, '').split(' ')[0],
|
||
};
|
||
|
||
case 'web':
|
||
return {
|
||
command: ctx.command,
|
||
query: ctx.webQuery || ctx.command,
|
||
};
|
||
|
||
default:
|
||
return {
|
||
command: ctx.command,
|
||
patterns: ctx.patterns,
|
||
externalPaths: ctx.externalPaths,
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 创建 Server 端权限回调函数
|
||
* 用于在 Agent 创建时设置
|
||
*/
|
||
export function createServerPermissionCallback(sessionId: string) {
|
||
return async (ctx: unknown): Promise<PermissionDecision> => {
|
||
const permCtx = ctx as PermissionContext;
|
||
|
||
// 检查 auto-approve 配置
|
||
if (isAutoApproved(sessionId, permCtx)) {
|
||
console.log(`[Permission] Auto-approved: ${permCtx.command}`);
|
||
return { allow: true, remember: false };
|
||
}
|
||
|
||
const requestId = randomUUID();
|
||
const permissionType = inferPermissionType(permCtx);
|
||
const context = buildDisplayContext(permCtx);
|
||
|
||
// 为文件操作生成 diff
|
||
let diff: DiffInfo | undefined;
|
||
if (permissionType === 'file' && (permCtx.fileOperation === 'write' || permCtx.fileOperation === 'edit')) {
|
||
diff = generateDiffInfo(permCtx.oldContent, permCtx.newContent);
|
||
}
|
||
|
||
// 构建请求 payload
|
||
const payload: PermissionRequestPayload = {
|
||
requestId,
|
||
permissionType,
|
||
context,
|
||
diff,
|
||
};
|
||
|
||
// 发送权限请求到客户端
|
||
const message: ServerMessage = {
|
||
type: 'permission_request',
|
||
sessionId,
|
||
payload,
|
||
};
|
||
|
||
broadcastToSession(sessionId, message);
|
||
|
||
// 等待响应(带超时)
|
||
return new Promise((resolve, reject) => {
|
||
const timeout = setTimeout(() => {
|
||
pendingRequests.delete(requestId);
|
||
// 超时默认拒绝
|
||
resolve({ allow: false, remember: false });
|
||
}, PERMISSION_TIMEOUT);
|
||
|
||
pendingRequests.set(requestId, { resolve, reject, timeout });
|
||
});
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 处理权限响应
|
||
* 由 WebSocket 消息处理器调用
|
||
*/
|
||
export function handlePermissionResponse(
|
||
requestId: string,
|
||
allow: boolean,
|
||
remember?: boolean
|
||
): boolean {
|
||
const pending = pendingRequests.get(requestId);
|
||
|
||
if (!pending) {
|
||
console.warn(`[Permission] No pending request found for: ${requestId}`);
|
||
return false;
|
||
}
|
||
|
||
clearTimeout(pending.timeout);
|
||
pendingRequests.delete(requestId);
|
||
pending.resolve({ allow, remember });
|
||
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 取消会话的所有待处理权限请求
|
||
* 用于会话断开时清理
|
||
*/
|
||
export function cancelPendingRequests(sessionId: string): void {
|
||
// 由于我们没有存储 sessionId -> requestId 的映射,
|
||
// 这个函数目前只是一个占位符
|
||
// 在实际使用中,如果需要按会话取消请求,需要维护额外的映射
|
||
console.log(`[Permission] Cancelling pending requests for session: ${sessionId}`);
|
||
}
|
||
|
||
/**
|
||
* 获取待处理请求数量(用于调试)
|
||
*/
|
||
export function getPendingRequestCount(): number {
|
||
return pendingRequests.size;
|
||
}
|