feat: 重构为 Monorepo 架构并实现 HTTP Server
架构变更: - 采用 pnpm workspaces 实现 Monorepo 结构 - 将现有代码迁移到 packages/core - 新增 packages/server HTTP 服务层 Server 功能: - REST API: 会话管理、工具管理、配置管理 - WebSocket: 实时双向通信支持 - SSE: 服务端事件推送 - Hono + Bun 作为运行时 API 端点: - GET/POST /api/sessions - 会话 CRUD - GET/POST /api/sessions/:id/messages - 消息管理 - GET /api/sessions/:id/events - SSE 事件流 - WS /api/ws/:sessionId - WebSocket 连接 - GET/POST /api/tools - 工具管理 - GET/PUT /api/config - 配置管理
This commit is contained in:
@@ -0,0 +1,322 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { FilePermissionChecker } from '../../../src/permission/checkers/file.js';
|
||||
import type { PermissionDecision, FilePermissionContext } from '../../../src/permission/types.js';
|
||||
|
||||
// Mock fs 以避免实际文件操作
|
||||
vi.mock('fs', () => ({
|
||||
existsSync: vi.fn(() => false),
|
||||
readFileSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock file-prompt
|
||||
vi.mock('../../../src/permission/file-prompt.js', () => ({
|
||||
promptFilePermission: vi.fn().mockResolvedValue({ allow: true, remember: false }),
|
||||
}));
|
||||
|
||||
describe('FilePermissionChecker - 文件权限检查器', () => {
|
||||
let checker: FilePermissionChecker;
|
||||
const testProjectRoot = '/test/project';
|
||||
|
||||
beforeEach(() => {
|
||||
checker = new FilePermissionChecker(testProjectRoot);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('默认配置', () => {
|
||||
it('加载默认配置', () => {
|
||||
const config = checker.getConfig();
|
||||
|
||||
expect(config.operations).toBeDefined();
|
||||
expect(config.sensitivePaths).toBeDefined();
|
||||
expect(config.externalDirectory).toBe('ask');
|
||||
});
|
||||
|
||||
it('读操作默认允许', () => {
|
||||
const config = checker.getConfig();
|
||||
|
||||
expect(config.operations.read).toBe('allow');
|
||||
expect(config.operations.list).toBe('allow');
|
||||
expect(config.operations.search).toBe('allow');
|
||||
expect(config.operations.grep).toBe('allow');
|
||||
expect(config.operations.info).toBe('allow');
|
||||
});
|
||||
|
||||
it('写操作默认需要确认', () => {
|
||||
const config = checker.getConfig();
|
||||
|
||||
expect(config.operations.write).toBe('ask');
|
||||
expect(config.operations.edit).toBe('ask');
|
||||
expect(config.operations.move).toBe('ask');
|
||||
expect(config.operations.copy).toBe('ask');
|
||||
expect(config.operations.delete).toBe('ask');
|
||||
expect(config.operations.mkdir).toBe('ask');
|
||||
});
|
||||
});
|
||||
|
||||
describe('读操作权限', () => {
|
||||
const readOperations: FilePermissionContext['operation'][] = [
|
||||
'read',
|
||||
'list',
|
||||
'search',
|
||||
'grep',
|
||||
'info',
|
||||
];
|
||||
|
||||
for (const operation of readOperations) {
|
||||
it(`${operation} 操作在项目内默认允许`, async () => {
|
||||
const result = await checker.checkFilePermission({
|
||||
operation,
|
||||
path: './src/index.ts',
|
||||
workdir: testProjectRoot,
|
||||
});
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.action).toBe('allow');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('写操作权限', () => {
|
||||
const writeOperations: FilePermissionContext['operation'][] = [
|
||||
'write',
|
||||
'edit',
|
||||
'move',
|
||||
'copy',
|
||||
'delete',
|
||||
'mkdir',
|
||||
];
|
||||
|
||||
for (const operation of writeOperations) {
|
||||
it(`${operation} 操作无回调时需要确认`, async () => {
|
||||
const result = await checker.checkFilePermission({
|
||||
operation,
|
||||
path: './src/new-file.ts',
|
||||
workdir: testProjectRoot,
|
||||
});
|
||||
|
||||
expect(result.action).toBe('ask');
|
||||
expect(result.needsConfirmation).toBe(true);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('敏感路径检查', () => {
|
||||
it('系统路径拒绝访问', async () => {
|
||||
const sensitivePaths = [
|
||||
'/etc/passwd',
|
||||
'/usr/bin/node',
|
||||
'/bin/sh',
|
||||
'/var/log/syslog',
|
||||
];
|
||||
|
||||
for (const testPath of sensitivePaths) {
|
||||
const result = await checker.checkFilePermission({
|
||||
operation: 'read',
|
||||
path: testPath,
|
||||
workdir: testProjectRoot,
|
||||
});
|
||||
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.action).toBe('deny');
|
||||
}
|
||||
});
|
||||
|
||||
it('用户敏感文件需要确认', async () => {
|
||||
const sensitivePaths = [
|
||||
'~/.ssh/id_rsa',
|
||||
'~/.aws/credentials',
|
||||
'./.env',
|
||||
];
|
||||
|
||||
for (const testPath of sensitivePaths) {
|
||||
const result = await checker.checkFilePermission({
|
||||
operation: 'read',
|
||||
path: testPath,
|
||||
workdir: testProjectRoot,
|
||||
});
|
||||
|
||||
// 敏感路径会触发 ask
|
||||
expect(result.action === 'ask' || result.action === 'deny').toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('外部目录访问', () => {
|
||||
it('项目外路径需要确认', async () => {
|
||||
const result = await checker.checkFilePermission({
|
||||
operation: 'read',
|
||||
path: '/home/other/file.txt',
|
||||
workdir: testProjectRoot,
|
||||
});
|
||||
|
||||
// 外部目录会触发 ask 或 deny
|
||||
expect(result.action === 'ask' || result.action === 'deny').toBe(true);
|
||||
});
|
||||
|
||||
it('项目内路径正常检查', async () => {
|
||||
const result = await checker.checkFilePermission({
|
||||
operation: 'read',
|
||||
path: './src/index.ts',
|
||||
workdir: testProjectRoot,
|
||||
});
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('波浪号展开', () => {
|
||||
it('展开 ~ 为 home 目录', async () => {
|
||||
const result = await checker.checkFilePermission({
|
||||
operation: 'read',
|
||||
path: '~/projects/file.txt',
|
||||
workdir: testProjectRoot,
|
||||
});
|
||||
|
||||
// 外部路径会触发 ask
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('回调处理', () => {
|
||||
it('用户允许时返回允许', async () => {
|
||||
const mockCallback = vi.fn().mockResolvedValue({
|
||||
allow: true,
|
||||
remember: false,
|
||||
} as PermissionDecision);
|
||||
|
||||
checker.setAskCallback(mockCallback);
|
||||
|
||||
const result = await checker.checkFilePermission({
|
||||
operation: 'write',
|
||||
path: './src/new-file.ts',
|
||||
workdir: testProjectRoot,
|
||||
});
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('用户拒绝时返回拒绝', async () => {
|
||||
const mockCallback = vi.fn().mockResolvedValue({
|
||||
allow: false,
|
||||
remember: false,
|
||||
} as PermissionDecision);
|
||||
|
||||
checker.setAskCallback(mockCallback);
|
||||
|
||||
const result = await checker.checkFilePermission({
|
||||
operation: 'delete',
|
||||
path: './src/file.ts',
|
||||
workdir: testProjectRoot,
|
||||
});
|
||||
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.action).toBe('deny');
|
||||
});
|
||||
});
|
||||
|
||||
describe('会话权限管理', () => {
|
||||
it('记住允许决定', async () => {
|
||||
const mockCallback = vi.fn().mockResolvedValue({
|
||||
allow: true,
|
||||
remember: true,
|
||||
} as PermissionDecision);
|
||||
|
||||
checker.setAskCallback(mockCallback);
|
||||
|
||||
// 第一次调用
|
||||
await checker.checkFilePermission({
|
||||
operation: 'write',
|
||||
path: './src/test.ts',
|
||||
workdir: testProjectRoot,
|
||||
});
|
||||
|
||||
// 第二次调用同一操作和路径
|
||||
const result = await checker.checkFilePermission({
|
||||
operation: 'write',
|
||||
path: './src/test.ts',
|
||||
workdir: testProjectRoot,
|
||||
});
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
// 第二次不应该调用回调
|
||||
expect(mockCallback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('记住拒绝决定', async () => {
|
||||
const mockCallback = vi.fn().mockResolvedValue({
|
||||
allow: false,
|
||||
remember: true,
|
||||
} as PermissionDecision);
|
||||
|
||||
checker.setAskCallback(mockCallback);
|
||||
|
||||
// 第一次调用
|
||||
await checker.checkFilePermission({
|
||||
operation: 'delete',
|
||||
path: './src/important.ts',
|
||||
workdir: testProjectRoot,
|
||||
});
|
||||
|
||||
// 第二次调用
|
||||
const result = await checker.checkFilePermission({
|
||||
operation: 'delete',
|
||||
path: './src/important.ts',
|
||||
workdir: testProjectRoot,
|
||||
});
|
||||
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(mockCallback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('清除会话权限后重新询问', async () => {
|
||||
const mockCallback = vi.fn().mockResolvedValue({
|
||||
allow: true,
|
||||
remember: true,
|
||||
} as PermissionDecision);
|
||||
|
||||
checker.setAskCallback(mockCallback);
|
||||
|
||||
// 第一次调用
|
||||
await checker.checkFilePermission({
|
||||
operation: 'write',
|
||||
path: './src/test.ts',
|
||||
workdir: testProjectRoot,
|
||||
});
|
||||
|
||||
// 清除权限
|
||||
checker.clearSessionPermissions();
|
||||
|
||||
// 再次调用
|
||||
await checker.checkFilePermission({
|
||||
operation: 'write',
|
||||
path: './src/test.ts',
|
||||
workdir: testProjectRoot,
|
||||
});
|
||||
|
||||
// 应该调用两次
|
||||
expect(mockCallback).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('check 接口(兼容 PermissionChecker)', () => {
|
||||
it('解析 read 操作', async () => {
|
||||
const result = await checker.check({
|
||||
command: 'read ./src/index.ts',
|
||||
workdir: testProjectRoot,
|
||||
});
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('解析 write 操作', async () => {
|
||||
const result = await checker.check({
|
||||
command: 'write ./src/new.ts',
|
||||
workdir: testProjectRoot,
|
||||
});
|
||||
|
||||
expect(result.action).toBe('ask');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user