feat(permission): 实现 WebSocket 权限确认机制

重构权限系统,将终端 UI 代码从 core 模块移除,实现基于 WebSocket 的权限确认流程:

Core 模块清理:
- 删除 permission/prompt.ts 和 file-prompt.ts(终端交互)
- 删除 diff.ts 中的 chalk 渲染函数
- 删除 config.ts 中的 inquirer 交互
- 移除 chalk 依赖

Server 权限处理:
- 新增 permission/handler.ts,实现 WebSocket 权限请求/响应
- 更新 agent/adapter.ts 设置权限回调
- 更新 ws.ts 处理 permission_response 消息

Web 权限组件:
- 新增 PermissionDialog 组件,显示权限请求详情和 Diff
- 更新 useChat hook 管理权限状态
- 更新 Chat 页面集成权限弹窗
This commit is contained in:
2025-12-13 01:09:35 +08:00
parent 5d4afecd48
commit 1d69fd876d
20 changed files with 739 additions and 1560 deletions
+13
View File
@@ -11,6 +11,7 @@ import type { SessionStatus } from '../types.js';
import { getSessionManager } from '../session/manager.js';
import { broadcastToSession } from '../ws.js';
import { emitStatusEvent, emitLogEvent } from '../sse.js';
import { createServerPermissionCallback } from '../permission/handler.js';
// ============================================================================
// Core 模块接口定义(避免直接依赖 @ai-assistant/core 类型)
@@ -41,6 +42,13 @@ interface ToolRegistry {
getAllTools(): unknown[];
}
/**
* Permission Manager 接口
*/
interface PermissionManager {
setAskCallback(callback: (ctx: unknown) => Promise<{ allow: boolean; remember?: boolean }>): void;
}
/**
* Core 模块接口
*/
@@ -48,6 +56,7 @@ interface CoreModule {
Agent: AgentConstructor;
toolRegistry: ToolRegistry;
loadConfig: () => unknown;
getPermissionManager: (projectRoot?: string) => PermissionManager;
}
// ============================================================================
@@ -114,6 +123,10 @@ export function getOrCreateAgent(sessionId: string): AgentInstance | null {
const agent = new coreModule.Agent(config);
agent.setRegistry(coreModule.toolRegistry);
// 设置权限回调,通过 WebSocket 请求用户确认
const permissionManager = coreModule.getPermissionManager();
permissionManager.setAskCallback(createServerPermissionCallback(sessionId));
agentCache.set(sessionId, agent);
return agent;
}
+192
View File
@@ -0,0 +1,192 @@
/**
* Server 端权限处理器
* 通过 WebSocket 发送权限请求并等待客户端响应
*/
import { randomUUID } from 'crypto';
import { broadcastToSession } from '../ws.js';
import type {
PermissionType,
PermissionRequestPayload,
PermissionRequestContext,
ServerMessage,
} from '../types.js';
/**
* 权限决策结果
*/
export interface PermissionDecision {
allow: boolean;
remember?: boolean;
}
/**
* 权限上下文(来自 core 模块)
*/
export interface PermissionContext {
command: string;
workdir: string;
patterns?: string[];
externalPaths?: string[];
}
// 等待中的权限请求
interface PendingRequest {
resolve: (decision: PermissionDecision) => void;
reject: (error: Error) => void;
timeout: ReturnType<typeof setTimeout>;
}
const pendingRequests = new Map<string, PendingRequest>();
// 默认超时时间(60秒)
const PERMISSION_TIMEOUT = 60000;
/**
* 从命令或上下文检测权限类型
*/
function detectPermissionType(ctx: PermissionContext): PermissionType {
const command = ctx.command.toLowerCase();
// 检测 git 操作
if (command.startsWith('git ')) {
return 'git';
}
// 检测文件操作
const fileOps = ['read', 'write', 'edit', 'delete', 'move', 'copy', 'mkdir'];
for (const op of fileOps) {
if (command.startsWith(`${op} `)) {
return 'file';
}
}
// 检测 web 操作
if (command.includes('fetch') || command.includes('http')) {
return 'web';
}
// 默认为 bash
return 'bash';
}
/**
* 构建权限请求上下文
*/
function buildRequestContext(ctx: PermissionContext): PermissionRequestContext {
const permType = detectPermissionType(ctx);
switch (permType) {
case 'file': {
const parts = ctx.command.split(' ');
const operation = parts[0];
const path = parts.slice(1).join(' ');
return {
operation,
path,
patterns: ctx.patterns,
externalPaths: ctx.externalPaths,
};
}
case 'git': {
const gitOp = ctx.command.replace(/^git\s+/, '').split(' ')[0];
return {
command: ctx.command,
gitOperation: gitOp,
};
}
case 'web': {
return {
command: ctx.command,
query: 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;
const requestId = randomUUID();
const permissionType = detectPermissionType(permCtx);
const context = buildRequestContext(permCtx);
// 构建请求 payload
const payload: PermissionRequestPayload = {
requestId,
permissionType,
context,
};
// 发送权限请求到客户端
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;
}
+65 -2
View File
@@ -89,12 +89,16 @@ export type Tool = z.infer<typeof ToolSchema>;
// 客户端发送的消息
export interface ClientMessage {
type: 'message' | 'cancel' | 'tool_response';
type: 'message' | 'cancel' | 'tool_response' | 'permission_response';
sessionId: string;
payload?: {
content?: string;
toolCallId?: string;
approved?: boolean;
// Permission response fields
requestId?: string;
allow?: boolean;
remember?: boolean;
};
}
@@ -109,11 +113,70 @@ export interface ServerMessage {
| 'done'
| 'cancelled'
| 'error'
| 'session_updated';
| 'session_updated'
| 'permission_request';
sessionId: string;
payload?: unknown;
}
// ============ Permission 相关 ============
export type PermissionType = 'bash' | 'file' | 'git' | 'web';
/**
* 权限请求上下文
*/
export interface PermissionRequestContext {
command?: string; // bash 命令
operation?: string; // 文件操作类型: read/write/edit/delete
path?: string; // 文件路径
gitOperation?: string; // git 操作
query?: string; // web 查询
patterns?: string[]; // 匹配模式
externalPaths?: string[]; // 外部路径
}
/**
* Diff 信息(文件写入/编辑时)
*/
export interface DiffHunkInfo {
oldStart: number;
oldCount: number;
newStart: number;
newCount: number;
lines: Array<{
type: 'add' | 'remove' | 'context';
lineNumber: number | null;
content: string;
}>;
}
export interface DiffInfo {
isNew: boolean;
additions: number;
deletions: number;
hunks: DiffHunkInfo[];
}
/**
* 权限请求消息 payload
*/
export interface PermissionRequestPayload {
requestId: string;
permissionType: PermissionType;
context: PermissionRequestContext;
diff?: DiffInfo;
}
/**
* 权限响应消息 payload
*/
export interface PermissionResponsePayload {
requestId: string;
allow: boolean;
remember?: boolean;
}
// ============ SSE 事件 ============
export interface SSEEvent {
+13
View File
@@ -7,6 +7,7 @@
import type { WSContext } from 'hono/ws';
import { getSessionManager } from './session/manager.js';
import { processMessage, cancelProcessing } from './agent/index.js';
import { handlePermissionResponse } from './permission/handler.js';
import type { ClientMessage, ServerMessage } from './types.js';
// 存储活跃的 WebSocket 连接
@@ -143,6 +144,18 @@ export async function handleWebSocketMessage(
break;
}
case 'permission_response': {
// 处理权限确认响应
const { requestId, allow, remember } = message.payload || {};
if (requestId) {
const handled = handlePermissionResponse(requestId, allow ?? false, remember);
if (!handled) {
console.warn(`[WS] Permission response for unknown request: ${requestId}`);
}
}
break;
}
default:
ws.send(
JSON.stringify({