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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user