diff --git a/.gitignore b/.gitignore index a1745fb..ffef366 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,6 @@ src/agentkit/server/static/ # Env .env + +# Runtime data (auth DB, conversation DB, etc.) +data/ diff --git a/AGENTS.md b/AGENTS.md index 383ae87..5920986 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -112,20 +112,23 @@ On failure: fallback to single-agent mode (lead or first active expert). | Layer | Modules | Purpose | |-------|---------|---------| | API | `server/`, `cli/` | FastAPI routes + Typer CLI | +| Auth | `server/auth/` | JWT + RBAC + terminal security (6-layer whitelist) | | Service | `core/`, `chat/`, `skills/`, `experts/` | Agent engine, routing, skills, expert teams | | Data | `memory/`, `session/`, `bus/` | Persistence, sessions, messaging | | Utility | `llm/`, `tools/`, `evolution/`, `quality/`, `mcp/` | LLM gateway, tools, self-evolution, quality, MCP | +| Client | `client/` | ConfigSync, RemoteLLMProvider integration | ### Key Subsystems -- **LLM Gateway** (`llm/`): 6 providers (OpenAI/Anthropic/Gemini/Doubao/Wenxin/Yuanbao), fallback, semantic cache, usage tracking +- **LLM Gateway** (`llm/`): 6 providers (OpenAI/Anthropic/Gemini/Doubao/Wenxin/Yuanbao), fallback, semantic cache, usage tracking, RemoteLLMProvider (client→server proxy with 401 refresh retry) - **Memory** (`memory/`): 4-layer (SOUL/USER/MEMORY/DAILY), WorkingMemory (Redis), EpisodicMemory (PG+pgvector), SemanticMemory (HTTP RAG) - **Evolution** (`evolution/`): Reflector, PromptOptimizer (genetic), PitfallDetector, ABTester - **Tools** (`tools/`): 21 built-in + MCP extension, composition (SequentialChain/ParallelFanOut/DynamicSelector) - **Pipeline** (`orchestrator/`): PipelineEngine, SagaOrchestrator, DynamicPipeline, HandoffManager - **Bus** (`bus/`): MemoryBus (in-process), RedisBus (distributed) +- **Auth** (`server/auth/`): JWT (access 15min + refresh 7d, HS256), API Key (constant-time compare), 3-level RBAC (member/operator/admin + permission bits), 6-layer terminal security (blocklist→shell-ops→builtin→global→user→session→danger), bcrypt password hashing (rounds=12) -### Server Routes (17 modules) +### Server Routes (22 modules) | Prefix | Module | Purpose | |--------|--------|---------| @@ -135,6 +138,7 @@ On failure: fallback to single-agent mode (lead or first active expert). | `/api/v1/chat` | chat.py | Chat REST + WebSocket | | `/api/v1/ws` | ws.py | WebSocket channel | | `/api/v1/llm` | llm.py | LLM usage | +| `/api/v1/llm/chat` | llm_gateway.py | LLM gateway proxy (JWT auth, SSE streaming) | | `/api/v1/health` | health.py | Health check | | `/api/v1/metrics` | metrics.py | Metrics | | `/api/v1/evolution` | evolution.py + evolution_dashboard.py | Self-evolution API | @@ -143,8 +147,13 @@ On failure: fallback to single-agent mode (lead or first active expert). | `/api/v1/kb` | kb_management.py | Knowledge base | | `/api/v1/skill-mgmt` | skill_management.py | Skill management | | `/api/v1/workflows` | workflows.py | Workflows | -| `/api/v1/terminal` | terminal.py | Terminal | +| `/api/v1/terminal` | terminal.py | Local terminal (client sidecar PTY) | +| `/api/v1/terminal/server` | terminal_server.py | Server terminal (server PTY + admin approval) | +| `/api/v1/terminal` | terminal_whitelist.py | Whitelist/blocklist/audit-log management | | `/api/v1/settings` | settings.py | Settings | +| `/api/v1/auth` | auth.py | Login/refresh/logout/me | +| `/api/v1/system` | system.py | System resources (SYSTEM_CONFIG permission) | +| `/api/v1/config` | config_sync.py | Config version + sync (polling) | ### WebSocket Chat Protocol @@ -158,6 +167,8 @@ Expert Team events: `team_formed`, `expert_step`, `expert_result`, `plan_update` - `/agent/code` — Code/workflow - `/agent/monitor` — Evolution dashboard - `/computer-use` — Desktop control +- `/login` — Login page (JWT auth) +- Terminal panel — Local + server terminal with whitelist manager ### Configuration Priority diff --git a/README.md b/README.md index 0c00215..a6099be 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ AgentKit 解决的核心问题:**从写 150 行 Agent 代码降为 10-20 行 Y - **生产就绪** -- 内置质量门禁、模型降级、用量统计、级联检测、状态持久化 - **四种使用** -- Python 库引用、CLI 聊天、Web GUI、桌面客户端 - **专家团队** -- Expert Team Mode,多专家协作执行复杂任务,前端以多角色对话流呈现 +- **企业架构** -- 客户端-服务端分离,JWT 认证 + 三级 RBAC + LLM 网关代理 + 终端六层安全防护 - **记忆持久化** -- SOUL/USER/MEMORY/DAILY 四层记忆,写入即生效 - **自进化** -- 反思驱动 Soul 更新,经验积累与陷阱检测 - **工具丰富** -- 内置 Shell、搜索、爬虫、记忆、桌面操控等工具,支持 MCP 扩展 @@ -211,6 +212,87 @@ result = await orchestrator.execute_plan(plan) 用户也可在聊天中通过 `@team:researcher,writer,reviewer 任务描述` 前缀触发团队模式。 +### 16. 企业级客户端-服务端架构 + +将 AgentKit 从纯本地运行架构演进为企业级客户端+服务端架构。客户端(Tauri 桌面端)作为 AI 工作台本地执行 Agent/终端/文件操作,服务端作为企业平台提供 LLM 网关(统一 Key 管理)、用户权限、审计日志、知识库共享能力。 + +**核心能力**: + +| 能力 | 说明 | +|------|------| +| JWT 认证 | Access Token (15min) + Refresh Token (7d),HS256 签名 | +| API Key 认证 | 服务间调用,常量时间比较(`hmac.compare_digest`) | +| 三级 RBAC | member / operator / admin + 独立权限位 | +| LLM 网关代理 | 客户端通过服务端间接调用 LLM,不在本地存储 API Key | +| 双模式终端 | 本地终端(客户端 sidecar PTY)+ 服务端终端(服务端 PTY + 管理员审批) | +| 六层终端安全 | 黑名单 → 内置安全 → 全局白名单 → 用户白名单 → 会话白名单 → 危险检测 | +| 终端审计日志 | 所有命令(执行/审批/拒绝/阻止)均记录,支持按用户/会话/模式过滤 | +| 配置同步 | 启动全量拉取 + 5 分钟轮询版本号 + 手动刷新 | +| Shell 操作符防护 | `&&`、`;`、`|`、`$()` 等操作符始终触发危险检测,绕过白名单前缀匹配 | + +**权限矩阵**: + +| 权限 | member | operator | admin | +|------|--------|----------|-------| +| CHAT | ✓ | ✓ | ✓ | +| KB_QUERY | ✓ | ✓ | ✓ | +| KB_WRITE | | ✓ | ✓ | +| WORKFLOW_EXECUTE | ✓ | ✓ | ✓ | +| TERMINAL_LOCAL_USE | | ✓ | ✓ | +| TERMINAL_SERVER_USE | | | ✓ | +| TERMINAL_WHITELIST_MANAGE | | ✓ | ✓ | +| USER_MANAGE | | | ✓ | +| SYSTEM_CONFIG | | | ✓ | + +**认证流程**: + +``` +客户端登录 → /api/v1/auth/login (username + password) + ← { access_token, refresh_token, user } + +Token 过期 → RemoteLLMProvider 收到 401 + → 调用 refresh_callback → /api/v1/auth/refresh + ← 新 access_token (refresh_token 不轮换) + → 重试原始请求 +``` + +**终端安全决策流**: + +``` +命令输入 + ↓ +1. 黑名单检查 → BLOCKED + ↓ +2. Shell 操作符检测 (&&/;/|/$()/`) → 标记为危险 + ↓ +3. 内置安全白名单 (ls/cat/git status...) → SAFE + ↓ +4. 全局白名单 (管理员配置) → SAFE + ↓ +5. 用户白名单 (DB 持久化) → SAFE + ↓ +6. 会话白名单 (本次会话临时) → SAFE + ↓ +7. 危险检测 (_is_dangerous) → 需确认/审批 + ↓ +本地终端: 用户确认 → 加入会话白名单 → 执行 +服务端终端: 管理员审批 (5分钟超时) → 执行 +``` + +**使用方式**: + +```python +from agentkit.llm import RemoteLLMProvider + +# 客户端通过服务端 LLM 网关调用(不在本地存储 API Key) +provider = RemoteLLMProvider( + server_url="https://api.example.com", + auth_token_provider=lambda: get_jwt_token(), + refresh_callback=refresh_tokens, # 401 时自动刷新 +) +response = await provider.chat(request) +``` + ## 架构图 ``` @@ -223,12 +305,19 @@ result = await orchestrator.execute_plan(plan) │ 前端 (Vue 3 + Ant Design Vue) │ │ ChatView · ExpertTeamView · ExpertMessage · PlanViz │ │ EvolutionView · WorkflowView · TerminalView · ComputerUse │ + │ LoginView · SystemMonitorPanel · WhitelistManager │ └──────────────────────────┼───────────────────────────────────┘ - │ WebSocket / SSE / HTTP + │ WebSocket / SSE / HTTP (JWT) ┌──────────────────────────┼───────────────────────────────────┐ │ 服务端 (FastAPI + Uvicorn) │ - │ portal.py · chat.py · evolution.py · workflows.py · ... │ - │ 17个路由模块 · Agent Pool · Expert Team · Memory Store │ + │ ┌─────────────────────────────────────────────────────┐ │ + │ │ 认证层: AuthMiddleware (JWT + API Key 双轨) │ │ + │ │ RBAC: require_permission / require_terminal_auth │ │ + │ └─────────────────────────────────────────────────────┘ │ + │ portal.py · chat.py · evolution.py · workflows.py │ + │ auth.py · terminal_server.py · terminal_whitelist.py │ + │ llm_gateway.py · config_sync.py · system.py · ... │ + │ 22个路由模块 · Agent Pool · Expert Team · Memory Store │ └──────────────────────────┼───────────────────────────────────┘ │ ┌──────────────┼──────────────┐ @@ -261,6 +350,13 @@ result = await orchestrator.execute_plan(plan) ┌─────────┐ ┌────────┐ ┌──────────┐ │DashScope│ │ OpenAI │ │ DeepSeek │ ... └─────────┘ └────────┘ └──────────┘ + + ┌──────────────────────────────────────────────────────────────┐ + │ 终端安全六层防护 │ + │ 黑名单 → Shell操作符检测 → 内置白名单 → 全局白名单 │ + │ → 用户白名单 → 会话白名单 → 危险检测 → 审批/确认 │ + │ 本地终端: 用户确认 | 服务端终端: 管理员审批 (5min超时) │ + └──────────────────────────────────────────────────────────────┘ ``` ### 模块分层 @@ -1233,6 +1329,24 @@ v2 增强:接受 SkillConfig 时自动创建 Skill 并启用 ReAct 模式,Qu | `/api/v1/skills` | GET | 列出所有 Skill | | `/api/v1/llm/usage` | GET | 查询 LLM 用量 | | `/api/v1/health` | GET | 健康检查 | +| `/api/v1/auth/login` | POST | 用户登录(用户名+密码 → JWT) | +| `/api/v1/auth/refresh` | POST | 刷新 access_token | +| `/api/v1/auth/logout` | POST | 登出(吊销 refresh_token) | +| `/api/v1/auth/me` | GET | 获取当前用户信息 | +| `/api/v1/llm/chat` | POST | LLM 网关代理(JWT 认证) | +| `/api/v1/llm/chat/stream` | POST | LLM 网关流式代理(SSE) | +| `/api/v1/terminal/ws` | WS | 本地终端 WebSocket(JWT via ?token=) | +| `/api/v1/terminal/server/ws` | WS | 服务端终端 WebSocket(管理员审批) | +| `/api/v1/terminal/approvals` | GET | 列出待审批命令(管理员) | +| `/api/v1/terminal/approvals/{id}/approve` | POST | 审批通过(管理员) | +| `/api/v1/terminal/approvals/{id}/reject` | POST | 审批拒绝(管理员) | +| `/api/v1/terminal/whitelist/global` | GET/POST/DELETE | 全局白名单管理(管理员) | +| `/api/v1/terminal/whitelist/user` | GET/POST/DELETE | 用户白名单管理 | +| `/api/v1/terminal/blocklist` | GET/POST/DELETE | 黑名单管理(管理员) | +| `/api/v1/terminal/audit-logs` | GET | 审计日志查询(管理员,支持 terminal_mode 过滤) | +| `/api/v1/system/resources` | GET | 系统资源监控(需 SYSTEM_CONFIG 权限) | +| `/api/v1/config/version` | GET | 配置版本号(内容哈希) | +| `/api/v1/config/all` | GET | 全量配置(技能+工作流+Agent) | ### Web GUI @@ -1416,11 +1530,12 @@ fischer-agentkit/ │ ├── bus/ # 消息总线(MemoryBus + RedisBus) │ ├── chat/ # 聊天路由(CostAwareRouter + ExecutionMode) │ ├── cli/ # CLI 命令(Typer) +│ ├── client/ # 客户端 SDK(ConfigSync + RemoteLLMProvider 集成) │ ├── core/ # 核心引擎(ReAct/Reflexion/ReWOO/ConfigDriven + HandoffTransport) │ ├── evaluation/ # 评估系统(RAGAS) │ ├── evolution/ # 自进化(反思/优化/陷阱检测/A/B测试) │ ├── experts/ # 专家团队(Expert/Team/Orchestrator/Plan/Router/Config/Registry) -│ ├── llm/ # LLM 网关(6 Provider + 缓存 + 用量追踪) +│ ├── llm/ # LLM 网关(6 Provider + 缓存 + 用量追踪 + RemoteLLMProvider) │ ├── marketplace/ # 多Agent市场(拍卖/财富) │ ├── mcp/ # MCP 协议 │ ├── memory/ # 记忆系统(SOUL/USER/MEMORY/DAILY + RAG) @@ -1430,6 +1545,9 @@ fischer-agentkit/ │ ├── quality/ # 质量保障(对齐/级联检测/门控) │ ├── router/ # 意图路由 │ ├── server/ # FastAPI 服务端 + Vue 3 前端 +│ │ ├── auth/ # 认证子系统(JWT + RBAC + 终端安全六层防护) +│ │ ├── routes/ # 22 个路由模块(含 auth/terminal_server/llm_gateway/config_sync) +│ │ └── frontend/ # Vue 3 SPA(含 LoginView/SystemMonitorPanel/WhitelistManager) │ ├── session/ # 会话管理 │ ├── skills/ # 技能系统 │ ├── telemetry/ # 遥测追踪 diff --git a/docs/brainstorms/2026-06-17-expert-team-evolution-roadmap-requirements.md b/docs/brainstorms/2026-06-17-expert-team-evolution-roadmap-requirements.md new file mode 100644 index 0000000..dcb40dd --- /dev/null +++ b/docs/brainstorms/2026-06-17-expert-team-evolution-roadmap-requirements.md @@ -0,0 +1,440 @@ +--- +title: "Fischer AgentKit 专家团演进路线图 v2 — 竞品差距分析与下一步方向" +type: strategy +status: active +created: 2026-06-17 +updated: 2026-06-17 +origin: 竞品深度调研 + 代码库全量扫描 +version: 2 +--- + +# Fischer AgentKit 专家团演进路线图 v2 + +## Summary + +Fischer AgentKit 定位为**通用 Agent 平台**,以 `@board`(群聊决策讨论)和 `@team`(流水线任务交付)构成双模式协作体系。基于对 10 个竞品的深度调研和代码库全量扫描,识别出当前核心问题是 `@team` 代码完整但完全未集成(死代码),且 `CollaborationPlan` 已被移除导致无法支持流水线编排。 + +v2 路线图相比 v1 的关键调整:专家模板从"50+"收缩为"少而精 5-8 个编程专家"(对标 Qoder/Cursor/Claude Code),隔离机制从"git worktree + 沙盒"降级为"上下文隔离"(对标 Trae Solo/Claude Code Subagent),编排模式从"hub-and-spoke"升级为"流水线模式"(对标 Qoder Team Lead + 阶段依赖)。新增 R0 修复死代码和前后端不一致。 + +`@board`(群聊决策讨论,保留名人专家)是唯一无竞品的能力,应保持和放大;`@team`(流水线任务交付,新增编程专家)是对标 Qoder 的核心补齐方向。两者构成"决策→执行"的完整闭环。 + +--- + +## Problem Frame + +### 当前状态(代码库全量扫描结果) + +Fischer AgentKit 专家团系统存在严重的"代码完整但未集成"问题: + +| 模式 | 容器 | 编排器 | 路由器 | WebSocket 集成 | 实际状态 | +|------|------|--------|--------|---------------|----------| +| 群聊讨论 | `BoardTeam` | `BoardOrchestrator` | `BoardRouter` (`@board`) | ✅ 已集成 | 可用 | +| 任务分解 | `ExpertTeam` | `TeamOrchestrator` | `ExpertTeamRouter` (`@team`) | ❌ 未集成 | **死代码** | + +**核心问题清单**: + +1. **`TeamOrchestrator` 从未被实例化** — `grep` 在 `src/agentkit/server` 中搜索 `TeamOrchestrator(` 返回零匹配,整个代码库中仅在 `__init__.py` 导出但无调用方 +2. **`CollaborationPlan` 已被移除** — 替换为简化的 `TeamPlan`(仅 `subtasks` 列表,无阶段依赖图),VOTE/FUSION 合并策略已删除仅保留 BEST +3. **前后端数据模型不一致** — 前端 `team.ts` 仍用 `plan_phases` 概念,后端 `TeamPlan` 已改为 `subtasks`,一旦集成 `@team` 会立即暴露 +4. **集成测试已损坏** — `tests/integration/test_expert_team.py` 导入已移除的 `CollaborationPlan, ParallelType, PhaseStatus, PlanPhase`,会 ImportError +5. **多模型路由未消费** — `ExpertConfig.llm` 字段存在但两个 Orchestrator 均硬编码 `model="default"` +6. **AGENTS.md 文档过时** — 描述的"CostAwareRouter 三层路由"、"serial/parallel/competitive + merge"已与实际简化后的代码不符 + +### 竞品差距矩阵(10 个竞品深度调研) + +| 能力维度 | Fischer AgentKit | Qoder | WorkBuddy | Trae Solo | Cursor 2.0 | Devin | Claude Code | Codex | 差距大小 | +|----------|-----------------|-------|-----------|-----------|------------|-------|-------------|-------|---------| +| 群聊讨论 | ✅ `@board` 独有 | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | **领先** | +| 任务分解执行 | ⚠️ 死代码 | ✅ 流水线 | ✅ 多窗口 | ✅ 主子 | ✅ 8并行 | ✅ 舰队 | ✅ 委派 | ✅ 沙箱 | **大** | +| 编排模式 | hub-and-spoke(未集成) | 流水线 | 多窗口并行 | 主子协同 | 纯并行 | 递归管理 | 委派+并行 | 计划→沙箱 | **大** | +| 隔离机制 | ❌ 零实现 | 多Workspace | 沙箱 | 上下文隔离 | git worktree | 独立VM | 上下文+worktree | 沙箱 | **大** | +| 专家模板数 | 9(名人) | 5类(编程) | 100+(办公) | 用户自定义 | 8并行 | 无上限 | 5内置+自定义 | 6角色插件 | **需定位** | +| 多模型路由 | 配置存在未消费 | 4模式 | 多模型 | 按需选择 | 按阶段 | 单一 | 单一 | GPT-5.5 | **中** | +| 仓库级理解 | ❌ | 10万+文件 | — | ✅ | ✅ | ✅ Wiki | ✅ | ✅ | **大** | +| 自进化系统 | ✅ 遗传+AB | ✅ Expert+Team Skill | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | **缩小** | +| 记忆系统 | ✅ 4层+pgvector | ✅ 记忆感知 | — | ✅ 智能压缩 | — | ✅ Wiki | ✅ Session | ✅ Memory | **缩小** | +| MCP 支持 | ✅ 完整 | ❌ | ✅ | — | ✅ | — | ✅ 标准 | ✅ | **持平** | +| IDE 集成 | Web+Tauri | IDE+JetBrains+VS Code | 桌面 | IDE | VS Code fork | Web+Desktop | CLI+VS Code | Web+CLI+IDE | **中** | + +### 三个独特优势(应保持和放大) + +1. **私董会群聊讨论** — 10 个竞品中唯一支持多专家群聊式决策讨论的能力。`@board` 前缀触发,多轮自主循环,主持人小结,用户可干预。无任何竞品实现此模式。 +2. **自进化系统(但优势在缩小)** — 遗传算法优化 prompt + A/B 测试 + 坑点检测 + 路径优化。Qoder 已跟进 Expert Skill + Team Skill 双向进化,需重新定位差异化(见 R4)。 +3. **4 层记忆系统(但优势在缩小)** — SOUL/USER/MEMORY/DAILY + pgvector 情节记忆 + 语义检索。竞品(Qoder 记忆感知、Claude Session Memory、Codex Memory)正在跟进,需深化场景化记忆。 + +### 三个关键差距(需优先补齐) + +1. **`@team` 死代码 + 编排模式不足** — 代码完整但从未调用,且现有 hub-and-spoke 不支持流水线编排。是投入产出比最高的改进。 +2. **无隔离机制** — 缺乏任何形式的隔离(worktree/容器/上下文),多 Agent 并行会互相干扰。选择上下文隔离作为最轻量方案。 +3. **编程专家模板缺失** — 当前 9 个均为名人专家(用于 @board),无编程领域专家(用于 @team 流水线)。 + +### 行业趋势(影响演进方向) + +1. **从 Agentic Coding 到 Agentic Engineering** — 行业从"AI 帮你写代码"转向"AI 帮你交付软件",Qoder 1.0、Devin、Trae SOLO 都体现这一范式切换 +2. **Human on the Loop 成共识** — 从 Human in the Loop(人在循环中)转向 Human on the Loop(人在循环上)— 人负责定义、驾驭、判断,Agent 负责执行 +3. **MCP 成事实标准** — Anthropic 的 Model Context Protocol 获得 OpenAI、Google、Meta 联合支持,Fischer 已支持,不再是差异化优势 +4. **记忆与进化能力成标配** — 竞品普遍跟进,Fischer 需从"有"升级为"深" + +--- + +## Requirements + +### R0: 修复死代码与前后端不一致 + +**目标**:在集成 `@team` 之前,修复代码库中已存在的死代码、损坏测试和前后端不一致问题,确保集成工作建立在干净的基线上。 + +**需求**: +- 修复 `tests/integration/test_expert_team.py` 中对已移除类的导入(`CollaborationPlan, ParallelType, PhaseStatus, PlanPhase`) +- 统一前后端数据模型:前端 `team.ts` 和 `ExpertTeamView.vue` 从 `plan_phases` 迁移到 `subtasks`(或后端恢复阶段概念,取决于 R1 决策) +- 更新 `AGENTS.md` 中过时的描述:CostAwareRouter 三层路由 → RequestPreprocessor、serial/parallel/competitive → hub-and-spoke +- 清理 `TeamOrchestrator` 的死代码状态:要么集成(R1),要么标记为实验性 + +**验收标准**: +- `pytest tests/integration/test_expert_team.py` 不再 ImportError +- 前后端数据模型一致 +- `AGENTS.md` 与代码实际状态一致 + +**关联文件**: +- `tests/integration/test_expert_team.py` — 修复导入 +- `src/agentkit/server/frontend/src/stores/team.ts` — 数据模型迁移 +- `src/agentkit/server/frontend/src/components/chat/ExpertTeamView.vue` — 数据模型迁移 +- `AGENTS.md` — 文档更新 + +--- + +### R1: `@team` 流水线模式集成到 WebSocket + +**目标**:将 `ExpertTeamRouter` + 重新实现的流水线 `TeamOrchestrator` + `ExpertTeam` 集成到 `chat.py` WebSocket 流程,支持阶段依赖的串行/并行混合编排,对标 Qoder 的 Team Lead + 流水线协作。 + +**需求**: + +**R1.1 流水线编排引擎**: +- 恢复阶段依赖图(`CollaborationPlan` 或等效设计),支持: + - 串行阶段:规划 → 前端 → 后端 → QA → 评审(有依赖关系) + - 并行阶段:同一阶段内无依赖的子任务并行执行 + - 阶段间数据传递:前一阶段输出作为后一阶段输入 +- Lead Expert 负责任务分解和阶段规划 +- 保留 BEST 合并策略用于并行子任务结果汇总 + +**R1.2 上下文隔离**: +- 每个子任务在独立上下文窗口运行,不继承主对话历史 +- 子任务间通过 `SharedWorkspace` 共享中间产物(代码片段、文档、测试结果) +- 子任务完成后,结果汇总到 Lead Expert 的上下文 + +**R1.3 WebSocket 集成**: +- 用户输入 `@team 任务描述` 时,通过 `ExpertTeamRouter.resolve()` 解析专家列表和任务 +- 创建 `ExpertTeam`,注册 handoff handler,广播 `team_formed` 事件 +- 流水线执行驱动:Lead 分解 → 阶段串行 → 阶段内并行 → 结果汇总 +- WebSocket 事件流:`team_formed` → `plan_update`(阶段计划)→ `expert_step`(每个子任务)→ `expert_result` → `phase_started`/`phase_completed` → `team_synthesis` → `team_dissolved` +- 失败时 fallback 到单 Agent REACT 模式(已有 `_fallback_to_single_agent`) + +**R1.4 路由支持**: +- 支持 `@team 任务描述` → 使用默认编程团队 +- 支持 `@team:frontend_engineer,backend_engineer 任务` → 显式指定专家 +- 支持 `@team:dev_team 任务` → 调用整组开发团队 + +**验收标准**: +- `@team 开发用户登录功能` 能触发流水线:规划 → 前端 → 后端 → QA → 评审 +- 前端 `ExpertTeamView.vue` 正确显示阶段计划、当前阶段、专家步骤 +- `TEAM_COLLAB` 执行模式不再 fallback 到 REACT +- 子任务在独立上下文执行,互不污染 +- 与 `@board` 互不干扰,用户可自由切换 + +**关联文件**: +- `src/agentkit/server/routes/chat.py` — 新增 `_execute_team_collab` 函数(参照 `_execute_board_meeting` 模式) +- `src/agentkit/experts/router.py` — `ExpertTeamRouter`(已实现,需集成) +- `src/agentkit/experts/orchestrator.py` — `TeamOrchestrator`(需重写为流水线模式) +- `src/agentkit/experts/plan.py` — `TeamPlan`(需恢复阶段依赖图) +- `src/agentkit/experts/team.py` — `ExpertTeam`(已实现,需适配流水线) +- `src/agentkit/server/frontend/src/components/chat/ExpertTeamView.vue` — 前端组件(需适配阶段展示) + +--- + +### R2: 新增 5-8 个编程领域专家模板 + +**目标**:为 `@team` 流水线模式新增 5-8 个编程领域核心专家,对标 Qoder 的 5 类专家(Team Lead + 前端/后端/QA/评审),与 `@board` 的名人专家形成区分。 + +**需求**: +- 新增编程专家模板(YAML 格式,存放 `configs/experts/`): + 1. **tech_lead** — 技术负责人:任务分解、架构决策、代码审查、阶段协调 + 2. **frontend_engineer** — 前端工程师:UI 实现、组件开发、样式编写、前端测试 + 3. **backend_engineer** — 后端工程师:API 设计、服务逻辑、数据库操作、后端测试 + 4. **qa_engineer** — QA 工程师:测试用例设计、自动化测试、缺陷验证、质量报告 + 5. **code_reviewer** — 代码审查员:代码规范、安全漏洞、性能问题、最佳实践 + 6. **devops_engineer** — DevOps 工程师:CI/CD、部署配置、基础设施、监控(可选) + 7. **security_expert** — 安全专家:安全审计、漏洞扫描、加密设计(可选) + 8. **database_expert** — 数据库专家:Schema 设计、查询优化、数据迁移(可选) +- 每个专家配置:`persona`、`thinking_style`、`speaking_style`、`bound_skills`(绑定工具)、`avatar`、`color` +- 新增 `dev_team.yaml` 组合模板,包含默认编程团队成员列表 +- 专家模板可通过 API 动态注册(已有 `ExpertTemplateRegistry.register()`) + +**验收标准**: +- `configs/experts/` 目录包含 5-8 个编程专家 YAML + 1 个 `dev_team.yaml` +- `@team:frontend_engineer,backend_engineer 开发用户登录功能` 能调用对应专家 +- `@team:dev_team 任务` 能调用默认编程团队 +- 前端 `MentionDropdown.vue` 支持按领域筛选专家(名人专家 vs 编程专家) + +**关联文件**: +- `configs/experts/` — 新增专家模板 YAML +- `src/agentkit/experts/registry.py` — `ExpertTemplateRegistry`(已实现,无需修改) +- `src/agentkit/server/frontend/src/components/chat/MentionDropdown.vue` — 专家筛选(需适配) + +--- + +### R3: 上下文隔离机制 + +**目标**:为 `@team` 流水线子任务提供上下文级隔离,对标 Trae Solo 的独立上下文环境和 Claude Code Subagent 的上下文隔离。子任务在独立上下文窗口运行,不继承主对话历史,避免上下文污染。 + +**需求**: +- **上下文隔离**: + - 每个子任务创建独立的 `ConfigDrivenAgent` 实例,不共享对话历史 + - 子任务的 LLM 调用使用独立上下文窗口,仅包含:任务描述 + 专家 persona + 前置阶段输出 + - 子任务间通过 `SharedWorkspace` 传递中间产物(代码片段、文档、测试结果) +- **SharedWorkspace 强化**: + - 复用现有 `SharedWorkspace`(Redis 后端 + 内存回退) + - 新增约定 key:`{task_id}/phase/{phase_id}/output` 存储阶段输出 + - 新增约定 key:`{task_id}/artifacts/{artifact_name}` 存储代码片段、文档等 + - Lead Expert 可读取所有阶段的输出用于汇总 +- **配置化**: + - `agentkit.yaml` 中配置隔离模式:`context`(默认)/ `none`(调试用) + - 上下文窗口大小可配置(默认遵循 LLM Provider 限制) + +**验收标准**: +- `@team` 子任务在独立上下文执行,主对话历史不泄漏到子任务 +- 阶段间数据通过 `SharedWorkspace` 正确传递 +- Lead Expert 能读取所有阶段输出进行汇总 +- 子任务失败不影响其他子任务的上下文 + +**关联文件**: +- `src/agentkit/experts/orchestrator.py` — 子任务上下文创建 +- `src/agentkit/core/shared_workspace.py` — `SharedWorkspace`(已实现,需强化约定 key) +- `src/agentkit/core/config_driven.py` — `ConfigDrivenAgent`(已实现,需确保独立实例) + +--- + +### R4: 多模型智能路由 + +**目标**:按任务类型和专家角色自动选择最优 LLM 模型,对标 Qoder 的 4 模式和 Trae 的多模型选择。当前 `ExpertConfig.llm` 字段已预留但 Orchestrator 硬编码 `model="default"`,需消费该字段。 + +**需求**: +- **专家级模型配置**: + - 每个 `ExpertConfig` 可配置 `llm.provider` 和 `llm.model` + - Orchestrator 读取 `ExpertConfig.llm` 而非硬编码 `model="default"` + - 示例:`tech_lead` 用 Claude Opus(推理强)、`frontend_engineer` 用 DeepSeek-Coder(代码强)、`qa_engineer` 用 GPT-4o(均衡) +- **任务类型路由**: + - 编程任务 → 代码能力强的模型(如 Claude Sonnet/Opus、DeepSeek-Coder) + - 对话任务 → 响应快的模型(如 GPT-4o-mini、Doubao-Lite) + - 推理任务 → 推理能力强的模型(如 Claude Opus、o4-mini) + - 中文任务 → 中文优化模型(如 Doubao、Wenxin、Qwen) +- **路由策略**: + - 基于 `ExpertConfig.llm` + 任务关键词 + 用户配置偏好 + - 支持用户在 `agentkit.yaml` 中自定义路由规则 + - 支持 `@team:expert --model=claude-opus 任务` 显式指定模型 +- **成本控制**: + - 每次路由决策记录模型选择原因 + - 月度成本报告按任务类型/模型维度统计 + - 超预算时自动降级到更便宜的模型 +- **Fallback 链**: + - 首选模型不可用时自动 fallback(已有 `_get_models_to_try`) + - Fallback 决策记录到 usage tracking + +**验收标准**: +- 编程专家自动使用代码能力强的模型 +- 用户可自定义路由规则 +- 成本报告展示模型选择分布 +- Fallback 链正常工作 +- `board_orchestrator.py` 和 `orchestrator.py` 不再硬编码 `model="default"` + +**关联文件**: +- `src/agentkit/experts/orchestrator.py` — 读取 `ExpertConfig.llm`(移除硬编码) +- `src/agentkit/experts/board_orchestrator.py` — 读取 `ExpertConfig.llm`(移除硬编码) +- `src/agentkit/llm/gateway.py` — `LLMGateway`(已实现,无需修改) +- `configs/experts/*.yaml` — 专家模板新增 `llm` 配置 + +--- + +### R5: 仓库级代码理解 + +**目标**:支持 10 万+ 文件级上下文检索和跨模块语义理解,对标 Qoder 的 Repo Wiki 和 Devin 的 Wiki。为 `@team` 编程任务提供代码上下文。 + +**需求**: +- **代码索引**: + - 对项目代码库建立向量索引(复用 pgvector) + - 支持函数级、类级、文件级粒度索引 + - 增量索引:文件变更时自动更新索引 +- **语义检索**: + - Agent 可通过 `code_search("用户认证逻辑")` 检索相关代码 + - 返回代码片段 + 文件路径 + 上下文 + - 支持自然语言查询和代码片段查询 +- **架构图谱**: + - 自动生成项目架构图(模块依赖、调用关系) + - 前端可视化展示(复用 `WorkflowView.vue` 的图编辑器) +- **Repo Wiki**: + - 自动生成项目文档(README、API 文档、架构说明) + - 代码变更时自动更新文档 + +**验收标准**: +- `@team 重构用户认证模块` 能自动检索相关代码 +- 架构图正确展示模块依赖关系 +- Repo Wiki 自动生成并可浏览 + +**关联文件**: +- `src/agentkit/memory/episodic.py` — `EpisodicMemory`(pgvector 可复用) +- `src/agentkit/tools/` — 新增 `code_search` 工具 +- `src/agentkit/server/frontend/src/components/` — 架构图可视化 + +--- + +### R6: IDE 插件(VS Code 扩展) + +**目标**:将 Fischer AgentKit 能力嵌入 VS Code 编辑器,对标 Cursor 的原生 IDE 体验和 Qoder 的 JetBrains 插件。 + +**需求**: +- **VS Code 扩展**: + - 侧边栏聊天面板(复用 Web GUI 的 Vue 组件) + - 内联代码补全和建议 + - 选中代码 → 右键 → "发送给专家团" + - `@team` 和 `@board` 前缀路由在 IDE 内可用 +- **上下文感知**: + - 自动获取当前打开的文件、光标位置、选中文本作为上下文 + - 诊断信息(lint 错误、类型错误)自动传递给 Agent + - 工作区符号搜索 +- **操作集成**: + - Agent 可直接编辑文件(通过 VS Code WorkspaceEdit API) + - 终端命令执行(通过 VS Code Terminal API) + - Git 操作(通过 VS Code Git API) +- **Tauri 桌面端保留**: + - 现有 Tauri 桌面应用继续维护 + - VS Code 扩展作为轻量级替代方案 + +**验收标准**: +- VS Code 扩展可安装和使用 +- `@team` 和 `@board` 在 IDE 内正常工作 +- Agent 可编辑文件和执行终端命令 +- 选中文本可发送给专家团 + +--- + +## Scope Boundaries + +### 本次范围内 + +- R0: 修复死代码与前后端不一致 — 最高优先级(基线修复) +- R1: `@team` 流水线集成 — 最高优先级(核心功能补齐) +- R2: 5-8 个编程专家模板 — 高优先级(场景覆盖) +- R3: 上下文隔离 — 中优先级(轻量隔离) +- R4: 多模型路由 — 中优先级(成本优化) +- R5: 仓库级理解 — 低优先级(长期) +- R6: IDE 插件 — 低优先级(长期) + +### 延后到后续迭代 + +- **git worktree 隔离**:v1 路线图中的 worktree 方案延后,v2 选择上下文隔离作为更轻量的方案。若未来需要文件系统级隔离再评估 +- **沙盒/容器隔离**:延后,需 Docker 环境,当前上下文隔离已满足需求 +- **实时协作**:多用户共享 Agent 会话(对标 Google Docs 协作模式) +- **Agent 市场**:用户分享和下载专家模板/Skill 配置 +- **语音交互**:语音输入和输出(对标 ChatGPT Voice、Cursor Voice Mode) +- **工作流模板库**:预置行业工作流模板(如"电商运营全流程") +- **企业级权限**:RBAC、审计日志、SSO 集成 + +### 不在产品身份内 + +- **纯代码补全**:不做 GitHub Copilot 式的行内补全,聚焦 Agent 级任务 +- **模型训练/微调**:不训练自有模型,保持模型无关性 +- **CI/CD 流水线**:不做持续集成/部署系统,与现有 DevOps 工具集成 +- **100+ 专家模板**:不走 WorkBuddy 的多而广路线,聚焦少而精 + +--- + +## Dependencies / Assumptions + +1. **R0 依赖**:无外部依赖,纯代码修复 +2. **R1 依赖**:R0 完成(基线干净);`ExpertTeamRouter` 代码已完整;需重新实现流水线 `TeamOrchestrator` 和阶段依赖图 +3. **R2 依赖**:`ExpertTemplateRegistry` 已支持动态注册,扩展工作主要是 YAML 编写 +4. **R3 依赖**:`SharedWorkspace` 已实现(Redis 后端 + 内存回退);`ConfigDrivenAgent` 支持独立实例 +5. **R4 依赖**:`LLMGateway` 已支持 6 个 Provider 和 fallback 链;`ExpertConfig.llm` 字段已预留 +6. **R5 依赖**:pgvector 已用于情节记忆,可复用;需要新增代码解析和索引模块 +7. **R6 依赖**:VS Code 扩展 API + 现有 Web GUI 组件可复用 + +**假设**: +- 用户有可用的 LLM API 密钥(至少一个 Provider) +- 项目代码库规模在 10 万文件以内(R5 的索引能力上限) +- Redis 可用于 `SharedWorkspace`(R3 的上下文共享) + +--- + +## Success Criteria + +### R0 成功标准 +- `pytest tests/integration/test_expert_team.py` 不再 ImportError +- 前后端数据模型一致(`subtasks` 或恢复 `plan_phases`,取决于 R1 决策) +- `AGENTS.md` 与代码实际状态一致 + +### R1 成功标准 +- `@team` 流水线在 WebSocket 中端到端可用 +- 前端正确显示阶段计划、当前阶段、专家步骤 +- 与 `@board` 互不干扰,用户可自由切换 +- 子任务在独立上下文执行,互不污染 + +### 整体成功标准 +- 编程专家模板数 5-8 个(R2) +- `@team` 子任务在上下文隔离环境中执行(R3) +- 编程任务自动选择代码模型,成本降低 30%+(R4) +- 10 万文件项目代码检索延迟 < 500ms(R5) +- VS Code 扩展日活用户 > 100(R6) + +### 差距缩小目标 + +| 能力维度 | 当前 | 目标 | 对标 | +|----------|------|------|------| +| 任务分解执行 | ⚠️ 死代码 | ✅ 流水线可用 | Qoder | +| 编排模式 | hub-and-spoke(未集成) | ✅ 流水线+并行 | Qoder | +| 隔离机制 | ❌ | ✅ 上下文隔离 | Trae Solo/Claude Code | +| 编程专家模板 | 0 | 5-8 个 | Qoder 5类 | +| 模型智能路由 | ❌ 硬编码 | ✅ 按专家路由 | Qoder/Trae | +| 仓库级理解 | ❌ | ✅ 10万文件 | Qoder/Devin | +| IDE 集成 | Web+Tauri | +VS Code 扩展 | Cursor/Qoder | +| 群聊讨论 | ✅ 独有 | ✅ 保持领先 | 无竞品 | +| 自进化 | ✅ 遗传+AB | ✅ 深化场景化 | Qoder Expert+Team Skill | +| 记忆系统 | ✅ 4层 | ✅ 场景化深化 | Qoder/Claude/Codex | + +--- + +## Implementation Priority + +| 优先级 | 需求 | 预期收益 | 复杂度 | 变化(vs v1) | +|--------|------|---------|--------|--------------| +| P0 | R0: 修复死代码+不一致 | 干净基线 | 低 | **新增** | +| P0 | R1: `@team` 流水线集成 | 核心功能可用 | 高 | 范围扩大(需重写编排器) | +| P1 | R2: 5-8 个编程专家 | 场景覆盖 | 中 | 从 50+ 收缩为少而精 | +| P1 | R3: 上下文隔离 | 子任务隔离 | 中 | 从 worktree+沙盒 降级 | +| P2 | R4: 多模型路由 | 成本优化 30%+ | 中 | 保持 | +| P2 | R5: 仓库级理解 | 代码任务质量 | 高 | 保持(长期) | +| P3 | R6: IDE 插件 | 用户入口扩展 | 高 | 保持(长期) | + +--- + +## Competitive Positioning + +### Fischer AgentKit 的差异化策略 + +**核心差异化(无竞品)**: +- `@board` 群聊决策讨论 — 10 个竞品中唯一支持多专家群聊式决策讨论 + +**对标差异化(有竞品但独特组合)**: +- `@board`(决策)+ `@team`(执行)双模式闭环 — 竞品要么只有执行(Qoder/Cursor/Devin),要么只有单模式 +- 自进化系统(遗传算法+AB测试)— 比 Qoder 的 Expert Skill + Team Skill 更深层,但需深化场景化 +- 4 层记忆系统(SOUL/USER/MEMORY/DAILY+pgvector)— 比竞品的记忆能力更结构化,但需场景化 + +**非差异化(持平)**: +- MCP 支持 — 已成事实标准 +- 多 Provider LLM Gateway — 竞品普遍支持 +- 工具系统 — 竞品普遍支持 + +**需补齐(落后)**: +- `@team` 任务分解执行 — 死代码,需集成 +- 流水线编排模式 — 需实现 +- 上下文隔离 — 需实现 +- 编程专家模板 — 需新增 +- 多模型路由 — 需消费已有字段 +- 仓库级理解 — 需实现(长期) diff --git a/docs/brainstorms/2026-06-18-chat-area-vi-redesign-requirements.md b/docs/brainstorms/2026-06-18-chat-area-vi-redesign-requirements.md new file mode 100644 index 0000000..ae9ee33 --- /dev/null +++ b/docs/brainstorms/2026-06-18-chat-area-vi-redesign-requirements.md @@ -0,0 +1,392 @@ +# Fischer AgentKit — 聊天主区 VI 重梳与新功能适配 (Requirements) + +**Date:** 2026-06-18 +**Status:** Draft +**Scope:** 聊天主区布局 + 右屏可收起 Tab 面板 + @team / @board 消息适配 +**Out of scope:** 侧边栏 (ChatSidebar)、顶部 (TopNav)、抽屉 (右抽屉)、移动端布局 + +--- + +## 1. Context & Problem + +### 1.1 现状 + +- 主色 `#6366f1` (indigo) —— Notion 风格暖灰基底,但品牌感弱,与「Fischer」德系工业严谨感的名字不匹配 +- 聊天主区是简单的气泡 + 流式步骤,缺少对**复杂 AI 响应**(@team 计划、@board 多轮讨论、工具调用链、错误恢复)的精细化表达 +- 新功能(@board 私董会、@team 专家团)已有后端事件流(`board_started` / `expert_speech` / `round_summary` / `team_formed` / `plan_update` / `phase_started` ...),但前端在 `ChatMessage.vue` 中只是把它们当成普通消息流式渲染,**没有专属视觉表达** +- 私董会当前色 `#8E44AD` 偏沉,与整体调性割裂 + +### 1.2 目标 + +- 形成 **「克制中性 + 强调色做语义信号」** 的 VI 体系 —— 文本是主体,色彩是信号 +- 重新设计聊天主区布局:常规气泡 + 复杂事件拆为 card(Cursor / Trae 范式) +- 为 @team / @board / 工具调用 / 错误四类复杂事件提供专属 card 视觉语言 +- 保留右屏可收起 Tab 面板(监控 / 技能 / 系统 / 知识库),但样式与新 VI 一致 +- 产出一份**静态高保真 mockup**(独立 preview 路由),供 review 后再进入实施 + +### 1.3 设计哲学 + +> **Notion 的留白 + Cursor 的复杂事件 card + Trae 的可收起右屏 + 我们自己的中性克制** + +- **Notion**:暖灰基底 (`#fbfbfa` / `#ededec`),柔和阴影 (low-opacity, more spread),暖色代码高亮 (Catppuccin Mocha) +- **Cursor**:复杂事件(agent 计划、工具调用、错误)拆为独立 card,不挤在普通消息流中 +- **Trae**:右屏 Tab 面板可收起,主区最大化 +- **我们自己的**:**主色由 indigo 改为近黑 + 中性灰**,强调色 (accent) 仅用于语义信号 + +--- + +## 2. Design Language Specification + +### 2.1 Color System (Light Theme) + +| Token | Value | 用途 | +|---|---|---| +| `--bg-primary` | `#ffffff` | 主背景 (消息区、card) | +| `--bg-secondary` | `#fbfbfa` | 次背景 (整体 app shell、输入框) | +| `--bg-tertiary` | `#f7f7f5` | 三级背景 (hover、嵌套 card) | +| `--bg-code` | `#1e1e2e` | 代码块背景 (Catppuccin Mocha) | +| `--text-primary` | `#1a1a1a` | 主文字 (95% 文本) | +| `--text-secondary` | `#4a4a4a` | 次文字 (元数据、辅助) | +| `--text-tertiary` | `#6b6b6a` | 三级文字 (时间戳、状态) | +| `--text-placeholder` | `#9b9b9a` | 占位符 | +| `--border-color` | `#ededec` | 默认边框 | +| `--border-color-hover` | `#dfdfde` | hover 边框 | + +**强调色 (新增):** + +| Token | Value | 语义 | 使用场景 | +|---|---|---|---| +| `--accent-team` | `#3b82f6` | 专家团协作 (中性蓝) | @team plan card、expert avatar、team 状态条 | +| `--accent-board` | `#a855f7` | 私董会讨论 (琥珀/紫) | @board banner、moderator 头像、board round 进度 | +| `--accent-team-soft` | `#dbeafe` | 蓝轻量背景 | @team card 顶条、chip | +| `--accent-board-soft` | `#f3e8ff` | 紫轻量背景 | @board card 顶条、moderator chip | + +**语义色 (保留):** + +| Token | Value | 语义 | +|---|---|---| +| `--color-success` | `#22c55e` | 工具调用成功、阶段 completed | +| `--color-warning` | `#f59e0b` | 主持人小结 (round summary) | +| `--color-error` | `#ef4444` | 错误、阶段 failed | +| `--color-info` | `#3b82f6` | 普通信息 | + +> 关键变化:**移除 `--color-primary` (indigo) 作为通用主色**,改用 `--accent-team` / `--accent-board` 作为功能语义色。Ant Design 的 `colorPrimary` token 改为 `#1a1a1a` (近黑),让通用 UI (按钮、菜单选中) 都是中性。 + +### 2.2 Color System (Dark Theme) + +| Token | Value | +|---|---| +| `--bg-primary` | `#1a1a1a` | +| `--bg-secondary` | `#1f1f1f` | +| `--bg-tertiary` | `#2a2a2a` | +| `--text-primary` | `#fbfbfa` | +| `--text-secondary` | `#cececd` | +| `--accent-team` | `#60a5fa` (亮蓝) | +| `--accent-team-soft` | `rgba(59, 130, 246, 0.15)` | +| `--accent-board` | `#c084fc` (亮紫) | +| `--accent-board-soft` | `rgba(168, 85, 247, 0.15)` | + +### 2.3 Typography + +- 主字体:现有 `font-family` 不变(系统字体栈) +- 字体大小:保留现有 token (`--font-xs` ~ `--font-xl`) +- **新增**:`--font-mono` (代码字体, SF Mono / Monaco / Consolas) +- **新增**:`--leading-message: 1.65` —— 消息正文行高(比现有 `--leading-normal: 1.5` 更松,提升长文阅读体验) + +### 2.4 Spacing & Layout + +- 间距:保留现有 4/8/12/16/20/24/32 scale +- 圆角:保留现有 4/6/10/14 scale +- **新增**:`--radius-card: 12px` (复杂 card 圆角,比 chat bubble 的 `--radius-lg: 10px` 略大) +- **新增**:`--space-message-gap: 24px` (消息之间间距) + +### 2.5 Shadow + +- 保留现有 4 档 shadow (sm/md/lg/xl) +- **新增**:`--shadow-card: 0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.04)` (复杂 card 边框+微阴影组合,Notion 风格) + +--- + +## 3. Chat Main Area — Layout & Message Architecture + +### 3.1 整体布局 + +``` +┌─────────────────────────────────────────────────────────┐ +│ TopNav (48px) │ +├──────────┬──────────────────────────────────┬───────────┤ +│ │ │ │ +│ Sidebar │ Chat Main Area │ Right │ +│ 280px │ (resizable) │ Panel │ +│ │ │ (320px) │ +│ │ ┌────────────────────────────┐ │ Tab: │ +│ │ │ BoardStatusView (顶部状态) │ │ - 监控 │ +│ │ ├────────────────────────────┤ │ - 技能 │ +│ │ │ │ │ - 系统 │ +│ │ │ Message List │ │ - 知识库 │ +│ │ │ (user / assistant 气泡) │ │ │ +│ │ │ + TeamPlanCard │ │ 可收起 → │ +│ │ │ + BoardRoundCard │ │ │ +│ │ │ + ToolCallCard │ │ │ +│ │ │ + ThinkingBlock │ │ │ +│ │ │ │ │ │ +│ │ ├────────────────────────────┤ │ │ +│ │ │ ChatInput (输入框) │ │ │ +│ │ └────────────────────────────┘ │ │ +└──────────┴──────────────────────────────────┴───────────┘ +``` + +- 主区最大宽度 `max-width: 860px` 居中(防止超宽屏阅读疲劳),左右侧贴边留白用 `--space-4` +- 右屏默认展开 320px,可收起至 0(折叠按钮在主区右上角,IconNav 同步) +- 消息垂直节奏:消息之间 `--space-message-gap: 24px`;气泡内部 padding `--space-3 var(--space-4)` + +### 3.2 Message Types & Visual Treatment + +| Type | Trigger | 视觉范式 | 颜色信号 | 关键元素 | +|---|---|---|---|---| +| **user** | `role: 'user'` | 60% 宽右对齐气泡 | 中性灰背景 `--bg-tertiary` | 文本 + 附件 pill + hover 显示「编辑/复制」 | +| **assistant (text)** | `role: 'assistant'`, `message_type: 'chat'` | 90% 宽左对齐 flat (无气泡) | 无 | Markdown 渲染 + 代码高亮 + 引用块 | +| **assistant (thinking)** | `thinking` 字段非空 | 折叠块 (Cursor 风格) | 灰色 `--text-tertiary` | 「已思考 N 秒」可点击展开 | +| **tool call** | `tool_calls[]` 非空 | 嵌套 card (边框 + 阴影) | 中性 + 成功绿 / 错误红 | 工具名 + 状态点 (●) + 折叠参数/结果 | +| **team plan** | `team_formed` / `plan_update` | 跨整行大 card | **蓝色顶条** (`--accent-team`) | Lead expert 头像 + 阶段时间线 + 状态图标 | +| **team expert step** | `expert_step` | 内嵌 plan card 的子项 | 蓝 | expert 头像 + 当前步骤描述 | +| **team expert result** | `expert_result` | 内嵌 plan card 的子项 | 蓝 | 折叠结果块 (类似 tool call) | +| **team synthesis** | `team_synthesis` | 跨整行结论 card | 蓝 | 渐变顶条 + 总结文本 | +| **board started** | `board_started` | 跨整行 banner card | **紫色顶条** (`--accent-board`) | 主题 + 专家 chips + 主持人标签 | +| **board expert speech** | `expert_speech` | 90% 宽左对齐,带 expert 头像 chip | 紫 | 头像 + 第 N 轮 + 角色标签 (主持/专家) | +| **board round summary** | `round_summary` | 主持人头像 + 总结文本 | 紫轻背景 `--accent-board-soft` | 折叠块 (默认收起避免噪声) | +| **board conclusion** | `board_concluded` + `final_answer` | 跨整行结论 card | 紫渐变顶条 | 共识 + 分歧 + 决策建议 | +| **error** | `error` | 跨整行 alert 样式 | 红色 `--color-error` | 错误信息 + 「重试」按钮 | + +### 3.3 关键交互细节 + +**气泡 (常规):** +- user 气泡 hover:显示「编辑」「复制」图标 +- assistant 文本 hover:底部浮现操作条「复制」「重新生成」「反馈 👍👎」 +- 流式光标:文本末尾 2px 宽闪烁竖线 (Cursor 风格) 或 8x8 圆点 (Claude 风格) —— **选竖线,更克制** +- Markdown 实时渲染:流式过程中不渲染重型 syntax highlight,避免闪烁 + +**Thinking Block (新增):** +- 默认收起,显示「已思考 N 秒」+ chevron +- 展开后显示 thinking 文本 (灰色) +- 动画:展开时 height transition 200ms + +**Tool Call Card:** +- 默认收起 (只显示工具名 + 状态点) +- 点击展开:参数 (折叠) + 结果 (折叠) +- 错误:状态点变红 + 行内错误信息 +- 成功:状态点变绿 +- 嵌套调用:缩进 16px,最多嵌套 3 层 + +**Team Plan Card (新增):** +``` +┌──────────────────────────────────────────────────────┐ +│ ▌ Lead: Sarah Chen (技术负责人) [In Progress] │ ← 蓝色顶条 4px +├──────────────────────────────────────────────────────┤ +│ 阶段 1: 数据模型设计 ● running │ +│ ├─ 张三 (后端) 正在分析 schema... │ +│ └─ 完成时间: ~2 min │ +│ │ +│ 阶段 2: API 实现 ○ pending (依赖 阶段1) │ +│ 阶段 3: 测试 ○ pending (依赖 阶段2) │ +├──────────────────────────────────────────────────────┤ +│ 进度: ████████░░░░░░ 45% 当前: 阶段 1 / 3 │ +└──────────────────────────────────────────────────────┘ +``` + +**Board Banner (新增):** +``` +┌──────────────────────────────────────────────────────┐ +│ ▌ 🏛️ 私董会 — 主题:「是否应投入资源做这个功能」 │ ← 紫色顶条 4px +├──────────────────────────────────────────────────────┤ +│ 专家: 🚀 Elon 🛒 Jeff 🧠 Charlie 💻 Paul │ +│ [主持人] Elion Musk │ +│ 轮次: 第 2 / 5 [████████░░░░] 40% │ +└──────────────────────────────────────────────────────┘ +``` + +**Board Round Card (单轮发言):** +``` +┌──────────────────────────────────────────────────────┐ +│ 🚀 Elon Musk · 第 2 轮 · 主持人 │ ← 紫色边 1px 左侧 +│ │ +│ 我认为第一性原理是... │ +│ [长文本...] │ +│ 09:32 │ +└──────────────────────────────────────────────────────┘ +``` + +--- + +## 4. Right Panel (Tab Panel) + +### 4.1 行为 + +- 位置:主区右侧,初始 320px 宽 +- Tab:监控 / 技能 / 系统 / 知识库 (4 个,对应附件图) +- 折叠按钮:主区右上角 (ChatView 顶部) 一个 chevron 按钮,折叠后宽度 0,折叠按钮变为「展开」chevron +- 折叠状态记忆:localStorage 持久化 +- 拖拽:可选 (后续迭代,本期不做) + +### 4.2 样式 + +- 背景:`--bg-secondary` (与主区背景区分但不抢戏) +- 顶部 Tab bar:48px 高,左对齐文字 Tab,激活 Tab 下方 2px 蓝色 (或当前 Tab 主题色) 指示条 +- 内边距:`var(--space-3) var(--space-4)` +- 卡片:与主区 card 一致 (12px 圆角 + 1px 边框) + +### 4.3 Tab 内容 (沿用现有数据) + +- **监控**:4 个 metric card (服务可用性 / 平均响应时间 / 请求总数 / 错误率) + 资源使用列表 + 服务状态列表 + 错误趋势图 +- **技能**:技能列表 (来自 `useSkillsStore`),点击展开详情 +- **系统**:系统信息 (CPU / 内存 / 磁盘) +- **知识库**:知识库源列表 + 检索测试入口 + +--- + +## 5. Input Area + +### 5.1 现状 + +- 输入框 + 发送按钮 + 模型选择 + (新增) 私董会按钮 + +### 5.2 改造 + +- **私董会按钮 → 重命名为「专家团」** 按钮,紫色 `--accent-board` 高亮 +- 新增「**@team** 团队」按钮 (蓝色 `--accent-team`),与私董会按钮并列 +- 两个按钮都点击打开对应 Modal (已有的 `BoardMeetingModal.vue` + 新建 `TeamMeetingModal.vue`) +- 拖拽附件:拖入文件到输入框显示「拖放以附加」浮层 +- @mention 自动补全:现有 `MentionDropdown` 保留 + +### 5.3 入口设计 + +``` +┌──────────────────────────────────────────────────┐ +│ [📎 附件] [👥 专家团] [🏛️ 私董会] │ +├──────────────────────────────────────────────────┤ +│ │ +│ 输入消息... │ +│ │ +├──────────────────────────────────────────────────┤ +│ [模型: Claude 4 Sonnet ▾] [清空] [发送 →] │ +└──────────────────────────────────────────────────┘ +``` + +--- + +## 6. Components to Build / Modify + +### 6.1 修改现有 + +| 文件 | 改动 | +|---|---| +| `src/agentkit/server/frontend/src/styles/tokens.css` | 新增 `--accent-team*` / `--accent-board*` / `--shadow-card` 等 token;移除 `--color-primary` 作为主色 (保留作为渐变背景) | +| `src/agentkit/server/frontend/src/styles/theme.ts` | `colorPrimary` 改为近黑;新增 accent 主题组件配置 | +| `src/agentkit/server/frontend/src/views/ChatView.vue` | 主区 max-width 居中 + 右屏折叠按钮 + 整体留白节奏调整 | +| `src/agentkit/server/frontend/src/components/chat/ChatMessage.vue` | 重构分发:按 `message_type` 路由到对应子组件 | +| `src/agentkit/server/frontend/src/components/chat/ExpertMessage.vue` | 适配新 VI | +| `src/agentkit/server/frontend/src/components/chat/ToolCallCard.vue` | 已有,确认样式对齐新 token | +| `src/agentkit/server/frontend/src/components/chat/ThinkingBlock.vue` | 已有,新增折叠动画 | +| `src/agentkit/server/frontend/src/components/chat/BoardStatusView.vue` | 用 `--accent-board` 替换 `#8E44AD` | +| `src/agentkit/server/frontend/src/components/chat/ExpertTeamView.vue` | 用 `--accent-team` 替换默认色 | +| `src/agentkit/server/frontend/src/components/chat/ChatInput.vue` | 新增「专家团」按钮 (蓝色) + 重命名「私董会」按钮为紫色 | +| `src/agentkit/server/frontend/src/components/chat/BoardMeetingModal.vue` | 适配新 VI | + +### 6.2 新增组件 + +| 文件 | 用途 | +|---|---| +| `src/agentkit/server/frontend/src/components/chat/TeamPlanCard.vue` | @team 计划可视化 (时间线 + 阶段 + 进度) | +| `src/agentkit/server/frontend/src/components/chat/BoardRoundCard.vue` | @board 单轮发言 (专家头像 + 轮次 + 文本) | +| `src/agentkit/server/frontend/src/components/chat/BoardConclusionCard.vue` | @board 最终结论 (共识 + 分歧 + 建议) | +| `src/agentkit/server/frontend/src/components/chat/ErrorCard.vue` | 错误提示 card (含重试按钮) | +| `src/agentkit/server/frontend/src/components/chat/UserBubble.vue` | user 消息气泡 (提取自 ChatMessage) | +| `src/agentkit/server/frontend/src/components/chat/AssistantBubble.vue` | assistant 消息 flat 块 (提取自 ChatMessage) | +| `src/agentkit/server/frontend/src/components/chat/TeamMeetingModal.vue` | @team 专家团选择 Modal (对标 BoardMeetingModal) | +| `src/agentkit/server/frontend/src/components/layout/RightPanel.vue` | 可折叠右屏 Tab 面板 | +| `src/agentkit/server/frontend/src/views/ChatPreviewView.vue` | 静态高保真 mockup 页面 (供 review) | + +### 6.3 Mockup 页面 (Preview View) + +6 个静态场景,**全部用假数据**: + +1. **欢迎页** —— 居中 logo + 4 个能力提示 + 输入框预填「@team:tech_lead,frontend_engineer,backend_engineer 设计一个用户认证系统」 +2. **User 消息 + Assistant 文本 + 代码块** —— user 提问,assistant 流式响应含 markdown + 代码 +3. **@team 计划 + 阶段进行中** —— TeamPlanCard 显示 3 阶段,第 1 阶段 running,专家子项展开 +4. **@board 多轮讨论** —— BoardBanner + 3 轮不同专家发言 + 1 个主持人小结 + 1 个最终结论 +5. **工具调用链** —— 嵌套 2 层 tool call,状态点变化 +6. **错误态** —— ErrorCard + 「重试」按钮 + +--- + +## 7. Dependencies & Assumptions + +### 7.1 Assumptions + +- A1: 用户已确认主色方向为「近黑 + 强调色」 +- A2: 用户已确认中等改造范围 (聊天主区 + 右屏),不碰侧边栏/顶部/抽屉 +- A3: 用户已确认混合消息范式 (气泡 + card) +- A4: 现有后端事件 (team_formed / expert_step / board_started / expert_speech / round_summary / board_concluded) 的 payload 字段不变 +- A5: 现有 WebSocket 协议不变,只改前端渲染 +- A6: 暗色模式 token 一并提供 (本需求重点是浅色,深色保持 token 同步) +- A7: 不实现 message editing / branch conversation (Trae 独有) +- A8: 不实现图片/文件 inline 渲染优化 (现有 `FilePreview.vue` 够用) + +### 7.2 Risks + +- **R1 (高)**: ChatMessage.vue 重构可能影响现有 unit test —— 需检查测试覆盖 +- **R2 (中)**: 大量组件修改可能引入回归 —— 建议每组件单独 PR +- **R3 (中)**: mockup 评审 → 实施可能有偏差 —— mockup 应在 PR 之前完成 + +### 7.3 Out of Scope (Explicit) + +- 侧边栏 (ChatSidebar.vue) 重构 +- 顶部导航 (TopNav.vue) 重构 +- 移动端响应式优化 +- 实际 WebSocket 数据流对接 (mockup 阶段) +- 可访问性 (a11y) 审计 +- 性能优化 (虚拟滚动、消息懒加载) —— 独立 PR + +--- + +## 8. Success Criteria + +### 8.1 完成定义 + +- [ ] 静态 mockup 页面 (`/agent/preview`) 6 个场景全部可看可交互 +- [ ] `tokens.css` + `theme.ts` 全部新 token 已就位且通过 lint +- [ ] 所有改动组件 `vue-tsc --noEmit` 通过 +- [ ] 所有改动组件 `ruff check` 通过 (Python 端) +- [ ] 现有 unit test 不回归 (`pytest tests/unit/ -x -q`) +- [ ] 暗色模式 token 同步更新 +- [ ] 视觉 review 通过 (用户签字) + +### 8.2 验收检查点 + +1. **Mockup review** (核心验收):用户看 `/agent/preview`,对 6 个场景逐一 review,决定是否进入实施 +2. **Token review**:对照本规范 §2 确认 token 命名和值 +3. **组件 review**:每个新组件单独 PR 走 code review + +--- + +## 9. Open Questions + +- Q1: @team 蓝 (`#3b82f6`) vs @board 紫 (`#a855f7`) 的分配是否调换?当前:蓝=协作,紫=深度 +- Q2: 流式光标样式:竖线 vs 圆点?提议:竖线 (更克制) +- Q3: 私董会按钮文字:是「私董会」还是「@board 讨论」?提议:「私董会」(短) +- Q4: 专家团按钮文字:是「专家团」「@team」「召唤专家」?提议:「专家团」 +- Q5: 是否需要「消息引用」功能 (Trae 的 <#file> 引用)?提议:本期不做 + +--- + +## 10. Reference + +- 参考设计语言:Notion (留白 / 暖灰 / 柔和阴影), Cursor (复杂事件 card), Trae (可收起右屏 + Side Chat) +- 行业共识 (setproduct 2026-05-29): + - Full-width is current best practice for serious AI chat. Bubbles signal "messenger" and undermine the tool framing. + - Use subtle background shading or alignment to separate user and assistant messages instead. + - Streaming cursor must exist; dropping it is a common mistake. + - Markdown rendering during stream: don't render heavy syntax highlight during streaming to avoid flickering. +- 来源:[Designing AI chat interfaces: Anatomy, patterns, pitfalls](https://www.setproduct.com/blog/ai-chat-interface-ui-design) +- 来源:[Trae CN 2026 完全指南](https://blog.csdn.net/the_finals/article/details/161893557) +- 来源:[Claude Code vs Cursor 2026](https://www.aidesigner.ai/blog/claude-code-vs-cursor) diff --git a/docs/plans/2026-06-19-001-feat-chat-area-vi-redesign-plan.md b/docs/plans/2026-06-19-001-feat-chat-area-vi-redesign-plan.md new file mode 100644 index 0000000..bfffdbf --- /dev/null +++ b/docs/plans/2026-06-19-001-feat-chat-area-vi-redesign-plan.md @@ -0,0 +1,469 @@ +--- +status: active +date: 2026-06-19 +type: feat +origin: docs/brainstorms/2026-06-18-chat-area-vi-redesign-requirements.md +--- + +# feat: 聊天主区 VI 重梳与新功能适配 — 实施计划(执行中 / 复审版) + +--- + +## Summary + +本计划承接 [聊天主区 VI 重梳需求文档](docs/brainstorms/2026-06-18-chat-area-vi-redesign-requirements.md),目标是把需求中的设计令牌、消息视觉范式、右屏 Tab 面板、@team / @board 消息适配以及 Preview 静态样例全部落地。 + +**当前进展(截至复审时点):** + +- `U1. 补齐设计令牌与主题映射` 已完成:`tokens.css` 与 `theme.ts` 新增 `--radius-card`、`--font-mono`、`--accent-team*`、`--accent-board*`、`--shadow-card` 等;关键组件已使用语义 token。 +- `U2. 消息模型与分发器对齐后端事件` 已完成:`IChatMessage` 扩展了 `plan_phases`、`error_detail`、`board_conclusion` 等字段;`useMessageRenderer.ts` 已新增 `team_plan`、`board_banner`、`board_conclusion`、`error` 等路由;`chat.ts` 已调整 `board_started` / `board_concluded` 事件推送。 +- `U3. TeamPlanCard 视觉升级` 已完成:蓝色顶条、阶段时间线、进度条、状态图标就位。 +- `U4. Board 复杂消息组件群` 已完成:`BoardBannerCard.vue`、`BoardRoundCard.vue`、`BoardConclusionCard.vue` 已新建/升级;`Scene4BoardDiscussion.vue` 已加入 banner、多轮发言、小结、结论的完整示例。 +- `U5. ErrorCard 重试与渲染集成` 已完成:ErrorCard 支持重试事件,`useMessageRenderer.ts` 已路由 error 类型。 +- `U6. ChatInput 交互补全` 已完成:拖拽上传浮层、清空按钮、专家团/私董会语义色按钮就位。 +- `U7. 右屏 Tab 面板内容补全` **部分完成**:Tab bar 与「监控」内容已就位,但「技能 / 系统 / 知识库」三个 Tab 的内容尚未根据 `activeTab` 切换渲染。 + +**剩余工作:** + +- `U7` 完成:实现 `SkillsTab` / `SystemTab` / `KnowledgeTab` 三个子组件,并在 `SystemMonitorPanel.vue` 中按 `activeTab` 切换。 +- `U7` 结对代码审查。 +- `U8` Preview 样例场景精修。 +- `U8` 结对代码审查。 +- `U9` BoardMeetingModal VI 适配收尾。 +- `U9` 结对代码审查。 +- `U10` 质量门与后端回归测试。 + +**执行纪律:** 每个开发单元完成后,启动一个子 agent 进行结对代码审查;审查通过后再进入下一单元。全部完成后统一跑 `vue-tsc --noEmit` / `npm run build` / `pytest` 并修复问题。 + +--- + +## Problem Frame + +聊天主区既要承载普通对话(user / assistant 文本),也要承载越来越复杂的 AI 运行态:专家团计划、私董会多轮讨论、工具调用链、错误恢复。现有实现已经把这些类型拆成独立组件,但: + +1. 视觉语言与需求文档仍有偏差(TeamPlanCard 缺少进度条、BoardRoundCard 缺少紫色边框与角色标签、ErrorCard 缺少重试); +2. 消息分发器没有为 `error`、`board_started`、`board_conclusion`、`plan_update` 等类型预留专属渲染路径; +3. 右屏 `SystemMonitorPanel` 只有「监控」有内容,「技能 / 系统 / 知识库」尚未接入; +4. 部分设计令牌(`--radius-card`、`--font-mono`)和交互细节(拖拽上传、清空输入、按钮语义色)尚未补齐。 + +--- + +## Requirements Traceability + +| 需求来源 | 本计划覆盖范围 | +|---|---| +| §2 Color System | 补齐 `--radius-card`、`--font-mono`,深色模式同步,移除硬编码旧色 | +| §3.1 整体布局 | ChatView max-width 已居中;右屏折叠由 `RightPanel` 负责,本计划补 Tab 内容 | +| §3.2 消息类型表 | 为 `team_plan`、`board_banner`、`board_speech`、`board_summary`、`board_conclusion`、`error` 补齐/升级组件与分发 | +| §3.3 关键交互 | ChatInput 拖拽浮层、清空按钮、按钮语义色;ErrorCard 重试;ToolCallCard 默认收起已具备 | +| §4 Right Panel | SystemMonitorPanel 增加「技能 / 系统 / 知识库」可切换内容 | +| §5 Input Area | 专家团/私董会按钮着色、拖拽上传、清空 | +| §6.3 Mockup 6 场景 | 调整 Scene1/4/5,使预览更接近需求图 | + +--- + +## Key Technical Decisions + +1. **继续沿用 `MessageShell` + `useMessageRenderer` 分发架构。** 复杂消息统一走 `ChatMessage` 分发,不在 `ChatView` 里写大量 `v-if`。 +2. **扩展 `IChatMessage` 模型承载复杂消息字段。** 新增 `plan_phases`、`error_detail`、`board_conclusion`,让后端事件可以无损透传到组件层,而不是把所有状态塞进 `content`。 +3. **Board 顶部 Banner 用消息流中的 `milestone` 卡片表达。** `board_started` 事件进入聊天列表时渲染为 `BoardBannerCard`,关闭原来的纯文本 `milestone` fallback;`board_concluded` 渲染为 `BoardConclusionCard`。 +4. **右屏 Tab 内容复用现有 store 与组件,但做紧凑版。** 避免把完整 `SkillsView` / `KnowledgeBaseView` 的页面级 padding 和 header 搬进侧边栏;新建 `SkillsPanel` / `KnowledgePanel` / `SystemPanel` 只保留核心列表/指标。 +5. **所有新增/修改组件必须先过 `vue-tsc --noEmit`。** 由于前端没有单元测试,类型检查 + 构建即主要质量门。 +6. **每个单元完成后启动结对审查子 agent。** 审查范围:类型安全、Vue 范式、设计令牌使用、边界条件处理;审查通过后再进入下一单元。 + +--- + +## Implementation Units + +### U1. 补齐设计令牌与主题映射 + +**Goal:** 让需求文档 §2 中新增的 `--radius-card`、`--font-mono` 等令牌就位,并清理组件中残留的硬编码色值。 + +**Requirements:** §2.1 / §2.2 / §2.4 / §2.5 + +**Dependencies:** 无 + +**Files:** +- `src/agentkit/server/frontend/src/styles/tokens.css` +- `src/agentkit/server/frontend/src/styles/theme.ts` +- `src/agentkit/server/frontend/src/components/chat/BoardMeetingModal.vue` +- `src/agentkit/server/frontend/src/components/layout/SystemMonitorPanel.vue` + +**Approach:** +- 在 `:root` 与 `[data-theme="dark"]` 中新增 `--radius-card: 12px` 和 `--font-mono: 'SF Mono', 'Fira Code', ...`。 +- `theme.ts` 的 `Card` / `Modal` 组件 token 优先读取 `--radius-card`。 +- 将 `BoardMeetingModal.vue` 中硬编码的 `rgba(142, 68, 173, ...)` 与 `rgba(99, 102, 241, ...)` 替换为 `--accent-board-soft` / `--accent-board-bg`。 +- 将 `SystemMonitorPanel.vue` 中硬编码的健康色 `#22c55e`、错误色 `#ef4444` 替换为语义令牌。 + +**Test scenarios:** +- `vue-tsc --noEmit` 通过。 +- 在浏览器开发者工具中检查 `:root` 包含 `--radius-card` 与 `--font-mono`。 +- 深色模式下 `--accent-board-soft` 为带透明度的紫色。 + +**Verification:** 类型检查通过;浏览器 computed styles 可见新增令牌;BoardMeetingModal 选中态背景不再出现旧紫色。 + +**Pair review:** 由子 agent 检查 token 命名一致性、深色模式同步、是否仍有遗漏硬编码色值。 + +--- + +### U2. 消息模型与分发器对齐后端事件 + +**Goal:** 让 `useMessageRenderer` 能正确识别 `error`、`board_started`、`board_conclusion`、`plan_update` 等消息类型,并给组件传递结构化数据。 + +**Requirements:** §3.2 / §6.1 + +**Dependencies:** U1 + +**Files:** +- `src/agentkit/server/frontend/src/api/types.ts` +- `src/agentkit/server/frontend/src/components/chat/helpers/useMessageRenderer.ts` +- `src/agentkit/server/frontend/src/components/chat/messages/index.ts` +- `src/agentkit/server/frontend/src/components/chat/ChatMessage.vue` +- `src/agentkit/server/frontend/src/stores/chat.ts` +- `src/agentkit/server/frontend/src/components/chat/messages/BoardBannerCard.vue`(已新建) +- `src/agentkit/server/frontend/src/components/chat/messages/BoardConclusionCard.vue`(已新建) + +**Approach:** +- 在 `IChatMessage` 中新增可选字段:`plan_phases?: ITeamPlanPhase[]`、`error_detail?: string`、`board_conclusion?: IBoardConcludedData`。 +- 扩展 `MessageViewType` 为 `'user' | 'assistant' | 'team_plan' | 'board_banner' | 'board_speech' | 'board_summary' | 'board_conclusion' | 'milestone' | 'error'`。 +- `resolveMessageType` 按 `message_type` 返回 `team_plan`(`plan_update`)、`board_banner`(新增 `board_started`)、`board_conclusion`、`error`(新增 `error`)。 +- `team_plan` 分支从 `message.plan_phases` 取阶段,而不是从 `tool_calls` 映射(当前实现是错误映射)。 +- `chat.ts` 中 `board_started` 不再推送纯文本 `milestone`,而是推送 `message_type: 'board_started'` 的消息;`board_concluded` 推送 `message_type: 'board_conclusion'` 并带上 `board_conclusion` 数据。 +- `error` 分支对应后端 `error` 事件或本地把 `status === 'error'` 的消息路由到 `ErrorCard`。 + +**Test scenarios:** +- Happy path: `plan_update` 消息渲染 `TeamPlanCard` 且阶段名称正确。 +- Happy path: `board_started` 渲染 `BoardBannerCard`,显示主题、专家 chips、轮次。 +- Happy path: `board_conclusion` 渲染 `BoardConclusionCard`,展示共识 / 分歧 / 建议。 +- Error path: 后端 `error` 事件渲染 `ErrorCard` 并带重试按钮。 +- Edge case: `message.plan_phases` 为空时 `TeamPlanCard` 不崩溃。 + +**Verification:** 在 Preview Scene3/4/6 中可见正确渲染;`vue-tsc` 无新增类型错误。 + +**Pair review:** 由子 agent 检查类型定义完整性、分发器分支覆盖、`chat.ts` 事件转换是否遗漏字段。 + +--- + +### U3. TeamPlanCard 视觉升级 + +**Goal:** 让专家团计划 card 符合需求图:蓝色顶条、Lead 头像、阶段时间线、进度条、状态图标。 + +**Requirements:** §3.2 Team Plan Card 图示 + +**Dependencies:** U2 + +**Files:** +- `src/agentkit/server/frontend/src/components/chat/messages/TeamPlanCard.vue` +- `src/agentkit/server/frontend/src/components/preview/scenes/Scene3TeamPlan.vue` + +**Approach:** +- 顶部增加 4px 高的 `--accent-team` 顶条,左侧显示 Lead 头像 + 「专家团计划」标签 + 完成计数。 +- 阶段列表使用相对时间线:每个阶段左侧圆点/状态图标(● 执行中、○ 待执行、✓ 完成、✕ 失败),下方可展开专家子步骤(预留 `result` / `milestone` 字段)。 +- 底部增加进度条:`completed / total` 百分比,用 `--accent-team` 填充。 +- 颜色统一使用 `--accent-team*` 系列,不再使用默认 Ant Design 紫色。 + +**Test scenarios:** +- Happy path: 3 个阶段中 1 个运行、2 个待执行,进度条显示 33%。 +- Edge case: 所有阶段失败,进度条为 0%,状态图标全为错误色。 +- Edge case: 阶段名超长时显示 `text-overflow: ellipsis`。 + +**Verification:** Preview Scene3 与需求图一致;无类型错误。 + +**Pair review:** 由子 agent 检查样式 token 使用、进度计算正确性、状态图标映射。 + +--- + +### U4. Board 复杂消息组件群 + +**Goal:** 补齐私董会所需的 `BoardBannerCard`、`BoardRoundCard` 紫色边框样式、`BoardConclusionCard`。 + +**Requirements:** §3.2 Board Banner / Board Round Card / board_conclusion + +**Dependencies:** U2 + +**Files:** +- `src/agentkit/server/frontend/src/components/chat/messages/BoardBannerCard.vue`(已新建) +- `src/agentkit/server/frontend/src/components/chat/messages/BoardRoundCard.vue` +- `src/agentkit/server/frontend/src/components/chat/messages/BoardConclusionCard.vue`(已新建) +- `src/agentkit/server/frontend/src/components/chat/messages/index.ts` +- `src/agentkit/server/frontend/src/components/preview/scenes/Scene4BoardDiscussion.vue` + +**Approach:** +- `BoardBannerCard`:跨整行 card,顶部 4px `--accent-board` 条,显示 🏛️ 私董会主题、专家 emoji chips、主持人标签、当前轮次 / 最大轮次进度条。 +- `BoardRoundCard`:外层包裹增加左侧 3px `--accent-board` 边框、`--accent-board-soft` 背景;header 显示专家头像 chip、第 N 轮、角色标签(主持/专家)。 +- `BoardConclusionCard`:跨整行 card,紫色渐变顶条,内部展示「共识」「分歧」「决策建议」三个区块;默认折叠分歧与建议,点击展开。 +- 导出到 `messages/index.ts`;更新 Preview Scene4 包含 banner + 3 轮发言 + 小结 + 结论。 + +**Test scenarios:** +- Happy path: banner 显示主题、专家、主持人、轮次进度。 +- Happy path: 专家发言 card 左侧紫边,主持人 meta 带「主持」标签。 +- Happy path: 结论 card 默认展开共识,折叠分歧/建议,点击可展开。 +- Edge case: 结论数据为空时隐藏对应区块。 + +**Verification:** Preview Scene4 完整呈现 banner、发言、小结、结论;`vue-tsc` 通过。 + +**Pair review:** 由子 agent 检查紫色 token 一致性、BoardRoundCard 结构与需求图差异、折叠状态实现。 + +--- + +### U5. ErrorCard 重试与渲染集成 + +**Goal:** 错误 card 支持重试操作,并能在消息流中正确触发。 + +**Requirements:** §3.2 error / §3.3 错误重试 + +**Dependencies:** U2 + +**Files:** +- `src/agentkit/server/frontend/src/components/chat/messages/ErrorCard.vue` +- `src/agentkit/server/frontend/src/components/chat/helpers/useMessageRenderer.ts` +- `src/agentkit/server/frontend/src/components/chat/ChatMessage.vue` +- `src/agentkit/server/frontend/src/components/preview/scenes/Scene6Error.vue` +- `src/agentkit/server/frontend/src/stores/chat.ts` + +**Approach:** +- `ErrorCard` 增加 `onRetry` prop / `emit('retry')`,在 card 底部显示「重试」按钮。 +- `ChatMessage` 监听 retry 事件,调用 `chatStore.resendLastUserMessage()` 或类似 action;若不存在则新增该 action:取当前对话最后一条 user 消息重新发送。 +- `useMessageRenderer` 对 `message_type === 'error'` 或 `status === 'error'` 的消息渲染 `ErrorCard`。 +- Preview Scene6 的 ErrorCard 带重试按钮,点击后触发重发模拟。 + +**Test scenarios:** +- Happy path: 点击 ErrorCard「重试」重新发送最后一条 user 消息。 +- Error path: 对话中没有 user 消息时重试按钮禁用或不显示。 +- Edge case: 连续多次重试不追加重复占位消息。 + +**Verification:** Scene6 可交互;类型检查通过。 + +**Pair review:** 由子 agent 检查重试逻辑幂等性、空消息边界、事件冒泡处理。 + +--- + +### U6. ChatInput 交互补全 + +**Goal:** 完成输入区的语义色按钮、拖拽上传浮层、清空按钮。 + +**Requirements:** §5.2 / §5.3 + +**Dependencies:** U1 + +**Files:** +- `src/agentkit/server/frontend/src/components/chat/ChatInput.vue` +- `src/agentkit/server/frontend/src/components/preview/scenes/Scene1Welcome.vue` + +**Approach:** +- 「专家团」按钮默认/悬停使用 `--accent-team`;「私董会」按钮默认/悬停使用 `--accent-board`;图标尺寸保持 14px。 +- 在 textarea 区域监听 `dragenter` / `dragover` / `dragleave` / `drop`,放下文件时显示「拖放以附加」半透明浮层并调用现有 `handleFileChange` 逻辑。 +- 在 footer 增加「清空」按钮,点击后 `inputText = ''` 并清空已选附件 pills。 +- Preview Scene1 的输入框预填文本改为 `@team:tech_lead,frontend_engineer,backend_engineer 设计一个用户认证系统`。 + +**Test scenarios:** +- Happy path: 拖拽文件到输入区出现浮层,松开后文件被引用到输入框。 +- Happy path: 点击清空后输入框与附件 pills 清空。 +- Edge case: 非文件拖拽(如文本)不触发上传浮层。 + +**Verification:** 浏览器手动验证;`vue-tsc` 通过。 + +**Pair review:** 由子 agent 检查拖拽事件处理是否健壮、清空是否同时清理附件、按钮语义色是否正确。 + +--- + +### U7. 右屏 Tab 面板内容补全 + +**Goal:** SystemMonitorPanel 的「技能 / 系统 / 知识库」三个 Tab 有真实内容。 + +**Requirements:** §4.1 / §4.3 + +**Dependencies:** 无 + +**Files:** +- `src/agentkit/server/frontend/src/components/layout/SystemMonitorPanel.vue` +- `src/agentkit/server/frontend/src/stores/skills.ts` +- `src/agentkit/server/frontend/src/stores/knowledge.ts` +- `src/agentkit/server/frontend/src/components/skills/SkillCard.vue` +- `src/agentkit/server/frontend/src/components/kb/SearchTest.vue` +- `src/agentkit/server/frontend/src/api/system.ts`(已新建) + +**Approach:** +- 保持 Tab bar 与切换逻辑不变,把当前监控内容拆到 `MonitorTab` 子组件。 +- 新增 `SkillsTab`:使用 `useSkillsStore` 拉取技能列表,以紧凑列表展示技能名 + 描述 + 状态,点击展开 `SkillDetail`;复用 `SkillCard` 但减少 padding。 +- 新增 `SystemTab`:展示当前 `resources`(CPU / 内存 / 磁盘)与服务健康检查,避免重复监控 Tab 的 4 个 metric card。 +- 新增 `KnowledgeTab`:使用 `useKnowledgeStore` 拉取 sources,展示知识库源列表,并在底部嵌入 `SearchTest` 做检索测试。 +- 激活 Tab 的下划线颜色改为 `--accent-team`。 + +**Test scenarios:** +- Happy path: 切换到「技能」Tab 可见技能列表,点击技能弹出详情。 +- Happy path: 切换到「系统」Tab 可见资源使用率与服务状态。 +- Happy path: 切换到「知识库」Tab 可见源列表与检索测试入口。 +- Error path: API 失败时显示友好错误提示。 + +**Verification:** 在 `/agent/chat` 打开右屏切换 4 个 Tab;`vue-tsc` / `npm run build` 通过。 + +**Pair review:** 由子 agent 检查 store 使用是否引入重复请求、Tab 切换状态、API 错误处理。 + +--- + +### U8. Preview 样例场景精修 + +**Goal:** 让 6 个 Preview 场景与需求文档 §6.3 一一对应,便于视觉 review。 + +**Requirements:** §6.3 + +**Dependencies:** U2 / U3 / U4 / U5 / U6 + +**Files:** +- `src/agentkit/server/frontend/src/components/preview/ChatPreviewShell.vue` +- `src/agentkit/server/frontend/src/components/preview/scenes/Scene1Welcome.vue` +- `src/agentkit/server/frontend/src/components/preview/scenes/Scene3TeamPlan.vue` +- `src/agentkit/server/frontend/src/components/preview/scenes/Scene4BoardDiscussion.vue` +- `src/agentkit/server/frontend/src/components/preview/scenes/Scene5ToolCall.vue` + +**Approach:** +- Scene1:顶部 hero + 4 能力提示 + 预填 `@team:...` 的输入框。 +- Scene3:使用升级后的 `TeamPlanCard`,第 1 阶段 running、专家子项展开。 +- Scene4:包含 `BoardBannerCard`、3 轮不同专家发言、1 个小结、1 个 `BoardConclusionCard`。 +- Scene5:展示嵌套 2 层 tool call,状态点变化(running → completed / error)。 +- ChatPreviewShell:场景切换按钮增加键盘左右箭头监听,方便演示。 + +**Test scenarios:** +- Happy path: 6 个场景全部可切换且无 console error。 +- Happy path: Scene4 的 banner 与结论 card 完整渲染。 +- Edge case: 快速切换场景不残留上一个场景的滚动位置(滚动容器复位)。 + +**Verification:** `/agent/preview` 可完整演示 6 个场景;构建通过。 + +**Pair review:** 由子 agent 检查 mock 数据类型与真实接口是否一致、场景切换是否健壮。 + +--- + +### U9. BoardMeetingModal VI 适配 + +**Goal:** 私董会 Modal 使用新的紫色语义令牌,移除旧 `#8E44AD` 与 indigo 硬编码。 + +**Requirements:** §6.1 + +**Dependencies:** U1 + +**Files:** +- `src/agentkit/server/frontend/src/components/chat/BoardMeetingModal.vue` + +**Approach:** +- 选中态背景改为 `--accent-board-bg` / `--accent-board-soft`。 +- 选中边框使用 `--accent-board`。 +- 主持人标签颜色从 Ant Design `purple` 改为自定义 Tag,背景 `--accent-board-soft`、文字 `--accent-board`。 +- hover 边框改为 `--color-primary`(近黑)。 + +**Test scenarios:** +- Happy path: 选中专家后卡片边框为紫色,主持人卡片背景为浅紫色。 +- Edge case: 深色模式下紫色令牌仍可见。 + +**Verification:** 在 ChatInput 打开「私董会」Modal 验证;`vue-tsc` 通过。 + +**Pair review:** 由子 agent 检查是否仍有遗漏硬编码色值、深色模式表现。 + +--- + +### U10. 质量门与后端回归 + +**Goal:** 确保前端类型安全、构建成功,后端单元测试不回归。 + +**Requirements:** §8.1 完成定义 + +**Dependencies:** U1-U9 + +**Files:** +- `src/agentkit/server/frontend/package.json` +- `src/agentkit/server/routes/chat.py`(仅验证上传接口未破坏) +- `src/agentkit/server/routes/experts.py`(仅验证专家列表未破坏) + +**Approach:** +- 在前端目录运行 `npx vue-tsc --noEmit`。 +- 运行 `npm run build`。 +- 在仓库根目录运行 `ruff check src/` 与 `ruff format src/`。 +- 运行 `python3 -m pytest tests/unit/ -x -q`;若 `test_rewoo_agent_yaml_loads` 失败则按已知问题跳过。 +- 修复所有阻塞性错误后重新跑门。 + +**Test scenarios:** +- `vue-tsc --noEmit` 通过。 +- `npm run build` 生成 `src/agentkit/server/static/` 且无报错。 +- `pytest tests/unit/ -x -q` 通过(允许跳过已知失败)。 +- `ruff check src/` 通过。 + +**Verification:** 三个命令均返回 0(或 pytest 仅因已知用例跳过)。 + +**Pair review:** 由子 agent 检查类型错误修复是否引入新的运行时问题、构建产物是否正常。 + +--- + +## Scope Boundaries + +### In Scope + +- 聊天主区 VI 令牌补齐与深色模式同步。 +- `ChatMessage` 分发器与 `IChatMessage` 模型扩展。 +- `TeamPlanCard`、`BoardBannerCard`、`BoardRoundCard`、`BoardConclusionCard`、`ErrorCard` 的升级/新建。 +- `ChatInput` 的拖拽上传、清空、语义色按钮。 +- `SystemMonitorPanel` 的「技能 / 系统 / 知识库」Tab 内容。 +- `ChatPreviewView` 6 个样例场景精修。 +- `BoardMeetingModal` VI 适配。 +- 质量门(vue-tsc / build / ruff / pytest)。 +- 每个开发单元后的结对代码审查。 + +### Out of Scope + +- 侧边栏 `ChatSidebar`、顶部 `TopNav`、抽屉布局改造。 +- 移动端响应式优化。 +- 实际 WebSocket 数据流改造(仅前端渲染层适配)。 +- 可访问性(a11y)专项审计。 +- 虚拟滚动、消息懒加载等性能优化。 +- 消息编辑 / 分支对话(Trae 范式)。 + +### Deferred to Follow-Up Work + +- UserBubble / AssistantText 的 hover 操作条(编辑/复制/重新生成/反馈)—— 需求 §3.3 提到,但本期以复杂事件 card 和右屏为主,可在后续 PR 补齐。 +- 流式光标竖线动画 —— 当前使用 `a-spin` 占位,后续可替换为闪烁竖线。 + +--- + +## Risks & Dependencies + +| 风险 | 等级 | 缓解措施 | +|---|---|---| +| 扩展 `IChatMessage` 会影响现有对话持久化格式 | 中 | 新增字段均为 optional;后端返回 JSON 不校验未知字段时无影响。 | +| `SystemMonitorPanel` 嵌入完整 Skills/KB 组件可能触发重复请求 | 中 | 使用 store 缓存,Tab 切换不重新 fetch;`onMounted` 仍负责首次加载。 | +| Preview Scene 精修引入新的假数据类型与真实后端不一致 | 低 | Preview 明确为静态 mockup,不依赖真实 WebSocket。 | +| 后端 `pytest` 已有已知失败 `test_rewoo_agent_yaml_loads` | 低 | 按项目规则跳过该用例。 | +| 多单元改动叠加导致类型错误难以定位 | 中 | 每单元独立跑 `vue-tsc --noEmit`,不累积错误。 | + +--- + +## Open Questions + +以下问题来自需求文档 §9,实施前按默认值处理,后续可再调整: + +1. **Q1** `@team` 蓝 vs `@board` 紫:保持当前分配(蓝=协作,紫=深度)。 +2. **Q2** 流式光标:保持当前 spin 占位,竖线动画 deferred。 +3. **Q3** 私董会按钮文字:保持「私董会」。 +4. **Q4** 专家团按钮文字:保持「专家团」。 +5. **Q5** 消息引用:本期不做。 + +--- + +## Success Criteria + +- [x] `tokens.css` + `theme.ts` 新增令牌就位。 +- [x] `BoardBannerCard` / `BoardRoundCard` / `BoardConclusionCard` 结构与样式就位。 +- [ ] `/agent/preview` 6 个场景全部可见且可交互。 +- [ ] 无硬编码旧色残留(U1、U9 收尾)。 +- [ ] `vue-tsc --noEmit` 与 `npm run build` 通过。 +- [ ] `ruff check src/` 通过。 +- [ ] `pytest tests/unit/ -x -q` 通过(允许跳过已知失败)。 +- [ ] 右屏 4 个 Tab 都有内容。 +- [ ] ErrorCard 支持重试。 +- [ ] 每个开发单元都经过子 agent 结对审查并确认无阻塞问题。 + +--- + +## Sources & Research + +- Origin: [docs/brainstorms/2026-06-18-chat-area-vi-redesign-requirements.md](docs/brainstorms/2026-06-18-chat-area-vi-redesign-requirements.md) +- Local patterns: `src/agentkit/server/frontend/src/components/chat/messages/`, `src/agentkit/server/frontend/src/components/layout/RightPanel.vue` diff --git a/docs/plans/2026-06-19-002-enterprise-client-server-architecture-plan.md b/docs/plans/2026-06-19-002-enterprise-client-server-architecture-plan.md new file mode 100644 index 0000000..7100c37 --- /dev/null +++ b/docs/plans/2026-06-19-002-enterprise-client-server-architecture-plan.md @@ -0,0 +1,1026 @@ +# 企业级 AI 客户端平台 — 架构方案文档 + +> 文档编号:2026-06-19-002 +> 创建日期:2026-06-19 +> 状态:方案评审中(含独立批判性分析) + +--- + +## 目录 + +1. [背景与动机](#1-背景与动机) +2. [产品定位](#2-产品定位) +3. [架构总览](#3-架构总览) +4. [客户端设计](#4-客户端设计) +5. [服务端设计](#5-服务端设计) +6. [权限模型](#6-权限模型) +7. [终端安全](#7-终端安全) +8. [LLM 调用链路](#8-llm-调用链路) +9. [数据同步](#9-数据同步) +10. [认证体系](#10-认证体系) +11. [数据库设计](#11-数据库设计) +12. [安全模型](#12-安全模型) +13. [实施路线图](#13-实施路线图) +14. [独立批判性分析](#14-独立批判性分析) +15. [方案优化与补全](#15-方案优化与补全) +16. [待决策问题](#16-待决策问题) + +--- + +## 1. 背景与动机 + +### 1.1 当前架构 + +Fischer AgentKit 当前是纯本地运行架构: + +- **CLI/Tauri 桌面端**:启动本地 Python sidecar(`127.0.0.1:{随机端口}`),前端通过 Tauri IPC 获取端口后连接本地 sidecar +- **LLM 配置**:API Key 存储在本地 `agentkit.yaml` + `.env` 中 +- **认证**:仅全局 API Key + `clients.yaml` 多客户端 key,无用户系统 +- **数据**:会话存储在本地 SQLite,情景记忆在 PostgreSQL(可选) +- **终端**:PTY 在本地 sidecar 中执行 + +### 1.2 改造动机 + +- **企业统一管理 LLM Key 和成本**:当前 Key 散落在各客户端,无法统一管控 +- **团队知识共享**:企业代码规范、最佳实践需要集中管理、团队共享 +- **权限与审计**:企业需要控制谁能使用终端等高危功能,需要审计操作记录 +- **多用户支持**:当前无用户概念,无法区分不同用户的会话和操作 + +### 1.3 对标产品 + +| 产品 | 客户端 | 服务端 | 差异 | +|------|--------|--------|------| +| Trae | 桌面 IDE + AI Agent | 云端模型 | 个人工具 | +| Cursor | VS Code Fork + AI | 云端模型 + Team | 团队工具 | +| Codex CLI | 终端 CLI | 云端模型 | 命令行助手 | +| **AgentKit 目标** | **桌面客户端 + AI Agent** | **企业平台(权限/KB/审计)** | **企业级 AI 开发平台** | + +**核心差异化**:代码不离开客户端 + Agent 本地执行 + 企业级治理 + +--- + +## 2. 产品定位 + +### 2.1 定位声明 + +> 企业级 AI 开发客户端平台:客户端是开发者的 AI 工作台(类似 Trae),服务端是企业共享资源与治理中心。 + +### 2.2 双买家模型 + +| 维度 | 开发者视角 | 企业 IT 视角 | +|------|----------|------------| +| 核心价值 | AI 辅助编程、本地执行 | 统一 Key 管理、成本管控、审计合规 | +| 部署模式 | 本地优先 | 集中部署 | +| 成功指标 | 任务完成率、响应延迟 | 合规率、成本可控 | + +### 2.3 差异化竞争力 + +1. **代码不离开客户端**(真差异化):Cursor/Trae 的 codebase indexing 在云端,AgentKit 的代码上下文完全本地 +2. **专家团/董事会模式**(已有优势):当前代码库已有的多 Agent 协作模式,Cursor/Trae 完全没有 +3. **企业级治理**(目标差异化):统一 LLM Key、成本管控、权限审计 + +--- + +## 3. 架构总览 + +### 3.1 双进程模型 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 企业服务端(云端/内网) │ +│ │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ LLM 网关 │ │ 用户权限 │ │ 审计日志 │ │ 知识库 │ │ +│ │ (统一Key) │ │ 中心 │ │ 中心 │ │ 中心 │ │ +│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ +│ │ +│ PostgreSQL + pgvector │ Redis │ MinIO(可选) │ +└───────────────────────┬─────────────────────────────────────┘ + │ HTTPS API + WSS + │ (认证: JWT + API Key) + │ + ┌─────────────┼─────────────┐ + │ │ │ + ▼ ▼ ▼ + ┌──────────────┐ ┌──────────┐ ┌──────────────┐ + │ Tauri 桌面 │ │ Tauri │ │ Web 管理台 │ + │ 客户端 │ │ 客户端 │ │ (浏览器) │ + │ │ │ │ │ │ + │ ┌──────────┐ │ └──────────┘ │ 用户管理 │ + │ │本地Sidecar│ │ │ 审计查看 │ + │ │(Python) │ │ │ 配置管理 │ + │ │├Agent引擎│ │ └──────────────┘ + │ │├终端 │ │ + │ │├文件操作 │ │ + │ │├会话存储 │ │ + │ │└LLM代理 │ │ + │ └──────────┘ │ + │ ┌──────────┐ │ + │ │前端(Vue3)│ │ + │ └──────────┘ │ + └──────────────┘ +``` + +### 3.2 核心设计原则 + +| 原则 | 说明 | 价值定位 | +|------|------|---------| +| LLM Key 不下发客户端 | 客户端通过服务端 LLM 网关间接调用 | **成本管控 + 审计**(非安全边界) | +| 代码不离开客户端 | Agent 在客户端本地执行,文件操作在本地 | **安全 + 隐私**(真差异化) | +| 终端在客户端本地执行 | 服务端不提供终端功能 | **安全**(服务端无 RCE 风险) | +| 离线降级 | 服务端不可用时客户端部分可用 | **可用性** | + +### 3.3 关键架构决策 + +| 决策点 | 选择 | 理由 | +|--------|------|------| +| 终端位置 | 本地 Python sidecar(非 Tauri Rust) | 避免用 Rust 重写 PTY + 命令分类逻辑 | +| LLM 调用模式 | 服务端代理(非客户端直连) | 统一 Key 管理 + 用量审计 + 成本管控 | +| 配置同步 | 轮询 + ETag(非 WebSocket 推送) | 配置变更频率低(周/月级),轮询足够 | +| 会话历史 | 本地 SQLite(不上传服务端) | 隐私保护 + 减少网络传输 | +| 服务端部署 | 支持内网私有化部署 | 跨地域公网延迟不可接受 | + +--- + +## 4. 客户端设计 + +### 4.1 客户端分层 + +``` +Tauri 桌面客户端 +├── UI 层(Vue 3 + Ant Design Vue) +│ ├── 对话界面(Chat) +│ ├── 工作台界面(Workspace) +│ ├── 终端界面(Terminal) +│ ├── 知识库浏览(KB Browser) +│ ├── 工作流管理(Workflow) +│ └── 设置中心(Settings) +│ +├── 本地服务层(Python Sidecar - FastAPI) +│ ├── /local/agent — 本地 Agent 执行引擎 +│ ├── /local/terminal — 本地终端(PTY) +│ ├── /local/files — 本地文件操作 +│ ├── /local/session — 本地会话存储(SQLite) +│ ├── /local/cache — 本地 LLM 语义缓存 +│ └── /local/llm-proxy — LLM 代理(转发到服务端网关) +│ +├── 远程通信层 +│ ├── /api/auth/* — 认证(登录/JWT) +│ ├── /api/llm/chat — LLM 代理(通过服务端网关) +│ ├── /api/kb/* — 知识库查询 +│ ├── /api/config/* — 配置同步 +│ └── /api/audit/* — 审计日志上报 +│ +└── 本地存储 + ├── SQLite(会话、缓存、配置快照) + ├── 文件系统(代码、项目) + └── 内存(Agent 运行时状态) +``` + +### 4.2 客户端保留的模块 + +| 当前模块 | 客户端角色 | 改造 | +|---------|----------|------| +| `core/` Agent 引擎 | 本地执行 | LLM 改为 RemoteLLMProvider | +| `tools/` 工具 | 本地执行 | 不变 | +| `chat/` 会话 | 本地 SQLite | 增加 owner_id | +| `routes/terminal.py` | 本地终端 | 增加权限检查 + 审计上报 | +| `llm/gateway.py` | 改为代理模式 | 新增 RemoteLLMProvider | +| `skills/` | 本地 + 远程同步 | 增加从服务端同步 | +| `experts/` | 本地 + 远程配置 | 增加从服务端同步 | + +### 4.3 客户端启动流程 + +``` +1. Tauri 启动 → 启动本地 Python Sidecar(127.0.0.1:{随机端口}) +2. 前端加载 → 检查本地是否有登录凭证(JWT) + ├─ 有且有效 → 直接进入主界面 + ├─ 有但过期 → 尝试 refresh → 成功则进入,失败则跳转登录 + └─ 无 → 跳转登录页 +3. 登录成功后: + a. 从服务端拉取用户信息 + 权限 + b. 从服务端同步配置(技能/Agent/工作流,带 ETag 增量) + c. 初始化本地 Agent 引擎(使用 RemoteLLMProvider) +4. 进入主界面,用户开始工作 +``` + +### 4.4 离线降级策略 + +| 功能 | 服务端可用 | 服务端不可用 | +|------|----------|------------| +| 对话 | 通过服务端 LLM 网关 | 用户自填 Key 直连 LLM(降级模式) | +| 终端 | 本地执行 + 审计上报 | 本地执行 + 审计暂存本地 | +| 文件操作 | 本地执行 | 本地执行 | +| 知识库 | 实时查询服务端 | 使用本地缓存的 KB 元数据 + 已缓存文档 | +| 工作流模板 | 实时拉取 | 使用本地缓存版本 | +| 配置同步 | 轮询更新 | 使用本地缓存 | + +--- + +## 5. 服务端设计 + +### 5.1 服务端模块(按必要性排序) + +| 优先级 | 模块 | 职责 | V1 是否必须 | +|--------|------|------|------------| +| P0 | LLM 网关 | 统一 Key、用量统计、成本管控、语义缓存、限流 | ✅ 必须 | +| P0 | 用户认证 | JWT 签发/验证、用户 CRUD | ✅ 必须 | +| P1 | 审计日志 | LLM 调用审计 + 危险命令审计 | ✅ 应该 | +| P1 | 知识库 | pgvector 语义搜索、团队隔离 | ✅ 应该 | +| P2 | 配置同步 API | 技能/Agent/工作流配置分发 | 可延后 | +| P3 | Web 管理台 | 用户管理、审计查看、配置管理 | 可延后 | +| P3 | 技能市场 | 技能发布与分发 | 可延后 | +| P3 | 工作流模板中心 | 工作流 CRUD、版本控制 | 可延后 | +| P4 | 生产系统集成 | CI/CD Webhook | 暴露 Webhook 即可 | + +### 5.2 服务端 API + +``` +认证 API: + POST /api/v1/auth/login — 登录 + POST /api/v1/auth/refresh — 刷新 token + POST /api/v1/auth/logout — 登出 + GET /api/v1/auth/me — 当前用户信息 + +LLM 网关 API: + POST /api/v1/llm/chat — LLM 对话(非流式) + POST /api/v1/llm/chat/stream — LLM 流式对话(SSE) + +知识库 API: + GET /api/v1/kb/search — 知识库搜索 + GET /api/v1/kb/documents/{id} — 获取文档 + +配置同步 API: + GET /api/v1/config/version — 配置版本号 + GET /api/v1/config/skills — 技能配置 + GET /api/v1/config/agents — Agent 配置 + GET /api/v1/config/workflows — 工作流模板 + +审计 API: + POST /api/v1/audit/terminal — 终端审计上报 + GET /api/v1/usage/me — 我的用量 + +管理 API(需 admin 权限): + GET /api/v1/admin/users — 用户管理 + POST /api/v1/admin/users — 创建用户 + GET /api/v1/admin/audit/terminal — 终端审计日志 + GET /api/v1/admin/usage — 全局用量统计 +``` + +--- + +## 6. 权限模型 + +### 6.1 三级角色 + 权限位 + +``` +角色: + member — 普通用户(对话 + 只读 KB + 工作流执行) + operator — 高级用户(+ 终端 + KB 写入) + admin — 管理员(+ 用户管理 + 系统配置) +``` + +### 6.2 权限位定义 + +```python +class Permission(str, Enum): + CHAT = "chat" # 对话 + KB_QUERY = "kb.query" # 知识库查询 + KB_WRITE = "kb.write" # 知识库写入 + WORKFLOW_EXECUTE = "workflow.execute" # 工作流执行 + TERMINAL_LOCAL_USE = "terminal.local.use" # 本地终端使用 + TERMINAL_SERVER_USE = "terminal.server.use" # 服务端终端使用 + TERMINAL_WHITELIST_MANAGE = "terminal.whitelist.manage" # 白名单管理 + USER_MANAGE = "user.manage" # 用户管理 + SYSTEM_CONFIG = "system.config" # 系统配置 + +ROLE_PERMISSIONS: dict[str, set[Permission]] = { + "member": {Permission.CHAT, Permission.KB_QUERY, Permission.WORKFLOW_EXECUTE}, + "operator": { + Permission.CHAT, Permission.KB_QUERY, Permission.KB_WRITE, + Permission.WORKFLOW_EXECUTE, Permission.TERMINAL_LOCAL_USE, + }, + "admin": set(Permission), # 含 TERMINAL_SERVER_USE + TERMINAL_WHITELIST_MANAGE +} +``` + +### 6.3 终端授权 + +终端分为**本地终端**和**服务端终端**两种模式,授权要求不同: + +| 终端模式 | 执行位置 | 所需权限 | 额外要求 | 审计策略 | +|---------|---------|---------|---------|---------| +| 本地终端 | 客户端本地 sidecar | `TERMINAL_LOCAL_USE` | `is_terminal_authorized=True` | 仅危险命令上报 | +| 服务端终端 | 服务端 PTY | `TERMINAL_SERVER_USE` | `is_server_terminal_authorized=True` | **全量命令记录** | + +**授权流程**: +1. `operator` 角色默认有本地终端权限,admin 可单独授予 `is_terminal_authorized` +2. 服务端终端权限仅 `admin` 角色默认拥有,或 admin 单独授予 `is_server_terminal_authorized` +3. 两种终端权限独立控制,互不影响 + +--- + +## 7. 终端安全 + +### 7.1 三层防护 + +``` +第一层:角色门禁 + → 用户必须有 TERMINAL_USE 权限 + is_terminal_authorized=True + → member 角色看不到终端入口 + +第二层:命令分类 + → 安全命令(ls, cat, pwd...):有权限即可执行 + → 危险命令(rm, sudo, chmod...):每次必须人工确认 + → 未知命令:默认视为危险 + +第三层:人工确认(每次) + → 危险命令每次执行都弹出确认对话框 + → 不提供"加入白名单"选项(危险命令不持久化白名单) + → 必须勾选"我已了解风险"才能确认 +``` + +### 7.2 终端审计 + +| 审计内容 | 上报策略 | +|---------|---------| +| 危险命令 + 确认结果 | 实时上报服务端 | +| 普通命令流水 | **不上报**(隐私保护,全量监控是 MDM/EDR 的职责) | +| 命令输出内容 | **不上报**(可能含敏感信息) | +| 审计元数据 | 命令文本、风险等级、是否确认、exit_code、时间戳 | + +### 7.3 BYOD 模式 + +- 支持纯本地模式(不接入企业服务端),此时无审计上报 +- 企业配发设备接入服务端时,审计上报自动启用 + +--- + +## 8. LLM 调用链路 + +### 8.1 调用流程 + +``` +客户端 Agent → 本地 LLM Proxy → 服务端 LLM 网关 → LLM Provider API + ↑ + JWT 认证 + 用量统计 + 成本管控 + 语义缓存(共享) + 限流/熔断 +``` + +### 8.2 RemoteLLMProvider + +```python +class RemoteLLMProvider(LLMProvider): + """通过服务端 LLM 网关调用 LLM,不在本地存储 API Key""" + + def __init__(self, server_url: str, auth_token_provider: Callable[[], str]): + self._server_url = server_url + self._auth_token_provider = auth_token_provider + + async def chat(self, request: LLMRequest) -> LLMResponse: + response = await httpx.post( + f"{self._server_url}/api/v1/llm/chat", + json=request.model_dump(), + headers={"Authorization": f"Bearer {self._auth_token_provider()}"}, + ) + return LLMResponse(**response.json()) + + async def chat_stream(self, request: LLMRequest): + async with httpx.AsyncClient() as client: + async with client.stream( + "POST", + f"{self._server_url}/api/v1/llm/chat/stream", + json=request.model_dump(), + headers={"Authorization": f"Bearer {self._auth_token_provider()}"}, + ) as response: + async for line in response.aiter_lines(): + if line.startswith("data: "): + yield StreamChunk(**json.loads(line[6:])) +``` + +### 8.3 流式 fallback 语义 + +**已知风险**:当前 `gateway.py` 的"首 chunk 前失败则 fallback"在单进程内简单,跨网络后客户端可能已收到部分 chunk,fallback 会导致内容跳变。 + +**处理方案**: +- 服务端网关在首 chunk 前完成 fallback(当前逻辑不变) +- 首 chunk 后不再 fallback,直接返回错误 +- 客户端收到错误后自行决定是否重试 + +### 8.4 延迟分析 + +| 部署拓扑 | RTT | 50 次 LLM 调用额外延迟 | 可接受性 | +|---------|-----|---------------------|---------| +| 同内网 | 5ms | +0.5s | ✅ 完全可接受 | +| 跨城内网 | 30ms | +3s | ✅ 可接受 | +| 跨地域公网 | 150ms | +15s | ⚠️ 开始难受 | +| 跨国公网 | 300ms | +30s | ❌ 不可接受 | + +**结论**:服务端必须支持**内网私有化部署**。跨地域公网部署需要客户端支持直连降级。 + +### 8.5 内网 LLM 支持 + +服务端 LLM 网关支持接入内网 LLM 服务: +- vLLM(OpenAI 兼容 API) +- Ollama(OpenAI 兼容 API) +- 当前 `ProviderConfig` 已是 OpenAI 兼容格式,只需配置 `base_url` 指向内网地址 + +--- + +## 9. 数据同步 + +### 9.1 同步策略(简化版) + +| 数据类型 | 方向 | 策略 | +|---------|------|------| +| 用户信息/权限 | 服务端 → 客户端 | 登录时拉取 + JWT 续期时刷新 | +| 技能/Agent/工作流配置 | 服务端 → 客户端 | 启动时全量拉取 + 每 5 分钟轮询版本号 | +| 知识库 | 服务端 → 客户端 | 实时查询 + 元数据本地缓存 + LRU 文档缓存 | +| 会话历史 | 纯本地 | 不上传服务端 | +| 终端审计 | 客户端 → 服务端 | 危险命令实时上报 + 离线暂存 | +| LLM 用量 | 服务端记录 | 通过 LLM 网关自动记录 | + +### 9.2 配置同步实现(轮询版) + +```python +# 客户端配置同步引擎 +class ConfigSync: + async def sync(self): + # 1. 检查版本号 + remote_version = await api.get("/api/v1/config/version") + if remote_version == local_version: + return # 无变更 + + # 2. 全量拉取(配置数据量小,无需增量) + skills = await api.get("/api/v1/config/skills") + agents = await api.get("/api/v1/config/agents") + workflows = await api.get("/api/v1/config/workflows") + + # 3. 更新本地缓存 + await update_local_cache(skills, agents, workflows) + local_version = remote_version + + async def start_polling(self): + while True: + await asyncio.sleep(300) # 5 分钟 + try: + await self.sync() + except Exception: + pass # 离线时静默失败 +``` + +### 9.3 知识库缓存策略 + +| 层级 | 缓存内容 | 大小 | 策略 | +|------|---------|------|------| +| L1 | KB 文档列表 + 标题 + 摘要 | ~100KB | 启动时全量拉取 | +| L2 | 最近使用的 N 篇文档全文 | ~10MB | LRU 缓存,按需拉取 | +| L3 | 实时查询 | - | L1/L2 miss 时请求服务端 | + +--- + +## 10. 认证体系 + +### 10.1 双轨认证 + +``` +请求进入 + │ + ├─ 有 Authorization: Bearer ? + │ └─ 是 → 验证 JWT → 提取 user_id → request.state.current_user = User + │ + ├─ 有 X-API-Key: ? + │ └─ 是 → 查 user_api_keys 表 → 识别 user_id → request.state.current_user = User + │ (兼容旧 clients.yaml → 识别为 system client) + │ + └─ 无凭证 + ├─ dev mode → 放行 + └─ 生产 → 401 +``` + +### 10.2 JWT 流程 + +``` +登录:POST /api/v1/auth/login {username, password} + → 验证密码 (bcrypt) + → 生成 access_token (15min) + refresh_token (7d) + → 返回 {access_token, refresh_token, user} + +刷新:POST /api/v1/auth/refresh {refresh_token} + → 生成新 access_token + +登出:POST /api/v1/auth/logout + → 撤销 refresh_token +``` + +### 10.3 SSO/OIDC(V2 规划) + +V1 支持本地账号认证。V2 预留 OIDC 接口,支持: +- Azure AD / Microsoft Entra ID +- Google Workspace +- 企业自建 OIDC Provider + +--- + +## 11. 数据库设计 + +### 11.1 服务端 PostgreSQL + +```sql +-- 用户表 +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + username VARCHAR(64) UNIQUE NOT NULL, + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + role VARCHAR(32) NOT NULL DEFAULT 'member', + is_active BOOLEAN NOT NULL DEFAULT TRUE, + is_terminal_authorized BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_login_at TIMESTAMPTZ, + created_by UUID REFERENCES users(id), + tenant_id UUID, -- 多租户预留 + metadata JSONB DEFAULT '{}' +); + +-- 用户 API Key 表 +CREATE TABLE user_api_keys ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + key_hash VARCHAR(255) UNIQUE NOT NULL, + key_prefix VARCHAR(16) NOT NULL, + name VARCHAR(64), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_used_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ, + is_revoked BOOLEAN NOT NULL DEFAULT FALSE +); + +-- 终端审计日志 +CREATE TABLE terminal_audit_logs ( + id BIGSERIAL PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id), + session_id VARCHAR(64) NOT NULL, + command TEXT NOT NULL, + command_hash VARCHAR(64) NOT NULL, + risk_level VARCHAR(16) NOT NULL, + was_confirmed BOOLEAN NOT NULL DEFAULT FALSE, + was_approved BOOLEAN NOT NULL DEFAULT FALSE, + executed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + exit_code INTEGER, + client_ip INET, + metadata JSONB DEFAULT '{}' +); + +-- LLM 用量记录 +CREATE TABLE llm_usage_records ( + id BIGSERIAL PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id), + provider VARCHAR(64) NOT NULL, + model VARCHAR(128) NOT NULL, + input_tokens INTEGER NOT NULL, + output_tokens INTEGER NOT NULL, + cost_cents INTEGER NOT NULL DEFAULT 0, + latency_ms INTEGER, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + metadata JSONB DEFAULT '{}' +); + +-- 用户会话(JWT refresh token) +CREATE TABLE user_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + refresh_token_hash VARCHAR(255) UNIQUE NOT NULL, + device_info JSONB DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + revoked_at TIMESTAMPTZ +); +``` + +### 11.2 客户端 SQLite(现有 + 改造) + +```sql +-- conversations 表增加 owner_id +ALTER TABLE conversations ADD COLUMN owner_id TEXT; +CREATE INDEX idx_conversations_owner ON conversations(owner_id); +``` + +### 11.3 多租户预留 + +所有服务端表预留 `tenant_id` 字段,V1 默认为 NULL(单租户),V2 启用多租户隔离。 + +--- + +## 12. 安全模型 + +### 12.1 分层安全 + +``` +Layer 1: 网络安全 + ├─ 客户端 ↔ 服务端:HTTPS + WSS + ├─ 服务端 ↔ LLM:HTTPS + └─ 服务端内部:VPC 隔离 + +Layer 2: 认证安全 + ├─ 用户登录:用户名+密码(bcrypt, rounds=12)→ JWT + ├─ 客户端认证:JWT(access 15min + refresh 7d) + ├─ 系统集成:API Key(SHA256 hash 存储) + └─ 登录限流:5次/分钟 + +Layer 3: 权限安全 + ├─ RBAC 三级角色 + 权限位 + ├─ 路由级权限检查 + ├─ 数据级隔离(owner_id / tenant_id) + └─ 终端双重授权 + +Layer 4: 操作安全 + ├─ 终端命令三层防护 + ├─ 危险命令不持久化白名单 + ├─ 审计日志(只追加,不可篡改) + └─ 敏感操作二次确认 + +Layer 5: 数据安全 + ├─ LLM API Key 不下发客户端 + ├─ 代码文件不离开客户端 + ├─ 会话历史不上传服务端 + └─ 审计日志脱敏 +``` + +### 12.2 LLM 成本配额 + +```python +# 按 user/tenant 设月度 token 配额 +class UsageQuota: + monthly_token_limit: int # 月度 token 上限 + monthly_cost_limit_cents: int # 月度费用上限(分) + current_tokens: int # 当月已用 + current_cost_cents: int # 当月已花费 + +# 服务端 LLM 网关检查 +async def check_quota(user: User) -> bool: + quota = await get_user_quota(user.id) + if quota.current_tokens >= quota.monthly_token_limit: + raise HTTPException(429, "Monthly token quota exceeded") + if quota.current_cost_cents >= quota.monthly_cost_limit_cents: + raise HTTPException(429, "Monthly cost quota exceeded") + return True +``` + +--- + +## 13. 实施路线图 + +### 13.1 修订后的路线图(含 Phase 0) + +| 阶段 | 内容 | 周期 | 交付物 | +|------|------|------|--------| +| **Phase 0** | 包拆分 + 双进程骨架 | 3 周 | `agentkit_core` / `agentkit_client` / `agentkit_server` 三包分离,双进程能跑 | +| **Phase 1** | LLM 网关 + 基础认证 | 3 周 | RemoteLLMProvider + 服务端 LLM 网关 + JWT 登录 | +| **Phase 2** | 权限 + 审计 + 配置同步 | 3 周 | 3 级 RBAC + 审计日志 + 配置轮询同步 | +| **Phase 3** | KB 共享 + 离线降级 | 3 周 | 团队 KB + KB 缓存 + 离线降级 | +| **Phase 4+** | Web 管理台 / SSO / 代码索引 | 延后 | 按需推进 | + +### 13.2 Phase 0:包拆分(前置必做) + +当前 `server/` 包是单体,没有客户端/服务端边界。Phase 0 需要拆分为: + +``` +agentkit/ +├── core/ — Agent 引擎(客户端服务端共用) +│ ├── base.py +│ ├── react.py +│ ├── rewoo.py +│ └── ... +├── llm/ — LLM 抽象层(共用) +│ ├── protocol.py +│ ├── gateway.py +│ ├── config.py +│ └── remote_provider.py — 新增 +├── tools/ — 工具(共用) +├── client/ — 客户端特有 +│ ├── sidecar.py — 本地 sidecar 入口 +│ ├── terminal.py — 终端(从 server/routes/ 迁移) +│ ├── files.py — 文件操作 +│ ├── session.py — 本地会话存储 +│ ├── sync.py — 配置同步引擎 +│ └── audit.py — 审计上报 +├── server/ — 服务端特有 +│ ├── app.py — 服务端 FastAPI app +│ ├── auth/ — 认证授权 +│ ├── llm_gateway/— LLM 网关 +│ ├── kb/ — 知识库 +│ ├── audit/ — 审计 +│ └── admin/ — 管理 API +└── shared/ — 共享模型/配置 + ├── models.py + └── config.py +``` + +### 13.3 Phase 1 验证目标 + +Phase 1 结束时验证核心价值: +- 企业统一管 LLM Key +- 审计 LLM 用量 +- 客户端通过服务端代理调用 LLM + +**如果 Phase 1 后企业不买单,停止后续投入。** + +### 13.4 替代方案:最小验证(4 周) + +如果不确定企业是否买单,可先做最小方案: + +- 服务端只做 LLM Key 代理 + 用量统计 +- 用户认证用 API Key(沿用现有 `clients.yaml`) +- 无 RBAC、无 KB 共享 +- 4 周可交付,用最小代价验证企业需求 + +--- + +## 14. 独立批判性分析 + +> 以下为独立架构师以全新视角对方案的批判性分析,未经修改。 + +### 14.1 需求目标分析 + +#### 14.1.1 产品定位不清晰 + +方案把两个**买家不同、成功指标不同、部署模式不同**的产品缝在一起: + +| 维度 | 开发者生产力工具 | 企业治理平台 | +|------|----------------|------------| +| 买家 | 开发者个人 | IT/安全/采购 | +| 核心指标 | 任务完成率、延迟 | 合规率、成本可控 | +| 部署 | 本地优先 | 集中部署 | + +方案同时声称要"类似 Trae/Cursor"又要"企业级平台",但**没有回答:先卖给谁?谁拍板?谁付钱?** + +#### 14.1.2 差异化竞争力 + +真正的差异化只有一条半: +- **"代码不离开客户端"**:真差异化(Cursor/Trae 的 codebase indexing 在云端) +- **"Agent 本地执行"**:半个差异化(Cursor 的 Agent 也是本地跑的) + +方案漏掉了真正能拉开差距的能力:当前代码库已有的**专家团/董事会模式**,这是 Cursor/Trae 完全没有的。 + +#### 14.1.3 遗漏的核心场景 + +1. **代码库语义索引**:Cursor 的核心竞争力,方案完全没提 +2. **IDE 集成**:开发者活在 VS Code/JetBrains 里,纯桌面端推广阻力大 +3. **离线/内网 LLM**:很多企业不能用云端 LLM +4. **多设备会话**:方案内部矛盾(会话纯本地 vs 多设备) +5. **模型评估/回归**:企业上线 Agent 前要评估 + +### 14.2 必要性分析 + +#### 14.2.1 服务端模块必要性 + +| 模块 | 必要性 | 理由 | +|------|--------|------| +| LLM 网关 | 必须 | "企业级"唯一不可替代的价值 | +| 用户认证 | 必须 | 但 V1 只需 3 级 | +| 审计日志 | 应该 | 范围要收窄(只审计 LLM 调用 + 危险命令) | +| 知识库 | 应该 | 可用现有 KB + pgvector 先跑 | +| 工作流模板 | 可延后 | YAML + Git 够用 | +| 技能市场 | 可延后 | 分发不是瓶颈 | +| 生产系统集成 | 不要做 | 暴露 Webhook 够用 | +| Web 管理台 | 可延后 | V1 用 CLI 管理 | + +**结论**:V1 服务端只需要 3 个模块——LLM 网关 + 用户认证 + 审计日志。 + +#### 14.2.2 LLM 代理模式利弊 + +代理模式的真实成本(被低估): +- ReAct/专家团循环是**多轮串行 LLM 调用**,一个复杂任务可能 20-50 次 +- 跨地域公网(RTT 150ms+)会明显劣化体验 + +代理模式的真实收益(被高估): +- "Key 不下发"保护的是**企业的 LLM 预算和用量审计**,不是真正的安全边界 +- Tauri 桌面端跑在用户机器上,用户能 dump 内存、抓包 + +**建议**:代理模式保留,但明确其价值是成本管控 + 审计,不是安全。服务端必须支持内网部署。 + +#### 14.2.3 四级 RBAC 过度设计 + +- `viewer`:谁会用 AI 编程工具只看不操作?想象出来的角色 +- `user vs power`:终端和 KB 管理是两个不相关的权限,不该绑成一个等级 + +企业真实需求是**权限点**,不是角色等级。建议 3 级 + 权限位。 + +#### 14.2.4 终端审计隐私 + +| 场景 | 审计合理性 | +|------|-----------| +| 企业配发电脑 | 合理 | +| BYOD | 不合理 | +| 危险命令确认记录 | 合理 | +| 全量命令流水 | 过度(是监控不是审计) | + +建议:只上报危险命令 + 确认结果,不上报普通命令流水。 + +### 14.3 架构设计合理性 + +#### 14.3.1 配置同步 WebSocket 过度设计 + +配置变更频率是**周/月级**,用 WebSocket 推送是杀鸡用牛刀。更简单的方案:启动时全量拉取 + 每 5 分钟轮询版本号 + 手动刷新。 + +#### 14.3.2 会话历史纯本地 vs 企业审计矛盾 + +如果企业要审计"员工用 AI 做了什么",会话历史是最核心的审计对象。纯本地意味着审计员看不到。 + +**建议**:拆成三层: +1. 完整对话内容:本地(默认) +2. 操作摘要:上报服务端(脱敏后) +3. LLM 调用元数据:服务端网关天然就有 + +#### 14.3.3 知识库不缓存导致离线断裂 + +KB 完全不缓存意味着离线时 Agent 无法引用任何团队知识,与"离线降级"原则矛盾。 + +**建议**:元数据缓存 + LRU 文档缓存 + 离线时只用已缓存文档。 + +#### 14.3.4 当前代码库适配性 + +FastAPI + Vue3 + Tauri 技术栈没问题,但当前 `server/` 包是单体,没有客户端/服务端边界。需要一次彻底的包结构重组(Phase 0)。 + +### 14.4 改造方案可行性 + +#### 14.4.1 最大技术风险:终端的位置 + +当前 `terminal.py` 的 PTY 跑在 Python sidecar 里。改造后: +- 路线 A(移到 Tauri Rust):风险太高,用 Rust 重写 PTY + 命令分类 +- 路线 B(保留本地 Python sidecar):**唯一现实路线**,终端逻辑不动 + +#### 14.4.2 RemoteLLMProvider 改造 + +`LLMProvider` 是干净的 ABC,RemoteLLMProvider 本身 2-3 天。难点在服务端流式透传 + 取消传播 + fallback 语义,1-2 周。 + +#### 14.4.3 路线图评估 + +"每阶段 2-3 周,共 8-12 周"——**不现实**,因为: +1. Phase 0(包拆分)没算进去 +2. 用户系统从零开始 +3. 终端位置决策未明确 +4. 双进程调试环境搭建 + +**现实评估**:12 周(4 阶段 × 3 周),假设 1-2 个全职开发。 + +#### 14.4.4 "看似简单实则困难"的改造点 + +1. 流式 fallback 语义跨网络后的重新定义 +2. `_verify_api_key` 的全局替换(十几个路由文件) +3. `clients.yaml` 兼容(两套认证并存) +4. Tauri sidecar 的远程地址注入 +5. 专家团/董事会的 LLM 调用放大延迟 + +### 14.5 改进建议汇总 + +#### 架构简化 +1. 砍掉 WebSocket 配置同步,用轮询 + ETag +2. 砍掉 viewer 角色,RBAC 改 3 级 + 权限位 +3. 砍掉"生产系统集成"模块,暴露 Webhook +4. 砍掉"技能市场"和"工作流模板中心",YAML + Git 够用 +5. 明确双进程模型 + +#### 遗漏的考虑 +| 遗漏项 | 重要性 | 建议 | +|--------|--------|------| +| 多租户隔离 | 高 | 所有表预留 tenant_id | +| SSO/OIDC | 高 | V2 支持,V1 预留接口 | +| 数据备份/DR | 高 | PG 备份 + Redis 持久化 | +| 客户端升级 | 中 | Tauri updater 机制 | +| LLM 成本配额 | 中 | 按 user/tenant 设月度限额 | +| 代码库语义索引 | 高 | "类 Cursor"的必修课 | +| API 版本化 | 低 | 客户端和服务端独立发版后需要 | + +#### 替代方案 + +**替代方案 B(推荐起步):服务端只做 LLM 代理** +- 砍掉 KB/工作流/技能市场/用户系统 +- 服务端只做 LLM Key 代理 + 用量统计 +- 用户认证用 API Key(沿用 clients.yaml) +- 4 周可交付,用最小代价验证企业需求 + +--- + +## 15. 方案优化与补全 + +基于独立分析,对原方案进行以下优化: + +### 15.1 已采纳的优化 + +| 原方案 | 优化后 | 依据 | +|--------|--------|------| +| 四级 RBAC (viewer/user/power/admin) | 三级 + 权限位 (member/operator/admin) | §14.2.3 | +| WebSocket 配置同步 | 轮询 + ETag | §14.3.1 | +| 9 个服务端模块并列 | V1 只做 3 个(LLM 网关 + 认证 + 审计) | §14.2.1 | +| 终端全量审计上报 | 只上报危险命令 | §14.2.4 | +| 知识库不缓存 | 元数据 + LRU 文档缓存 | §14.3.3 | +| 8-12 周路线图 | 12 周(含 Phase 0 包拆分) | §14.4.3 | +| 会话历史纯本地 | 三层拆分(内容本地 + 摘要上报 + 元数据服务端) | §14.3.2 | + +### 15.2 新增补全 + +| 补全项 | 说明 | +|--------|------| +| Phase 0 包拆分 | 前置必做,3 周 | +| 多租户预留 | 所有表预留 tenant_id | +| SSO/OIDC 接口预留 | V1 本地账号,V2 OIDC | +| LLM 成本配额 | 按 user/tenant 月度限额 | +| 内网 LLM 支持 | vLLM/Ollama 接入 | +| 客户端直连降级 | 服务端不可用时用户自填 Key 直连 LLM | +| 代码库语义索引 | 列入 Phase 4+ 规划 | +| 数据备份策略 | PG 备份 + Redis 持久化 | + +### 15.3 替代方案:最小验证路径 + +如果不确定企业是否买单,建议先走 4 周最小方案: + +``` +Week 1-2: 服务端 LLM Key 代理 + 用量统计 +Week 3: 客户端 RemoteLLMProvider +Week 4: 联调 + 企业试用 +``` + +验证核心假设:**企业是否真需要统一管 LLM Key 和成本**。 + +验证通过后,再按 Phase 0 → Phase 3 推进完整方案。 + +--- + +## 16. 待决策问题 + +以下问题需要明确后才能进入实施: + +### 16.1 必须先回答的三个问题 + +| # | 问题 | 影响 | +|---|------|------| +| 1 | **卖给谁?** 开发者个人还是企业 IT? | 决定优先级和功能取舍 | +| 2 | **服务端部署在哪?** 内网还是公网? | 决定代理模式是否可接受 | +| 3 | **终端跑在哪?** Tauri Rust 还是本地 sidecar? | 决定 Phase 0 工作量 | + +### 16.2 架构决策 + +| # | 问题 | 选项 | 建议 | +|---|------|------|------| +| 4 | 先做完整方案还是最小验证? | 完整 12 周 vs 最小 4 周 | 先做最小验证 | +| 5 | 是否支持 BYOD? | 支持/不支持 | 支持(纯本地模式) | +| 6 | 会话内容是否上传? | 纯本地/摘要上报/全量上传 | 摘要上报 | +| 7 | V1 是否支持 SSO? | 支持/不支持/预留接口 | 预留接口 | +| 8 | 代码库语义索引何时做? | Phase 3/Phase 4+/不做 | Phase 4+ | + +--- + +## 附录 A:当前代码库关键文件索引 + +| 模块 | 文件 | 改造角色 | +|------|------|---------| +| LLM 协议 | `src/agentkit/llm/protocol.py` | 共用,新增 RemoteLLMProvider | +| LLM 网关 | `src/agentkit/llm/gateway.py` | 服务端保留,客户端改代理 | +| LLM 配置 | `src/agentkit/llm/config.py` | 服务端 | +| Agent 引擎 | `src/agentkit/core/` | 客户端本地执行 | +| 工具 | `src/agentkit/tools/` | 客户端本地执行 | +| 终端 | `src/agentkit/server/routes/terminal.py` | 迁移到 `client/terminal.py` | +| 会话存储 | `src/agentkit/chat/sqlite_conversation_store.py` | 客户端,增加 owner_id | +| 鉴权中间件 | `src/agentkit/server/middleware.py` | 服务端,改造为 JWT + API Key 双轨 | +| 客户端配置 | `src/agentkit/server/client_config.py` | 服务端,迁移到 DB | +| 服务端配置 | `src/agentkit/server/config.py` | 服务端,增加 auth 配置 | +| 专家团 | `src/agentkit/experts/` | 客户端,配置从服务端同步 | +| 知识库 | `src/agentkit/memory/knowledge_base.py` | 服务端 | +| 情景记忆 | `src/agentkit/memory/episodic.py` | 服务端,增加 user_id | +| Tauri sidecar | `src-tauri/src/sidecar.rs` | 客户端,增加远程地址注入 | +| Tauri 配置 | `src-tauri/tauri.conf.json` | 客户端,放宽 CSP | +| 前端 API | `src/agentkit/server/frontend/src/api/base.ts` | 客户端,支持双目标 | +| 前端 Chat | `src/agentkit/server/frontend/src/stores/chat.ts` | 客户端,WS 附加 token | + +## 附录 B:术语表 + +| 术语 | 定义 | +|------|------| +| Sidecar | Tauri 启动的本地 Python 进程,提供本地 API | +| LLM 网关 | 服务端统一管理 LLM API Key 和用量的代理服务 | +| RemoteLLMProvider | 客户端通过服务端 LLM 网关调用 LLM 的 Provider 实现 | +| RBAC | 基于角色的访问控制 | +| 权限位 | 独立于角色的细粒度权限(如 terminal.local.use) | +| BYOD | Bring Your Own Device,自带设备 | +| 离线降级 | 服务端不可用时客户端部分功能仍可使用 | +| 本地终端 | 在客户端本地机器上执行命令的终端模式 | +| 服务端终端 | 在服务端机器上执行命令的终端模式,需 admin 授权 | +| 危险命令 | 可能影响系统安全的终端命令(rm/sudo/chmod 等) | +| 内置白名单 | 系统级不可修改的安全命令列表(_SAFE_COMMAND_PREFIXES) | +| 全局白名单 | admin 管理的命令白名单,仅服务端终端适用 | +| 用户白名单 | 用户自管的命令白名单,仅本地终端适用 | +| 会话白名单 | 临时白名单,当前会话有效,会话结束清除 | +| 黑名单 | admin 管理的禁止命令列表,所有终端模式生效,优先级最高 | +| 终端审批 | 服务端终端中非白名单危险命令需 admin 实时批准的机制 | +| 审计日志 | 记录用户操作的不可篡改日志 | +| 租户 | 多租户架构中的隔离单位(企业/团队) | + +--- + +*文档结束。待决策问题明确后进入实施阶段。* diff --git a/docs/plans/2026-06-19-003-feat-enterprise-client-server-evolution-plan.md b/docs/plans/2026-06-19-003-feat-enterprise-client-server-evolution-plan.md new file mode 100644 index 0000000..bdbab17 --- /dev/null +++ b/docs/plans/2026-06-19-003-feat-enterprise-client-server-evolution-plan.md @@ -0,0 +1,448 @@ +--- +title: "feat: Enterprise Client-Server Architecture Evolution" +type: feat +status: completed +created: 2026-06-19 +completed: 2026-06-19 +origin: docs/plans/2026-06-19-002-enterprise-client-server-architecture-plan.md +deepened: false +--- + +# feat: Enterprise Client-Server Architecture Evolution + +## Summary + +将 Fischer AgentKit 从纯本地运行架构演进为企业级客户端+服务端架构。客户端(Tauri 桌面端)作为 AI 工作台本地执行 Agent/终端/文件操作,服务端作为企业平台提供 LLM 网关(统一 Key 管理)、用户权限、审计日志、知识库共享能力。采用渐进式改造,每个阶段设立检查点验证核心假设。 + +## Problem Frame + +当前 AgentKit 是纯本地架构:Tauri 启动本地 Python sidecar,LLM API Key 存在本地 agentkit.yaml,无用户系统、无权限管理、无服务端部署。企业场景需要:统一管控 LLM Key 和成本、团队知识共享、权限与审计、多用户支持。 + +## Scope Boundaries + +### In Scope + +- 服务端 LLM 网关(统一 Key + 用量统计 + 流式透传) +- 用户认证(JWT + API Key 双轨) +- 三级 RBAC + 权限位 +- 双模式终端(本地 + 服务端)+ 三层白名单 +- 终端审计日志 +- 配置同步(轮询版) +- 前端连接模式切换 + +### Deferred to Follow-Up Work + +- Web 管理台(V1 用 CLI 管理) +- 技能市场 / 工作流模板中心(YAML + Git 够用) +- 生产系统集成(暴露 Webhook 即可) +- SSO/OIDC(V1 本地账号,V2 预留接口) +- 代码库语义索引 +- 多租户隔离(表预留 tenant_id,V2 启用) +- 多设备会话同步 + +## Key Technical Decisions + +### KTD1: 终端保留在本地 Python sidecar(非 Tauri Rust) + +**决策**:终端 PTY 逻辑保留在 Python sidecar 中,不迁移到 Tauri Rust 层。 + +**理由**:当前 `terminal.py` + `PTYSession` 已完整实现 PTY 管理、安全检测、确认流程。用 Rust 重写风险高、收益低。本地 sidecar 模式下终端逻辑不变,服务端终端新增独立端点。 + +### KTD2: LLM 代理模式(非客户端直连) + +**决策**:客户端通过服务端 LLM 网关间接调用 LLM,不在本地存储 API Key。 + +**理由**:核心价值是成本管控 + 用量审计(非安全边界)。服务端必须支持内网部署以避免跨地域延迟。客户端保留直连降级能力(服务端不可用时用户自填 Key)。 + +### KTD3: 配置同步用轮询(非 WebSocket 推送) + +**决策**:启动时全量拉取 + 每 5 分钟轮询版本号 + 手动刷新。 + +**理由**:配置变更频率是周/月级,WebSocket 推送是杀鸡用牛刀。轮询实现量是 WebSocket 的 1/10,覆盖 99% 场景。 + +### KTD4: 三级 RBAC + 权限位(非四级角色) + +**决策**:member / operator / admin 三级角色 + 独立权限位。 + +**理由**:四级中的 viewer 是想象出来的角色。企业真实需求是权限点(能否用终端、能否管理 KB),不是角色等级。权限位比固定等级更灵活。 + +### KTD5: 双模式终端 + 三层白名单 + +**决策**:支持本地终端(客户端 sidecar PTY)和服务端终端(服务端 PTY),均配三层白名单(内置/全局/用户)+ 黑名单。 + +**理由**:本地终端满足开发者本地操作需求,服务端终端满足 DevOps 管理服务器需求。白名单机制平衡安全与效率。 + +## High-Level Technical Design + +``` +Phase 1: LLM Gateway + Auth Phase 2: RBAC + Audit + Terminal +┌──────────────┐ ┌──────────────┐ +│ Client │ │ Client │ +│ RemoteLLM │──JWT──► │ Terminal │──JWT──► +│ Provider │ │ (Local) │ +└──────┬───────┘ └──────┬───────┘ + │ SSE │ WS + ▼ ▼ +┌──────────────┐ ┌──────────────┐ +│ Server │ │ Server │ +│ LLM Gateway │ │ Auth+RBAC │ +│ + JWT Auth │ │ + Audit │ +│ + Usage │ │ + Whitelist │ +└──────────────┘ └──────────────┘ + +Phase 3: Server Terminal + KB +┌──────────────┐ +│ Client │ +│ Terminal │──JWT──► +│ (Server) │ +└──────┬───────┘ + │ WSS + ▼ +┌──────────────┐ +│ Server │ +│ Server PTY │ +│ + Approval │ +│ + KB Search │ +└──────────────┘ +``` + +--- + +## Implementation Units + +### U1. 服务端 LLM 网关 API + +**Goal**: 在服务端新增 LLM 代理端点,接收客户端请求并转发到 LLM Provider,记录用量。 + +**Dependencies**: 无(首个单元) + +**Files**: +- `src/agentkit/server/routes/llm_gateway.py`(新建) +- `src/agentkit/server/app.py`(注册路由) +- `tests/unit/test_llm_gateway.py`(新建) + +**Approach**: +- 新增 `POST /api/v1/llm/chat`(非流式)和 `POST /api/v1/llm/chat/stream`(SSE 流式) +- 复用现有 `LLMGateway.chat()` 和 `chat_stream()` +- 流式端点将 `StreamChunk` 序列化为 SSE 格式(`data: {json}\n\n`) +- 记录每次调用的 user_id、model、tokens、cost 到 `llm_usage_records` 表 +- JWT 认证(U2 完成前临时用 API Key) + +**Test scenarios**: +- 非流式 chat 请求返回正确的 `LLMResponse` JSON +- 流式 chat 请求返回 SSE 格式,每个 chunk 是 `data: {json}\n\n` +- 无效 model 返回 404 +- LLM Provider 失败时返回 502 +- 用量记录正确写入数据库(model, tokens, cost) +- 无认证时返回 401 + +**Checkpoint CP1**: 服务端 LLM 网关可独立运行,通过 curl 验证流式和非流式调用。 + +--- + +### U2. JWT 认证模块 + +**Goal**: 实现用户注册/登录/JWT 签发验证,替换当前纯 API Key 认证。 + +**Dependencies**: U1 + +**Files**: +- `src/agentkit/server/auth/__init__.py`(新建) +- `src/agentkit/server/auth/models.py`(新建,UserModel + UserApiKeyModel) +- `src/agentkit/server/auth/jwt_utils.py`(新建,JWT 签发/验证) +- `src/agentkit/server/auth/password.py`(新建,bcrypt 哈希) +- `src/agentkit/server/auth/middleware.py`(新建,AuthMiddleware) +- `src/agentkit/server/auth/dependencies.py`(新建,require_permission 依赖) +- `src/agentkit/server/routes/auth.py`(新建,login/refresh/logout/me) +- `src/agentkit/server/app.py`(注册路由 + 中间件替换) +- `tests/unit/test_auth.py`(新建) + +**Approach**: +- `UserModel`: id, username, email, password_hash, role, is_active, is_terminal_authorized, is_server_terminal_authorized +- `UserApiKeyModel`: user_id, key_hash(SHA256), key_prefix, name, expires_at, is_revoked +- JWT: access_token(15min) + refresh_token(7d),HS256 签名 +- AuthMiddleware: JWT 优先 → API Key → 兼容 clients.yaml → dev mode 放行 +- `require_permission(*perms)`: FastAPI 依赖注入,检查 `request.state.current_user` 的角色权限 +- 初始 admin 引导: 首次启动时从 `AGENTKIT_ADMIN_USERNAME/PASSWORD` 创建 admin 用户 +- 数据库: V1 用 SQLite(复用 aiosqlite),后续切换 PostgreSQL + +**Test scenarios**: +- 注册/登录返回 access_token + refresh_token +- refresh_token 可刷新 access_token +- 错误密码返回 401 +- JWT 过期后返回 401,refresh 后可用 +- API Key 认证识别用户身份 +- 无认证 dev mode 放行 +- 无认证生产 mode 返回 401 +- `require_permission` 正确拒绝/放行 +- 初始 admin 创建成功 + +**Checkpoint CP2**: 可通过登录 API 获取 JWT,使用 JWT 访问 LLM 网关。 + +--- + +### U3. RemoteLLMProvider(客户端 LLM 代理) + +**Goal**: 在客户端新增 `RemoteLLMProvider`,通过服务端 LLM 网关调用 LLM。 + +**Dependencies**: U1, U2 + +**Files**: +- `src/agentkit/llm/remote_provider.py`(新建) +- `src/agentkit/llm/gateway.py`(增加 RemoteLLMProvider 注册支持) +- `tests/unit/test_remote_provider.py`(新建) + +**Approach**: +- `RemoteLLMProvider(LLMProvider)`: 实现 `chat()` 和 `chat_stream()` +- `chat()`: POST `/api/v1/llm/chat`,带 `Authorization: Bearer {jwt}` +- `chat_stream()`: POST `/api/v1/llm/chat/stream`,解析 SSE 流为 `StreamChunk` +- 构造函数接收 `server_url` + `auth_token_provider`(callable,返回当前 JWT) +- 错误处理: 401 触发 token 刷新重试,502 抛 `LLMProviderError` +- 客户端 `LLMGateway` 配置为使用 `RemoteLLMProvider` 时,不加载本地 Provider 配置 + +**Test scenarios**: +- `chat()` 正确发送请求并解析 `LLMResponse` +- `chat_stream()` 正确解析 SSE 流为 `StreamChunk` 序列 +- 401 响应触发 token 刷新重试 +- 502 响应抛出 `LLMProviderError` +- 网络超时抛出异常 +- `is_final=True` 的 chunk 正确标记 + +**Checkpoint CP3**: 客户端 Agent 通过 RemoteLLMProvider → 服务端网关 → LLM Provider 完成对话。 + +--- + +### U4. 前端认证 + 连接模式切换 + +**Goal**: 前端实现登录页、JWT 管理、API Client 附加 JWT、连接模式切换。 + +**Dependencies**: U2, U3 + +**Files**: +- `src/agentkit/server/frontend/src/stores/auth.ts`(新建) +- `src/agentkit/server/frontend/src/api/base.ts`(改造,附加 JWT) +- `src/agentkit/server/frontend/src/views/LoginView.vue`(新建) +- `src/agentkit/server/frontend/src/router/index.ts`(路由守卫) +- `src/agentkit/server/frontend/src/api/auth.ts`(新建) +- `src/agentkit/server/frontend/src/App.vue`(启动时初始化认证) + +**Approach**: +- `auth` store: user, accessToken, refreshToken, permissions, hasPermission(), canAccessTerminal() +- `BaseApiClient.request()`: 自动附加 `Authorization: Bearer {token}`,401 时自动刷新 +- `BaseApiClient.createWebSocket()`: 附加 `?token={jwt}` 查询参数 +- 登录页: 用户名 + 密码 → 调用 `/api/v1/auth/login` → 存储 JWT → 跳转主页 +- 路由守卫: 未登录跳转 `/login`,权限检查 +- `initApiBaseURL()`: Tauri 模式检查 localStorage 中的远程服务器配置 + +**Test scenarios**: +- 登录成功后 JWT 存入 localStorage,跳转主页 +- 登录失败显示错误提示 +- API 请求自动附加 JWT +- 401 响应自动触发 token 刷新 +- token 刷新失败跳转登录页 +- WebSocket 连接附加 token 参数 +- 路由守卫正确拦截未登录用户 + +**Checkpoint CP4**: 前端登录 → JWT 认证 → 通过服务端 LLM 网关对话,全链路打通。 + +--- + +### U5. 权限模型 + RBAC 中间件 + +**Goal**: 实现三级 RBAC + 权限位,集成到所有路由。 + +**Dependencies**: U2 + +**Files**: +- `src/agentkit/server/auth/permissions.py`(新建,Permission enum + ROLE_PERMISSIONS) +- `src/agentkit/server/auth/dependencies.py`(完善 require_permission) +- `src/agentkit/server/routes/portal.py`(增加权限检查) +- `src/agentkit/server/routes/terminal.py`(增加权限检查) +- `src/agentkit/server/routes/settings.py`(增加权限检查) +- `src/agentkit/server/routes/kb_management.py`(增加权限检查) +- `tests/unit/test_permissions.py`(新建) + +**Approach**: +- `Permission` enum: CHAT, KB_QUERY, KB_WRITE, WORKFLOW_EXECUTE, TERMINAL_LOCAL_USE, TERMINAL_SERVER_USE, TERMINAL_WHITELIST_MANAGE, USER_MANAGE, SYSTEM_CONFIG +- `ROLE_PERMISSIONS`: member / operator / admin 权限映射 +- `require_permission(*perms)`: 检查 `request.state.current_user` 的角色权限 +- 现有路由增加 `Depends(require_permission(Permission.XXX))` +- dev mode(无用户): 终端等高危功能仍需认证,其他放行 + +**Test scenarios**: +- member 角色可对话但不可访问终端 +- operator 角色可使用本地终端但不可管理用户 +- admin 角色可访问所有功能 +- 未认证用户在 dev mode 可对话但不可用终端 +- 权限不足返回 403 + +**Checkpoint CP5**: 不同角色用户访问不同功能,权限检查生效。 + +--- + +### U6. 本地终端白名单 + 审计上报 + +**Goal**: 本地终端增加三层白名单机制 + 危险命令审计上报。 + +**Dependencies**: U5 + +**Files**: +- `src/agentkit/server/routes/terminal.py`(改造白名单逻辑) +- `src/agentkit/server/auth/models.py`(增加白名单表模型) +- `src/agentkit/server/routes/terminal_whitelist.py`(新建,白名单管理 API) +- `src/agentkit/server/app.py`(注册白名单路由) +- `tests/unit/test_terminal_whitelist.py`(新建) + +**Approach**: +- 白名单匹配优先级: 黑名单 → 内置安全白名单 → 用户白名单 → 会话白名单 → 危险检测 +- 用户白名单存储在服务端 DB(`terminal_whitelist_user` 表),客户端缓存 +- 黑名单存储在服务端 DB(`terminal_blocklist` 表),admin 管理 +- 危险命令确认后可选加入会话白名单(不持久化到用户白名单) +- 危险命令执行后上报审计日志到服务端(`terminal_audit_logs` 表) +- 白名单管理 API: GET/POST/DELETE `/api/v1/terminal/whitelist/user` + +**Test scenarios**: +- 内置安全白名单命令直接执行 +- 用户白名单命令直接执行 +- 会话白名单命令直接执行 +- 黑名单命令被拒绝 +- 危险命令弹出确认,确认后执行 +- 危险命令拒绝后不执行 +- 危险命令审计日志正确上报 +- 白名单 CRUD API 正常工作 + +**Checkpoint CP6**: 本地终端白名单生效,危险命令需确认且审计上报。 + +--- + +### U7. 配置同步引擎 + +**Goal**: 客户端从服务端同步技能/Agent/工作流配置。 + +**Dependencies**: U2 + +**Files**: +- `src/agentkit/server/routes/config_sync.py`(新建,配置同步 API) +- `src/agentkit/server/app.py`(注册路由) +- `src/agentkit/client/sync.py`(新建,客户端同步引擎) +- `tests/unit/test_config_sync.py`(新建) + +**Approach**: +- 服务端 API: `GET /api/v1/config/version`(返回版本号)+ `GET /api/v1/config/skills|agents|workflows`(返回配置列表) +- 客户端 `ConfigSync`: 启动时全量拉取 → 每 5 分钟轮询版本号 → 版本变化时全量拉取 +- 配置缓存在本地 SQLite(`config_cache` 表) +- 离线时使用本地缓存 + +**Test scenarios**: +- 启动时全量拉取配置 +- 版本号未变化时不拉取 +- 版本号变化时全量拉取 +- 离线时使用本地缓存 +- 配置正确更新到本地 + +**Checkpoint CP7**: 客户端启动后自动同步服务端配置,离线时使用缓存。 + +--- + +### U8. 服务端终端 + 审批机制 + +**Goal**: 新增服务端终端模式,非白名单危险命令需 admin 审批。 + +**Dependencies**: U5, U6 + +**Files**: +- `src/agentkit/server/routes/terminal_server.py`(新建,服务端终端 WebSocket) +- `src/agentkit/server/auth/models.py`(增加审批表模型) +- `src/agentkit/server/routes/terminal_whitelist.py`(增加全局白名单 + 审批 API) +- `src/agentkit/server/app.py`(注册路由) +- `tests/unit/test_terminal_server.py`(新建) + +**Approach**: +- `WS /api/v1/terminal/server/ws`: 服务端 PTY WebSocket +- 权限检查: `TERMINAL_SERVER_USE` + `is_server_terminal_authorized` +- 命令执行流程: 黑名单 → 内置白名单 → 全局白名单 → 会话白名单 → 非白名单需 admin 审批 +- 审批机制: 创建 `terminal_approvals` 记录 → WebSocket 推送 admin → admin 批准/拒绝 → 执行/取消 +- 审批超时: 5 分钟自动拒绝 +- 全量审计: 所有命令(含执行/拒绝/审批)记录到 `terminal_audit_logs` +- 会话隔离: 每个用户独立 PTY 会话,工作目录隔离 +- 资源限制: 最大并发会话数、会话超时 + +**Test scenarios**: +- 有权限用户可连接服务端终端 +- 无权限用户被拒绝 +- 全局白名单命令直接执行 +- 非白名单危险命令创建审批请求 +- admin 批准后命令执行 +- admin 拒绝后命令取消 +- 审批超时自动拒绝 +- 所有命令审计日志记录 +- 会话隔离正确 + +**Checkpoint CP8**: 服务端终端可用,非白名单命令需 admin 审批,全量审计。 + +--- + +### U9. 前端终端双模式 UI + 白名单管理 + +**Goal**: 前端终端支持本地/服务端模式切换,白名单管理页面。 + +**Dependencies**: U6, U8 + +**Files**: +- `src/agentkit/server/frontend/src/components/terminal/TerminalPanel.vue`(改造,模式切换) +- `src/agentkit/server/frontend/src/components/terminal/WhitelistManager.vue`(新建) +- `src/agentkit/server/frontend/src/api/terminal.ts`(改造,支持双模式) +- `src/agentkit/server/frontend/src/stores/auth.ts`(增加 canUseServerTerminal) + +**Approach**: +- 终端面板顶部: `[本地终端] [服务端终端]` Tab 切换 +- 本地终端: 连接本地 sidecar WebSocket +- 服务端终端: 连接服务端 WebSocket(带 JWT) +- 非白名单命令: 本地终端用户确认 / 服务端终端显示"等待审批" +- 白名单管理页: Tab(我的白名单 / 全局白名单 / 黑名单) + +**Test scenarios**: +- 本地终端模式正常执行命令 +- 服务端终端模式正常执行命令 +- 模式切换正确切换 WebSocket 连接 +- 无权限用户看不到服务端终端 Tab +- 白名单管理 CRUD 正常工作 +- 危险命令确认对话框正确显示 + +**Checkpoint CP9**: 前端双模式终端可用,白名单管理页面正常。 + +--- + +## Checkpoint Summary + +| 检查点 | 验证内容 | 通过标准 | +|--------|---------|---------| +| CP1 | 服务端 LLM 网关 | curl 验证流式和非流式调用 | +| CP2 | JWT 认证 | 登录获取 JWT,使用 JWT 访问 API | +| CP3 | 客户端 LLM 代理 | 客户端 Agent 通过服务端网关完成对话 | +| CP4 | 全链路打通 | 前端登录 → JWT → 服务端 LLM → 对话 | +| CP5 | 权限模型 | 不同角色访问不同功能,权限检查生效 | +| CP6 | 本地终端白名单 | 危险命令需确认且审计上报 | +| CP7 | 配置同步 | 客户端自动同步服务端配置 | +| CP8 | 服务端终端 | 非白名单命令需 admin 审批 | +| CP9 | 前端双模式终端 | 双模式切换 + 白名单管理 | + +## Risks & Dependencies + +| 风险 | 影响 | 缓解 | +|------|------|------| +| 流式 fallback 跨网络语义变化 | 客户端可能看到内容跳变 | 服务端首 chunk 前完成 fallback,首 chunk 后不 fallback | +| `_verify_api_key` 全局替换遗漏 | 部分路由无权限检查 | 代码审查 + 测试覆盖 | +| clients.yaml 兼容 | 两套认证并存增加复杂度 | V1 保留兼容,V2 废弃 | +| Tauri sidecar 远程地址注入 | 前端 API 调用目标变化 | base.ts 支持双目标 | +| 专家团 LLM 调用放大延迟 | 多 expert 串行 × RTT | 服务端网关连接复用 | + +## Verification Strategy + +每个检查点(CP)必须通过以下验证才能进入下一阶段: +1. **单元测试**: 新增代码的单元测试全部通过 +2. **集成测试**: 关键链路的集成测试通过 +3. **手动验证**: 按检查点描述的手动验证步骤通过 +4. **回归测试**: `pytest tests/unit/ -x -q` 不新增失败(已知 `test_rewoo_agent_yaml_loads` 除外) +5. **前端类型检查**: `npx vue-tsc --noEmit` 通过 +6. **代码规范**: `ruff check` 改动文件无新增错误 diff --git a/src/agentkit/chat/sqlite_conversation_store.py b/src/agentkit/chat/sqlite_conversation_store.py index 6c4b0b0..ae74d39 100644 --- a/src/agentkit/chat/sqlite_conversation_store.py +++ b/src/agentkit/chat/sqlite_conversation_store.py @@ -264,8 +264,7 @@ class SqliteConversationStore: db = await self._ensure_db() cursor = await db.execute( - "SELECT id, created_at, updated_at FROM conversations " - "ORDER BY updated_at DESC LIMIT ?", + "SELECT id, created_at, updated_at FROM conversations ORDER BY updated_at DESC LIMIT ?", (limit,), ) rows = await cursor.fetchall() diff --git a/src/agentkit/client/__init__.py b/src/agentkit/client/__init__.py new file mode 100644 index 0000000..0681eaa --- /dev/null +++ b/src/agentkit/client/__init__.py @@ -0,0 +1 @@ +"""AgentKit client-side modules (desktop / sidecar).""" diff --git a/src/agentkit/client/sync.py b/src/agentkit/client/sync.py new file mode 100644 index 0000000..7edb326 --- /dev/null +++ b/src/agentkit/client/sync.py @@ -0,0 +1,331 @@ +"""Client-side configuration sync engine. + +Pulls skill/agent/workflow configs from the server on startup and polls +for changes every 5 minutes. Configs are cached in a local SQLite DB so +the client can operate offline. + +Usage:: + + from agentkit.client.sync import ConfigSync + + sync = ConfigSync( + server_url="http://localhost:8001", + token_provider=lambda: jwt_token, # or None for dev mode + cache_db_path="~/.agentkit/config_cache.db", + ) + await sync.start() # Initial full pull + await sync.poll_loop() # Background polling (or run in a task) + configs = sync.get_configs() # Returns cached configs + await sync.stop() + +Design (KTD3): + - Polling, not WebSocket push (config changes are weekly/monthly) + - Full pull on version change (not incremental diff) + - SQLite cache for offline operation + - 5-minute poll interval (configurable) +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import os +import sqlite3 +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Callable + +import httpx + +logger = logging.getLogger(__name__) + + +# ── Defaults ────────────────────────────────────────────────────────── + +DEFAULT_POLL_INTERVAL = 300 # 5 minutes +DEFAULT_TIMEOUT = 30.0 +DEFAULT_CACHE_DB_PATH = Path( + os.environ.get("AGENTKIT_CONFIG_CACHE", str(Path.home() / ".agentkit" / "config_cache.db")) +) + + +# ── SQLite cache schema ─────────────────────────────────────────────── + +_CACHE_SCHEMA = """ +CREATE TABLE IF NOT EXISTS config_cache ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL +); +""" + + +# ── ConfigSync ──────────────────────────────────────────────────────── + + +class ConfigSync: + """Client-side configuration sync engine. + + Pulls configs from the server, caches them locally, and polls for + changes on a configurable interval. + + Attributes: + server_url: Base URL of the AgentKit server (e.g. ``http://localhost:8001``). + token_provider: Callable that returns the current JWT access token + (or ``None`` if not authenticated). Called on each request. + cache_db_path: Path to the local SQLite cache file. + poll_interval: Seconds between version polls (default 300 = 5 min). + timeout: HTTP request timeout in seconds. + """ + + def __init__( + self, + *, + server_url: str, + token_provider: Callable[[], str | None] | None = None, + cache_db_path: str | Path | None = None, + poll_interval: int = DEFAULT_POLL_INTERVAL, + timeout: float = DEFAULT_TIMEOUT, + ) -> None: + self.server_url = server_url.rstrip("/") + self.token_provider = token_provider + self.cache_db_path = Path(cache_db_path) if cache_db_path else DEFAULT_CACHE_DB_PATH + self.poll_interval = poll_interval + self.timeout = timeout + + self._client: httpx.AsyncClient | None = None + self._poll_task: asyncio.Task | None = None + self._stopped = False + + # In-memory cache (mirrors the SQLite cache for fast access) + self._version: str | None = None + self._skills: list[dict[str, Any]] = [] + self._workflows: list[dict[str, Any]] = [] + self._last_synced_at: str | None = None + + # ── Lifecycle ───────────────────────────────────────────────── + + async def start(self) -> bool: + """Perform an initial full sync. + + Tries to pull configs from the server. On failure, loads the + local cache. Returns ``True`` if the server was reachable. + """ + self._init_cache_db() + self._client = httpx.AsyncClient(timeout=self.timeout) + + # Try server sync first + success = await self._pull_all() + if not success: + logger.info("Server unreachable, loading cached configs") + self._load_from_cache() + + return success + + async def stop(self) -> None: + """Stop the polling loop and close the HTTP client.""" + self._stopped = True + if self._poll_task and not self._poll_task.done(): + self._poll_task.cancel() + try: + await self._poll_task + except asyncio.CancelledError: + pass + if self._client: + await self._client.aclose() + self._client = None + + async def poll_loop(self) -> None: + """Background polling loop. + + Every ``poll_interval`` seconds, checks the server's config + version. If it differs from the cached version, re-pulls all + configs. On network errors, keeps the existing cache and retries + on the next interval. + """ + while not self._stopped: + try: + await asyncio.sleep(self.poll_interval) + if self._stopped: + break + await self._check_and_sync() + except asyncio.CancelledError: + break + except Exception as e: + logger.warning(f"Config poll error: {e}") + + def start_polling(self) -> asyncio.Task: + """Start the polling loop as a background task.""" + self._poll_task = asyncio.create_task(self.poll_loop()) + return self._poll_task + + # ── Sync operations ─────────────────────────────────────────── + + async def _check_and_sync(self) -> bool: + """Check the server version; re-pull if changed. + + Returns ``True`` if configs were re-pulled, ``False`` if the + version was unchanged or the server was unreachable. + """ + if not self._client: + return False + + try: + resp = await self._client.get( + f"{self.server_url}/api/v1/config/version", + headers=self._build_headers(), + ) + if resp.status_code != 200: + logger.warning(f"Version check failed: HTTP {resp.status_code}") + return False + + server_version = resp.json().get("version") + if server_version == self._version: + logger.debug("Config version unchanged, skipping sync") + return False + + logger.info(f"Config version changed: {self._version} → {server_version}") + return await self._pull_all() + + except (httpx.HTTPError, OSError) as e: + logger.warning(f"Version check network error: {e}") + return False + + async def _pull_all(self) -> bool: + """Pull all configs from the server and update the cache. + + Returns ``True`` on success, ``False`` on failure. + """ + if not self._client: + return False + + try: + resp = await self._client.get( + f"{self.server_url}/api/v1/config/all", + headers=self._build_headers(), + ) + if resp.status_code != 200: + logger.warning(f"Config pull failed: HTTP {resp.status_code}") + return False + + data = resp.json() + self._version = data.get("version") + self._skills = data.get("skills", []) + self._workflows = data.get("workflows", []) + self._last_synced_at = data.get("synced_at") + + self._save_to_cache(data) + logger.info( + f"Synced {len(self._skills)} skills, {len(self._workflows)} workflows " + f"(version={self._version})" + ) + return True + + except (httpx.HTTPError, OSError, json.JSONDecodeError) as e: + logger.warning(f"Config pull error: {e}") + return False + + # ── Cache access ────────────────────────────────────────────── + + def get_version(self) -> str | None: + """Return the current cached config version hash.""" + return self._version + + def get_skills(self) -> list[dict[str, Any]]: + """Return the cached skill configs.""" + return list(self._skills) + + def get_workflows(self) -> list[dict[str, Any]]: + """Return the cached workflow configs.""" + return list(self._workflows) + + def get_all(self) -> dict[str, Any]: + """Return all cached configs as a single dict.""" + return { + "version": self._version, + "skills": list(self._skills), + "workflows": list(self._workflows), + "synced_at": self._last_synced_at, + } + + def get_skill(self, name: str) -> dict[str, Any] | None: + """Return a single skill config by name, or ``None``.""" + for skill in self._skills: + if skill.get("name") == name: + return skill + return None + + def get_workflow(self, workflow_id: str) -> dict[str, Any] | None: + """Return a single workflow config by ID, or ``None``.""" + for wf in self._workflows: + if wf.get("workflow_id") == workflow_id: + return wf + return None + + # ── Internal helpers ────────────────────────────────────────── + + def _build_headers(self) -> dict[str, str]: + """Build HTTP headers with JWT if available.""" + headers: dict[str, str] = {"Accept": "application/json"} + if self.token_provider: + token = self.token_provider() + if token: + headers["Authorization"] = f"Bearer {token}" + return headers + + def _init_cache_db(self) -> None: + """Initialize the SQLite cache database.""" + self.cache_db_path.parent.mkdir(parents=True, exist_ok=True) + with sqlite3.connect(str(self.cache_db_path)) as conn: + conn.executescript(_CACHE_SCHEMA) + conn.commit() + + def _save_to_cache(self, data: dict[str, Any]) -> None: + """Save the synced configs to the local SQLite cache.""" + now = datetime.now(timezone.utc).isoformat() + with sqlite3.connect(str(self.cache_db_path)) as conn: + conn.executescript(_CACHE_SCHEMA) + entries = [ + ("version", json.dumps(data.get("version")), now), + ("skills", json.dumps(data.get("skills", [])), now), + ("workflows", json.dumps(data.get("workflows", [])), now), + ("synced_at", json.dumps(data.get("synced_at")), now), + ] + conn.executemany( + "INSERT OR REPLACE INTO config_cache (key, value, updated_at) VALUES (?, ?, ?)", + entries, + ) + conn.commit() + + def _load_from_cache(self) -> bool: + """Load configs from the local SQLite cache. + + Returns ``True`` if the cache had data, ``False`` otherwise. + """ + try: + with sqlite3.connect(str(self.cache_db_path)) as conn: + conn.row_factory = sqlite3.Row + cursor = conn.execute( + "SELECT key, value FROM config_cache WHERE key IN (?, ?, ?, ?)", + ("version", "skills", "workflows", "synced_at"), + ) + rows = {row["key"]: row["value"] for row in cursor.fetchall()} + + if not rows: + return False + + self._version = json.loads(rows.get("version", "null")) + self._skills = json.loads(rows.get("skills", "[]")) + self._workflows = json.loads(rows.get("workflows", "[]")) + self._last_synced_at = json.loads(rows.get("synced_at", "null")) + + logger.info( + f"Loaded {len(self._skills)} skills, {len(self._workflows)} workflows " + f"from cache (version={self._version})" + ) + return True + + except Exception as e: + logger.warning(f"Failed to load config cache: {e}") + return False diff --git a/src/agentkit/experts/board_router.py b/src/agentkit/experts/board_router.py index dc7a4a7..fb2dff7 100644 --- a/src/agentkit/experts/board_router.py +++ b/src/agentkit/experts/board_router.py @@ -24,11 +24,16 @@ logger = logging.getLogger(__name__) # Pattern to match @board or @board:expert1,expert2 prefix BOARD_PREFIX_PATTERN = re.compile(r"^@board(?::(\S+))?\s*(.*)", re.DOTALL) +# Pattern to extract optional rounds=N option from the topic +BOARD_ROUNDS_PATTERN = re.compile(r"^rounds=(\d+)\s*") + # Valid expert name: alphanumeric, underscore, hyphen, 1-64 chars _EXPERT_NAME_RE = re.compile(r"^[a-zA-Z0-9_-]{1,64}$") MAX_EXPERTS = 10 # Maximum number of experts in a board DEFAULT_TEMPLATE = "private_board" +DEFAULT_MAX_ROUNDS = 5 +MAX_ROUNDS_LIMIT = 50 @dataclass @@ -42,6 +47,7 @@ class BoardRoutingResult: topic: Discussion topic extracted from input use_default_template: Whether to use default private_board template match_method: How the match was made ("explicit_board" | "default_template" | "") + max_rounds: Optional user-specified max rounds; None means use server default """ matched: bool = False @@ -50,6 +56,7 @@ class BoardRoutingResult: topic: str = "" use_default_template: bool = False match_method: str = "" + max_rounds: int | None = None class BoardRouter: @@ -85,6 +92,13 @@ class BoardRouter: expert_list_str = match.group(1) # e.g., "expert1,expert2" or None topic = match.group(2).strip() # The actual topic content + # Extract optional rounds=N option from the start of the topic + rounds_match = BOARD_ROUNDS_PATTERN.match(topic) + if rounds_match: + requested_rounds = int(rounds_match.group(1)) + result.max_rounds = max(1, min(requested_rounds, MAX_ROUNDS_LIMIT)) + topic = topic[rounds_match.end() :].strip() + result.matched = True result.board_mode = True result.topic = topic if topic else "" @@ -107,9 +121,7 @@ class BoardRouter: result.use_default_template = False else: # All names invalid — fall back to default template - logger.warning( - "All expert names invalid, falling back to default template" - ) + logger.warning("All expert names invalid, falling back to default template") result.use_default_template = True result.specified_experts = self._load_default_template_members() else: diff --git a/src/agentkit/experts/router.py b/src/agentkit/experts/router.py index 755a9e9..4bda8ce 100644 --- a/src/agentkit/experts/router.py +++ b/src/agentkit/experts/router.py @@ -38,7 +38,7 @@ class ExpertTeamRoutingResult: team_mode: bool = False specified_experts: list[str] = field(default_factory=list) task_content: str = "" - auto_compose: bool = False + auto_compose: bool = False # Deprecated: always False, kept for backward compatibility match_method: str = "" # "explicit_team" | "" @@ -90,7 +90,11 @@ class ExpertTeamRouter: template = self._registry.get(stripped) if template and template.config.bound_skills: # This is a team template — expand to its members - result.specified_experts = template.config.bound_skills[:MAX_EXPERTS] + members = [n for n in template.config.bound_skills if _EXPERT_NAME_RE.match(n)] + if len(members) != len(template.config.bound_skills): + invalid = set(template.config.bound_skills) - set(members) + logger.warning(f"Invalid expert names in team template rejected: {invalid}") + result.specified_experts = members[:MAX_EXPERTS] result.auto_compose = False result.match_method = "explicit_team" return result diff --git a/src/agentkit/llm/__init__.py b/src/agentkit/llm/__init__.py index 2a2f7b5..463626d 100644 --- a/src/agentkit/llm/__init__.py +++ b/src/agentkit/llm/__init__.py @@ -7,6 +7,7 @@ from agentkit.llm.providers.anthropic import AnthropicProvider from agentkit.llm.providers.openai import OpenAICompatibleProvider from agentkit.llm.providers.tracker import UsageSummary, UsageTracker from agentkit.llm.providers.usage_store import UsageRecord +from agentkit.llm.remote_provider import RemoteLLMProvider from agentkit.llm.retry import ( CircuitBreaker, CircuitBreakerConfig, @@ -31,6 +32,7 @@ __all__ = [ "LLMConfig", "ProviderConfig", "OpenAICompatibleProvider", + "RemoteLLMProvider", "RetryConfig", "RetryPolicy", "UsageTracker", diff --git a/src/agentkit/llm/remote_provider.py b/src/agentkit/llm/remote_provider.py new file mode 100644 index 0000000..1ec54df --- /dev/null +++ b/src/agentkit/llm/remote_provider.py @@ -0,0 +1,254 @@ +"""Remote LLM Provider — forwards requests to a server-side LLM gateway.""" + +import json +import logging +from collections.abc import AsyncIterator, Awaitable, Callable +from typing import Any + +import httpx + +from agentkit.core.exceptions import LLMProviderError, ModelNotFoundError +from agentkit.llm.protocol import ( + LLMProvider, + LLMRequest, + LLMResponse, + StreamChunk, + TokenUsage, + ToolCall, +) + +logger = logging.getLogger(__name__) + + +class RemoteLLMProvider(LLMProvider): + """LLM Provider that forwards requests to a server-side LLM gateway. + + This provider does NOT store API keys locally — all LLM calls go through + the server's LLM gateway which manages keys, usage tracking, and cost control. + + On 401 responses, the provider can optionally invoke a ``refresh_callback`` + to obtain a fresh JWT, then retry the request once. This mirrors the + browser-side refresh flow in ``auth.ts``. + """ + + def __init__( + self, + server_url: str, + auth_token_provider: Callable[[], str], + timeout: float = 120.0, + refresh_callback: Callable[[], Awaitable[bool]] | None = None, + ): + """Initialize the remote provider. + + Args: + server_url: Base URL of the server (e.g., "https://api.example.com") + auth_token_provider: Callable that returns the current JWT token + timeout: Request timeout in seconds + refresh_callback: Optional async callable that attempts a token + refresh and returns ``True`` on success. When provided, a 401 + response triggers one refresh + retry cycle. + """ + self._server_url = server_url.rstrip("/") + self._auth_token_provider = auth_token_provider + self._timeout = timeout + self._refresh_callback = refresh_callback + self._client = httpx.AsyncClient(timeout=timeout) + + async def close(self) -> None: + """Close the HTTP client connection pool.""" + await self._client.aclose() + + def _headers(self) -> dict[str, str]: + """Build request headers with JWT authentication.""" + token = self._auth_token_provider() + return { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + + def _build_payload(self, request: LLMRequest) -> dict[str, Any]: + """Convert LLMRequest to server API payload.""" + return { + "messages": request.messages, + "model": request.model, + "temperature": request.temperature, + "max_tokens": request.max_tokens, + "tools": request.tools, + "tool_choice": request.tool_choice, + "timeout": request.timeout, + } + + def _extract_error_detail(self, resp: httpx.Response) -> str: + """Extract a human-readable error detail from an error response.""" + try: + body = resp.json() + except Exception: + return resp.text + if isinstance(body, dict): + if "detail" in body: + return str(body["detail"]) + if "error" in body: + return str(body["error"]) + return str(body) + + def _parse_response(self, data: dict[str, Any], request: LLMRequest) -> LLMResponse: + """Parse server response JSON into an LLMResponse.""" + usage_data = data.get("usage") or {} + usage = TokenUsage( + prompt_tokens=usage_data.get("prompt_tokens", 0), + completion_tokens=usage_data.get("completion_tokens", 0), + ) + tool_calls: list[ToolCall] = [] + for tc in data.get("tool_calls") or []: + tool_calls.append( + ToolCall( + id=tc.get("id", ""), + name=tc.get("name", ""), + arguments=tc.get("arguments") or {}, + ) + ) + return LLMResponse( + content=data.get("content", ""), + model=data.get("model", request.model), + usage=usage, + tool_calls=tool_calls, + latency_ms=data.get("latency_ms", 0.0), + ) + + def _parse_chunk(self, data: dict[str, Any], request: LLMRequest) -> StreamChunk: + """Parse a single SSE data payload into a StreamChunk.""" + usage: TokenUsage | None = None + usage_data = data.get("usage") + if usage_data: + usage = TokenUsage( + prompt_tokens=usage_data.get("prompt_tokens", 0), + completion_tokens=usage_data.get("completion_tokens", 0), + ) + tool_calls: list[ToolCall] = [] + for tc in data.get("tool_calls") or []: + tool_calls.append( + ToolCall( + id=tc.get("id", ""), + name=tc.get("name", ""), + arguments=tc.get("arguments") or {}, + ) + ) + return StreamChunk( + content=data.get("content", ""), + model=data.get("model", request.model), + tool_calls=tool_calls, + usage=usage, + is_final=data.get("is_final", False), + ) + + async def _try_refresh(self) -> bool: + """Attempt a token refresh via the configured callback. + + Returns ``True`` if the refresh succeeded (or no callback is + configured, in which case 401 is not recoverable). + """ + if self._refresh_callback is None: + return False + try: + return bool(await self._refresh_callback()) + except Exception as exc: + logger.warning(f"Token refresh callback failed: {exc}") + return False + + async def chat(self, request: LLMRequest) -> LLMResponse: + """Send a non-streaming chat request to the server gateway.""" + url = f"{self._server_url}/api/v1/llm/chat" + payload = self._build_payload(request) + + for attempt in range(2): # original + 1 retry after refresh + headers = self._headers() + try: + resp = await self._client.post(url, json=payload, headers=headers) + except httpx.TimeoutException as e: + raise LLMProviderError("remote", f"Request timeout after {self._timeout}s") from e + except httpx.HTTPError as e: + raise LLMProviderError("remote", str(e)) from e + + if resp.status_code == 401 and attempt == 0 and await self._try_refresh(): + logger.info("LLM gateway returned 401; refreshed token, retrying once.") + continue + if resp.status_code == 401: + raise ConnectionError("Authentication failed") + if resp.status_code == 404: + raise ModelNotFoundError(request.model) + if resp.status_code == 502: + detail = self._extract_error_detail(resp) + raise LLMProviderError("remote", f"Server LLM gateway error: {detail}") + if resp.status_code != 200: + raise LLMProviderError( + "remote", f"Unexpected status {resp.status_code}: {resp.text}" + ) + + data = resp.json() + return self._parse_response(data, request) + + # Should be unreachable — loop exits via return or raise above. + raise ConnectionError("Authentication failed") + + async def chat_stream(self, request: LLMRequest) -> AsyncIterator[StreamChunk]: + """Send a streaming chat request to the server gateway. + + Parses SSE response (data: {json}\\n\\n) into StreamChunk objects. + Terminates on ``data: [DONE]``. Raises LLMProviderError if the + stream contains an error payload. + """ + url = f"{self._server_url}/api/v1/llm/chat/stream" + payload = self._build_payload(request) + + try: + for attempt in range(2): # original + 1 retry after refresh + headers = self._headers() + async with self._client.stream( + "POST", url, json=payload, headers=headers + ) as response: + if response.status_code == 401 and attempt == 0 and await self._try_refresh(): + logger.info( + "LLM gateway stream returned 401; refreshed token, retrying once." + ) + continue + if response.status_code == 401: + raise ConnectionError("Authentication failed") + if response.status_code == 404: + raise ModelNotFoundError(request.model) + if response.status_code == 502: + await response.aread() + detail = self._extract_error_detail(response) + raise LLMProviderError( + "remote", f"Server LLM gateway error: {detail}" + ) + if response.status_code != 200: + await response.aread() + raise LLMProviderError( + "remote", + f"Unexpected status {response.status_code}: {response.text}", + ) + + async for line in response.aiter_lines(): + line = line.strip() + if not line or not line.startswith("data: "): + continue + data_str = line[6:] + if data_str == "[DONE]": + break + try: + data = json.loads(data_str) + except json.JSONDecodeError: + logger.warning(f"Failed to parse SSE line: {data_str}") + continue + if "error" in data: + raise LLMProviderError( + "remote", + f"Stream error: {data.get('detail', data['error'])}", + ) + yield self._parse_chunk(data, request) + # Stream completed successfully — exit retry loop. + break + except httpx.TimeoutException as e: + raise LLMProviderError("remote", f"Request timeout after {self._timeout}s") from e + except httpx.HTTPError as e: + raise LLMProviderError("remote", str(e)) from e diff --git a/src/agentkit/server/app.py b/src/agentkit/server/app.py index e2543c1..b76256e 100644 --- a/src/agentkit/server/app.py +++ b/src/agentkit/server/app.py @@ -28,6 +28,7 @@ from agentkit.server.routes import ( tasks, skills, llm, + llm_gateway as llm_gateway_routes, health, metrics, ws, @@ -39,10 +40,18 @@ from agentkit.server.routes import ( skill_management, workflows, chat, + config_sync, terminal, + terminal_server, + terminal_whitelist, settings, + experts, + system, + auth as auth_routes, ) -from agentkit.server.middleware import APIKeyAuthMiddleware, RateLimitMiddleware +from agentkit.server.auth.jwt_utils import get_jwt_secret +from agentkit.server.auth.middleware import AuthMiddleware +from agentkit.server.middleware import RateLimitMiddleware from agentkit.server.task_store import create_task_store from agentkit.server.runner import BackgroundRunner from agentkit.core.logging import setup_structured_logging @@ -355,9 +364,7 @@ async def lifespan(app: FastAPI): if p.is_dir(): loaded = expert_registry.load_from_directory(str(p)) if loaded: - logger.info( - f"Loaded {len(loaded)} ExpertTemplates from {p}" - ) + logger.info(f"Loaded {len(loaded)} ExpertTemplates from {p}") total_loaded += len(loaded) app.state.expert_template_registry = expert_registry @@ -563,7 +570,40 @@ def create_app( ) # Auth middleware - app.add_middleware(APIKeyAuthMiddleware, api_key=effective_api_key) + # AuthMiddleware (JWT + API Key dual-track) runs *before* the legacy + # APIKeyAuthMiddleware so browser sessions (JWT) and programmatic clients + # (API key) can coexist during the U2 → U5 migration. Both layers are + # kept; AuthMiddleware short-circuits on success, so APIKeyAuthMiddleware + # only sees requests that AuthMiddleware could not authenticate. + # + # JWT auth is only active when AGENTKIT_JWT_SECRET is explicitly set. + # When unset, the middleware runs in dev mode (no JWT enforcement) but + # the auth routes still issue tokens signed with an ephemeral secret. + jwt_secret = get_jwt_secret() + client_keys: dict[str, str] = {} + try: + from agentkit.server.middleware import _load_client_keys + + client_keys = _load_client_keys() + except Exception as e: + logger.warning(f"Failed to load client keys for AuthMiddleware: {e}") + + app.add_middleware( + AuthMiddleware, + jwt_secret=jwt_secret or "", + api_key=effective_api_key, + client_keys=client_keys, + ) + # NOTE: APIKeyAuthMiddleware is intentionally NOT added here. + # AuthMiddleware above already handles X-API-Key headers (with + # constant-time comparison and proper current_user assignment). + # Adding APIKeyAuthMiddleware would make it the outermost middleware + # (Starlette add_middleware inserts at position 0), causing it to run + # BEFORE AuthMiddleware and reject JWT-only requests in production. + + # Expose JWT secret on app.state for routes (None = dev mode, routes will + # generate an ephemeral secret via get_or_create_jwt_secret()). + app.state.jwt_secret = jwt_secret # Rate limiting middleware if effective_rate_limit is not None: @@ -864,8 +904,10 @@ def create_app( app.include_router(tasks.router, prefix="/api/v1") app.include_router(skills.router, prefix="/api/v1") app.include_router(llm.router, prefix="/api/v1") + app.include_router(llm_gateway_routes.router, prefix="/api/v1") app.include_router(health.router, prefix="/api/v1") app.include_router(metrics.router, prefix="/api/v1") + app.include_router(system.router, prefix="/api/v1") app.include_router(ws.router, prefix="/api/v1") app.include_router(evolution.router, prefix="/api/v1") app.include_router(memory.router, prefix="/api/v1") @@ -875,8 +917,13 @@ def create_app( app.include_router(skill_management.router, prefix="/api/v1") app.include_router(workflows.router, prefix="/api/v1") app.include_router(chat.router, prefix="/api/v1") + app.include_router(config_sync.router, prefix="/api/v1") app.include_router(settings.router, prefix="/api/v1") app.include_router(terminal.router, prefix="/api/v1") + app.include_router(terminal_server.router, prefix="/api/v1") + app.include_router(terminal_whitelist.router, prefix="/api/v1") + app.include_router(experts.router, prefix="/api/v1") + app.include_router(auth_routes.router, prefix="/api/v1") # Serve GUI when in GUI mode gui_mode = os.environ.get("AGENTKIT_GUI_MODE") diff --git a/src/agentkit/server/auth/__init__.py b/src/agentkit/server/auth/__init__.py new file mode 100644 index 0000000..f2a4c0c --- /dev/null +++ b/src/agentkit/server/auth/__init__.py @@ -0,0 +1,9 @@ +"""Authentication subsystem (JWT + API Key). + +Modules: +- password: bcrypt hashing/verification +- models: SQLAlchemy 2 user/api-key/session models + init_auth_db +- jwt_utils: access/refresh token pair creation and verification +- middleware: AuthMiddleware (JWT + API Key dual-track) +- dependencies: FastAPI dependencies (get_current_user, require_authenticated) +""" diff --git a/src/agentkit/server/auth/dependencies.py b/src/agentkit/server/auth/dependencies.py new file mode 100644 index 0000000..e461a40 --- /dev/null +++ b/src/agentkit/server/auth/dependencies.py @@ -0,0 +1,248 @@ +"""FastAPI dependency injections for authentication and authorization. + +These dependencies read the ``current_user`` payload that +:class:`AuthMiddleware` stores on ``request.state``. + +- :func:`get_current_user` — returns the user payload or ``None`` (dev mode). +- :func:`require_authenticated` — 401 if not authenticated. +- :func:`require_permission` — 403 if the user's role lacks the permission. +- :func:`require_terminal_authorized` — 403 if the user is not authorized + to use the local terminal (checks both the role permission and the + per-user ``is_terminal_authorized`` flag from the DB). +""" + +from __future__ import annotations + +import logging +from typing import Any, Callable + +import aiosqlite +from fastapi import HTTPException, Request + +from agentkit.server.auth.models import DEFAULT_AUTH_DB_PATH +from agentkit.server.auth.permissions import Permission, has_permission, is_dev_mode + +logger = logging.getLogger(__name__) + + +async def get_current_user(request: Request) -> dict[str, Any] | None: + """Return the current user payload, or ``None`` in dev mode. + + The payload is set by :class:`AuthMiddleware` and contains + ``user_id``, ``username``, and ``role``. When no auth middleware is + active (e.g. dev mode with no keys configured), returns ``None``. + """ + return getattr(request.state, "current_user", None) + + +async def require_authenticated(request: Request) -> dict[str, Any]: + """Require an authenticated user. Raises 401 if not authenticated. + + Use this as a FastAPI dependency for endpoints that need *any* + logged-in user but do not require a specific role. + """ + user = await get_current_user(request) + if user is None: + raise HTTPException( + status_code=401, + detail="Authentication required", + ) + return user + + +def require_permission(*permissions: Permission) -> Callable[..., Any]: + """Build a FastAPI dependency that requires the given permissions. + + The user must have *all* of the given permissions (AND semantics). + For OR semantics, call the dependency multiple times or use + :func:`require_any_permission`. + + Usage:: + + from agentkit.server.auth.dependencies import require_permission + from agentkit.server.auth.permissions import Permission + + @router.post("/users", dependencies=[Depends(require_permission(Permission.USER_MANAGE))]) + async def create_user(...): ... + + Dev mode policy: + - If the user is ``None`` (dev mode) AND none of the required + permissions are "high-risk" (terminal / user / system), the + request is allowed with a synthetic dev-mode user. + - High-risk permissions always require authentication, even in + dev mode. + + Args: + permissions: One or more permissions the user must have. + + Returns: + A FastAPI dependency function. + """ + + async def _dependency(request: Request) -> dict[str, Any]: + user = await get_current_user(request) + + # Dev mode: no authenticated user + if is_dev_mode(user): + high_risk = any( + p + in { + Permission.TERMINAL_LOCAL_USE, + Permission.TERMINAL_SERVER_USE, + Permission.TERMINAL_WHITELIST_MANAGE, + Permission.USER_MANAGE, + Permission.SYSTEM_CONFIG, + } + for p in permissions + ) + if high_risk: + raise HTTPException( + status_code=401, + detail="Authentication required for this endpoint", + ) + # Synthetic dev-mode user with admin-like permissions for non-high-risk + return {"user_id": None, "username": "dev", "role": "admin", "dev_mode": True} + + # Authenticated user — check each permission + for perm in permissions: + if not has_permission(user, perm): + raise HTTPException( + status_code=403, + detail=f"Permission denied: requires {perm.value}", + ) + return user + + return _dependency + + +def require_any_permission(*permissions: Permission) -> Callable[..., Any]: + """Build a FastAPI dependency that requires at least one of the permissions. + + Similar to :func:`require_permission` but uses OR semantics. + """ + + async def _dependency(request: Request) -> dict[str, Any]: + user = await get_current_user(request) + + if is_dev_mode(user): + high_risk = any( + p + in { + Permission.TERMINAL_LOCAL_USE, + Permission.TERMINAL_SERVER_USE, + Permission.TERMINAL_WHITELIST_MANAGE, + Permission.USER_MANAGE, + Permission.SYSTEM_CONFIG, + } + for p in permissions + ) + if high_risk: + raise HTTPException( + status_code=401, + detail="Authentication required for this endpoint", + ) + return {"user_id": None, "username": "dev", "role": "admin", "dev_mode": True} + + if not any(has_permission(user, p) for p in permissions): + needed = " or ".join(p.value for p in permissions) + raise HTTPException( + status_code=403, + detail=f"Permission denied: requires {needed}", + ) + return user + + return _dependency + + +async def _resolve_db_path(request: Request): + """Resolve the auth DB path from app.state or the default.""" + path = getattr(request.app.state, "auth_db_path", None) + if path: + from pathlib import Path + + return Path(path) + return DEFAULT_AUTH_DB_PATH + + +async def require_terminal_authorized(request: Request) -> dict[str, Any]: + """Require a user authorized to use the local terminal. + + This checks both: + 1. The user's role has ``TERMINAL_LOCAL_USE`` permission. + 2. The user's ``is_terminal_authorized`` flag is True in the DB. + + The DB check ensures that an admin can revoke terminal access in + real-time without waiting for the JWT to expire. + """ + user = await require_permission(Permission.TERMINAL_LOCAL_USE)(request) + + if user.get("dev_mode"): + return user + + user_id = user.get("user_id") + if not user_id: + raise HTTPException(status_code=401, detail="Authentication required") + + db_path = await _resolve_db_path(request) + async with aiosqlite.connect(str(db_path)) as db: + db.row_factory = aiosqlite.Row + cursor = await db.execute( + "SELECT is_terminal_authorized, is_active FROM users WHERE id = ?", + (user_id,), + ) + row = await cursor.fetchone() + + if row is None: + raise HTTPException(status_code=404, detail="User not found") + + if not bool(row["is_active"]): + raise HTTPException(status_code=403, detail="Account is disabled") + + if not bool(row["is_terminal_authorized"]): + raise HTTPException( + status_code=403, + detail="Terminal access not authorized for this account", + ) + + return user + + +async def require_server_terminal_authorized(request: Request) -> dict[str, Any]: + """Require a user authorized to use the server terminal. + + Checks: + 1. The user's role has ``TERMINAL_SERVER_USE`` permission. + 2. The user's ``is_server_terminal_authorized`` flag is True in the DB. + 3. The user's ``is_active`` flag is True (account not disabled). + """ + user = await require_permission(Permission.TERMINAL_SERVER_USE)(request) + + if user.get("dev_mode"): + return user + + user_id = user.get("user_id") + if not user_id: + raise HTTPException(status_code=401, detail="Authentication required") + + db_path = await _resolve_db_path(request) + async with aiosqlite.connect(str(db_path)) as db: + db.row_factory = aiosqlite.Row + cursor = await db.execute( + "SELECT is_server_terminal_authorized, is_active FROM users WHERE id = ?", + (user_id,), + ) + row = await cursor.fetchone() + + if row is None: + raise HTTPException(status_code=404, detail="User not found") + + if not bool(row["is_active"]): + raise HTTPException(status_code=403, detail="Account is disabled") + + if not bool(row["is_server_terminal_authorized"]): + raise HTTPException( + status_code=403, + detail="Server terminal access not authorized for this account", + ) + + return user diff --git a/src/agentkit/server/auth/jwt_utils.py b/src/agentkit/server/auth/jwt_utils.py new file mode 100644 index 0000000..3a4f1ed --- /dev/null +++ b/src/agentkit/server/auth/jwt_utils.py @@ -0,0 +1,163 @@ +"""JWT issuance and verification utilities. + +Tokens are HS256-signed with a secret read from the ``AGENTKIT_JWT_SECRET`` +environment variable. In dev mode (no secret configured) a random secret is +generated in-process and a warning is logged — this secret is *not* persisted +and will invalidate all tokens on restart, so it must never be used in +production. + +Access tokens are short-lived (15 min) and carry ``type="access"``. +Refresh tokens are long-lived (7 days) and carry ``type="refresh"``. +""" + +from __future__ import annotations + +import logging +import os +import secrets +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from typing import Any + +import jwt + +logger = logging.getLogger(__name__) + +# Token lifetimes +ACCESS_TOKEN_TTL = timedelta(minutes=15) +REFRESH_TOKEN_TTL = timedelta(days=7) + +# JWT algorithm +JWT_ALGORITHM = "HS256" + + +@dataclass +class TokenPair: + """A signed access + refresh JWT pair with their expiry timestamps.""" + + access_token: str + refresh_token: str + access_expires_at: datetime + refresh_expires_at: datetime + + +def get_jwt_secret() -> str | None: + """Return the configured JWT secret, or ``None`` if not configured. + + Reads from the ``AGENTKIT_JWT_SECRET`` env var. Returns ``None`` when + the env var is not set — callers (middleware, routes) decide how to + handle the unset case. Use :func:`get_or_create_jwt_secret` when a + non-empty secret is required (e.g. for signing tokens in dev mode). + """ + secret = os.environ.get("AGENTKIT_JWT_SECRET") + if secret: + return secret + logger.warning( + "AGENTKIT_JWT_SECRET is not set. JWT auth is disabled in the " + "middleware; token-signing routes will use an ephemeral secret " + "that is invalidated on restart. Set AGENTKIT_JWT_SECRET for " + "production use." + ) + return None + + +def get_or_create_jwt_secret() -> str: + """Return the configured JWT secret, or generate an ephemeral one. + + Use this when a non-empty secret is required (e.g. signing tokens). + For middleware configuration, prefer :func:`get_jwt_secret` so that + the absence of a secret disables JWT auth (dev mode). + """ + secret = os.environ.get("AGENTKIT_JWT_SECRET") + if secret: + return secret + ephemeral = secrets.token_urlsafe(48) + logger.warning( + "AGENTKIT_JWT_SECRET is not set — generated an ephemeral dev secret. " + "All JWTs will be invalidated on process restart. " + "Set AGENTKIT_JWT_SECRET in the environment for production use." + ) + return ephemeral + + +def create_token_pair( + user_id: str, + username: str, + role: str, + secret: str, + *, + now: datetime | None = None, +) -> TokenPair: + """Create a signed access + refresh JWT pair. + + Args: + user_id: Subject (user id) — stored as ``sub``. + username: Username claim. + role: Role claim (e.g. ``member``, ``admin``). + secret: HS256 signing secret. + now: Override the issued-at time (for testing). Defaults to UTC now. + + Returns: + A :class:`TokenPair` with both signed tokens and their expiry times. + """ + if not secret: + raise ValueError("JWT secret must not be empty") + + issued_at = now or datetime.now(timezone.utc) + access_exp = issued_at + ACCESS_TOKEN_TTL + refresh_exp = issued_at + REFRESH_TOKEN_TTL + + access_payload: dict[str, Any] = { + "sub": user_id, + "username": username, + "role": role, + "type": "access", + "iat": int(issued_at.timestamp()), + "exp": int(access_exp.timestamp()), + } + refresh_payload: dict[str, Any] = { + "sub": user_id, + "username": username, + "role": role, + "type": "refresh", + "iat": int(issued_at.timestamp()), + "exp": int(refresh_exp.timestamp()), + } + + access_token = jwt.encode(access_payload, secret, algorithm=JWT_ALGORITHM) + refresh_token = jwt.encode(refresh_payload, secret, algorithm=JWT_ALGORITHM) + + # PyJWT >= 2 returns str; older versions returned bytes. Normalize. + if isinstance(access_token, bytes): + access_token = access_token.decode("utf-8") + if isinstance(refresh_token, bytes): + refresh_token = refresh_token.decode("utf-8") + + return TokenPair( + access_token=access_token, + refresh_token=refresh_token, + access_expires_at=access_exp, + refresh_expires_at=refresh_exp, + ) + + +def verify_token(token: str, secret: str) -> dict[str, Any]: + """Verify a JWT and return its payload. + + Args: + token: The JWT string to verify. + secret: The HS256 signing secret. + + Returns: + The decoded payload as a dict (contains ``sub``, ``username``, + ``role``, ``type``, ``iat``, ``exp``). + + Raises: + jwt.InvalidTokenError: If the token is malformed, expired, or has + an invalid signature. Subclasses include ``ExpiredSignatureError`` + and ``DecodeError``. + """ + if not secret: + raise jwt.InvalidTokenError("JWT secret must not be empty") + + return jwt.decode(token, secret, algorithms=[JWT_ALGORITHM]) diff --git a/src/agentkit/server/auth/middleware.py b/src/agentkit/server/auth/middleware.py new file mode 100644 index 0000000..e7930f5 --- /dev/null +++ b/src/agentkit/server/auth/middleware.py @@ -0,0 +1,191 @@ +"""Authentication middleware — JWT + API Key dual-track. + +This middleware runs *before* the legacy :class:`APIKeyAuthMiddleware` so that +JWT-authenticated requests (browser sessions) and API-Key-authenticated +requests (programmatic clients) can coexist during the U2 → U5 migration. + +Authentication order per request: + +1. **Whitelist** — paths in :data:`AuthMiddleware.WHITELIST_PATHS` pass through. +2. **JWT** — ``Authorization: Bearer `` header. On success, the decoded + payload is stored on ``request.state.current_user``. +3. **API Key** — ``X-API-Key`` header. Compared in constant time against the + global ``api_key`` and any ``client_keys`` (loaded from ``clients.yaml``). +4. **Dev mode** — when no JWT secret, no global API key, and no client keys + are configured, all requests pass through (with a one-time warning). +5. Otherwise → ``401 Unauthorized``. +""" + +from __future__ import annotations + +import hmac +import logging +from typing import Any + +import jwt +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import JSONResponse + +from agentkit.server.auth.jwt_utils import verify_token + +logger = logging.getLogger(__name__) + + +class AuthMiddleware(BaseHTTPMiddleware): + """Dual-track JWT + API Key authentication middleware. + + Args: + app: ASGI app to wrap. + jwt_secret: HS256 signing secret. Empty string disables JWT auth. + api_key: Global API key (e.g. from ``server.api_key`` in agentkit.yaml). + client_keys: Mapping of ``client_name -> api_key`` (from clients.yaml). + """ + + WHITELIST_PATHS = ( + "/api/v1/health", + "/api/v1/auth/login", + "/api/v1/auth/refresh", + "/api/v1/auth/logout", + "/docs", + "/openapi.json", + "/redoc", + ) + + def __init__( + self, + app, + jwt_secret: str = "", + api_key: str | None = None, + client_keys: dict[str, str] | None = None, + ) -> None: + super().__init__(app) + self._jwt_secret = jwt_secret or "" + self._api_key = api_key + self._client_keys: dict[str, str] = dict(client_keys) if client_keys else {} + self._dev_mode_warned = False + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _is_whitelisted(self, path: str) -> bool: + """Return True if ``path`` starts with any whitelisted prefix.""" + return any(path.startswith(p) for p in self.WHITELIST_PATHS) + + def _is_dev_mode(self) -> bool: + """Dev mode = no JWT secret, no global API key, no client keys.""" + return not self._jwt_secret and self._api_key is None and not self._client_keys + + def _verify_jwt(self, token: str) -> dict[str, Any] | None: + """Verify a JWT bearer token. Returns payload or None.""" + if not self._jwt_secret: + return None + try: + payload = verify_token(token, self._jwt_secret) + except jwt.InvalidTokenError: + return None + # Only accept access tokens for request auth (not refresh tokens) + if payload.get("type") != "access": + return None + return payload + + def _verify_api_key(self, provided_key: str) -> tuple[bool, str | None]: + """Verify an API key in constant time against all known keys. + + Returns ``(matched, client_name)``. ``client_name`` is the key from + ``client_keys`` that matched, or ``None`` for the global API key. + """ + if not provided_key: + return False, None + candidates: list[tuple[str, str | None]] = [] + if self._api_key: + candidates.append((self._api_key, None)) + for name, key in self._client_keys.items(): + candidates.append((key, name)) + if not candidates: + return False, None + provided_bytes = provided_key.encode("utf-8") + # Compare against ALL candidates (no short-circuit) to maintain + # constant-time behavior at the multi-key level. + matched_name: str | None = None + found = False + for candidate, name in candidates: + if hmac.compare_digest(provided_bytes, candidate.encode("utf-8")): + found = True + matched_name = name + return found, matched_name + + # ------------------------------------------------------------------ + # Dispatch + # ------------------------------------------------------------------ + + async def dispatch(self, request: Request, call_next): + path = request.url.path + + # 1. Whitelist + if self._is_whitelisted(path): + return await call_next(request) + + # 2. JWT (Authorization: Bearer ) — also accept ?token= + # query parameter for WebSocket clients where setting headers is + # not possible. The query param is only honored for /ws paths to + # limit its exposure in access logs. + auth_header = request.headers.get("Authorization", "") + if auth_header.lower().startswith("bearer "): + token = auth_header[7:].strip() + payload = self._verify_jwt(token) + if payload is not None: + request.state.current_user = { + "user_id": payload.get("sub"), + "username": payload.get("username"), + "role": payload.get("role"), + } + return await call_next(request) + # Fall through to API key check, then 401 + elif path.startswith("/api/v1/ws") or path.startswith("/ws"): + token = request.query_params.get("token") + if token: + payload = self._verify_jwt(token) + if payload is not None: + request.state.current_user = { + "user_id": payload.get("sub"), + "username": payload.get("username"), + "role": payload.get("role"), + } + return await call_next(request) + + # 3. API Key (X-API-Key header) + api_key = request.headers.get("X-API-Key") + if api_key: + matched, client_name = self._verify_api_key(api_key) + if matched: + # Set current_user so RBAC (require_permission) works. + # API key clients get 'operator' role by default — enough + # for chat/KB/workflow but not user management. + request.state.current_user = { + "user_id": None, + "username": client_name or "api_client", + "role": "operator", + } + return await call_next(request) + + # 4. Dev mode (no auth configured) + if self._is_dev_mode(): + if not self._dev_mode_warned: + logger.warning( + "AuthMiddleware running in dev mode (no JWT secret, " + "no API key, no client keys). All requests pass through. " + "Set AGENTKIT_JWT_SECRET or server.api_key for production." + ) + self._dev_mode_warned = True + return await call_next(request) + + # 5. Unauthorized + return JSONResponse( + status_code=401, + content={ + "error": "Unauthorized", + "message": "Valid JWT (Authorization: Bearer) or API key (X-API-Key) required", + }, + ) diff --git a/src/agentkit/server/auth/models.py b/src/agentkit/server/auth/models.py new file mode 100644 index 0000000..4c1d6bb --- /dev/null +++ b/src/agentkit/server/auth/models.py @@ -0,0 +1,355 @@ +"""SQLAlchemy 2 user / API-key / session models for the auth subsystem. + +V1 uses SQLite (via aiosqlite) for zero-config local deployments. UUIDs are +stored as ``String(36)`` so the same schema works on both SQLite and +PostgreSQL without dialect-specific types. + +Use :func:`init_auth_db` to create the tables on startup. +""" + +from __future__ import annotations + +import logging +import os +from collections.abc import Mapping +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import aiosqlite +from sqlalchemy import String +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + +logger = logging.getLogger(__name__) + +# Default auth DB path: project-local data/auth.db +# (mirrors portal.py's pattern of using Path(__file__).parents[4] / "data" / ...) +_PROJECT_ROOT = Path(__file__).parents[4] +DEFAULT_AUTH_DB_PATH = Path(os.environ.get("AGENTKIT_AUTH_DB", _PROJECT_ROOT / "data" / "auth.db")) + + +def _now_iso() -> str: + """Return current UTC time as ISO 8601 string.""" + return datetime.now(timezone.utc).isoformat() + + +# --------------------------------------------------------------------------- +# SQLAlchemy 2 declarative models +# --------------------------------------------------------------------------- + + +class Base(DeclarativeBase): + """Declarative base for auth models.""" + + +class UserModel(Base): + """User account record. + + Attributes mirror the V1 user schema: UUID id, unique username/email, + bcrypt password hash, role, active flag, terminal-authorization flags, + and audit timestamps. ``created_by`` is a self-reference (nullable) used + when an admin creates another user. + """ + + __tablename__ = "users" + + id: Mapped[str] = mapped_column(String(36), primary_key=True) + username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True) + email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True) + password_hash: Mapped[str] = mapped_column(String(255), nullable=False) + role: Mapped[str] = mapped_column(String(32), nullable=False, default="member") + is_active: Mapped[bool] = mapped_column(default=True, nullable=False) + is_terminal_authorized: Mapped[bool] = mapped_column(default=False, nullable=False) + is_server_terminal_authorized: Mapped[bool] = mapped_column(default=False, nullable=False) + created_at: Mapped[str] = mapped_column(String(64), nullable=False, default=_now_iso) + updated_at: Mapped[str] = mapped_column(String(64), nullable=False, default=_now_iso) + last_login_at: Mapped[str | None] = mapped_column(String(64), nullable=True) + created_by: Mapped[str | None] = mapped_column(String(36), nullable=True) + + +class UserApiKeyModel(Base): + """Per-user API key (issued via ``agentkit pair`` or UI). + + The full key is never stored — only its SHA-256 hash. ``key_prefix`` is + the first 16 chars of the plaintext key, shown to users for identification. + """ + + __tablename__ = "user_api_keys" + + id: Mapped[str] = mapped_column(String(36), primary_key=True) + user_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + key_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True) + key_prefix: Mapped[str] = mapped_column(String(16), nullable=False) + name: Mapped[str | None] = mapped_column(String(64), nullable=True) + created_at: Mapped[str] = mapped_column(String(64), nullable=False, default=_now_iso) + last_used_at: Mapped[str | None] = mapped_column(String(64), nullable=True) + expires_at: Mapped[str | None] = mapped_column(String(64), nullable=True) + is_revoked: Mapped[bool] = mapped_column(default=False, nullable=False) + + +class UserSessionModel(Base): + """Refresh-token session record. + + Stores the SHA-256 hash of the refresh token (never the plaintext). + ``revoked_at`` is set on logout / forced revocation. + """ + + __tablename__ = "user_sessions" + + id: Mapped[str] = mapped_column(String(36), primary_key=True) + user_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + refresh_token_hash: Mapped[str] = mapped_column( + String(64), unique=True, nullable=False, index=True + ) + device_info: Mapped[str] = mapped_column(String(2048), nullable=False, default="{}") + created_at: Mapped[str] = mapped_column(String(64), nullable=False, default=_now_iso) + expires_at: Mapped[str] = mapped_column(String(64), nullable=False) + revoked_at: Mapped[str | None] = mapped_column(String(64), nullable=True) + + +class TerminalWhitelistUserModel(Base): + """Per-user terminal command whitelist. + + Each row is a command prefix (e.g. ``"docker"``, ``"git push"``) that + the user has approved for direct execution without confirmation. + ``scope`` is ``"user"`` for the user-managed list. (Reserved for + future ``"global"`` scope managed by admins — see U8.) + """ + + __tablename__ = "terminal_whitelist_user" + + id: Mapped[str] = mapped_column(String(36), primary_key=True) + user_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + command_pattern: Mapped[str] = mapped_column(String(512), nullable=False) + scope: Mapped[str] = mapped_column(String(16), nullable=False, default="user") + created_at: Mapped[str] = mapped_column(String(64), nullable=False, default=_now_iso) + created_by: Mapped[str | None] = mapped_column(String(36), nullable=True) + + +class TerminalBlocklistModel(Base): + """Admin-managed terminal command blocklist. + + Commands matching any pattern here are always rejected, regardless + of other whitelist membership. Patterns are matched as command-prefix + (case-insensitive). + """ + + __tablename__ = "terminal_blocklist" + + id: Mapped[str] = mapped_column(String(36), primary_key=True) + command_pattern: Mapped[str] = mapped_column(String(512), nullable=False, unique=True) + reason: Mapped[str | None] = mapped_column(String(512), nullable=True) + created_at: Mapped[str] = mapped_column(String(64), nullable=False, default=_now_iso) + created_by: Mapped[str | None] = mapped_column(String(36), nullable=True) + + +class TerminalAuditLogModel(Base): + """Audit log for every terminal command decision. + + ``decision`` is one of: ``executed`` (ran without confirmation), + ``confirmed`` (ran after user confirmation), ``rejected`` (blocked + by user or blocklist), ``denied`` (blocked by safety check). + """ + + __tablename__ = "terminal_audit_logs" + + id: Mapped[str] = mapped_column(String(36), primary_key=True) + user_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True) + username: Mapped[str | None] = mapped_column(String(64), nullable=True) + session_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + command: Mapped[str] = mapped_column(String(4096), nullable=False) + decision: Mapped[str] = mapped_column(String(16), nullable=False) + reason: Mapped[str | None] = mapped_column(String(512), nullable=True) + cwd: Mapped[str | None] = mapped_column(String(1024), nullable=True) + exit_code: Mapped[int | None] = mapped_column(nullable=True) + terminal_mode: Mapped[str] = mapped_column(String(16), nullable=False, default="local") + created_at: Mapped[str] = mapped_column(String(64), nullable=False, default=_now_iso) + + +class TerminalApprovalModel(Base): + """Approval request for a non-whitelisted server-terminal command. + + Lifecycle: + - ``pending``: Created when a user runs a non-whitelisted command + on the server terminal. Admins see it in their approval queue. + - ``approved``: Admin approved → command executes. + - ``rejected``: Admin rejected → command cancelled. + - ``expired``: 5-minute timeout reached → auto-rejected. + - ``cancelled``: User cancelled the request before admin acted. + """ + + __tablename__ = "terminal_approvals" + + id: Mapped[str] = mapped_column(String(36), primary_key=True) + user_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + username: Mapped[str] = mapped_column(String(64), nullable=False) + session_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + command: Mapped[str] = mapped_column(String(4096), nullable=False) + reason: Mapped[str | None] = mapped_column(String(512), nullable=True) + status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending") + reviewer_id: Mapped[str | None] = mapped_column(String(36), nullable=True) + reviewer_username: Mapped[str | None] = mapped_column(String(64), nullable=True) + review_note: Mapped[str | None] = mapped_column(String(512), nullable=True) + created_at: Mapped[str] = mapped_column(String(64), nullable=False, default=_now_iso) + reviewed_at: Mapped[str | None] = mapped_column(String(64), nullable=True) + expires_at: Mapped[str] = mapped_column(String(64), nullable=False) + + +# --------------------------------------------------------------------------- +# Schema DDL (kept in sync with the models above for aiosqlite bootstrap) +# --------------------------------------------------------------------------- + +_SCHEMA_SQL = """ +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'member', + is_active INTEGER NOT NULL DEFAULT 1, + is_terminal_authorized INTEGER NOT NULL DEFAULT 0, + is_server_terminal_authorized INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + last_login_at TEXT, + created_by TEXT +); +CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); +CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); + +CREATE TABLE IF NOT EXISTS user_api_keys ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + key_hash TEXT NOT NULL UNIQUE, + key_prefix TEXT NOT NULL, + name TEXT, + created_at TEXT NOT NULL, + last_used_at TEXT, + expires_at TEXT, + is_revoked INTEGER NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_user_api_keys_user_id ON user_api_keys(user_id); +CREATE INDEX IF NOT EXISTS idx_user_api_keys_key_hash ON user_api_keys(key_hash); + +CREATE TABLE IF NOT EXISTS user_sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + refresh_token_hash TEXT NOT NULL UNIQUE, + device_info TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + revoked_at TEXT +); +CREATE INDEX IF NOT EXISTS idx_user_sessions_user_id ON user_sessions(user_id); +CREATE INDEX IF NOT EXISTS idx_user_sessions_refresh_token_hash + ON user_sessions(refresh_token_hash); + +CREATE TABLE IF NOT EXISTS terminal_whitelist_user ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + command_pattern TEXT NOT NULL, + scope TEXT NOT NULL DEFAULT 'user', + created_at TEXT NOT NULL, + created_by TEXT +); +CREATE INDEX IF NOT EXISTS idx_terminal_whitelist_user_user_id + ON terminal_whitelist_user(user_id); + +CREATE TABLE IF NOT EXISTS terminal_blocklist ( + id TEXT PRIMARY KEY, + command_pattern TEXT NOT NULL UNIQUE, + reason TEXT, + created_at TEXT NOT NULL, + created_by TEXT +); + +CREATE TABLE IF NOT EXISTS terminal_audit_logs ( + id TEXT PRIMARY KEY, + user_id TEXT, + username TEXT, + session_id TEXT NOT NULL, + command TEXT NOT NULL, + decision TEXT NOT NULL, + reason TEXT, + cwd TEXT, + exit_code INTEGER, + terminal_mode TEXT NOT NULL DEFAULT 'local', + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_terminal_audit_logs_user_id + ON terminal_audit_logs(user_id); +CREATE INDEX IF NOT EXISTS idx_terminal_audit_logs_session_id + ON terminal_audit_logs(session_id); +CREATE INDEX IF NOT EXISTS idx_terminal_audit_logs_created_at + ON terminal_audit_logs(created_at); + +CREATE TABLE IF NOT EXISTS terminal_approvals ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + username TEXT NOT NULL, + session_id TEXT NOT NULL, + command TEXT NOT NULL, + reason TEXT, + status TEXT NOT NULL DEFAULT 'pending', + reviewer_id TEXT, + reviewer_username TEXT, + review_note TEXT, + created_at TEXT NOT NULL, + reviewed_at TEXT, + expires_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_terminal_approvals_user_id + ON terminal_approvals(user_id); +CREATE INDEX IF NOT EXISTS idx_terminal_approvals_session_id + ON terminal_approvals(session_id); +CREATE INDEX IF NOT EXISTS idx_terminal_approvals_status + ON terminal_approvals(status); +""" + + +async def init_auth_db(db_path: str | Path | None = None) -> Path: + """Create auth tables if they do not exist. + + Uses aiosqlite directly (no SQLAlchemy engine) for a lightweight, + zero-config bootstrap that mirrors :class:`SqliteConversationStore`. + + Args: + db_path: Path to the SQLite file. Defaults to + :data:`DEFAULT_AUTH_DB_PATH` (``data/auth.db`` under the project + root, overridable via ``AGENTKIT_AUTH_DB`` env var). + + Returns: + The resolved :class:`Path` to the auth DB file. + """ + path = Path(db_path) if db_path is not None else DEFAULT_AUTH_DB_PATH + path.parent.mkdir(parents=True, exist_ok=True) + + async with aiosqlite.connect(str(path)) as db: + await db.execute("PRAGMA journal_mode=WAL") + await db.executescript(_SCHEMA_SQL) + await db.commit() + + logger.info(f"Auth DB initialized at {path}") + return path + + +# --------------------------------------------------------------------------- +# Row → dict helpers (used by routes to avoid exposing ORM internals) +# --------------------------------------------------------------------------- + + +def user_row_to_dict(row: aiosqlite.Row | Mapping[str, object]) -> dict[str, Any]: + """Convert a ``users`` row into a JSON-safe dict.""" + return { + "id": row["id"], + "username": row["username"], + "email": row["email"], + "role": row["role"], + "is_active": bool(row["is_active"]), + "is_terminal_authorized": bool(row["is_terminal_authorized"]), + "is_server_terminal_authorized": bool(row["is_server_terminal_authorized"]), + "created_at": row["created_at"], + "updated_at": row["updated_at"], + "last_login_at": row["last_login_at"], + "created_by": row["created_by"], + } diff --git a/src/agentkit/server/auth/password.py b/src/agentkit/server/auth/password.py new file mode 100644 index 0000000..1f5773c --- /dev/null +++ b/src/agentkit/server/auth/password.py @@ -0,0 +1,37 @@ +"""Password hashing utilities (bcrypt).""" + +import bcrypt + + +def hash_password(password: str) -> str: + """Hash a password using bcrypt with cost factor 12. + + Args: + password: Plain-text password. + + Returns: + bcrypt hash as a utf-8 string (includes salt + cost factor). + """ + salt = bcrypt.gensalt(rounds=12) + return bcrypt.hashpw(password.encode("utf-8"), salt).decode("utf-8") + + +def verify_password(password: str, password_hash: str) -> bool: + """Verify a password against a bcrypt hash. + + Args: + password: Plain-text password to verify. + password_hash: bcrypt hash string (as produced by :func:`hash_password`). + + Returns: + True if the password matches the hash, False otherwise. + Also returns False on malformed hash strings to avoid leaking + exception details to callers. + """ + try: + return bcrypt.checkpw( + password.encode("utf-8"), + password_hash.encode("utf-8"), + ) + except (ValueError, TypeError): + return False diff --git a/src/agentkit/server/auth/permissions.py b/src/agentkit/server/auth/permissions.py new file mode 100644 index 0000000..8303aa8 --- /dev/null +++ b/src/agentkit/server/auth/permissions.py @@ -0,0 +1,147 @@ +"""Permission model and role-permission mapping (U5). + +This module defines the canonical set of permissions and the default +permission grants for each role. It is the single source of truth for +both the server (FastAPI dependencies) and the client (frontend store). + +Roles +----- +- ``member`` — regular user: chat, KB query, workflow execution. +- ``operator`` — power user: member permissions + local terminal + + KB write + whitelist management. +- ``admin`` — full access: operator permissions + server terminal + + user management + system config. + +The ``is_terminal_authorized`` / ``is_server_terminal_authorized`` flags +on :class:`UserModel` act as *additional gates* on top of the role-based +permission. A user must have *both* the role permission AND the personal +authorization flag to use a terminal. This allows admins to grant +terminal access on a per-user basis without changing the user's role. +""" + +from __future__ import annotations + +from enum import Enum +from typing import Any + + +class Permission(str, Enum): + """Permission bits. + + The string values are stable identifiers used in JWT payloads, + the frontend store, and audit logs. Do not rename existing values + (only add new ones) once released. + """ + + # Chat / Agent + CHAT = "CHAT" + + # Knowledge base + KB_QUERY = "KB_QUERY" + KB_WRITE = "KB_WRITE" + + # Workflows + WORKFLOW_EXECUTE = "WORKFLOW_EXECUTE" + + # Terminal — local (client sidecar) + TERMINAL_LOCAL_USE = "TERMINAL_LOCAL_USE" + + # Terminal — server (server PTY) + TERMINAL_SERVER_USE = "TERMINAL_SERVER_USE" + + # Terminal whitelist management (user's own + global if admin) + TERMINAL_WHITELIST_MANAGE = "TERMINAL_WHITELIST_MANAGE" + + # User management (create / disable / change role) + USER_MANAGE = "USER_MANAGE" + + # System configuration (server settings, LLM keys) + SYSTEM_CONFIG = "SYSTEM_CONFIG" + + +# Default role → permission mapping. +# +# Order matters for documentation but not for lookup (we use set membership). +ROLE_PERMISSIONS: dict[str, frozenset[Permission]] = { + "member": frozenset( + { + Permission.CHAT, + Permission.KB_QUERY, + Permission.WORKFLOW_EXECUTE, + } + ), + "operator": frozenset( + { + Permission.CHAT, + Permission.KB_QUERY, + Permission.KB_WRITE, + Permission.WORKFLOW_EXECUTE, + Permission.TERMINAL_LOCAL_USE, + Permission.TERMINAL_WHITELIST_MANAGE, + } + ), + "admin": frozenset( + { + Permission.CHAT, + Permission.KB_QUERY, + Permission.KB_WRITE, + Permission.WORKFLOW_EXECUTE, + Permission.TERMINAL_LOCAL_USE, + Permission.TERMINAL_SERVER_USE, + Permission.TERMINAL_WHITELIST_MANAGE, + Permission.USER_MANAGE, + Permission.SYSTEM_CONFIG, + } + ), +} + + +def get_role_permissions(role: str | None) -> frozenset[Permission]: + """Return the permission set for a role. + + Unknown roles get an empty set (no permissions). ``None`` (dev mode, + no authenticated user) also returns an empty set — callers must + handle the dev-mode bypass separately. + """ + if role is None: + return frozenset() + return ROLE_PERMISSIONS.get(role, frozenset()) + + +def has_permission(user: dict[str, Any] | None, permission: Permission) -> bool: + """Check if a user payload has a specific permission. + + Args: + user: The ``current_user`` dict set by :class:`AuthMiddleware`, + containing at least ``role``. May be ``None`` in dev mode. + permission: The required permission. + + Returns: + ``True`` if the user's role grants the permission. + ``False`` if the user is ``None`` or the role lacks the permission. + + Note: + This only checks the *role-based* permission. Terminal endpoints + must additionally check ``is_terminal_authorized`` / + ``is_server_terminal_authorized`` on the user record (re-fetched + from the DB, not the JWT payload, to reflect real-time changes). + """ + if user is None: + return False + role = user.get("role") + return permission in get_role_permissions(role) + + +def is_dev_mode(user: dict[str, Any] | None) -> bool: + """Return True if the request is in dev mode (no authenticated user). + + Dev mode is when ``AuthMiddleware`` passes through requests without + authentication (no JWT secret, no API key configured). In dev mode, + the ``current_user`` attribute is not set, so ``user`` is ``None``. + + Dev mode policy: + - Read-only / low-risk endpoints (chat, KB query, workflows) are allowed. + - High-risk endpoints (terminal, user management, system config) require + explicit authentication even in dev mode. + """ + return user is None diff --git a/src/agentkit/server/auth/terminal_security.py b/src/agentkit/server/auth/terminal_security.py new file mode 100644 index 0000000..7cdc8e1 --- /dev/null +++ b/src/agentkit/server/auth/terminal_security.py @@ -0,0 +1,418 @@ +"""Six-layer terminal whitelist + audit log helpers. + +Whitelist evaluation priority (highest first): + 1. Blocklist (admin-managed, always rejects) + 2. Built-in safe whitelist (``_SAFE_COMMAND_PREFIXES`` from shell.py) + 3. Global whitelist (admin-managed, DB-backed) + 4. User whitelist (per-user, stored in DB, cached in-memory) + 5. Session whitelist (per-session, in-memory only) + 6. Danger detection (``_is_dangerous`` — requires confirmation) + +Security note: shell operators (&&, ;, |, $(), backticks) are ALWAYS +checked before any whitelist match returns SAFE. A whitelisted prefix +like ``git push`` will NOT auto-execute ``git push && rm -rf /`` — +the shell operator triggers danger detection regardless of whitelist +membership. + +This module is import-safe: it does not open any DB connection at import +time. All DB access is via explicit ``db_path`` arguments. +""" + +from __future__ import annotations + +import logging +import os +import shlex +import uuid +from collections.abc import Callable +from pathlib import Path + +import aiosqlite + +from agentkit.tools.shell import ( + _DANGEROUS_BINARY_FLAGS, + _SAFE_COMMAND_PREFIXES, +) +from agentkit.server.auth.models import _now_iso + +logger = logging.getLogger(__name__) + + +# ── Shell operator detection ───────────────────────────────────────── + +# Shell chain/substitution operators that bypass whitelist prefix matching. +# If ANY of these appear in a command, the command is treated as dangerous +# regardless of whitelist membership — the whitelist pattern only matches +# the prefix, not the chained payload. +_SHELL_OPERATORS = ("&&", "||", ";", "|", "$(", "`", ">", "<", "\n") + + +def _has_shell_operators(command: str) -> bool: + """Return True if the command contains shell chain/substitution operators. + + These operators allow chaining arbitrary commands behind a whitelisted + prefix (e.g. ``git push && rm -rf /``). When present, the command must + go through danger detection even if the prefix matches a whitelist. + """ + return any(op in command for op in _SHELL_OPERATORS) + + +# ── Decision constants ──────────────────────────────────────────────── + +DECISION_EXECUTED = "executed" # Ran without confirmation (whitelisted) +DECISION_CONFIRMED = "confirmed" # Ran after user confirmation +DECISION_REJECTED = "rejected" # User rejected confirmation prompt +DECISION_BLOCKED = "blocked" # Blocked by blocklist +DECISION_DENIED = "denied" # Blocked by safety check (non-whitelist) + + +# ── Pattern matching ───────────────────────────────────────────────── + + +def _extract_binary(command: str) -> str | None: + """Extract the binary name (basename) from a command string. + + Returns ``None`` if the command cannot be parsed. + """ + try: + tokens = shlex.split(command.strip()) + if not tokens: + return None + return os.path.basename(tokens[0]).lower() + except ValueError: + return None + + +def _matches_pattern(command: str, pattern: str) -> bool: + """Check if a command matches a whitelist/blocklist pattern. + + A pattern matches if: + - The pattern is a single word and equals the command's binary name. + - The pattern is a multi-word phrase and the command starts with it. + + Matching is case-insensitive. + """ + cmd_lower = command.strip().lower() + pattern_lower = pattern.strip().lower() + if not pattern_lower: + return False + + if " " in pattern_lower: + return cmd_lower.startswith(pattern_lower) + + binary = _extract_binary(command) + if binary is None: + return False + return binary == pattern_lower + + +def _matches_any_pattern(command: str, patterns: list[str]) -> tuple[bool, str | None]: + """Return (matched, matched_pattern) for the first matching pattern.""" + for pattern in patterns: + if _matches_pattern(command, pattern): + return True, pattern + return False, None + + +# ── Built-in whitelist check ────────────────────────────────────────── + + +def _matches_builtin_whitelist(command: str) -> bool: + """Check if a command matches the built-in safe whitelist. + + Mirrors the logic in :func:`terminal._is_single_command_dangerous` — + a command is built-in safe if its binary (or full prefix) appears in + ``_SAFE_COMMAND_PREFIXES``. + """ + cmd_lower = command.strip().lower() + if not cmd_lower: + return False + + binary = _extract_binary(command) + if binary is None: + return False + + for prefix in _SAFE_COMMAND_PREFIXES: + prefix_lower = prefix.lower().strip() + if " " in prefix_lower: + if cmd_lower.startswith(prefix_lower): + return True + else: + if binary == prefix_lower: + return True + return False + + +# ── DB-backed whitelist / blocklist lookups ─────────────────────────── + + +async def _fetch_user_whitelist(db_path: str | Path, user_id: str) -> list[str]: + """Fetch a user's whitelist command patterns from the DB.""" + patterns: list[str] = [] + try: + async with aiosqlite.connect(str(db_path)) as db: + cursor = await db.execute( + "SELECT command_pattern FROM terminal_whitelist_user " + "WHERE user_id = ? AND scope = 'user'", + (user_id,), + ) + rows = await cursor.fetchall() + patterns = [row[0] for row in rows] + except Exception as e: + logger.warning(f"Failed to fetch user whitelist for {user_id}: {e}") + return patterns + + +async def _fetch_global_whitelist(db_path: str | Path) -> list[str]: + """Fetch the global (admin-managed) whitelist command patterns.""" + patterns: list[str] = [] + try: + async with aiosqlite.connect(str(db_path)) as db: + cursor = await db.execute( + "SELECT command_pattern FROM terminal_whitelist_user WHERE scope = 'global'" + ) + rows = await cursor.fetchall() + patterns = [row[0] for row in rows] + except Exception as e: + logger.warning(f"Failed to fetch global whitelist: {e}") + return patterns + + +async def _fetch_blocklist(db_path: str | Path) -> list[tuple[str, str | None]]: + """Fetch the global blocklist (pattern, reason) pairs from the DB.""" + patterns: list[tuple[str, str | None]] = [] + try: + async with aiosqlite.connect(str(db_path)) as db: + cursor = await db.execute( + "SELECT command_pattern, reason FROM terminal_blocklist" + ) + rows = await cursor.fetchall() + patterns = [(row[0], row[1]) for row in rows] + except Exception as e: + logger.warning(f"Failed to fetch blocklist: {e}") + return patterns + + +# ── Audit log writer ────────────────────────────────────────────────── + + +async def write_audit_log( + db_path: str | Path, + *, + user_id: str | None, + username: str | None, + session_id: str, + command: str, + decision: str, + reason: str | None = None, + cwd: str | None = None, + exit_code: int | None = None, + terminal_mode: str = "local", +) -> None: + """Write a terminal audit log entry. + + Failures are logged but never raised — auditing must not break the + terminal flow. + """ + try: + async with aiosqlite.connect(str(db_path)) as db: + await db.execute( + """INSERT INTO terminal_audit_logs + (id, user_id, username, session_id, command, decision, + reason, cwd, exit_code, terminal_mode, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + ( + str(uuid.uuid4()), + user_id, + username, + session_id, + command[:4096], + decision, + reason, + cwd, + exit_code, + terminal_mode, + _now_iso(), + ), + ) + await db.commit() + except Exception as e: + logger.warning(f"Failed to write terminal audit log: {e}") + + +# ── Top-level safety check ─────────────────────────────────────────── + + +class SafetyDecision: + """Result of a six-layer whitelist safety check.""" + + __slots__ = ("safe", "decision", "reason", "matched_layer") + + def __init__( + self, + *, + safe: bool, + decision: str, + reason: str | None = None, + matched_layer: str | None = None, + ) -> None: + self.safe = safe + self.decision = decision + self.reason = reason + self.matched_layer = matched_layer + + def __repr__(self) -> str: + return ( + f"SafetyDecision(safe={self.safe}, decision={self.decision!r}, " + f"matched_layer={self.matched_layer!r})" + ) + + +async def check_command_safety_v2( + command: str, + *, + session_id: str, + session_whitelist: set[str], + user_id: str | None, + db_path: str | Path | None, + is_dangerous_fn: Callable[[str], bool] | None = None, +) -> SafetyDecision: + """Evaluate a command against the six-layer whitelist. + + Args: + command: The raw command string. + session_id: Current terminal session ID (for logging). + session_whitelist: In-memory per-session whitelist (binary names + or command prefixes). Mutated by the caller after confirmation. + user_id: The authenticated user's ID (``None`` in dev mode). + db_path: Path to the auth DB. If ``None``, DB-backed layers are + skipped (dev mode without DB). + is_dangerous_fn: Override for the danger-detection function. + Defaults to :func:`terminal._is_dangerous`. + + Returns: + A :class:`SafetyDecision` describing whether the command may + execute and which layer matched. + """ + if is_dangerous_fn is None: + from agentkit.server.routes.terminal import _is_dangerous + + is_dangerous_fn = _is_dangerous + + command_stripped = command.strip() + if not command_stripped: + return SafetyDecision( + safe=False, + decision=DECISION_DENIED, + reason="空命令", + matched_layer=None, + ) + + # Security: detect shell operators early. Commands with shell operators + # (&&, ;, |, $(), etc.) can chain arbitrary payloads behind a whitelisted + # prefix. Such commands must ALWAYS go through danger detection, even if + # the prefix matches a whitelist entry. + has_shell_ops = _has_shell_operators(command_stripped) + + # Layer 1: Blocklist (admin-managed, always rejects) + if db_path is not None: + blocklist = await _fetch_blocklist(db_path) + for pattern, reason in blocklist: + if _matches_pattern(command_stripped, pattern): + return SafetyDecision( + safe=False, + decision=DECISION_BLOCKED, + reason=reason or f"命令匹配黑名单规则: {pattern}", + matched_layer="blocklist", + ) + + # Layer 2: Built-in safe whitelist + # Shell operators bypass ALL whitelist layers — even built-in safe + # commands like "ls" cannot chain "ls && rm -rf /" without confirmation. + if not has_shell_ops and _matches_builtin_whitelist(command_stripped): + return SafetyDecision( + safe=True, + decision=DECISION_EXECUTED, + matched_layer="builtin", + ) + + # Layer 3: Global whitelist (admin-managed, DB-backed) + if not has_shell_ops and db_path is not None: + global_patterns = await _fetch_global_whitelist(db_path) + matched, _ = _matches_any_pattern(command_stripped, global_patterns) + if matched: + return SafetyDecision( + safe=True, + decision=DECISION_EXECUTED, + matched_layer="global_whitelist", + ) + + # Layer 4: User whitelist (DB-backed) + if not has_shell_ops and db_path is not None and user_id is not None: + user_patterns = await _fetch_user_whitelist(db_path, user_id) + matched, _ = _matches_any_pattern(command_stripped, user_patterns) + if matched: + return SafetyDecision( + safe=True, + decision=DECISION_EXECUTED, + matched_layer="user_whitelist", + ) + + # Layer 5: Session whitelist (in-memory) + if not has_shell_ops: + for wl_entry in session_whitelist: + if _matches_pattern(command_stripped, wl_entry): + return SafetyDecision( + safe=True, + decision=DECISION_EXECUTED, + matched_layer="session_whitelist", + ) + + # Layer 6: Danger detection + if not is_dangerous_fn(command_stripped): + return SafetyDecision( + safe=True, + decision=DECISION_EXECUTED, + matched_layer="danger_check_passed", + ) + + # Not whitelisted and dangerous — require confirmation (local) or + # approval (server terminal). + return SafetyDecision( + safe=False, + decision=DECISION_DENIED, + reason="此命令被识别为潜在危险操作,需要用户确认", + matched_layer=None, + ) + + +# ── Whitelist mutation helpers ──────────────────────────────────────── + + +def compute_whitelist_entry(command: str) -> str | None: + """Compute the whitelist entry to add for a confirmed command. + + For binaries with dangerous-flag variants (e.g. ``rm -rf``), we return + ``None`` — dangerous binaries must ALWAYS require confirmation and are + never added to the session whitelist. For other binaries, we add just + the binary name so subsequent calls with the same binary auto-execute. + + Returns ``None`` if the command cannot be parsed or contains shell + operators (such commands should never be whitelisted). + """ + try: + tokens = shlex.split(command.strip()) + if not tokens: + return None + # Never whitelist commands with shell operators — they can chain + # arbitrary payloads and must always require confirmation. + if _has_shell_operators(command.strip()): + return None + binary = os.path.basename(tokens[0]).lower() + if binary in _DANGEROUS_BINARY_FLAGS: + # Dangerous binaries always require confirmation — do NOT + # add to session whitelist. + return None + return binary + except ValueError: + return None diff --git a/src/agentkit/server/frontend/components.d.ts b/src/agentkit/server/frontend/components.d.ts index 42231c0..7ba3ab6 100644 --- a/src/agentkit/server/frontend/components.d.ts +++ b/src/agentkit/server/frontend/components.d.ts @@ -41,6 +41,7 @@ declare module 'vue' { ASelectOption: typeof import('ant-design-vue/es')['SelectOption'] ASpace: typeof import('ant-design-vue/es')['Space'] ASpin: typeof import('ant-design-vue/es')['Spin'] + AssistantText: typeof import('./src/components/chat/messages/AssistantText.vue')['default'] ASwitch: typeof import('ant-design-vue/es')['Switch'] ATable: typeof import('ant-design-vue/es')['Table'] ATabPane: typeof import('ant-design-vue/es')['TabPane'] @@ -48,8 +49,14 @@ declare module 'vue' { ATag: typeof import('ant-design-vue/es')['Tag'] ATextarea: typeof import('ant-design-vue/es')['Textarea'] AUploadDragger: typeof import('ant-design-vue/es')['UploadDragger'] + BoardBannerCard: typeof import('./src/components/chat/messages/BoardBannerCard.vue')['default'] + BoardConclusionCard: typeof import('./src/components/chat/messages/BoardConclusionCard.vue')['default'] + BoardMeetingModal: typeof import('./src/components/chat/BoardMeetingModal.vue')['default'] + BoardRoundCard: typeof import('./src/components/chat/messages/BoardRoundCard.vue')['default'] + BoardStatusView: typeof import('./src/components/chat/BoardStatusView.vue')['default'] ChatInput: typeof import('./src/components/chat/ChatInput.vue')['default'] ChatMessage: typeof import('./src/components/chat/ChatMessage.vue')['default'] + ChatPreviewShell: typeof import('./src/components/preview/ChatPreviewShell.vue')['default'] ChatSidebar: typeof import('./src/components/chat/ChatSidebar.vue')['default'] CodeDiffViewer: typeof import('./src/components/code/CodeDiffViewer.vue')['default'] CommandHistory: typeof import('./src/components/terminal/CommandHistory.vue')['default'] @@ -57,15 +64,19 @@ declare module 'vue' { ContextPill: typeof import('./src/components/chat/ContextPill.vue')['default'] DashboardOverview: typeof import('./src/components/evolution/DashboardOverview.vue')['default'] DocumentUpload: typeof import('./src/components/kb/DocumentUpload.vue')['default'] + ErrorCard: typeof import('./src/components/chat/messages/ErrorCard.vue')['default'] ExperiencePanel: typeof import('./src/components/evolution/ExperiencePanel.vue')['default'] ExperienceTimeline: typeof import('./src/components/evolution/ExperienceTimeline.vue')['default'] ExpertMessage: typeof import('./src/components/chat/ExpertMessage.vue')['default'] ExpertTeamView: typeof import('./src/components/chat/ExpertTeamView.vue')['default'] + FileAttachment: typeof import('./src/components/chat/messages/FileAttachment.vue')['default'] FilePreview: typeof import('./src/components/chat/FilePreview.vue')['default'] FileTree: typeof import('./src/components/code/FileTree.vue')['default'] FlowCanvas: typeof import('./src/components/workflow/FlowCanvas.vue')['default'] IconNav: typeof import('./src/components/layout/IconNav.vue')['default'] + KnowledgeTab: typeof import('./src/components/layout/tabs/KnowledgeTab.vue')['default'] MentionDropdown: typeof import('./src/components/chat/MentionDropdown.vue')['default'] + MessageShell: typeof import('./src/components/chat/messages/MessageShell.vue')['default'] MetricsChart: typeof import('./src/components/evolution/MetricsChart.vue')['default'] MetricsPanel: typeof import('./src/components/evolution/MetricsPanel.vue')['default'] NodePalette: typeof import('./src/components/workflow/NodePalette.vue')['default'] @@ -77,22 +88,37 @@ declare module 'vue' { PlanVisualization: typeof import('./src/components/chat/PlanVisualization.vue')['default'] PropertyPanel: typeof import('./src/components/workflow/PropertyPanel.vue')['default'] QuadrantPanel: typeof import('./src/components/layout/QuadrantPanel.vue')['default'] + RightPanel: typeof import('./src/components/layout/RightPanel.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] + Scene1Welcome: typeof import('./src/components/preview/scenes/Scene1Welcome.vue')['default'] + Scene2UserAssistant: typeof import('./src/components/preview/scenes/Scene2UserAssistant.vue')['default'] + Scene3TeamPlan: typeof import('./src/components/preview/scenes/Scene3TeamPlan.vue')['default'] + Scene4BoardDiscussion: typeof import('./src/components/preview/scenes/Scene4BoardDiscussion.vue')['default'] + Scene5ToolCall: typeof import('./src/components/preview/scenes/Scene5ToolCall.vue')['default'] + Scene6Error: typeof import('./src/components/preview/scenes/Scene6Error.vue')['default'] SearchTest: typeof import('./src/components/kb/SearchTest.vue')['default'] SideNav: typeof import('./src/components/layout/SideNav.vue')['default'] SkillCard: typeof import('./src/components/skills/SkillCard.vue')['default'] SkillDetail: typeof import('./src/components/skills/SkillDetail.vue')['default'] SkillNode: typeof import('./src/components/workflow/SkillNode.vue')['default'] + SkillsTab: typeof import('./src/components/layout/tabs/SkillsTab.vue')['default'] SourceConfig: typeof import('./src/components/kb/SourceConfig.vue')['default'] SplashScreen: typeof import('./src/components/layout/SplashScreen.vue')['default'] SplitPane: typeof import('./src/components/layout/SplitPane.vue')['default'] + SystemMonitorPanel: typeof import('./src/components/layout/SystemMonitorPanel.vue')['default'] + SystemTab: typeof import('./src/components/layout/tabs/SystemTab.vue')['default'] + TeamModal: typeof import('./src/components/chat/TeamModal.vue')['default'] + TeamPlanCard: typeof import('./src/components/chat/messages/TeamPlanCard.vue')['default'] TerminalEmulator: typeof import('./src/components/terminal/TerminalEmulator.vue')['default'] + TerminalPanel: typeof import('./src/components/terminal/TerminalPanel.vue')['default'] ThinkingBlock: typeof import('./src/components/chat/ThinkingBlock.vue')['default'] TitleBar: typeof import('./src/components/layout/TitleBar.vue')['default'] ToolCallCard: typeof import('./src/components/chat/ToolCallCard.vue')['default'] ToolCallIndicator: typeof import('./src/components/chat/ToolCallIndicator.vue')['default'] TopNav: typeof import('./src/components/layout/TopNav.vue')['default'] UsagePanel: typeof import('./src/components/evolution/UsagePanel.vue')['default'] + UserBubble: typeof import('./src/components/chat/messages/UserBubble.vue')['default'] + WhitelistManager: typeof import('./src/components/terminal/WhitelistManager.vue')['default'] } } diff --git a/src/agentkit/server/frontend/src/App.vue b/src/agentkit/server/frontend/src/App.vue index 9dd1659..2cbbd9f 100644 --- a/src/agentkit/server/frontend/src/App.vue +++ b/src/agentkit/server/frontend/src/App.vue @@ -9,18 +9,36 @@ import { ref, onMounted } from 'vue' import { ConfigProvider as AConfigProvider } from 'ant-design-vue' import zhCN from 'ant-design-vue/es/locale/zh_CN' +import { useRouter } from 'vue-router' import { useThemeStore } from './stores/theme' +import { useAuthStore } from './stores/auth' import { isTauri, startBackend, checkBackendHealth } from './api/tauri' -import { initApiBaseURL } from './api/base' +import { + initApiBaseURL, + setTokenProvider, + setRefreshProvider, + setUnauthorizedHandler, +} from './api/base' import SplashScreen from './components/layout/SplashScreen.vue' const themeStore = useThemeStore() +const authStore = useAuthStore() +const router = useRouter() const loading = ref(isTauri()) const loadingStatus = ref('正在初始化...') const loadError = ref('') onMounted(async () => { + // Wire the auth store into the API client so every request gets a JWT + // attached automatically, and 401s trigger a token refresh. + setTokenProvider(() => authStore.accessToken) + setRefreshProvider(() => authStore.refreshIfPossible()) + setUnauthorizedHandler(() => { + authStore.logoutLocal() + router.replace({ name: 'login' }) + }) + if (isTauri()) { try { loadingStatus.value = '正在启动后端...' diff --git a/src/agentkit/server/frontend/src/api/auth.ts b/src/agentkit/server/frontend/src/api/auth.ts new file mode 100644 index 0000000..8295881 --- /dev/null +++ b/src/agentkit/server/frontend/src/api/auth.ts @@ -0,0 +1,66 @@ +/** + * Auth API client — login / refresh / logout / me + * + * Talks to the server-side ``/api/v1/auth/*`` endpoints implemented in + * ``src/agentkit/server/routes/auth.py``. + */ + +import { BaseApiClient } from './base' + +/** Public user representation returned by the server. */ +export interface IAuthUser { + id: string + username: string + email: string + role: 'member' | 'operator' | 'admin' | string + is_active: boolean + is_terminal_authorized: boolean + is_server_terminal_authorized: boolean +} + +/** JWT pair + user info returned by /auth/login and /auth/refresh. */ +export interface ITokenPair { + access_token: string + refresh_token: string + token_type: 'bearer' + expires_in: number + user: IAuthUser +} + +class AuthApiClient extends BaseApiClient { + constructor(baseUrl: string = '/api/v1') { + super(baseUrl) + } + + /** Authenticate with username + password. */ + async login(username: string, password: string): Promise { + return this.request('/auth/login', { + method: 'POST', + body: JSON.stringify({ username, password }), + }) + } + + /** Exchange a refresh token for a new access token. */ + async refresh(refreshToken: string): Promise { + return this.request('/auth/refresh', { + method: 'POST', + body: JSON.stringify({ refresh_token: refreshToken }), + }) + } + + /** Revoke a refresh token (idempotent). */ + async logout(refreshToken: string): Promise<{ revoked: boolean }> { + return this.request<{ revoked: boolean }>('/auth/logout', { + method: 'POST', + body: JSON.stringify({ refresh_token: refreshToken }), + }) + } + + /** Fetch the current user's profile (requires Authorization header). */ + async me(): Promise { + return this.request('/auth/me') + } +} + +export const authApi = new AuthApiClient() +export { AuthApiClient } diff --git a/src/agentkit/server/frontend/src/api/base.ts b/src/agentkit/server/frontend/src/api/base.ts index 9909bad..ac9c907 100644 --- a/src/agentkit/server/frontend/src/api/base.ts +++ b/src/agentkit/server/frontend/src/api/base.ts @@ -23,6 +23,36 @@ export function getDynamicBaseURL(): string { return _dynamicBaseURL } +/** + * Token provider hook. + * + * The auth store registers itself here via ``setTokenProvider`` so that + * ``BaseApiClient`` can read the current access token without a circular + * import (base.ts ← auth.ts ← stores/auth.ts ← base.ts). + */ +type TokenProvider = () => string | null +type RefreshProvider = () => Promise +type UnauthorizedHandler = () => void + +let _tokenProvider: TokenProvider | null = null +let _refreshProvider: RefreshProvider | null = null +let _unauthorizedHandler: UnauthorizedHandler | null = null + +/** Register the access-token provider (called by the auth store on init). */ +export function setTokenProvider(provider: TokenProvider): void { + _tokenProvider = provider +} + +/** Register the refresh-token provider (called by the auth store on init). */ +export function setRefreshProvider(provider: RefreshProvider): void { + _refreshProvider = provider +} + +/** Register a handler invoked when refresh fails (redirect to /login). */ +export function setUnauthorizedHandler(handler: UnauthorizedHandler): void { + _unauthorizedHandler = handler +} + export class BaseApiClient { protected baseUrl: string @@ -30,46 +60,121 @@ export class BaseApiClient { this.baseUrl = baseUrl } - protected async request(path: string, options: RequestInit = {}): Promise { - const effectiveUrl = _dynamicBaseURL - ? (path.startsWith('/api/') ? `${_dynamicBaseURL}${path}` : `${_dynamicBaseURL}${this.baseUrl}${path}`) - : (path.startsWith('/api/') ? path : `${this.baseUrl}${path}`) + /** + * Resolve the effective URL for a request path. + * + * - Paths starting with ``/api/`` are treated as absolute (no ``baseUrl`` + * prefix). + * - Other paths are prefixed with ``this.baseUrl``. + * - In Tauri mode, the dynamic sidecar base URL is prepended. + */ + private _resolveUrl(path: string): string { + const isAbsoluteApi = path.startsWith('/api/') + const suffix = isAbsoluteApi ? path : `${this.baseUrl}${path}` + return _dynamicBaseURL ? `${_dynamicBaseURL}${suffix}` : suffix + } + + /** + * Build the request headers, attaching the JWT if available. + */ + private _buildHeaders(options: RequestInit): Record { const headers: Record = { ...options.headers as Record, } - // Don't set Content-Type for FormData (upload) if (!(options.body instanceof FormData)) { headers['Content-Type'] = 'application/json' } - - const response = await fetch(effectiveUrl, { ...options, headers }) - - if (!response.ok) { - const error: IApiError = { - status: response.status, - message: response.statusText, - } - try { - const body = await response.json() - error.detail = body.detail ?? body.message - error.message = error.detail ?? error.message - } catch { - // response body is not JSON - } - throw error + const token = _tokenProvider?.() + if (token) { + headers['Authorization'] = `Bearer ${token}` } - - return response.json() as Promise + return headers } + /** + * Send a request, retrying once with a refreshed token on 401. + * + * If the refresh fails, the unauthorized handler is invoked (typically + * redirecting to /login) and the original 401 error is re-thrown. + */ + protected async request(path: string, options: RequestInit = {}): Promise { + const response = await this._send(path, options) + + if (response.status === 401 && _refreshProvider) { + const newToken = await _refreshProvider() + if (newToken) { + // Retry the original request with the new token + const retried = await this._send(path, options) + if (!retried.ok) { + throw await this._buildError(retried) + } + return (retried.json() as Promise) ?? null + } + // Refresh failed — notify the app to redirect to login + _unauthorizedHandler?.() + } + + if (!response.ok) { + throw await this._buildError(response) + } + + // Some endpoints (logout) return empty body; guard against JSON parse errors + const text = await response.text() + if (!text) { + return null as unknown as T + } + try { + return JSON.parse(text) as T + } catch { + return text as unknown as T + } + } + + /** Low-level fetch with headers + URL resolved. */ + private async _send(path: string, options: RequestInit): Promise { + const effectiveUrl = this._resolveUrl(path) + const headers = this._buildHeaders(options) + return fetch(effectiveUrl, { ...options, headers }) + } + + private async _buildError(response: Response): Promise { + const error: IApiError = { + status: response.status, + message: response.statusText, + } + try { + const body = await response.json() + error.detail = body.detail ?? body.message + error.message = error.detail ?? error.message + } catch { + // response body is not JSON + } + return error + } + + /** Get the current access token (for WebSocket URL construction). */ + protected getToken(): string | null { + return _tokenProvider?.() ?? null + } + + /** + * Create a WebSocket connection, attaching the JWT as a query parameter. + * + * The server's AuthMiddleware accepts ``?token=`` as a fallback for + * WebSocket connections where setting the Authorization header is not + * possible. + */ protected createWebSocket(path: string): WebSocket { + const token = _tokenProvider?.() + const tokenSuffix = token ? `?token=${encodeURIComponent(token)}` : '' + if (_dynamicBaseURL) { const url = new URL(_dynamicBaseURL) const protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' - return new WebSocket(`${protocol}//${url.host}${this.baseUrl}${path}`) + return new WebSocket(`${protocol}//${url.host}${this.baseUrl}${path}${tokenSuffix}`) } const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' const host = window.location.host - return new WebSocket(`${protocol}//${host}${this.baseUrl}${path}`) + return new WebSocket(`${protocol}//${host}${this.baseUrl}${path}${tokenSuffix}`) } } diff --git a/src/agentkit/server/frontend/src/api/client.ts b/src/agentkit/server/frontend/src/api/client.ts index 5852036..18fe3e8 100644 --- a/src/agentkit/server/frontend/src/api/client.ts +++ b/src/agentkit/server/frontend/src/api/client.ts @@ -5,6 +5,8 @@ import type { IConversation, ITaskRecord, TaskStatus, + IExpertsResponse, + IUploadResponse, } from './types' import { BaseApiClient } from './base' @@ -55,6 +57,21 @@ class ApiClient extends BaseApiClient { createWebSocket(): WebSocket { return super.createWebSocket('/ws') } + + /** List available expert templates for @board mode (uses /api/v1 prefix) */ + async getExperts(): Promise { + return this.request('/api/v1/experts') + } + + /** Upload a file to be referenced in chat messages */ + async uploadFile(file: File): Promise { + const formData = new FormData() + formData.append('file', file) + return this.request('/api/v1/chat/upload', { + method: 'POST', + body: formData, + }) + } } export const apiClient = new ApiClient() diff --git a/src/agentkit/server/frontend/src/api/system.ts b/src/agentkit/server/frontend/src/api/system.ts new file mode 100644 index 0000000..aaad806 --- /dev/null +++ b/src/agentkit/server/frontend/src/api/system.ts @@ -0,0 +1,71 @@ +/** System monitoring API client — /api/v1/health, /api/v1/metrics, /api/v1/system */ + +import { BaseApiClient } from './base' + +const API_BASE = '/api/v1' + +export interface IHealthCheck { + status: string + version: string + checks: Record +} + +export interface IMetricsData { + tasks: { + total_tasks: number + completed_tasks: number + failed_tasks: number + pending_tasks: number + } + agents: { + total_agents: number + } + skills: { + total_skills: number + } + version: string +} + +export interface ISystemResources { + cpu: { + count: number + load_average: number[] | null + } + memory: { + total_bytes: number + used_bytes: number + free_bytes: number + percent: number | null + } + disk: { + total_bytes: number + used_bytes: number + free_bytes: number + percent: number | null + } + timestamp: number +} + +class SystemApiClient extends BaseApiClient { + constructor(baseUrl: string = API_BASE) { + super(baseUrl) + } + + async getHealth(): Promise { + return this.request('/health') + } + + async getMetrics(): Promise { + return this.request('/metrics') + } + + async getResources(): Promise { + return this.request('/system/resources') + } +} + +export const systemApi = new SystemApiClient() + +export const getHealth = systemApi.getHealth.bind(systemApi) +export const getMetrics = systemApi.getMetrics.bind(systemApi) +export const getResources = systemApi.getResources.bind(systemApi) diff --git a/src/agentkit/server/frontend/src/api/terminal.ts b/src/agentkit/server/frontend/src/api/terminal.ts index 5d8d8bc..d8570cd 100644 --- a/src/agentkit/server/frontend/src/api/terminal.ts +++ b/src/agentkit/server/frontend/src/api/terminal.ts @@ -1,4 +1,4 @@ -/** Terminal API client */ +/** Terminal API client — supports local + server terminal modes. */ import { BaseApiClient, getDynamicBaseURL } from './base' @@ -30,24 +30,99 @@ export interface IExecuteResponse { reason?: string } +// ── Whitelist / Blocklist / Audit / Approval types ─────────────────── + +export interface IWhitelistEntry { + id: string + user_id?: string + command_pattern: string + scope: 'user' | 'global' + created_at: string +} + +export interface IBlocklistEntry { + id: string + command_pattern: string + reason: string + created_at: string +} + +export interface IAuditLogEntry { + id: string + user_id: string | null + session_id: string + command: string + decision: string + reason: string | null + exit_code: number | null + terminal_mode: string + created_at: string +} + +export interface IApprovalEntry { + id: string + user_id: string + username: string + session_id: string + command: string + reason: string | null + status: 'pending' | 'approved' | 'rejected' | 'expired' | 'cancelled' + reviewer_id: string | null + reviewer_username: string | null + review_note: string | null + created_at: string + reviewed_at: string | null + expires_at: string +} + class TerminalApiClient extends BaseApiClient { constructor(baseUrl: string = API_BASE) { super(baseUrl) } - /** Create a terminal WebSocket URL (Tauri-aware) */ + /** Create a local terminal WebSocket URL (Tauri-aware, JWT-authenticated). */ createTerminalWsUrl(sessionId?: string): string { + const token = this.getToken() + const params = new URLSearchParams() + if (sessionId) params.set('session_id', sessionId) + if (token) params.set('token', token) + const query = params.toString() ? `?${params.toString()}` : '' + const dynamicBase = getDynamicBaseURL() if (dynamicBase) { const url = new URL(dynamicBase) const protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' - let base = `${protocol}//${url.host}${this.baseUrl}/terminal/ws` - return sessionId ? `${base}?session_id=${sessionId}` : base + return `${protocol}//${url.host}${this.baseUrl}/terminal/ws${query}` } const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' const host = window.location.host - const base = `${protocol}//${host}${this.baseUrl}/terminal/ws` - return sessionId ? `${base}?session_id=${sessionId}` : base + return `${protocol}//${host}${this.baseUrl}/terminal/ws${query}` + } + + /** + * Create a server terminal WebSocket URL with JWT authentication. + * + * Server terminal requires ``TERMINAL_SERVER_USE`` permission and the + * ``is_server_terminal_authorized`` DB flag. The JWT is passed as a + * ``?token=`` query parameter since browsers cannot set Authorization + * headers on WebSocket connections. + */ + createServerTerminalWsUrl(sessionId?: string): string { + const token = this.getToken() + const params = new URLSearchParams() + if (sessionId) params.set('session_id', sessionId) + if (token) params.set('token', token) + const query = params.toString() ? `?${params.toString()}` : '' + + const dynamicBase = getDynamicBaseURL() + if (dynamicBase) { + const url = new URL(dynamicBase) + const protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' + return `${protocol}//${url.host}${this.baseUrl}/terminal/server/ws${query}` + } + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + const host = window.location.host + return `${protocol}//${host}${this.baseUrl}/terminal/server/ws${query}` } /** Execute a command via REST API */ @@ -79,6 +154,103 @@ class TerminalApiClient extends BaseApiClient { method: 'DELETE', }) } + + // ── User whitelist ────────────────────────────────────────────── + + async listUserWhitelist(): Promise<{ entries: IWhitelistEntry[] }> { + return this.request<{ entries: IWhitelistEntry[] }>('/terminal/whitelist/user') + } + + async addUserWhitelist(commandPattern: string): Promise { + return this.request('/terminal/whitelist/user', { + method: 'POST', + body: JSON.stringify({ command_pattern: commandPattern }), + }) + } + + async deleteUserWhitelist(entryId: string): Promise { + await this.request(`/terminal/whitelist/user/${entryId}`, { + method: 'DELETE', + }) + } + + // ── Global whitelist (admin) ──────────────────────────────────── + + async listGlobalWhitelist(): Promise<{ entries: IWhitelistEntry[] }> { + return this.request<{ entries: IWhitelistEntry[] }>('/terminal/whitelist/global') + } + + async addGlobalWhitelist(commandPattern: string): Promise { + return this.request('/terminal/whitelist/global', { + method: 'POST', + body: JSON.stringify({ command_pattern: commandPattern }), + }) + } + + async deleteGlobalWhitelist(entryId: string): Promise { + await this.request(`/terminal/whitelist/global/${entryId}`, { + method: 'DELETE', + }) + } + + // ── Blocklist (admin) ─────────────────────────────────────────── + + async listBlocklist(): Promise<{ entries: IBlocklistEntry[] }> { + return this.request<{ entries: IBlocklistEntry[] }>('/terminal/blocklist') + } + + async addBlocklist(commandPattern: string, reason: string): Promise { + return this.request('/terminal/blocklist', { + method: 'POST', + body: JSON.stringify({ command_pattern: commandPattern, reason }), + }) + } + + async deleteBlocklist(entryId: string): Promise { + await this.request(`/terminal/blocklist/${entryId}`, { + method: 'DELETE', + }) + } + + // ── Audit logs (admin) ────────────────────────────────────────── + + async listAuditLogs(params?: { + user_id?: string + session_id?: string + terminal_mode?: string + limit?: number + offset?: number + }): Promise<{ entries: IAuditLogEntry[]; total: number }> { + const query = new URLSearchParams() + if (params?.user_id) query.set('user_id', params.user_id) + if (params?.session_id) query.set('session_id', params.session_id) + if (params?.terminal_mode) query.set('terminal_mode', params.terminal_mode) + if (params?.limit) query.set('limit', String(params.limit)) + if (params?.offset) query.set('offset', String(params.offset)) + const qs = query.toString() ? `?${query.toString()}` : '' + return this.request<{ entries: IAuditLogEntry[]; total: number }>(`/terminal/audit-logs${qs}`) + } + + // ── Approvals (admin) ─────────────────────────────────────────── + + async listApprovals(status?: string): Promise<{ approvals: IApprovalEntry[]; total: number }> { + const qs = status ? `?status=${encodeURIComponent(status)}` : '' + return this.request<{ approvals: IApprovalEntry[]; total: number }>(`/terminal/approvals${qs}`) + } + + async approveApproval(approvalId: string, note?: string): Promise { + return this.request(`/terminal/approvals/${approvalId}/approve`, { + method: 'POST', + body: JSON.stringify({ note: note ?? '' }), + }) + } + + async rejectApproval(approvalId: string, note?: string): Promise { + return this.request(`/terminal/approvals/${approvalId}/reject`, { + method: 'POST', + body: JSON.stringify({ note: note ?? '' }), + }) + } } export const terminalApi = new TerminalApiClient() diff --git a/src/agentkit/server/frontend/src/api/types.ts b/src/agentkit/server/frontend/src/api/types.ts index f9c72e8..a6f6f26 100644 --- a/src/agentkit/server/frontend/src/api/types.ts +++ b/src/agentkit/server/frontend/src/api/types.ts @@ -26,6 +26,7 @@ export interface IToolCallData { result?: string error?: string duration?: number + children?: IToolCallData[] } /** Single chat message */ @@ -38,16 +39,30 @@ export interface IChatMessage { routing_method?: string confidence?: number task_id?: string - status?: 'completed' | 'pending' + status?: 'completed' | 'pending' | 'error' tool_calls?: IToolCallData[] thinking?: string expert_id?: string expert_name?: string expert_color?: string expert_avatar?: string - message_type?: 'chat' | 'handoff' | 'assist_request' | 'plan_update' | 'milestone' | 'board_speech' | 'board_summary' | 'board_conclusion' + message_type?: + | 'chat' + | 'handoff' + | 'assist_request' + | 'plan_update' + | 'milestone' + | 'board_started' + | 'board_speech' + | 'board_summary' + | 'board_conclusion' + | 'error' board_round?: number board_role?: 'moderator' | 'expert' | 'user' | 'summary' + plan_phases?: ITeamPlanPhase[] + error_detail?: string + board_started?: IBoardStartedData + board_conclusion?: IBoardConcludedData } /** Conversation with messages */ @@ -218,6 +233,34 @@ export interface IBoardMessage { timestamp: number } +/** Expert template (matches backend GET /api/v1/experts response item) */ +export interface IExpertTemplate { + name: string + description: string + is_builtin: boolean + avatar: string + color: string + persona: string + thinking_style: string + speaking_style: string + decision_framework: string +} + +/** Experts list response (matches backend GET /api/v1/experts) */ +export interface IExpertsResponse { + experts: IExpertTemplate[] + total: number +} + +/** File upload response (matches backend POST /api/v1/chat/upload) */ +export interface IUploadResponse { + filename: string + stored_name: string + content_type: string + size: number + download_url: string +} + /** API error */ export interface IApiError { status: number diff --git a/src/agentkit/server/frontend/src/components/chat/BoardMeetingModal.vue b/src/agentkit/server/frontend/src/components/chat/BoardMeetingModal.vue new file mode 100644 index 0000000..373ba3c --- /dev/null +++ b/src/agentkit/server/frontend/src/components/chat/BoardMeetingModal.vue @@ -0,0 +1,479 @@ + + + + + diff --git a/src/agentkit/server/frontend/src/components/chat/BoardStatusView.vue b/src/agentkit/server/frontend/src/components/chat/BoardStatusView.vue index ce14dd7..c152a97 100644 --- a/src/agentkit/server/frontend/src/components/chat/BoardStatusView.vue +++ b/src/agentkit/server/frontend/src/components/chat/BoardStatusView.vue @@ -2,145 +2,93 @@
- 🏛️ 私董会 - - 讨论中 - - - 总结中 - - - 已完成 - + {{ chatStore.boardState.topic }}
-
- {{ chatStore.boardState.topic }} -
-
- -
-
- {{ expert.avatar }} - {{ expert.name }} - 主持人 -
-
- -
- + 第 {{ chatStore.boardState.current_round }} / {{ chatStore.boardState.max_rounds }} 轮 - +
+
+ + {{ expert.name }} + 主持人 +
diff --git a/src/agentkit/server/frontend/src/components/chat/ChatInput.vue b/src/agentkit/server/frontend/src/components/chat/ChatInput.vue index 99746d6..ffb7fda 100644 --- a/src/agentkit/server/frontend/src/components/chat/ChatInput.vue +++ b/src/agentkit/server/frontend/src/components/chat/ChatInput.vue @@ -1,5 +1,15 @@ diff --git a/src/agentkit/server/frontend/src/components/chat/ChatSidebar.vue b/src/agentkit/server/frontend/src/components/chat/ChatSidebar.vue index 5ae5821..85e086c 100644 --- a/src/agentkit/server/frontend/src/components/chat/ChatSidebar.vue +++ b/src/agentkit/server/frontend/src/components/chat/ChatSidebar.vue @@ -47,7 +47,7 @@ const emit = defineEmits<{ select: [id: string] }>() -const collapsed = ref(true) +const collapsed = ref(false) function handleCreate(): void { emit('create') diff --git a/src/agentkit/server/frontend/src/components/chat/ExpertTeamView.vue b/src/agentkit/server/frontend/src/components/chat/ExpertTeamView.vue index e670722..18ca63d 100644 --- a/src/agentkit/server/frontend/src/components/chat/ExpertTeamView.vue +++ b/src/agentkit/server/frontend/src/components/chat/ExpertTeamView.vue @@ -1,237 +1,153 @@ diff --git a/src/agentkit/server/frontend/src/components/chat/TeamModal.vue b/src/agentkit/server/frontend/src/components/chat/TeamModal.vue new file mode 100644 index 0000000..bbf2ceb --- /dev/null +++ b/src/agentkit/server/frontend/src/components/chat/TeamModal.vue @@ -0,0 +1,330 @@ + + + + + diff --git a/src/agentkit/server/frontend/src/components/chat/ToolCallCard.vue b/src/agentkit/server/frontend/src/components/chat/ToolCallCard.vue index b978ce5..1a9e8d5 100644 --- a/src/agentkit/server/frontend/src/components/chat/ToolCallCard.vue +++ b/src/agentkit/server/frontend/src/components/chat/ToolCallCard.vue @@ -24,6 +24,14 @@
{{ previewText }}
+
+ +
@@ -45,9 +53,15 @@ import { } from '@ant-design/icons-vue' import type { IToolCallData } from '@/api/types' -const props = defineProps<{ - toolCall: IToolCallData -}>() +const props = withDefaults( + defineProps<{ + toolCall: IToolCallData + depth?: number + }>(), + { depth: 0 } +) + +const MAX_DEPTH = 5 const expanded = ref(false) @@ -203,4 +217,13 @@ const previewText = computed(() => { max-height: 40px; overflow: hidden; } + +.tool-call-card__children { + display: flex; + flex-direction: column; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + padding-left: var(--space-4); + border-top: 1px solid var(--border-color-split); +} diff --git a/src/agentkit/server/frontend/src/components/chat/helpers/useMessageRenderer.ts b/src/agentkit/server/frontend/src/components/chat/helpers/useMessageRenderer.ts new file mode 100644 index 0000000..23018ee --- /dev/null +++ b/src/agentkit/server/frontend/src/components/chat/helpers/useMessageRenderer.ts @@ -0,0 +1,198 @@ +import { computed, type Component } from 'vue' +import type { IChatMessage } from '@/api/types' +import UserBubble from '@/components/chat/messages/UserBubble.vue' +import AssistantText from '@/components/chat/messages/AssistantText.vue' +import TeamPlanCard from '@/components/chat/messages/TeamPlanCard.vue' +import BoardBannerCard from '@/components/chat/messages/BoardBannerCard.vue' +import BoardRoundCard from '@/components/chat/messages/BoardRoundCard.vue' +import BoardConclusionCard from '@/components/chat/messages/BoardConclusionCard.vue' +import ErrorCard from '@/components/chat/messages/ErrorCard.vue' + +export type MessageViewType = + | 'user' + | 'assistant' + | 'team_plan' + | 'board_banner' + | 'board_speech' + | 'board_summary' + | 'board_conclusion' + | 'milestone' + | 'error' + +export interface MessageShellMeta { + name: string + meta?: string + avatar?: string + color?: string +} + +export interface MessageRenderSpec { + type: MessageViewType + shell: MessageShellMeta + component: Component + props: Record +} + +export function resolveMessageType(message: IChatMessage): MessageViewType { + if (message.role === 'user') return 'user' + if (message.status === 'error' || message.message_type === 'error') return 'error' + + switch (message.message_type) { + case 'plan_update': + return 'team_plan' + case 'board_started': + return 'board_banner' + case 'board_speech': + return 'board_speech' + case 'board_summary': + return 'board_summary' + case 'board_conclusion': + return 'board_conclusion' + case 'milestone': + return 'milestone' + default: + return 'assistant' + } +} + +function formatTime(timestamp: string): string { + return new Date(timestamp).toLocaleTimeString('zh-CN', { + hour: '2-digit', + minute: '2-digit', + }) +} + +export function useMessageRenderer(message: IChatMessage) { + return computed(() => { + const type = resolveMessageType(message) + const time = formatTime(message.timestamp) + + switch (type) { + case 'user': + return { + type, + shell: { name: '用户', meta: time }, + component: UserBubble, + props: { content: message.content || '' }, + } + + case 'team_plan': { + const phases = message.plan_phases ?? [] + return { + type, + shell: { + name: message.expert_name || '专家团', + meta: time, + avatar: message.expert_avatar, + color: message.expert_color, + }, + component: TeamPlanCard, + props: { + phases, + leadName: message.expert_name || 'Lead', + leadAvatar: message.expert_avatar, + leadColor: message.expert_color, + }, + } + } + + case 'board_banner': { + const data = message.board_started + const experts = data?.experts ?? [] + return { + type, + shell: { name: '私董会', meta: time }, + component: BoardBannerCard, + props: { + topic: data?.topic || message.content || '未命名主题', + experts, + maxRounds: data?.max_rounds ?? 5, + currentRound: message.board_round ?? 1, + }, + } + } + + case 'board_speech': + return { + type, + shell: { + name: message.expert_name || '专家', + meta: message.board_round ? `第 ${message.board_round} 轮${message.board_role === 'moderator' ? ' · 主持' : ''}` : time, + avatar: message.expert_avatar, + color: message.expert_color, + }, + component: BoardRoundCard, + props: { + name: message.expert_name || '专家', + avatar: message.expert_avatar || '', + color: message.expert_color, + round: message.board_round, + role: message.board_role === 'moderator' ? 'moderator' : 'expert', + content: message.content || '', + }, + } + + case 'board_summary': + return { + type, + shell: { + name: message.expert_name || '主持人', + meta: message.board_round ? `第 ${message.board_round} 轮 · 小结` : time, + avatar: message.expert_avatar, + color: message.expert_color, + }, + component: BoardRoundCard, + props: { + name: message.expert_name || '主持人', + avatar: message.expert_avatar || '', + color: message.expert_color, + round: message.board_round, + role: 'summary', + content: message.content || '', + }, + } + + case 'board_conclusion': + return { + type, + shell: { name: '主持人', meta: time }, + component: BoardConclusionCard, + props: { + data: message.board_conclusion ?? { + summary: message.content || '', + decision_advice: '', + total_rounds: message.board_round ?? 0, + consensus_points: [], + dissent_points: [], + }, + }, + } + + case 'error': + return { + type, + shell: { name: '系统', meta: time }, + component: ErrorCard, + props: { + title: '请求失败', + detail: message.error_detail || message.content || '', + }, + } + + case 'milestone': + case 'assistant': + default: + return { + type, + shell: { + name: message.expert_name || 'AI Agent', + meta: time, + avatar: message.expert_avatar, + color: message.expert_color, + }, + component: AssistantText, + props: { message }, + } + } + }) +} diff --git a/src/agentkit/server/frontend/src/components/chat/messages/AssistantText.vue b/src/agentkit/server/frontend/src/components/chat/messages/AssistantText.vue new file mode 100644 index 0000000..5c10197 --- /dev/null +++ b/src/agentkit/server/frontend/src/components/chat/messages/AssistantText.vue @@ -0,0 +1,335 @@ + + + + + diff --git a/src/agentkit/server/frontend/src/components/chat/messages/BoardBannerCard.vue b/src/agentkit/server/frontend/src/components/chat/messages/BoardBannerCard.vue new file mode 100644 index 0000000..4f3d925 --- /dev/null +++ b/src/agentkit/server/frontend/src/components/chat/messages/BoardBannerCard.vue @@ -0,0 +1,137 @@ + + + + + diff --git a/src/agentkit/server/frontend/src/components/chat/messages/BoardConclusionCard.vue b/src/agentkit/server/frontend/src/components/chat/messages/BoardConclusionCard.vue new file mode 100644 index 0000000..5a7cb82 --- /dev/null +++ b/src/agentkit/server/frontend/src/components/chat/messages/BoardConclusionCard.vue @@ -0,0 +1,135 @@ + + + + + diff --git a/src/agentkit/server/frontend/src/components/chat/messages/BoardRoundCard.vue b/src/agentkit/server/frontend/src/components/chat/messages/BoardRoundCard.vue new file mode 100644 index 0000000..7f81aab --- /dev/null +++ b/src/agentkit/server/frontend/src/components/chat/messages/BoardRoundCard.vue @@ -0,0 +1,126 @@ + + + + + diff --git a/src/agentkit/server/frontend/src/components/chat/messages/ErrorCard.vue b/src/agentkit/server/frontend/src/components/chat/messages/ErrorCard.vue new file mode 100644 index 0000000..39adbf0 --- /dev/null +++ b/src/agentkit/server/frontend/src/components/chat/messages/ErrorCard.vue @@ -0,0 +1,107 @@ + + + + + diff --git a/src/agentkit/server/frontend/src/components/chat/messages/FileAttachment.vue b/src/agentkit/server/frontend/src/components/chat/messages/FileAttachment.vue new file mode 100644 index 0000000..24365e4 --- /dev/null +++ b/src/agentkit/server/frontend/src/components/chat/messages/FileAttachment.vue @@ -0,0 +1,138 @@ + + + + + diff --git a/src/agentkit/server/frontend/src/components/chat/messages/MessageShell.vue b/src/agentkit/server/frontend/src/components/chat/messages/MessageShell.vue new file mode 100644 index 0000000..2c9435d --- /dev/null +++ b/src/agentkit/server/frontend/src/components/chat/messages/MessageShell.vue @@ -0,0 +1,148 @@ + + + + + diff --git a/src/agentkit/server/frontend/src/components/chat/messages/TeamPlanCard.vue b/src/agentkit/server/frontend/src/components/chat/messages/TeamPlanCard.vue new file mode 100644 index 0000000..96f34b7 --- /dev/null +++ b/src/agentkit/server/frontend/src/components/chat/messages/TeamPlanCard.vue @@ -0,0 +1,271 @@ + + + + + diff --git a/src/agentkit/server/frontend/src/components/chat/messages/UserBubble.vue b/src/agentkit/server/frontend/src/components/chat/messages/UserBubble.vue new file mode 100644 index 0000000..a167196 --- /dev/null +++ b/src/agentkit/server/frontend/src/components/chat/messages/UserBubble.vue @@ -0,0 +1,45 @@ + + + + + diff --git a/src/agentkit/server/frontend/src/components/chat/messages/index.ts b/src/agentkit/server/frontend/src/components/chat/messages/index.ts new file mode 100644 index 0000000..45b8142 --- /dev/null +++ b/src/agentkit/server/frontend/src/components/chat/messages/index.ts @@ -0,0 +1,9 @@ +export { default as MessageShell } from './MessageShell.vue' +export { default as UserBubble } from './UserBubble.vue' +export { default as AssistantText } from './AssistantText.vue' +export { default as TeamPlanCard } from './TeamPlanCard.vue' +export { default as BoardBannerCard } from './BoardBannerCard.vue' +export { default as BoardRoundCard } from './BoardRoundCard.vue' +export { default as BoardConclusionCard } from './BoardConclusionCard.vue' +export { default as ErrorCard } from './ErrorCard.vue' +export { default as FileAttachment } from './FileAttachment.vue' diff --git a/src/agentkit/server/frontend/src/components/kb/SearchTest.vue b/src/agentkit/server/frontend/src/components/kb/SearchTest.vue index 1e50550..ed46a08 100644 --- a/src/agentkit/server/frontend/src/components/kb/SearchTest.vue +++ b/src/agentkit/server/frontend/src/components/kb/SearchTest.vue @@ -97,7 +97,9 @@ async function handleSearch(): Promise { } onMounted(() => { - kbStore.fetchSources() + if (kbStore.sources.length === 0) { + kbStore.fetchSources() + } }) diff --git a/src/agentkit/server/frontend/src/components/layout/AgentLayout.vue b/src/agentkit/server/frontend/src/components/layout/AgentLayout.vue index ac934fc..1b83ed0 100644 --- a/src/agentkit/server/frontend/src/components/layout/AgentLayout.vue +++ b/src/agentkit/server/frontend/src/components/layout/AgentLayout.vue @@ -1,69 +1,90 @@ + + diff --git a/src/agentkit/server/frontend/src/components/layout/SystemMonitorPanel.vue b/src/agentkit/server/frontend/src/components/layout/SystemMonitorPanel.vue new file mode 100644 index 0000000..6556939 --- /dev/null +++ b/src/agentkit/server/frontend/src/components/layout/SystemMonitorPanel.vue @@ -0,0 +1,427 @@ + + + + + diff --git a/src/agentkit/server/frontend/src/components/layout/TopNav.vue b/src/agentkit/server/frontend/src/components/layout/TopNav.vue index a0a5bbf..274d872 100644 --- a/src/agentkit/server/frontend/src/components/layout/TopNav.vue +++ b/src/agentkit/server/frontend/src/components/layout/TopNav.vue @@ -1,7 +1,12 @@ + + diff --git a/src/agentkit/server/frontend/src/views/TerminalView.vue b/src/agentkit/server/frontend/src/views/TerminalView.vue index dcead2b..8c9de12 100644 --- a/src/agentkit/server/frontend/src/views/TerminalView.vue +++ b/src/agentkit/server/frontend/src/views/TerminalView.vue @@ -1,7 +1,7 @@ - + +
diff --git a/tests/unit/experts/test_board_router.py b/tests/unit/experts/test_board_router.py index 72a78d4..605e08e 100644 --- a/tests/unit/experts/test_board_router.py +++ b/tests/unit/experts/test_board_router.py @@ -57,12 +57,14 @@ def _make_registry_with_experts() -> ExpertTemplateRegistry: task_mode="llm_generate", prompt={"identity": "Private Board"}, ) - registry.register(ExpertTemplate( - name="private_board", - config=board_config, - is_builtin=True, - description="默认私董会模板", - )) + registry.register( + ExpertTemplate( + name="private_board", + config=board_config, + is_builtin=True, + description="默认私董会模板", + ) + ) return registry @@ -164,6 +166,34 @@ class TestBoardRouterResolve: assert result.matched is True assert result.topic == "" + def test_resolve_with_rounds_option(self): + """@board:expert1,expert2 rounds=N 主题 → 解析最大轮次""" + router = BoardRouter(template_registry=_make_registry_with_experts()) + result = router.resolve("@board:elon_musk,jeff_bezos rounds=3 SpaceX上市问题") + + assert result.matched is True + assert result.max_rounds == 3 + assert result.topic == "SpaceX上市问题" + assert result.specified_experts == ["elon_musk", "jeff_bezos"] + + def test_resolve_rounds_option_clamped(self): + """rounds 超出范围会被限制""" + router = BoardRouter(template_registry=_make_registry_with_experts()) + result = router.resolve("@board rounds=999 讨论主题") + + assert result.matched is True + assert result.max_rounds == 50 + assert result.topic == "讨论主题" + + def test_resolve_without_rounds_option(self): + """未指定 rounds 时 max_rounds 为 None""" + router = BoardRouter(template_registry=_make_registry_with_experts()) + result = router.resolve("@board:elon_musk 讨论主题") + + assert result.matched is True + assert result.max_rounds is None + assert result.topic == "讨论主题" + def test_resolve_invalid_expert_names_filtered(self): """无效专家名被过滤""" router = BoardRouter(template_registry=_make_registry_with_experts()) @@ -282,6 +312,7 @@ class TestBoardRoutingResult: assert result.topic == "" assert result.use_default_template is False assert result.match_method == "" + assert result.max_rounds is None def test_custom_values(self): """自定义值""" @@ -292,6 +323,7 @@ class TestBoardRoutingResult: topic="测试主题", use_default_template=True, match_method="explicit_board", + max_rounds=7, ) assert result.matched is True assert result.board_mode is True diff --git a/tests/unit/experts/test_team_orchestrator.py b/tests/unit/experts/test_team_orchestrator.py index 6528955..57c9db5 100644 --- a/tests/unit/experts/test_team_orchestrator.py +++ b/tests/unit/experts/test_team_orchestrator.py @@ -130,7 +130,7 @@ def _make_mock_llm_gateway( decomp_response.content = phases_json synth_response = MagicMock() synth_response.content = synthesis_content - gateway.chat = AsyncMock(side_effect=[decomp_response, synth_response]) + gateway.chat = AsyncMock(side_effect=[decomp_response, synth_response, synth_response]) else: response = MagicMock() response.content = synthesis_content @@ -202,6 +202,15 @@ class TestPipelineExecution: ) team._experts["lead"].agent._llm_gateway = gateway + # Track execution order + execution_order: list[str] = [] + for name in ["lead", "member1", "member2"]: + original_execute = team._experts[name].agent.execute + async def tracking_execute(task_msg, _orig=original_execute, _name=name): + execution_order.append(_name) + return await _orig(task_msg) + team._experts[name].agent.execute = tracking_execute + result = await orchestrator.execute("串行任务") assert result["status"] == "completed" @@ -210,6 +219,8 @@ class TestPipelineExecution: # All phases should be completed for ph in plan.phases: assert ph.status == PhaseStatus.COMPLETED + # Verify sequential execution order: A(lead) → B(member1) → C(member2) + assert execution_order == ["lead", "member1", "member2"] @pytest.mark.asyncio async def test_pipeline_parallel_phases(self): diff --git a/tests/unit/server/test_chat_team.py b/tests/unit/server/test_chat_team.py index c704a8d..97a4f7b 100644 --- a/tests/unit/server/test_chat_team.py +++ b/tests/unit/server/test_chat_team.py @@ -272,6 +272,8 @@ class TestTeamCollabErrorHandling: ) # 应该调用了 dissolve 清理 mock_team.dissolve.assert_called() + # 失败时不应发送 final_answer + assert not any(msg.get("type") == "final_answer" for msg in websocket.sent) def _make_mock_orchestrator_result_failing() -> MagicMock: @@ -402,6 +404,39 @@ class TestTeamCollabEventRelay: call_args = mock_team.handoff_transport.register_handler.call_args assert call_args[0][0] == "team:test-channel" + @pytest.mark.asyncio + async def test_team_events_relayed_to_websocket( + self, websocket, session_manager, mock_orchestrator_result + ): + """注册的 handler 将团队事件转发到 WebSocket""" + session_id = session_manager._test_session_id + with patch( + "agentkit.experts.team.ExpertTeam" + ) as mock_team_cls, patch( + "agentkit.experts.orchestrator.TeamOrchestrator" + ) as mock_orch_cls: + mock_team = _make_mock_team() + mock_team.team_channel = "team:test-channel" + mock_team_cls.return_value = mock_team + mock_orch_cls.return_value = _make_mock_orchestrator(mock_orchestrator_result) + + await _execute_team_collab( + websocket, session_id, "@team 开发功能", session_manager + ) + + # Get the registered handler and invoke it with a sample event + register_call = mock_team.handoff_transport.register_handler.call_args + handler = register_call[0][1] # second arg is the handler callback + await handler({"type": "expert_step", "expert": "lead", "message": "开始分析"}) + + # Verify the event was relayed to WebSocket via emit_team_event + team_event_msgs = [ + msg for msg in websocket.sent + if msg.get("type") == "expert_step" + ] + assert len(team_event_msgs) == 1 + assert team_event_msgs[0].get("data", {}).get("expert") == "lead" + @pytest.mark.asyncio async def test_create_team_called_with_lead_and_members( self, websocket, session_manager, mock_orchestrator_result @@ -460,7 +495,7 @@ class TestTeamCollabEventRelay: final_msgs = [msg for msg in websocket.sent if msg.get("type") == "final_answer"] assert len(final_msgs) == 1 # 应该包含 phase_results 的内容 - assert "阶段1结果" in final_msgs[0]["content"] or "阶段2结果" in final_msgs[0]["content"] + assert "阶段1结果" in final_msgs[0]["content"] and "阶段2结果" in final_msgs[0]["content"] @pytest.mark.asyncio async def test_completely_empty_result( diff --git a/tests/unit/server/test_kb_management.py b/tests/unit/server/test_kb_management.py index 8d1e366..63e9ac1 100644 --- a/tests/unit/server/test_kb_management.py +++ b/tests/unit/server/test_kb_management.py @@ -6,6 +6,7 @@ import io from unittest.mock import AsyncMock, MagicMock, patch import pytest +from fastapi import Request from fastapi.testclient import TestClient from agentkit.llm.gateway import LLMGateway @@ -39,11 +40,24 @@ def tool_registry(): @pytest.fixture def app(mock_llm_gateway, skill_registry, tool_registry): - return create_app( + application = create_app( llm_gateway=mock_llm_gateway, skill_registry=skill_registry, tool_registry=tool_registry, ) + # Inject a dev-mode admin user so the KB_WRITE / KB_QUERY permission + # checks pass. In production, AuthMiddleware sets this from JWT. + @application.middleware("http") + async def _set_dev_admin_user(request: Request, call_next): + request.state.current_user = { + "user_id": "dev-admin", + "username": "dev-admin", + "role": "admin", + "dev_mode": True, + } + return await call_next(request) + + return application @pytest.fixture diff --git a/tests/unit/server/test_settings_routes.py b/tests/unit/server/test_settings_routes.py index 5936b26..42282ce 100644 --- a/tests/unit/server/test_settings_routes.py +++ b/tests/unit/server/test_settings_routes.py @@ -7,6 +7,7 @@ import unittest.mock import pytest import yaml +from fastapi import Request from fastapi.testclient import TestClient from agentkit.server.app import create_app @@ -83,9 +84,22 @@ def server_config(config_file): @pytest.fixture def app(server_config): - return create_app( + application = create_app( server_config=server_config, ) + # Inject a dev-mode admin user so the SYSTEM_CONFIG permission checks + # on PUT endpoints pass. In production, AuthMiddleware sets this from JWT. + @application.middleware("http") + async def _set_dev_admin_user(request: Request, call_next): + request.state.current_user = { + "user_id": "dev-admin", + "username": "dev-admin", + "role": "admin", + "dev_mode": True, + } + return await call_next(request) + + return application @pytest.fixture @@ -364,6 +378,19 @@ class TestNoConfigPath: with unittest.mock.patch("pathlib.Path.exists", return_value=False): app = create_app(llm_gateway=LLMGateway()) # server_config is None in this case + + # Inject dev-admin user so the SYSTEM_CONFIG permission check passes + # and the request reaches the config-path check (which returns 400). + @app.middleware("http") + async def _set_dev_admin_user(request: Request, call_next): + request.state.current_user = { + "user_id": "dev-admin", + "username": "dev-admin", + "role": "admin", + "dev_mode": True, + } + return await call_next(request) + client = TestClient(app) response = client.put( diff --git a/tests/unit/server/test_terminal_routes.py b/tests/unit/server/test_terminal_routes.py index e65015d..236c760 100644 --- a/tests/unit/server/test_terminal_routes.py +++ b/tests/unit/server/test_terminal_routes.py @@ -3,6 +3,7 @@ from __future__ import annotations import pytest +from fastapi import Request from fastapi.testclient import TestClient from agentkit.llm.gateway import LLMGateway @@ -54,6 +55,22 @@ def app(mock_llm_gateway, skill_registry, tool_registry): skill_registry=skill_registry, tool_registry=tool_registry, ) + # Inject a dev-mode admin user so the terminal permission checks pass. + # In production, the AuthMiddleware sets request.state.current_user from + # the JWT; here we simulate an authenticated admin for unit testing. + # The ``dev_mode: True`` flag skips the DB-based is_terminal_authorized + # check in require_terminal_authorized (we don't have a real auth DB + # in these unit tests). + @application.middleware("http") + async def _set_dev_admin_user(request: Request, call_next): + request.state.current_user = { + "user_id": "dev-admin", + "username": "dev-admin", + "role": "admin", + "dev_mode": True, + } + return await call_next(request) + return application diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py new file mode 100644 index 0000000..3d84aa2 --- /dev/null +++ b/tests/unit/test_auth.py @@ -0,0 +1,662 @@ +"""Unit tests for the JWT auth module (U2). + +Covers: +- password hashing / verification +- JWT token pair creation / verification (success, expired, invalid) +- AuthMiddleware (whitelist, JWT, API key, dev mode, 401) +- /auth/login (correct + wrong password) +- /auth/refresh (valid refresh token) +- /auth/me (returns user info) +""" + +from __future__ import annotations + +import uuid +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any + +import aiosqlite +import jwt +import pytest +from fastapi import FastAPI, Request +from fastapi.testclient import TestClient + +from agentkit.server.auth.jwt_utils import ( + create_token_pair, + verify_token, +) +from agentkit.server.auth.middleware import AuthMiddleware +from agentkit.server.auth.models import init_auth_db +from agentkit.server.auth.password import hash_password, verify_password +from agentkit.server.routes import auth as auth_routes + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def jwt_secret() -> str: + """A fixed JWT secret for deterministic tests.""" + return "test-jwt-secret-for-unit-tests-do-not-use-in-prod" + + +@pytest.fixture +async def tmp_auth_db(tmp_path: Path) -> Path: + """Create a fresh auth DB in a temp directory and return its path.""" + db_path = tmp_path / "auth.db" + await init_auth_db(db_path) + return db_path + + +@pytest.fixture +async def auth_db_with_user(tmp_auth_db: Path) -> dict[str, Any]: + """Insert a test user into the auth DB and return user fields + plaintext password.""" + user_id = str(uuid.uuid4()) + username = "testuser" + email = "testuser@example.com" + password = "correct-horse-battery-staple" + password_hash = hash_password(password) + now_iso = datetime.now(timezone.utc).isoformat() + + async with aiosqlite.connect(str(tmp_auth_db)) as db: + await db.execute( + "INSERT INTO users " + "(id, username, email, password_hash, role, is_active, " + " is_terminal_authorized, is_server_terminal_authorized, " + " created_at, updated_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ( + user_id, + username, + email, + password_hash, + "member", + 1, + 0, + 0, + now_iso, + now_iso, + ), + ) + await db.commit() + + return { + "id": user_id, + "username": username, + "email": email, + "password": password, + "password_hash": password_hash, + "role": "member", + "db_path": tmp_auth_db, + } + + +@pytest.fixture +def auth_app(jwt_secret: str, auth_db_with_user: dict[str, Any]) -> FastAPI: + """A FastAPI app with auth routes + AuthMiddleware, wired to the test DB + secret.""" + app = FastAPI() + app.state.jwt_secret = jwt_secret + app.state.auth_db_path = str(auth_db_with_user["db_path"]) + app.add_middleware(AuthMiddleware, jwt_secret=jwt_secret) + app.include_router(auth_routes.router, prefix="/api/v1") + return app + + +@pytest.fixture +def auth_client(auth_app: FastAPI) -> TestClient: + """TestClient for the auth-only app (no auth middleware).""" + return TestClient(auth_app) + + +# --------------------------------------------------------------------------- +# Password tests +# --------------------------------------------------------------------------- + + +class TestPassword: + """bcrypt hash_password / verify_password.""" + + def test_hash_and_verify_correct_password(self): + """hash_password then verify_password with the same password → True.""" + password = "my-secret-password-123" + hashed = hash_password(password) + assert hashed != password + assert hashed.startswith("$2b$12$") + assert verify_password(password, hashed) is True + + def test_verify_wrong_password_returns_false(self): + """verify_password with a different password → False.""" + hashed = hash_password("correct-password") + assert verify_password("wrong-password", hashed) is False + + def test_hash_is_salt_randomized(self): + """Same password hashed twice → different hashes (salt randomization).""" + password = "same-password" + h1 = hash_password(password) + h2 = hash_password(password) + assert h1 != h2 + # Both should still verify against the original password + assert verify_password(password, h1) is True + assert verify_password(password, h2) is True + + def test_verify_malformed_hash_returns_false(self): + """verify_password with a malformed hash → False (no exception).""" + assert verify_password("any-password", "not-a-valid-bcrypt-hash") is False + + +# --------------------------------------------------------------------------- +# JWT tests +# --------------------------------------------------------------------------- + + +class TestJWT: + """create_token_pair / verify_token.""" + + def test_create_token_pair_returns_valid_tokens(self, jwt_secret: str): + """create_token_pair returns two non-empty JWT strings.""" + pair = create_token_pair( + user_id="user-123", + username="alice", + role="member", + secret=jwt_secret, + ) + assert pair.access_token + assert pair.refresh_token + assert pair.access_token != pair.refresh_token + assert pair.access_expires_at > datetime.now(timezone.utc) + assert pair.refresh_expires_at > pair.access_expires_at + + def test_verify_access_token_succeeds(self, jwt_secret: str): + """verify_token on a fresh access token returns the payload.""" + pair = create_token_pair( + user_id="user-123", + username="alice", + role="member", + secret=jwt_secret, + ) + payload = verify_token(pair.access_token, jwt_secret) + assert payload["sub"] == "user-123" + assert payload["username"] == "alice" + assert payload["role"] == "member" + assert payload["type"] == "access" + + def test_verify_refresh_token_succeeds(self, jwt_secret: str): + """verify_token on a refresh token returns the payload with type=refresh.""" + pair = create_token_pair( + user_id="user-123", + username="alice", + role="member", + secret=jwt_secret, + ) + payload = verify_token(pair.refresh_token, jwt_secret) + assert payload["type"] == "refresh" + + def test_verify_expired_token_raises(self, jwt_secret: str): + """verify_token on an expired token raises jwt.ExpiredSignatureError.""" + past = datetime.now(timezone.utc) - timedelta(hours=1) + pair = create_token_pair( + user_id="user-123", + username="alice", + role="member", + secret=jwt_secret, + now=past, + ) + with pytest.raises(jwt.ExpiredSignatureError): + verify_token(pair.access_token, jwt_secret) + + def test_verify_invalid_token_raises(self, jwt_secret: str): + """verify_token on a malformed token raises jwt.InvalidTokenError.""" + with pytest.raises(jwt.InvalidTokenError): + verify_token("not.a.valid.jwt", jwt_secret) + + def test_verify_wrong_secret_raises(self, jwt_secret: str): + """verify_token with the wrong secret raises jwt.InvalidSignatureError.""" + pair = create_token_pair( + user_id="user-123", + username="alice", + role="member", + secret=jwt_secret, + ) + with pytest.raises(jwt.InvalidTokenError): + verify_token(pair.access_token, "a-different-secret") + + def test_empty_secret_raises(self): + """create_token_pair with an empty secret raises ValueError.""" + with pytest.raises(ValueError): + create_token_pair( + user_id="user-123", + username="alice", + role="member", + secret="", + ) + + +# --------------------------------------------------------------------------- +# AuthMiddleware tests +# --------------------------------------------------------------------------- + + +def _make_minimal_app() -> FastAPI: + """Minimal FastAPI app with a public + protected endpoint.""" + app = FastAPI() + + @app.get("/api/v1/health") + async def health(): + return {"status": "ok"} + + @app.get("/api/v1/auth/login") + async def login_page(): + return {"page": "login"} + + @app.get("/api/v1/protected") + async def protected(request: Request): + user = getattr(request.state, "current_user", None) + return {"user": user} + + return app + + +class TestAuthMiddleware: + """AuthMiddleware dispatch logic.""" + + def test_whitelist_path_passes_without_auth(self): + """Whitelisted paths (e.g. /api/v1/health) pass without any auth.""" + app = _make_minimal_app() + app.add_middleware(AuthMiddleware, jwt_secret="s", api_key="k") + client = TestClient(app) + resp = client.get("/api/v1/health") + assert resp.status_code == 200 + assert resp.json() == {"status": "ok"} + + def test_login_path_whitelisted(self): + """/api/v1/auth/login is whitelisted so users can log in.""" + app = _make_minimal_app() + app.add_middleware(AuthMiddleware, jwt_secret="s", api_key="k") + client = TestClient(app) + resp = client.get("/api/v1/auth/login") + assert resp.status_code == 200 + + def test_jwt_auth_success_sets_current_user(self, jwt_secret: str): + """Valid JWT → 200 and request.state.current_user is populated.""" + app = _make_minimal_app() + app.add_middleware(AuthMiddleware, jwt_secret=jwt_secret) + client = TestClient(app) + + pair = create_token_pair( + user_id="user-1", + username="alice", + role="member", + secret=jwt_secret, + ) + resp = client.get( + "/api/v1/protected", + headers={"Authorization": f"Bearer {pair.access_token}"}, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["user"]["user_id"] == "user-1" + assert body["user"]["username"] == "alice" + assert body["user"]["role"] == "member" + + def test_jwt_with_wrong_secret_returns_401(self, jwt_secret: str): + """JWT signed with a different secret → 401.""" + app = _make_minimal_app() + app.add_middleware(AuthMiddleware, jwt_secret=jwt_secret) + client = TestClient(app) + + pair = create_token_pair( + user_id="user-1", + username="alice", + role="member", + secret="a-different-secret", + ) + resp = client.get( + "/api/v1/protected", + headers={"Authorization": f"Bearer {pair.access_token}"}, + ) + assert resp.status_code == 401 + + def test_refresh_token_rejected_for_request_auth(self, jwt_secret: str): + """A refresh token (type=refresh) must not authenticate requests.""" + app = _make_minimal_app() + app.add_middleware(AuthMiddleware, jwt_secret=jwt_secret, api_key="fallback-key") + client = TestClient(app) + + pair = create_token_pair( + user_id="user-1", + username="alice", + role="member", + secret=jwt_secret, + ) + resp = client.get( + "/api/v1/protected", + headers={"Authorization": f"Bearer {pair.refresh_token}"}, + ) + # Should fall through to API key check (no X-API-Key) → 401 + assert resp.status_code == 401 + + def test_api_key_auth_success(self): + """Valid X-API-Key → 200.""" + app = _make_minimal_app() + app.add_middleware(AuthMiddleware, api_key="my-global-key") + client = TestClient(app) + resp = client.get( + "/api/v1/protected", + headers={"X-API-Key": "my-global-key"}, + ) + assert resp.status_code == 200 + + def test_api_key_client_keys_success(self): + """API key matching a client_keys entry → 200.""" + app = _make_minimal_app() + app.add_middleware( + AuthMiddleware, + client_keys={"client-a": "key-for-a"}, + ) + client = TestClient(app) + resp = client.get( + "/api/v1/protected", + headers={"X-API-Key": "key-for-a"}, + ) + assert resp.status_code == 200 + + def test_api_key_wrong_returns_401(self): + """Wrong X-API-Key → 401.""" + app = _make_minimal_app() + app.add_middleware(AuthMiddleware, api_key="my-global-key") + client = TestClient(app) + resp = client.get( + "/api/v1/protected", + headers={"X-API-Key": "wrong-key"}, + ) + assert resp.status_code == 401 + + def test_dev_mode_passes_through(self): + """No JWT secret, no API key, no client keys → dev mode, all pass.""" + app = _make_minimal_app() + app.add_middleware(AuthMiddleware) + client = TestClient(app) + resp = client.get("/api/v1/protected") + assert resp.status_code == 200 + + def test_no_auth_returns_401(self): + """Auth configured but no credentials provided → 401.""" + app = _make_minimal_app() + app.add_middleware(AuthMiddleware, jwt_secret="s", api_key="k") + client = TestClient(app) + resp = client.get("/api/v1/protected") + assert resp.status_code == 401 + body = resp.json() + assert body["error"] == "Unauthorized" + + def test_jwt_via_query_param_on_ws_path(self, jwt_secret: str): + """WebSocket paths accept ?token= as a fallback for clients + that cannot set the Authorization header (e.g. browser WebSocket API). + """ + app = _make_minimal_app() + + @app.get("/api/v1/ws/echo") + async def ws_echo(request: Request): + user = getattr(request.state, "current_user", None) + return {"user": user} + + app.add_middleware(AuthMiddleware, jwt_secret=jwt_secret) + client = TestClient(app) + + pair = create_token_pair( + user_id="user-1", + username="alice", + role="member", + secret=jwt_secret, + ) + resp = client.get(f"/api/v1/ws/echo?token={pair.access_token}") + assert resp.status_code == 200 + body = resp.json() + assert body["user"]["user_id"] == "user-1" + assert body["user"]["username"] == "alice" + + def test_jwt_via_query_param_rejected_on_non_ws_path(self, jwt_secret: str): + """The ?token= query parameter is only honored on /ws paths — + using it on a regular REST endpoint should not authenticate. + """ + app = _make_minimal_app() + app.add_middleware(AuthMiddleware, jwt_secret=jwt_secret, api_key="fallback") + client = TestClient(app) + + pair = create_token_pair( + user_id="user-1", + username="alice", + role="member", + secret=jwt_secret, + ) + # /api/v1/protected is NOT a /ws path → token query param ignored + resp = client.get(f"/api/v1/protected?token={pair.access_token}") + # No Authorization header, no X-API-Key → 401 (query param not honored) + assert resp.status_code == 401 + + def test_jwt_via_query_param_invalid_token_returns_401(self, jwt_secret: str): + """An invalid ?token= value on a /ws path → 401 (falls through).""" + app = _make_minimal_app() + + @app.get("/api/v1/ws/echo") + async def ws_echo(request: Request): + user = getattr(request.state, "current_user", None) + return {"user": user} + + app.add_middleware(AuthMiddleware, jwt_secret=jwt_secret, api_key="fallback") + client = TestClient(app) + + resp = client.get("/api/v1/ws/echo?token=not.a.valid.jwt") + assert resp.status_code == 401 + + +# --------------------------------------------------------------------------- +# Auth routes tests +# --------------------------------------------------------------------------- + + +class TestLoginRoute: + """POST /api/v1/auth/login.""" + + def test_login_correct_password_returns_token( + self, + auth_client: TestClient, + auth_db_with_user: dict[str, Any], + ): + """Login with correct password → 200 + TokenResponse.""" + resp = auth_client.post( + "/api/v1/auth/login", + json={ + "username": auth_db_with_user["username"], + "password": auth_db_with_user["password"], + }, + ) + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["access_token"] + assert body["refresh_token"] + assert body["token_type"] == "bearer" + assert body["expires_in"] == 900 + assert body["user"]["username"] == auth_db_with_user["username"] + assert body["user"]["email"] == auth_db_with_user["email"] + assert body["user"]["role"] == "member" + assert body["user"]["is_active"] is True + + def test_login_wrong_password_returns_401( + self, + auth_client: TestClient, + auth_db_with_user: dict[str, Any], + ): + """Login with wrong password → 401.""" + resp = auth_client.post( + "/api/v1/auth/login", + json={ + "username": auth_db_with_user["username"], + "password": "totally-wrong-password", + }, + ) + assert resp.status_code == 401 + assert "Invalid username or password" in resp.json()["detail"] + + def test_login_unknown_user_returns_401( + self, + auth_client: TestClient, + ): + """Login with unknown username → 401.""" + resp = auth_client.post( + "/api/v1/auth/login", + json={"username": "ghost", "password": "anything"}, + ) + assert resp.status_code == 401 + + +class TestRefreshRoute: + """POST /api/v1/auth/refresh.""" + + def test_refresh_valid_token_returns_new_access_token( + self, + auth_client: TestClient, + auth_db_with_user: dict[str, Any], + ): + """Refresh with a valid refresh token → 200 + new TokenResponse.""" + # First log in to get a refresh token + login_resp = auth_client.post( + "/api/v1/auth/login", + json={ + "username": auth_db_with_user["username"], + "password": auth_db_with_user["password"], + }, + ) + assert login_resp.status_code == 200 + refresh_token = login_resp.json()["refresh_token"] + + # Now refresh + resp = auth_client.post( + "/api/v1/auth/refresh", + json={"refresh_token": refresh_token}, + ) + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["access_token"] + assert body["refresh_token"] + assert body["user"]["username"] == auth_db_with_user["username"] + + def test_refresh_invalid_token_returns_401( + self, + auth_client: TestClient, + ): + """Refresh with an invalid token → 401.""" + resp = auth_client.post( + "/api/v1/auth/refresh", + json={"refresh_token": "not.a.valid.jwt"}, + ) + assert resp.status_code == 401 + + def test_refresh_revoked_token_returns_401( + self, + auth_client: TestClient, + auth_db_with_user: dict[str, Any], + ): + """Refresh with a revoked token → 401.""" + # Login + login_resp = auth_client.post( + "/api/v1/auth/login", + json={ + "username": auth_db_with_user["username"], + "password": auth_db_with_user["password"], + }, + ) + refresh_token = login_resp.json()["refresh_token"] + + # Logout (revokes the refresh token) + logout_resp = auth_client.post( + "/api/v1/auth/logout", + json={"refresh_token": refresh_token}, + ) + assert logout_resp.status_code == 200 + assert logout_resp.json()["revoked"] is True + + # Refresh should now fail + resp = auth_client.post( + "/api/v1/auth/refresh", + json={"refresh_token": refresh_token}, + ) + assert resp.status_code == 401 + + +class TestMeRoute: + """GET /api/v1/auth/me.""" + + def test_me_returns_user_info( + self, + auth_client: TestClient, + auth_db_with_user: dict[str, Any], + ): + """Authenticated /me → 200 + UserResponse.""" + # Login to get an access token + login_resp = auth_client.post( + "/api/v1/auth/login", + json={ + "username": auth_db_with_user["username"], + "password": auth_db_with_user["password"], + }, + ) + access_token = login_resp.json()["access_token"] + + # Call /me with the access token + resp = auth_client.get( + "/api/v1/auth/me", + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["id"] == auth_db_with_user["id"] + assert body["username"] == auth_db_with_user["username"] + assert body["email"] == auth_db_with_user["email"] + assert body["role"] == "member" + assert body["is_active"] is True + + def test_me_without_auth_returns_401( + self, + auth_client: TestClient, + ): + """/me without authentication → 401.""" + resp = auth_client.get("/api/v1/auth/me") + assert resp.status_code == 401 + + +# --------------------------------------------------------------------------- +# init_auth_db test +# --------------------------------------------------------------------------- + + +class TestInitAuthDb: + """init_auth_db creates the expected tables.""" + + async def test_init_creates_tables(self, tmp_path: Path): + """init_auth_db creates users, user_api_keys, user_sessions tables.""" + db_path = tmp_path / "auth.db" + await init_auth_db(db_path) + assert db_path.exists() + + # Verify tables exist by querying sqlite_master + async with aiosqlite.connect(str(db_path)) as db: + cursor = await db.execute( + "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name" + ) + rows = await cursor.fetchall() + names = [r[0] for r in rows] + + assert "users" in names + assert "user_api_keys" in names + assert "user_sessions" in names + + async def test_init_is_idempotent(self, tmp_path: Path): + """init_auth_db can be called twice without error.""" + db_path = tmp_path / "auth.db" + await init_auth_db(db_path) + await init_auth_db(db_path) + assert db_path.exists() diff --git a/tests/unit/test_chat_routes.py b/tests/unit/test_chat_routes.py index fbcae8c..034021c 100644 --- a/tests/unit/test_chat_routes.py +++ b/tests/unit/test_chat_routes.py @@ -1,5 +1,8 @@ """Tests for Chat API routes.""" +import tempfile +from pathlib import Path + import pytest from unittest.mock import AsyncMock, MagicMock, patch from fastapi.testclient import TestClient @@ -96,3 +99,42 @@ class TestChatSessionCRUD: json={"content": "Hello"}, ) assert resp.status_code == 404 + + +class TestChatFileUpload: + def test_upload_and_download_file(self, client, monkeypatch): + from agentkit.server.routes import chat as chat_module + + with tempfile.TemporaryDirectory() as tmpdir: + monkeypatch.setattr(chat_module, "UPLOAD_DIR", Path(tmpdir)) + resp = client.post( + "/api/v1/chat/upload", + files={"file": ("hello.txt", b"hello world", "text/plain")}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["filename"] == "hello.txt" + assert data["content_type"] == "text/plain" + assert data["size"] == 11 + assert "/api/v1/chat/uploads/" in data["download_url"] + + stored_name = data["stored_name"] + download = client.get(f"/api/v1/chat/uploads/{stored_name}") + assert download.status_code == 200 + assert download.content == b"hello world" + + def test_upload_file_exceeds_size_limit(self, client, monkeypatch): + from agentkit.server.routes import chat as chat_module + + with tempfile.TemporaryDirectory() as tmpdir: + monkeypatch.setattr(chat_module, "UPLOAD_DIR", Path(tmpdir)) + large_content = b"x" * (chat_module.MAX_UPLOAD_SIZE + 1) + resp = client.post( + "/api/v1/chat/upload", + files={"file": ("big.bin", large_content, "application/octet-stream")}, + ) + assert resp.status_code == 413 + + def test_download_missing_file(self, client): + resp = client.get("/api/v1/chat/uploads/notfound.bin") + assert resp.status_code == 404 diff --git a/tests/unit/test_config_sync.py b/tests/unit/test_config_sync.py new file mode 100644 index 0000000..4fb6ba7 --- /dev/null +++ b/tests/unit/test_config_sync.py @@ -0,0 +1,497 @@ +"""Tests for U7: configuration sync engine. + +Covers: + - Server-side config sync API (version, all, skills, agents, workflows) + - Client-side ConfigSync (start, poll, cache, offline) +""" + +from __future__ import annotations + +import asyncio +import json +import sqlite3 +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import pytest +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient + +from agentkit.client.sync import ConfigSync +from agentkit.server.routes import config_sync + + +# ── Test fixtures ───────────────────────────────────────────────────── + + +class _FakeSkillConfig: + """Minimal skill config mock with to_dict().""" + + def __init__(self, name: str, version: str = "1.0.0"): + self.name = name + self.version = version + + def to_dict(self) -> dict[str, Any]: + return {"name": self.name, "version": self.version, "description": f"Skill {self.name}"} + + +class _FakeSkill: + def __init__(self, name: str, version: str = "1.0.0"): + self.config = _FakeSkillConfig(name, version) + + +class _FakeSkillRegistry: + """Minimal SkillRegistry mock.""" + + def __init__(self, skills: dict[str, _FakeSkill] | None = None): + self._skills = skills or {} + + def list_skills(self) -> list[str]: + return sorted(self._skills.keys()) + + def get(self, name: str) -> _FakeSkill | None: + return self._skills.get(name) + + +class _FakeWorkflow: + """Minimal workflow definition mock with model_dump().""" + + def __init__(self, workflow_id: str, name: str, version: int = 1): + self.workflow_id = workflow_id + self.name = name + self.version = version + self.stages: list = [] + self.triggers: list = [] + self.created_at = "2026-01-01T00:00:00Z" + self.updated_at = "2026-01-01T00:00:00Z" + + def model_dump(self) -> dict[str, Any]: + return { + "workflow_id": self.workflow_id, + "name": self.name, + "version": self.version, + "stages": self.stages, + "triggers": self.triggers, + "created_at": self.created_at, + "updated_at": self.updated_at, + } + + +class _FakeWorkflowStore: + def __init__(self, workflows: list[_FakeWorkflow] | None = None): + self._workflows = workflows or [] + + def list_workflows(self) -> list[_FakeWorkflow]: + return list(self._workflows) + + +@pytest.fixture +def config_app_with_data() -> FastAPI: + """App with mock skill registry + workflow store.""" + app = FastAPI() + app.state.skill_registry = _FakeSkillRegistry({ + "react_agent": _FakeSkill("react_agent", "1.0.0"), + "code_reviewer": _FakeSkill("code_reviewer", "2.1.0"), + }) + app.state.workflow_store = _FakeWorkflowStore([ + _FakeWorkflow("wf-1", "CI Pipeline", 1), + _FakeWorkflow("wf-2", "Deploy", 3), + ]) + app.include_router(config_sync.router, prefix="/api/v1") + + # Dev-admin middleware + @app.middleware("http") + async def _set_dev_admin_user(request, call_next): + request.state.current_user = { + "user_id": "dev-admin", + "username": "dev-admin", + "role": "admin", + "dev_mode": True, + } + return await call_next(request) + + return app + + +@pytest.fixture +def config_app_empty() -> FastAPI: + """App with no skills or workflows.""" + app = FastAPI() + app.state.skill_registry = _FakeSkillRegistry() + app.state.workflow_store = _FakeWorkflowStore() + app.include_router(config_sync.router, prefix="/api/v1") + + @app.middleware("http") + async def _set_dev_admin_user(request, call_next): + request.state.current_user = { + "user_id": "dev-admin", + "username": "dev-admin", + "role": "admin", + "dev_mode": True, + } + return await call_next(request) + + return app + + +@pytest.fixture +async def config_client(config_app_with_data: FastAPI): + transport = ASGITransport(app=config_app_with_data) + async with AsyncClient(transport=transport, base_url="http://test") as client: + yield client + + +# ── Server API tests ────────────────────────────────────────────────── + + +class TestConfigVersionEndpoint: + """Test GET /api/v1/config/version.""" + + @pytest.mark.asyncio + async def test_version_returns_hash(self, config_client: AsyncClient): + resp = await config_client.get("/api/v1/config/version") + assert resp.status_code == 200 + data = resp.json() + assert "version" in data + assert len(data["version"]) == 64 # SHA-256 hex + assert data["skill_count"] == 2 + assert data["workflow_count"] == 2 + assert "computed_at" in data + + @pytest.mark.asyncio + async def test_version_is_stable(self, config_client: AsyncClient): + """Same configs → same version hash.""" + resp1 = await config_client.get("/api/v1/config/version") + resp2 = await config_client.get("/api/v1/config/version") + assert resp1.json()["version"] == resp2.json()["version"] + + @pytest.mark.asyncio + async def test_version_empty_app(self, config_app_empty: FastAPI): + transport = ASGITransport(app=config_app_empty) + async with AsyncClient(transport=transport, base_url="http://test") as client: + resp = await client.get("/api/v1/config/version") + assert resp.status_code == 200 + data = resp.json() + assert data["skill_count"] == 0 + assert data["workflow_count"] == 0 + assert len(data["version"]) == 64 + + +class TestConfigAllEndpoint: + """Test GET /api/v1/config/all.""" + + @pytest.mark.asyncio + async def test_all_returns_skills_and_workflows(self, config_client: AsyncClient): + resp = await config_client.get("/api/v1/config/all") + assert resp.status_code == 200 + data = resp.json() + + assert "version" in data + assert len(data["version"]) == 64 + assert "synced_at" in data + + skills = data["skills"] + assert len(skills) == 2 + skill_names = {s["name"] for s in skills} + assert skill_names == {"react_agent", "code_reviewer"} + + workflows = data["workflows"] + assert len(workflows) == 2 + wf_ids = {w["workflow_id"] for w in workflows} + assert wf_ids == {"wf-1", "wf-2"} + + @pytest.mark.asyncio + async def test_all_version_matches_version_endpoint(self, config_client: AsyncClient): + all_resp = await config_client.get("/api/v1/config/all") + ver_resp = await config_client.get("/api/v1/config/version") + assert all_resp.json()["version"] == ver_resp.json()["version"] + + +class TestConfigSkillsEndpoint: + """Test GET /api/v1/config/skills.""" + + @pytest.mark.asyncio + async def test_skills_returns_list(self, config_client: AsyncClient): + resp = await config_client.get("/api/v1/config/skills") + assert resp.status_code == 200 + data = resp.json() + assert len(data["skills"]) == 2 + assert data["count"] == 2 + assert "synced_at" in data + + +class TestConfigAgentsEndpoint: + """Test GET /api/v1/config/agents.""" + + @pytest.mark.asyncio + async def test_agents_returns_list(self, config_client: AsyncClient): + resp = await config_client.get("/api/v1/config/agents") + assert resp.status_code == 200 + data = resp.json() + assert len(data["agents"]) == 2 + assert data["count"] == 2 + + +class TestConfigWorkflowsEndpoint: + """Test GET /api/v1/config/workflows.""" + + @pytest.mark.asyncio + async def test_workflows_returns_list(self, config_client: AsyncClient): + resp = await config_client.get("/api/v1/config/workflows") + assert resp.status_code == 200 + data = resp.json() + assert len(data["workflows"]) == 2 + assert data["count"] == 2 + + +# ── Client-side ConfigSync tests ────────────────────────────────────── + + +@pytest.fixture +async def sync_server(config_app_with_data: FastAPI): + """Start the config app as an ASGI server on a random port.""" + import uvicorn + + config = uvicorn.Config( + config_app_with_data, + host="127.0.0.1", + port=0, # Random port + log_level="warning", + ) + server = uvicorn.Server(config) + + task = asyncio.create_task(server.serve()) + # Wait for server to start + while not server.started: + await asyncio.sleep(0.01) + + # Get the actual port + sockets = list(server.servers[0].sockets) + port = sockets[0].getsockname()[1] + + yield f"http://127.0.0.1:{port}" + + server.should_exit = True + await task + + +class TestConfigSync: + """Test the client-side ConfigSync engine.""" + + @pytest.mark.asyncio + async def test_start_pulls_configs(self, sync_server: str, tmp_path: Path): + """ConfigSync.start() pulls configs from the server.""" + sync = ConfigSync( + server_url=sync_server, + token_provider=None, + cache_db_path=tmp_path / "cache.db", + ) + try: + success = await sync.start() + assert success is True + assert sync.get_version() is not None + assert len(sync.get_version()) == 64 + skills = sync.get_skills() + assert len(skills) == 2 + workflows = sync.get_workflows() + assert len(workflows) == 2 + finally: + await sync.stop() + + @pytest.mark.asyncio + async def test_get_skill_by_name(self, sync_server: str, tmp_path: Path): + sync = ConfigSync( + server_url=sync_server, + cache_db_path=tmp_path / "cache.db", + ) + try: + await sync.start() + skill = sync.get_skill("react_agent") + assert skill is not None + assert skill["name"] == "react_agent" + + missing = sync.get_skill("nonexistent") + assert missing is None + finally: + await sync.stop() + + @pytest.mark.asyncio + async def test_get_workflow_by_id(self, sync_server: str, tmp_path: Path): + sync = ConfigSync( + server_url=sync_server, + cache_db_path=tmp_path / "cache.db", + ) + try: + await sync.start() + wf = sync.get_workflow("wf-1") + assert wf is not None + assert wf["name"] == "CI Pipeline" + + missing = sync.get_workflow("nonexistent") + assert missing is None + finally: + await sync.stop() + + @pytest.mark.asyncio + async def test_cache_persists_across_instances(self, sync_server: str, tmp_path: Path): + """Configs cached by one instance are loadable by another.""" + cache_path = tmp_path / "cache.db" + + # First instance: pull and cache + sync1 = ConfigSync(server_url=sync_server, cache_db_path=cache_path) + await sync1.start() + version1 = sync1.get_version() + assert version1 is not None + await sync1.stop() + + # Second instance: no server, load from cache + sync2 = ConfigSync( + server_url="http://127.0.0.1:1", # Unreachable port + cache_db_path=cache_path, + ) + try: + success = await sync2.start() + assert success is False # Server unreachable + # But cache should be loaded + assert sync2.get_version() == version1 + assert len(sync2.get_skills()) == 2 + assert len(sync2.get_workflows()) == 2 + finally: + await sync2.stop() + + @pytest.mark.asyncio + async def test_poll_detects_version_change( + self, config_app_with_data: FastAPI, tmp_path: Path + ): + """Polling detects when the server's config version changes.""" + import uvicorn + + config = uvicorn.Config( + config_app_with_data, + host="127.0.0.1", + port=0, + log_level="warning", + ) + server = uvicorn.Server(config) + task = asyncio.create_task(server.serve()) + while not server.started: + await asyncio.sleep(0.01) + port = list(server.servers[0].sockets)[0].getsockname()[1] + server_url = f"http://127.0.0.1:{port}" + + try: + sync = ConfigSync( + server_url=server_url, + cache_db_path=tmp_path / "cache.db", + poll_interval=1, # 1 second for fast testing + ) + await sync.start() + sync.start_polling() # Start background polling + version_before = sync.get_version() + + # Change the server's config + config_app_with_data.state.skill_registry._skills["new_skill"] = _FakeSkill("new_skill") + + # Wait for the poll to detect the change + await asyncio.sleep(2.5) + + version_after = sync.get_version() + assert version_after != version_before + assert len(sync.get_skills()) == 3 # Original 2 + new_skill + + await sync.stop() + finally: + server.should_exit = True + await task + + @pytest.mark.asyncio + async def test_offline_uses_cache(self, tmp_path: Path): + """When the server is unreachable, the client uses cached configs.""" + cache_path = tmp_path / "cache.db" + + # Pre-populate the cache directly + cache_path.parent.mkdir(parents=True, exist_ok=True) + with sqlite3.connect(str(cache_path)) as conn: + conn.executescript( + "CREATE TABLE IF NOT EXISTS config_cache " + "(key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TEXT NOT NULL);" + ) + now = datetime.now(timezone.utc).isoformat() + conn.executemany( + "INSERT OR REPLACE INTO config_cache (key, value, updated_at) VALUES (?, ?, ?)", + [ + ("version", json.dumps("cached-version-hash"), now), + ("skills", json.dumps([{"name": "cached_skill", "version": "1.0.0"}]), now), + ("workflows", json.dumps([{"workflow_id": "cached-wf", "name": "Cached"}]), now), + ("synced_at", json.dumps("2026-01-01T00:00:00Z"), now), + ], + ) + conn.commit() + + # Create a sync instance pointing to an unreachable server + sync = ConfigSync( + server_url="http://127.0.0.1:1", # Unreachable + cache_db_path=cache_path, + ) + try: + success = await sync.start() + assert success is False # Server unreachable + # But cache loaded + assert sync.get_version() == "cached-version-hash" + skills = sync.get_skills() + assert len(skills) == 1 + assert skills[0]["name"] == "cached_skill" + finally: + await sync.stop() + + @pytest.mark.asyncio + async def test_offline_no_cache_returns_empty(self, tmp_path: Path): + """When the server is unreachable and no cache exists, returns empty.""" + sync = ConfigSync( + server_url="http://127.0.0.1:1", # Unreachable + cache_db_path=tmp_path / "nonexistent.db", + ) + try: + success = await sync.start() + assert success is False + assert sync.get_version() is None + assert sync.get_skills() == [] + assert sync.get_workflows() == [] + finally: + await sync.stop() + + @pytest.mark.asyncio + async def test_token_provider_attaches_jwt(self, sync_server: str, tmp_path: Path): + """The token_provider callable is used to attach JWT to requests.""" + token_holder = {"token": "test-jwt-token"} + + sync = ConfigSync( + server_url=sync_server, + token_provider=lambda: token_holder["token"], + cache_db_path=tmp_path / "cache.db", + ) + try: + # Should work even with a fake token (dev mode doesn't check) + success = await sync.start() + assert success is True + finally: + await sync.stop() + + @pytest.mark.asyncio + async def test_get_all_returns_combined_dict(self, sync_server: str, tmp_path: Path): + sync = ConfigSync( + server_url=sync_server, + cache_db_path=tmp_path / "cache.db", + ) + try: + await sync.start() + all_configs = sync.get_all() + assert "version" in all_configs + assert "skills" in all_configs + assert "workflows" in all_configs + assert "synced_at" in all_configs + assert len(all_configs["skills"]) == 2 + assert len(all_configs["workflows"]) == 2 + finally: + await sync.stop() diff --git a/tests/unit/test_llm_gateway_routes.py b/tests/unit/test_llm_gateway_routes.py new file mode 100644 index 0000000..9b09a97 --- /dev/null +++ b/tests/unit/test_llm_gateway_routes.py @@ -0,0 +1,265 @@ +"""Unit tests for LLM Gateway proxy routes (U1). + +Covers: +- Non-streaming chat returns serialized LLMResponse JSON +- Streaming chat returns SSE-formatted chunks +- Invalid model returns 404 (ModelNotFoundError) +- LLM provider failure returns 502 (LLMProviderError) +- Empty messages list returns 422 (Pydantic validation) +""" + +import json +from unittest.mock import AsyncMock, MagicMock + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from agentkit.core.exceptions import LLMProviderError, ModelNotFoundError +from agentkit.llm.protocol import LLMResponse, StreamChunk, TokenUsage, ToolCall +from agentkit.server.routes import llm_gateway as llm_gateway_module + + +def _make_response( + content: str = "Hello from LLM", + model: str = "test-model", + prompt_tokens: int = 10, + completion_tokens: int = 20, + tool_calls: list[ToolCall] | None = None, + latency_ms: float = 123.4, +) -> LLMResponse: + return LLMResponse( + content=content, + model=model, + usage=TokenUsage(prompt_tokens=prompt_tokens, completion_tokens=completion_tokens), + tool_calls=tool_calls or [], + latency_ms=latency_ms, + ) + + +def _make_chunks() -> list[StreamChunk]: + return [ + StreamChunk(content="Hello", model="test-model"), + StreamChunk(content=" world", model="test-model"), + StreamChunk( + content="", + model="test-model", + usage=TokenUsage(prompt_tokens=10, completion_tokens=20), + is_final=True, + ), + ] + + +@pytest.fixture +def mock_gateway(): + """A mock LLMGateway. + + `chat` is an AsyncMock (returns awaitable). + `chat_stream` is a regular MagicMock that returns an async generator + when called (matching the real gateway's async-generator behavior). + """ + gateway = MagicMock() + gateway.chat = AsyncMock() + # chat_stream must return an async generator when called, not a coroutine. + gateway.chat_stream = MagicMock() + return gateway + + +@pytest.fixture +def app(mock_gateway): + application = FastAPI() + application.state.llm_gateway = mock_gateway + application.include_router(llm_gateway_module.router, prefix="/api/v1") + return application + + +@pytest.fixture +def client(app): + return TestClient(app) + + +class TestLLMGatewayChatNonStreaming: + """POST /api/v1/llm/chat — non-streaming""" + + def test_chat_returns_llm_response_json(self, client, mock_gateway): + """Non-streaming chat returns serialized LLMResponse JSON.""" + mock_gateway.chat.return_value = _make_response( + content="Hello from LLM", + model="test-model", + prompt_tokens=10, + completion_tokens=20, + tool_calls=[ToolCall(id="tc_1", name="search", arguments={"q": "test"})], + latency_ms=123.4, + ) + + response = client.post( + "/api/v1/llm/chat", + json={ + "messages": [{"role": "user", "content": "Hi"}], + "model": "test-model", + }, + ) + + assert response.status_code == 200 + data = response.json() + assert data["content"] == "Hello from LLM" + assert data["model"] == "test-model" + assert data["usage"]["prompt_tokens"] == 10 + assert data["usage"]["completion_tokens"] == 20 + assert data["usage"]["total_tokens"] == 30 + assert data["latency_ms"] == 123.4 + assert len(data["tool_calls"]) == 1 + assert data["tool_calls"][0]["id"] == "tc_1" + assert data["tool_calls"][0]["name"] == "search" + assert data["tool_calls"][0]["arguments"] == {"q": "test"} + + # Verify gateway.chat was called with the right args + mock_gateway.chat.assert_awaited_once() + call_kwargs = mock_gateway.chat.await_args.kwargs + assert call_kwargs["model"] == "test-model" + assert call_kwargs["messages"] == [{"role": "user", "content": "Hi"}] + + def test_chat_invalid_model_returns_404(self, client, mock_gateway): + """Invalid model returns 404 (ModelNotFoundError).""" + mock_gateway.chat.side_effect = ModelNotFoundError("nonexistent/model") + + response = client.post( + "/api/v1/llm/chat", + json={ + "messages": [{"role": "user", "content": "Hi"}], + "model": "nonexistent/model", + }, + ) + + assert response.status_code == 404 + assert "nonexistent/model" in response.json()["detail"] + + def test_chat_provider_error_returns_502(self, client, mock_gateway): + """LLM Provider failure returns 502 (LLMProviderError).""" + mock_gateway.chat.side_effect = LLMProviderError("openai", "API error") + + response = client.post( + "/api/v1/llm/chat", + json={ + "messages": [{"role": "user", "content": "Hi"}], + "model": "openai/gpt-4o", + }, + ) + + assert response.status_code == 502 + assert "openai" in response.json()["detail"] + + def test_chat_empty_messages_returns_422(self, client): + """Empty messages list returns 422 (Pydantic validation).""" + response = client.post( + "/api/v1/llm/chat", + json={ + "messages": [], + "model": "test-model", + }, + ) + + assert response.status_code == 422 + + +class TestLLMGatewayChatStream: + """POST /api/v1/llm/chat/stream — SSE streaming""" + + def test_stream_returns_sse_format(self, client, mock_gateway): + """Streaming chat returns SSE-formatted chunks terminated by [DONE].""" + + async def fake_stream(**_kwargs): + for chunk in _make_chunks(): + yield chunk + + mock_gateway.chat_stream.return_value = fake_stream() + + with client.stream( + "POST", + "/api/v1/llm/chat/stream", + json={ + "messages": [{"role": "user", "content": "Hi"}], + "model": "test-model", + }, + ) as response: + assert response.status_code == 200 + assert response.headers["content-type"].startswith("text/event-stream") + assert response.headers["cache-control"] == "no-cache" + assert response.headers["connection"] == "keep-alive" + + chunks_text = "" + for line in response.iter_lines(): + chunks_text += line + "\n" + + # Each data line should be valid JSON, ending with [DONE] + data_lines = [ + line[len("data: ") :] for line in chunks_text.split("\n") if line.startswith("data: ") + ] + assert data_lines[-1] == "[DONE]" + + parsed = [json.loads(d) for d in data_lines[:-1]] + assert len(parsed) == 3 + assert parsed[0]["content"] == "Hello" + assert parsed[1]["content"] == " world" + assert parsed[2]["is_final"] is True + assert parsed[2]["usage"]["total_tokens"] == 30 + + def test_stream_invalid_model_emits_error_then_done(self, client, mock_gateway): + """ModelNotFoundError during stream emits error payload then [DONE].""" + + async def failing_stream(**_kwargs): + raise ModelNotFoundError("nonexistent/model") + yield # pragma: no cover - makes this an async generator + + mock_gateway.chat_stream.return_value = failing_stream() + + with client.stream( + "POST", + "/api/v1/llm/chat/stream", + json={ + "messages": [{"role": "user", "content": "Hi"}], + "model": "nonexistent/model", + }, + ) as response: + assert response.status_code == 200 + chunks_text = "" + for line in response.iter_lines(): + chunks_text += line + "\n" + + data_lines = [ + line[len("data: ") :] for line in chunks_text.split("\n") if line.startswith("data: ") + ] + assert data_lines[-1] == "[DONE]" + error_payload = json.loads(data_lines[0]) + assert error_payload["error"] == "model_not_found" + assert "nonexistent/model" in error_payload["detail"] + + def test_stream_provider_error_emits_error_then_done(self, client, mock_gateway): + """LLMProviderError during stream emits error payload then [DONE].""" + + async def failing_stream(**_kwargs): + raise LLMProviderError("openai", "API error") + yield # pragma: no cover - makes this an async generator + + mock_gateway.chat_stream.return_value = failing_stream() + + with client.stream( + "POST", + "/api/v1/llm/chat/stream", + json={ + "messages": [{"role": "user", "content": "Hi"}], + "model": "openai/gpt-4o", + }, + ) as response: + assert response.status_code == 200 + chunks_text = "" + for line in response.iter_lines(): + chunks_text += line + "\n" + + data_lines = [ + line[len("data: ") :] for line in chunks_text.split("\n") if line.startswith("data: ") + ] + assert data_lines[-1] == "[DONE]" + error_payload = json.loads(data_lines[0]) + assert error_payload["error"] == "provider_error" + assert "openai" in error_payload["detail"] diff --git a/tests/unit/test_permissions.py b/tests/unit/test_permissions.py new file mode 100644 index 0000000..9a9c79d --- /dev/null +++ b/tests/unit/test_permissions.py @@ -0,0 +1,370 @@ +"""Unit tests for the permission model and RBAC dependencies (U5). + +Covers: +- Permission enum values +- ROLE_PERMISSIONS mapping (member / operator / admin) +- has_permission() for each role +- require_permission() dependency (success, 403, dev mode) +- require_terminal_authorized() (success, 403 on role, 403 on flag) +""" + +from __future__ import annotations + +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import aiosqlite +import pytest +from fastapi import Depends, FastAPI, Request +from fastapi.testclient import TestClient + +from agentkit.server.auth.dependencies import ( + require_authenticated, + require_permission, + require_terminal_authorized, +) +from agentkit.server.auth.models import init_auth_db +from agentkit.server.auth.password import hash_password +from agentkit.server.auth.permissions import ( + Permission, + get_role_permissions, + has_permission, + is_dev_mode, +) + + +# --------------------------------------------------------------------------- +# Permission model tests +# --------------------------------------------------------------------------- + + +class TestPermissionModel: + """Permission enum and role mapping.""" + + def test_permission_values_are_strings(self): + """All Permission members have string values (used in JWT + audit logs).""" + for perm in Permission: + assert isinstance(perm.value, str) + assert perm.value == perm.name # value matches name for simplicity + + def test_member_role_permissions(self): + """member has chat + KB query + workflow, but no terminal/admin.""" + perms = get_role_permissions("member") + assert Permission.CHAT in perms + assert Permission.KB_QUERY in perms + assert Permission.WORKFLOW_EXECUTE in perms + assert Permission.KB_WRITE not in perms + assert Permission.TERMINAL_LOCAL_USE not in perms + assert Permission.TERMINAL_SERVER_USE not in perms + assert Permission.USER_MANAGE not in perms + assert Permission.SYSTEM_CONFIG not in perms + + def test_operator_role_permissions(self): + """operator has member perms + KB write + local terminal + whitelist manage.""" + perms = get_role_permissions("operator") + assert Permission.CHAT in perms + assert Permission.KB_QUERY in perms + assert Permission.KB_WRITE in perms + assert Permission.WORKFLOW_EXECUTE in perms + assert Permission.TERMINAL_LOCAL_USE in perms + assert Permission.TERMINAL_WHITELIST_MANAGE in perms + # operator does NOT have server terminal or admin perms + assert Permission.TERMINAL_SERVER_USE not in perms + assert Permission.USER_MANAGE not in perms + assert Permission.SYSTEM_CONFIG not in perms + + def test_admin_role_permissions(self): + """admin has all permissions.""" + perms = get_role_permissions("admin") + for perm in Permission: + assert perm in perms, f"admin should have {perm.value}" + + def test_unknown_role_returns_empty(self): + """Unknown role → empty permission set (no permissions).""" + assert get_role_permissions("superuser") == frozenset() + assert get_role_permissions("") == frozenset() + + def test_none_role_returns_empty(self): + """None role (dev mode) → empty permission set.""" + assert get_role_permissions(None) == frozenset() + + def test_has_permission_member(self): + """member has CHAT but not TERMINAL_LOCAL_USE.""" + user = {"role": "member", "user_id": "u1", "username": "alice"} + assert has_permission(user, Permission.CHAT) is True + assert has_permission(user, Permission.TERMINAL_LOCAL_USE) is False + + def test_has_permission_admin(self): + """admin has all permissions.""" + user = {"role": "admin", "user_id": "u1", "username": "admin"} + for perm in Permission: + assert has_permission(user, perm) is True + + def test_has_permission_none_user(self): + """None user (dev mode) → no permissions via has_permission.""" + assert has_permission(None, Permission.CHAT) is False + + def test_is_dev_mode(self): + """is_dev_mode returns True when user is None.""" + assert is_dev_mode(None) is True + assert is_dev_mode({"role": "member"}) is False + + +# --------------------------------------------------------------------------- +# require_permission dependency tests +# --------------------------------------------------------------------------- + + +def _make_protected_app() -> FastAPI: + """App with endpoints protected by various permission dependencies.""" + app = FastAPI() + + @app.get("/chat") + async def chat_endpoint(_user=Depends(require_permission(Permission.CHAT))): + return {"ok": True} + + @app.get("/terminal") + async def terminal_endpoint(_user=Depends(require_permission(Permission.TERMINAL_LOCAL_USE))): + return {"ok": True} + + @app.get("/admin") + async def admin_endpoint(_user=Depends(require_permission(Permission.USER_MANAGE))): + return {"ok": True} + + @app.get("/any-auth") + async def any_auth_endpoint(user=Depends(require_authenticated)): + return {"user": user} + + return app + + +def _set_user(app: FastAPI, user: dict[str, Any] | None) -> None: + """Install middleware that sets request.state.current_user.""" + + @app.middleware("http") + async def set_user_middleware(request: Request, call_next): + if user is None: + # Simulate dev mode: don't set current_user at all + return await call_next(request) + request.state.current_user = user + return await call_next(request) + + +class TestRequirePermission: + """require_permission FastAPI dependency.""" + + def test_member_can_chat(self): + """member role → 200 on /chat.""" + app = _make_protected_app() + _set_user(app, {"role": "member", "user_id": "u1", "username": "alice"}) + client = TestClient(app) + resp = client.get("/chat") + assert resp.status_code == 200 + + def test_member_cannot_use_terminal(self): + """member role → 403 on /terminal (no TERMINAL_LOCAL_USE).""" + app = _make_protected_app() + _set_user(app, {"role": "member", "user_id": "u1", "username": "alice"}) + client = TestClient(app) + resp = client.get("/terminal") + assert resp.status_code == 403 + assert "TERMINAL_LOCAL_USE" in resp.json()["detail"] + + def test_operator_can_use_terminal(self): + """operator role → 200 on /terminal.""" + app = _make_protected_app() + _set_user(app, {"role": "operator", "user_id": "u1", "username": "bob"}) + client = TestClient(app) + resp = client.get("/terminal") + assert resp.status_code == 200 + + def test_member_cannot_access_admin(self): + """member role → 403 on /admin (no USER_MANAGE).""" + app = _make_protected_app() + _set_user(app, {"role": "member", "user_id": "u1", "username": "alice"}) + client = TestClient(app) + resp = client.get("/admin") + assert resp.status_code == 403 + + def test_admin_can_access_admin(self): + """admin role → 200 on /admin.""" + app = _make_protected_app() + _set_user(app, {"role": "admin", "user_id": "u1", "username": "root"}) + client = TestClient(app) + resp = client.get("/admin") + assert resp.status_code == 200 + + def test_dev_mode_allows_low_risk(self): + """Dev mode (no user) → 200 on /chat (low-risk allowed).""" + app = _make_protected_app() + _set_user(app, None) + client = TestClient(app) + resp = client.get("/chat") + assert resp.status_code == 200 + + def test_dev_mode_blocks_high_risk(self): + """Dev mode (no user) → 401 on /terminal (high-risk requires auth).""" + app = _make_protected_app() + _set_user(app, None) + client = TestClient(app) + resp = client.get("/terminal") + assert resp.status_code == 401 + + def test_dev_mode_blocks_admin(self): + """Dev mode (no user) → 401 on /admin (high-risk requires auth).""" + app = _make_protected_app() + _set_user(app, None) + client = TestClient(app) + resp = client.get("/admin") + assert resp.status_code == 401 + + def test_require_authenticated_blocks_dev_mode(self): + """require_authenticated → 401 in dev mode.""" + app = _make_protected_app() + _set_user(app, None) + client = TestClient(app) + resp = client.get("/any-auth") + assert resp.status_code == 401 + + def test_require_authenticated_passes_authenticated(self): + """require_authenticated → 200 when user is set.""" + app = _make_protected_app() + _set_user(app, {"role": "member", "user_id": "u1", "username": "alice"}) + client = TestClient(app) + resp = client.get("/any-auth") + assert resp.status_code == 200 + assert resp.json()["user"]["username"] == "alice" + + +# --------------------------------------------------------------------------- +# require_terminal_authorized tests +# --------------------------------------------------------------------------- + + +@pytest.fixture +async def tmp_auth_db_with_users(tmp_path: Path) -> Path: + """Create an auth DB with users having different terminal authorizations.""" + db_path = tmp_path / "auth.db" + await init_auth_db(db_path) + now_iso = datetime.now(timezone.utc).isoformat() + + # operator with terminal authorized + operator_id = str(uuid.uuid4()) + # operator without terminal authorized + operator_no_term_id = str(uuid.uuid4()) + # member (no terminal permission in role) + member_id = str(uuid.uuid4()) + + async with aiosqlite.connect(str(db_path)) as db: + await db.execute( + "INSERT INTO users " + "(id, username, email, password_hash, role, is_active, " + " is_terminal_authorized, is_server_terminal_authorized, " + " created_at, updated_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + (operator_id, "op1", "op1@x.com", hash_password("p"), "operator", 1, 1, 0, now_iso, now_iso), + ) + await db.execute( + "INSERT INTO users " + "(id, username, email, password_hash, role, is_active, " + " is_terminal_authorized, is_server_terminal_authorized, " + " created_at, updated_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + (operator_no_term_id, "op2", "op2@x.com", hash_password("p"), "operator", 1, 0, 0, now_iso, now_iso), + ) + await db.execute( + "INSERT INTO users " + "(id, username, email, password_hash, role, is_active, " + " is_terminal_authorized, is_server_terminal_authorized, " + " created_at, updated_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + (member_id, "m1", "m1@x.com", hash_password("p"), "member", 1, 1, 0, now_iso, now_iso), + ) + await db.commit() + + return db_path + + +class TestRequireTerminalAuthorized: + """require_terminal_authorized dependency.""" + + def test_operator_with_flag_can_access(self, tmp_auth_db_with_users: Path): + """operator with is_terminal_authorized=True → 200.""" + app = FastAPI() + app.state.auth_db_path = str(tmp_auth_db_with_users) + + @app.get("/term") + async def term_endpoint(_user=Depends(require_terminal_authorized)): + return {"ok": True} + + @app.middleware("http") + async def set_user(request: Request, call_next): + request.state.current_user = { + "user_id": _get_user_id(tmp_auth_db_with_users, "op1"), + "username": "op1", + "role": "operator", + } + return await call_next(request) + + client = TestClient(app) + resp = client.get("/term") + assert resp.status_code == 200 + + def test_operator_without_flag_blocked(self, tmp_auth_db_with_users: Path): + """operator with is_terminal_authorized=False → 403.""" + app = FastAPI() + app.state.auth_db_path = str(tmp_auth_db_with_users) + + @app.get("/term") + async def term_endpoint(_user=Depends(require_terminal_authorized)): + return {"ok": True} + + @app.middleware("http") + async def set_user(request: Request, call_next): + request.state.current_user = { + "user_id": _get_user_id(tmp_auth_db_with_users, "op2"), + "username": "op2", + "role": "operator", + } + return await call_next(request) + + client = TestClient(app) + resp = client.get("/term") + assert resp.status_code == 403 + assert "Terminal access not authorized" in resp.json()["detail"] + + def test_member_blocked_by_role(self, tmp_auth_db_with_users: Path): + """member (no TERMINAL_LOCAL_USE permission) → 403.""" + app = FastAPI() + app.state.auth_db_path = str(tmp_auth_db_with_users) + + @app.get("/term") + async def term_endpoint(_user=Depends(require_terminal_authorized)): + return {"ok": True} + + @app.middleware("http") + async def set_user(request: Request, call_next): + request.state.current_user = { + "user_id": _get_user_id(tmp_auth_db_with_users, "m1"), + "username": "m1", + "role": "member", + } + return await call_next(request) + + client = TestClient(app) + resp = client.get("/term") + assert resp.status_code == 403 + assert "TERMINAL_LOCAL_USE" in resp.json()["detail"] + + +def _get_user_id(db_path: Path, username: str) -> str: + """Look up a user's ID from the test DB (synchronous helper).""" + import sqlite3 + + conn = sqlite3.connect(str(db_path)) + cursor = conn.execute("SELECT id FROM users WHERE username = ?", (username,)) + row = cursor.fetchone() + conn.close() + return row[0] if row else "" diff --git a/tests/unit/test_remote_provider.py b/tests/unit/test_remote_provider.py new file mode 100644 index 0000000..6fa2761 --- /dev/null +++ b/tests/unit/test_remote_provider.py @@ -0,0 +1,419 @@ +"""Tests for RemoteLLMProvider (U3) — client-side LLM gateway forwarding.""" + +import json +from unittest.mock import AsyncMock, MagicMock + +import httpx +import pytest +from pytest_httpx import HTTPXMock + +from agentkit.core.exceptions import LLMProviderError, ModelNotFoundError +from agentkit.llm.protocol import LLMRequest, LLMResponse, StreamChunk +from agentkit.llm.remote_provider import RemoteLLMProvider + +SERVER_URL = "http://test-server:8000" +CHAT_URL = f"{SERVER_URL}/api/v1/llm/chat" +STREAM_URL = f"{SERVER_URL}/api/v1/llm/chat/stream" + + +def _make_provider() -> RemoteLLMProvider: + return RemoteLLMProvider( + server_url=SERVER_URL, + auth_token_provider=lambda: "test-jwt-token", + timeout=30.0, + ) + + +def _make_request() -> LLMRequest: + return LLMRequest( + messages=[{"role": "user", "content": "Hello"}], + model="gpt-4o-mini", + temperature=0.5, + max_tokens=1000, + ) + + +def _ok_response_body( + content: str = "Hi there!", + model: str = "gpt-4o-mini", + tool_calls: list | None = None, +) -> dict: + return { + "content": content, + "model": model, + "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}, + "tool_calls": tool_calls or [], + "latency_ms": 123.4, + } + + +class TestRemoteLLMProviderChat: + """chat() method tests.""" + + async def test_chat_returns_llm_response(self, httpx_mock: HTTPXMock): + httpx_mock.add_response(url=CHAT_URL, json=_ok_response_body()) + + provider = _make_provider() + response = await provider.chat(_make_request()) + + assert isinstance(response, LLMResponse) + assert response.content == "Hi there!" + assert response.model == "gpt-4o-mini" + assert response.usage.prompt_tokens == 10 + assert response.usage.completion_tokens == 5 + assert response.usage.total_tokens == 15 + assert response.latency_ms == 123.4 + assert response.tool_calls == [] + + async def test_chat_with_tool_calls(self, httpx_mock: HTTPXMock): + httpx_mock.add_response( + url=CHAT_URL, + json=_ok_response_body( + content="", + tool_calls=[ + {"id": "call_1", "name": "get_weather", "arguments": {"city": "Beijing"}}, + ], + ), + ) + + provider = _make_provider() + response = await provider.chat(_make_request()) + + assert response.has_tool_calls + assert len(response.tool_calls) == 1 + tc = response.tool_calls[0] + assert tc.id == "call_1" + assert tc.name == "get_weather" + assert tc.arguments == {"city": "Beijing"} + + async def test_chat_401_raises_connection_error(self, httpx_mock: HTTPXMock): + httpx_mock.add_response(url=CHAT_URL, status_code=401, json={"detail": "Unauthorized"}) + + provider = _make_provider() + with pytest.raises(ConnectionError, match="Authentication failed"): + await provider.chat(_make_request()) + + async def test_chat_404_raises_model_not_found(self, httpx_mock: HTTPXMock): + httpx_mock.add_response( + url=CHAT_URL, status_code=404, json={"detail": "Model not found: gpt-4o-mini"} + ) + + provider = _make_provider() + with pytest.raises(ModelNotFoundError): + await provider.chat(_make_request()) + + async def test_chat_502_raises_provider_error(self, httpx_mock: HTTPXMock): + httpx_mock.add_response( + url=CHAT_URL, status_code=502, json={"detail": "Upstream provider error"} + ) + + provider = _make_provider() + with pytest.raises(LLMProviderError, match="Server LLM gateway error"): + await provider.chat(_make_request()) + + async def test_chat_timeout_raises_provider_error(self, httpx_mock: HTTPXMock): + httpx_mock.add_exception(httpx.ReadTimeout("read timeout")) + + provider = _make_provider() + with pytest.raises(LLMProviderError, match="Request timeout"): + await provider.chat(_make_request()) + + async def test_chat_payload_correct(self, httpx_mock: HTTPXMock): + httpx_mock.add_response(url=CHAT_URL, json=_ok_response_body()) + + provider = _make_provider() + request = LLMRequest( + messages=[{"role": "user", "content": "Hi"}], + model="gpt-4o-mini", + temperature=0.3, + max_tokens=500, + tools=[{"type": "function", "function": {"name": "search"}}], + tool_choice="auto", + ) + await provider.chat(request) + + sent = httpx_mock.get_request() + body = json.loads(sent.content) + assert body["messages"] == [{"role": "user", "content": "Hi"}] + assert body["model"] == "gpt-4o-mini" + assert body["temperature"] == 0.3 + assert body["max_tokens"] == 500 + assert body["tools"] == [{"type": "function", "function": {"name": "search"}}] + assert body["tool_choice"] == "auto" + + async def test_chat_headers_include_auth(self, httpx_mock: HTTPXMock): + httpx_mock.add_response(url=CHAT_URL, json=_ok_response_body()) + + provider = _make_provider() + await provider.chat(_make_request()) + + sent = httpx_mock.get_request() + assert sent.headers["Authorization"] == "Bearer test-jwt-token" + assert sent.headers["Content-Type"] == "application/json" + + async def test_chat_unexpected_status_raises_provider_error(self, httpx_mock: HTTPXMock): + httpx_mock.add_response(url=CHAT_URL, status_code=500, text="Internal Server Error") + + provider = _make_provider() + with pytest.raises(LLMProviderError, match="Unexpected status 500"): + await provider.chat(_make_request()) + + +class TestRemoteLLMProviderStream: + """chat_stream() method tests.""" + + @staticmethod + def _make_stream_response(sse_lines: list[str], status_code: int = 200) -> MagicMock: + """Create a mock httpx streaming response context manager.""" + response = MagicMock() + response.status_code = status_code + + async def aiter_lines(): + for line in sse_lines: + yield line + + response.aiter_lines = aiter_lines + response.aread = AsyncMock(return_value=b"") + response.text = "" + + context = MagicMock() + context.__aenter__ = AsyncMock(return_value=response) + context.__aexit__ = AsyncMock(return_value=False) + return context + + @staticmethod + def _sse(data: dict) -> str: + return f"data: {json.dumps(data)}" + + async def test_stream_parses_sse(self): + sse_lines = [ + TestRemoteLLMProviderStream._sse( + {"content": "Hello", "model": "gpt-4o-mini", "is_final": False} + ), + "", + TestRemoteLLMProviderStream._sse( + {"content": " world", "model": "gpt-4o-mini", "is_final": False} + ), + "", + TestRemoteLLMProviderStream._sse( + { + "content": "", + "model": "gpt-4o-mini", + "is_final": True, + "usage": { + "prompt_tokens": 5, + "completion_tokens": 3, + "total_tokens": 8, + }, + } + ), + "", + "data: [DONE]", + "", + ] + + provider = _make_provider() + provider._client.stream = MagicMock(return_value=self._make_stream_response(sse_lines)) + + chunks = [] + async for chunk in provider.chat_stream(_make_request()): + chunks.append(chunk) + + assert len(chunks) == 3 + assert chunks[0].content == "Hello" + assert chunks[0].is_final is False + assert chunks[1].content == " world" + assert chunks[2].is_final is True + assert chunks[2].usage is not None + assert chunks[2].usage.prompt_tokens == 5 + assert chunks[2].usage.completion_tokens == 3 + + async def test_stream_done_terminates(self): + """data: [DONE] should stop iteration — later lines must not be yielded.""" + sse_lines = [ + self._sse({"content": "Hi", "model": "gpt-4o-mini", "is_final": False}), + "", + "data: [DONE]", + "", + self._sse({"content": "should not appear", "model": "gpt-4o-mini", "is_final": False}), + "", + ] + + provider = _make_provider() + provider._client.stream = MagicMock(return_value=self._make_stream_response(sse_lines)) + + chunks = [] + async for chunk in provider.chat_stream(_make_request()): + chunks.append(chunk) + + assert len(chunks) == 1 + assert chunks[0].content == "Hi" + + async def test_stream_error_raises(self): + """An error payload in the stream should raise LLMProviderError.""" + sse_lines = [ + self._sse({"error": "provider_error", "detail": "upstream failed"}), + "", + ] + + provider = _make_provider() + provider._client.stream = MagicMock(return_value=self._make_stream_response(sse_lines)) + + with pytest.raises(LLMProviderError, match="Stream error"): + async for _ in provider.chat_stream(_make_request()): + pass + + async def test_stream_empty(self): + """A stream with only [DONE] yields no chunks.""" + sse_lines = ["data: [DONE]", ""] + + provider = _make_provider() + provider._client.stream = MagicMock(return_value=self._make_stream_response(sse_lines)) + + chunks = [] + async for chunk in provider.chat_stream(_make_request()): + chunks.append(chunk) + + assert chunks == [] + + async def test_stream_is_final(self): + """A chunk with is_final=True should be correctly marked.""" + sse_lines = [ + self._sse( + { + "content": "response", + "model": "gpt-4o-mini", + "is_final": True, + "usage": { + "prompt_tokens": 1, + "completion_tokens": 1, + "total_tokens": 2, + }, + } + ), + "", + "data: [DONE]", + "", + ] + + provider = _make_provider() + provider._client.stream = MagicMock(return_value=self._make_stream_response(sse_lines)) + + chunks = [] + async for chunk in provider.chat_stream(_make_request()): + chunks.append(chunk) + + assert len(chunks) == 1 + assert isinstance(chunks[0], StreamChunk) + assert chunks[0].is_final is True + assert chunks[0].usage is not None + assert chunks[0].usage.total_tokens == 2 + + async def test_stream_tool_calls_in_chunk(self): + """Tool calls in a stream chunk should be parsed correctly.""" + sse_lines = [ + self._sse( + { + "content": "", + "model": "gpt-4o-mini", + "is_final": True, + "tool_calls": [ + {"id": "tc_1", "name": "search", "arguments": {"q": "test"}}, + ], + "usage": { + "prompt_tokens": 2, + "completion_tokens": 2, + "total_tokens": 4, + }, + } + ), + "", + "data: [DONE]", + "", + ] + + provider = _make_provider() + provider._client.stream = MagicMock(return_value=self._make_stream_response(sse_lines)) + + chunks = [] + async for chunk in provider.chat_stream(_make_request()): + chunks.append(chunk) + + assert len(chunks) == 1 + assert len(chunks[0].tool_calls) == 1 + assert chunks[0].tool_calls[0].name == "search" + assert chunks[0].tool_calls[0].arguments == {"q": "test"} + + async def test_stream_401_raises_connection_error(self): + sse_lines: list[str] = [] + + provider = _make_provider() + provider._client.stream = MagicMock( + return_value=self._make_stream_response(sse_lines, status_code=401) + ) + + with pytest.raises(ConnectionError, match="Authentication failed"): + async for _ in provider.chat_stream(_make_request()): + pass + + async def test_stream_skips_non_data_lines(self): + """Lines without 'data: ' prefix should be skipped.""" + sse_lines = [ + ": comment line", + "", + self._sse({"content": "ok", "model": "gpt-4o-mini", "is_final": False}), + "", + "event: ping", + "data: [DONE]", + "", + ] + + provider = _make_provider() + provider._client.stream = MagicMock(return_value=self._make_stream_response(sse_lines)) + + chunks = [] + async for chunk in provider.chat_stream(_make_request()): + chunks.append(chunk) + + assert len(chunks) == 1 + assert chunks[0].content == "ok" + + +class TestRemoteLLMProviderHelpers: + """Tests for helper methods.""" + + def test_headers_include_bearer_token(self): + provider = RemoteLLMProvider( + server_url="https://api.example.com", + auth_token_provider=lambda: "abc123", + ) + headers = provider._headers() + assert headers["Authorization"] == "Bearer abc123" + assert headers["Content-Type"] == "application/json" + + def test_server_url_trailing_slash_stripped(self): + provider = RemoteLLMProvider( + server_url="https://api.example.com/", + auth_token_provider=lambda: "token", + ) + assert provider._server_url == "https://api.example.com" + + def test_build_payload_includes_all_fields(self): + provider = _make_provider() + request = LLMRequest( + messages=[{"role": "user", "content": "Hi"}], + model="gpt-4", + temperature=0.1, + max_tokens=50, + tools=[{"type": "function"}], + tool_choice="required", + timeout=10.0, + ) + payload = provider._build_payload(request) + assert payload["messages"] == [{"role": "user", "content": "Hi"}] + assert payload["model"] == "gpt-4" + assert payload["temperature"] == 0.1 + assert payload["max_tokens"] == 50 + assert payload["tools"] == [{"type": "function"}] + assert payload["tool_choice"] == "required" + assert payload["timeout"] == 10.0 diff --git a/tests/unit/test_terminal_server.py b/tests/unit/test_terminal_server.py new file mode 100644 index 0000000..1c0add2 --- /dev/null +++ b/tests/unit/test_terminal_server.py @@ -0,0 +1,659 @@ +"""Tests for U8: server-side terminal + approval mechanism. + +Covers: + - Approval management API (list, approve, reject, expire) + - Global whitelist CRUD + - Global whitelist integration with safety check + - Approval creation and lifecycle +""" + +from __future__ import annotations + +import asyncio +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import aiosqlite +import pytest +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient + +from agentkit.server.auth.models import init_auth_db +from agentkit.server.auth.terminal_security import check_command_safety_v2 +from agentkit.server.routes import terminal_server, terminal_whitelist + + +def _future_iso(minutes: int = 5) -> str: + """Return an ISO timestamp `minutes` in the future.""" + return (datetime.now(timezone.utc) + timedelta(minutes=minutes)).isoformat() + + +def _past_iso(minutes: int = 10) -> str: + """Return an ISO timestamp `minutes` in the past.""" + return (datetime.now(timezone.utc) - timedelta(minutes=minutes)).isoformat() + + +# ── Fixtures ────────────────────────────────────────────────────────── + + +@pytest.fixture +async def server_app(tmp_path: Path) -> FastAPI: + """App with terminal_server routes + dev-admin middleware.""" + db_path = tmp_path / "test_auth.db" + await init_auth_db(db_path) + + app = FastAPI() + app.state.auth_db_path = str(db_path) + app.state.allow_dev_terminal = True + app.include_router(terminal_server.router, prefix="/api/v1") + app.include_router(terminal_whitelist.router, prefix="/api/v1") + + @app.middleware("http") + async def _set_dev_admin_user(request, call_next): + request.state.current_user = { + "user_id": "dev-admin-id", + "username": "dev-admin", + "role": "admin", + "dev_mode": False, + } + return await call_next(request) + + return app + + +@pytest.fixture +async def server_client(server_app: FastAPI): + transport = ASGITransport(app=server_app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + yield client + + +@pytest.fixture +async def server_db_path(server_app: FastAPI) -> Path: + return Path(server_app.state.auth_db_path) + + +# ── Approval management API tests ───────────────────────────────────── + + +class TestApprovalListAPI: + """Test GET /api/v1/terminal/approvals.""" + + @pytest.mark.asyncio + async def test_list_empty_approvals(self, server_client: AsyncClient): + resp = await server_client.get("/api/v1/terminal/approvals") + assert resp.status_code == 200 + data = resp.json() + assert data["approvals"] == [] + assert data["total"] == 0 + + @pytest.mark.asyncio + async def test_list_approvals_after_creation( + self, server_client: AsyncClient, server_db_path: Path + ): + # Create an approval directly in the DB + async with aiosqlite.connect(str(server_db_path)) as db: + await db.execute( + """INSERT INTO terminal_approvals + (id, user_id, username, session_id, command, reason, status, + created_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?)""", + ( + "appr-1", + "user-1", + "alice", + "session-1", + "rm -rf /tmp/test", + "Dangerous command", + "2026-01-01T00:00:00Z", + _future_iso(), + ), + ) + await db.commit() + + resp = await server_client.get("/api/v1/terminal/approvals") + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 1 + assert data["approvals"][0]["command"] == "rm -rf /tmp/test" + assert data["approvals"][0]["status"] == "pending" + + @pytest.mark.asyncio + async def test_list_approvals_filter_by_status( + self, server_client: AsyncClient, server_db_path: Path + ): + # Create approvals with different statuses + async with aiosqlite.connect(str(server_db_path)) as db: + for i, status in enumerate(["pending", "approved", "rejected"]): + await db.execute( + """INSERT INTO terminal_approvals + (id, user_id, username, session_id, command, status, + created_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + ( + f"appr-{i}", + "user-1", + "alice", + f"session-{i}", + f"cmd-{i}", + status, + "2026-01-01T00:00:00Z", + _future_iso(), + ), + ) + await db.commit() + + # Filter by pending + resp = await server_client.get("/api/v1/terminal/approvals?status=pending") + data = resp.json() + assert data["total"] == 1 + assert data["approvals"][0]["status"] == "pending" + + # Filter by approved + resp = await server_client.get("/api/v1/terminal/approvals?status=approved") + data = resp.json() + assert data["total"] == 1 + assert data["approvals"][0]["status"] == "approved" + + +class TestApprovalApproveAPI: + """Test POST /api/v1/terminal/approvals/{id}/approve.""" + + @pytest.mark.asyncio + async def test_approve_pending_request( + self, server_client: AsyncClient, server_db_path: Path + ): + # Create a pending approval + async with aiosqlite.connect(str(server_db_path)) as db: + await db.execute( + """INSERT INTO terminal_approvals + (id, user_id, username, session_id, command, status, + created_at, expires_at) + VALUES (?, ?, ?, ?, ?, 'pending', ?, ?)""", + ( + "appr-approve-1", + "user-1", + "alice", + "session-1", + "rm -rf /tmp", + "2026-01-01T00:00:00Z", + _future_iso(), + ), + ) + await db.commit() + + resp = await server_client.post( + "/api/v1/terminal/approvals/appr-approve-1/approve", + json={"note": "Approved for testing"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "approved" + assert data["reviewer_username"] == "dev-admin" + assert data["review_note"] == "Approved for testing" + assert data["reviewed_at"] is not None + + @pytest.mark.asyncio + async def test_approve_nonexistent_returns_404(self, server_client: AsyncClient): + resp = await server_client.post( + "/api/v1/terminal/approvals/nonexistent/approve" + ) + assert resp.status_code == 404 + + @pytest.mark.asyncio + async def test_approve_already_reviewed_returns_404( + self, server_client: AsyncClient, server_db_path: Path + ): + # Create an already-approved approval + async with aiosqlite.connect(str(server_db_path)) as db: + await db.execute( + """INSERT INTO terminal_approvals + (id, user_id, username, session_id, command, status, + created_at, expires_at, reviewed_at) + VALUES (?, ?, ?, ?, ?, 'approved', ?, ?, ?)""", + ( + "appr-done", + "user-1", + "alice", + "session-1", + "ls", + "2026-01-01T00:00:00Z", + _future_iso(), + "2026-01-01T00:01:00Z", + ), + ) + await db.commit() + + resp = await server_client.post( + "/api/v1/terminal/approvals/appr-done/approve" + ) + assert resp.status_code == 404 + + +class TestApprovalRejectAPI: + """Test POST /api/v1/terminal/approvals/{id}/reject.""" + + @pytest.mark.asyncio + async def test_reject_pending_request( + self, server_client: AsyncClient, server_db_path: Path + ): + async with aiosqlite.connect(str(server_db_path)) as db: + await db.execute( + """INSERT INTO terminal_approvals + (id, user_id, username, session_id, command, status, + created_at, expires_at) + VALUES (?, ?, ?, ?, ?, 'pending', ?, ?)""", + ( + "appr-reject-1", + "user-1", + "alice", + "session-1", + "mkfs /dev/sda", + "2026-01-01T00:00:00Z", + _future_iso(), + ), + ) + await db.commit() + + resp = await server_client.post( + "/api/v1/terminal/approvals/appr-reject-1/reject", + json={"note": "Too dangerous"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "rejected" + assert data["review_note"] == "Too dangerous" + + +class TestApprovalExpiration: + """Test that stale approvals are auto-expired.""" + + @pytest.mark.asyncio + async def test_expired_approvals_auto_marked( + self, server_client: AsyncClient, server_db_path: Path + ): + # Create an approval that has already expired + past = _past_iso() + async with aiosqlite.connect(str(server_db_path)) as db: + await db.execute( + """INSERT INTO terminal_approvals + (id, user_id, username, session_id, command, status, + created_at, expires_at) + VALUES (?, ?, ?, ?, ?, 'pending', ?, ?)""", + ( + "appr-expired", + "user-1", + "alice", + "session-1", + "rm -rf /", + "2026-01-01T00:00:00Z", + past, # Already expired + ), + ) + await db.commit() + + # Listing triggers auto-expiration + resp = await server_client.get("/api/v1/terminal/approvals") + assert resp.status_code == 200 + + # The expired approval should now have status "expired" + resp = await server_client.get( + "/api/v1/terminal/approvals?status=expired" + ) + data = resp.json() + assert data["total"] == 1 + assert data["approvals"][0]["status"] == "expired" + + # It should no longer be pending + resp = await server_client.get( + "/api/v1/terminal/approvals?status=pending" + ) + data = resp.json() + assert data["total"] == 0 + + +# ── Global whitelist CRUD tests ─────────────────────────────────────── + + +class TestGlobalWhitelistAPI: + """Test the global whitelist management endpoints.""" + + @pytest.mark.asyncio + async def test_list_empty_global_whitelist(self, server_client: AsyncClient): + resp = await server_client.get("/api/v1/terminal/whitelist/global") + assert resp.status_code == 200 + assert resp.json()["entries"] == [] + + @pytest.mark.asyncio + async def test_add_global_whitelist_entry(self, server_client: AsyncClient): + resp = await server_client.post( + "/api/v1/terminal/whitelist/global", + json={"command_pattern": "docker"}, + ) + assert resp.status_code == 201 + data = resp.json() + assert data["command_pattern"] == "docker" + assert "id" in data + + @pytest.mark.asyncio + async def test_add_duplicate_global_whitelist_returns_409( + self, server_client: AsyncClient + ): + await server_client.post( + "/api/v1/terminal/whitelist/global", + json={"command_pattern": "docker"}, + ) + resp = await server_client.post( + "/api/v1/terminal/whitelist/global", + json={"command_pattern": "docker"}, + ) + assert resp.status_code == 409 + + @pytest.mark.asyncio + async def test_list_global_whitelist_after_add(self, server_client: AsyncClient): + await server_client.post( + "/api/v1/terminal/whitelist/global", + json={"command_pattern": "docker"}, + ) + await server_client.post( + "/api/v1/terminal/whitelist/global", + json={"command_pattern": "kubectl"}, + ) + + resp = await server_client.get("/api/v1/terminal/whitelist/global") + assert resp.status_code == 200 + entries = resp.json()["entries"] + assert len(entries) == 2 + + @pytest.mark.asyncio + async def test_delete_global_whitelist_entry(self, server_client: AsyncClient): + add_resp = await server_client.post( + "/api/v1/terminal/whitelist/global", + json={"command_pattern": "docker"}, + ) + entry_id = add_resp.json()["id"] + + del_resp = await server_client.delete( + f"/api/v1/terminal/whitelist/global/{entry_id}" + ) + assert del_resp.status_code == 204 + + # Verify deleted + list_resp = await server_client.get("/api/v1/terminal/whitelist/global") + assert list_resp.json()["entries"] == [] + + @pytest.mark.asyncio + async def test_delete_nonexistent_global_returns_404(self, server_client: AsyncClient): + resp = await server_client.delete( + "/api/v1/terminal/whitelist/global/nonexistent" + ) + assert resp.status_code == 404 + + +# ── Integration: global whitelist + safety check ────────────────────── + + +class TestGlobalWhitelistIntegration: + """Integration: global whitelist allows commands in safety check.""" + + @pytest.mark.asyncio + async def test_global_whitelist_allows_dangerous_command( + self, server_client: AsyncClient, server_db_path: Path + ): + """Add 'rm' to global whitelist, then verify safety check passes.""" + # Add "rm" to global whitelist via API + resp = await server_client.post( + "/api/v1/terminal/whitelist/global", + json={"command_pattern": "rm"}, + ) + assert resp.status_code == 201 + + # Check command safety — rm should now be allowed via global whitelist + decision = await check_command_safety_v2( + "rm -rf /tmp/test", + session_id="test-session", + session_whitelist=set(), + user_id="user-1", + db_path=server_db_path, + ) + assert decision.safe is True + assert decision.matched_layer == "global_whitelist" + + @pytest.mark.asyncio + async def test_global_whitelist_priority_over_user( + self, server_client: AsyncClient, server_db_path: Path + ): + """Global whitelist is checked before user whitelist.""" + # Add to global whitelist + await server_client.post( + "/api/v1/terminal/whitelist/global", + json={"command_pattern": "rm"}, + ) + + # Even without user whitelist, global whitelist allows it + decision = await check_command_safety_v2( + "rm -rf /tmp/test", + session_id="test-session", + session_whitelist=set(), + user_id="user-without-whitelist", + db_path=server_db_path, + ) + assert decision.safe is True + assert decision.matched_layer == "global_whitelist" + + @pytest.mark.asyncio + async def test_blocklist_overrides_global_whitelist( + self, server_client: AsyncClient, server_db_path: Path + ): + """Blocklist takes priority over global whitelist.""" + # Add "rm" to global whitelist + await server_client.post( + "/api/v1/terminal/whitelist/global", + json={"command_pattern": "rm"}, + ) + + # Add "rm" to blocklist via the whitelist management API + resp = await server_client.post( + "/api/v1/terminal/blocklist", + json={"command_pattern": "rm", "reason": "rm is globally blocked"}, + ) + assert resp.status_code == 201 + + # Blocklist should take priority + decision = await check_command_safety_v2( + "rm -rf /tmp/test", + session_id="test-session", + session_whitelist=set(), + user_id="user-1", + db_path=server_db_path, + ) + assert decision.safe is False + assert decision.decision == "blocked" + + +# ── Approval lifecycle integration ──────────────────────────────────── + + +class TestApprovalLifecycle: + """Test the full approval lifecycle via the API.""" + + @pytest.mark.asyncio + async def test_full_approval_lifecycle( + self, server_client: AsyncClient, server_db_path: Path + ): + """Create → list pending → approve → list approved.""" + # 1. Create a pending approval directly + approval_id = "lifecycle-1" + async with aiosqlite.connect(str(server_db_path)) as db: + await db.execute( + """INSERT INTO terminal_approvals + (id, user_id, username, session_id, command, reason, status, + created_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?)""", + ( + approval_id, + "user-1", + "alice", + "session-1", + "rm -rf /tmp/test", + "Cleanup temp directory", + "2026-01-01T00:00:00Z", + _future_iso(), + ), + ) + await db.commit() + + # 2. List pending — should see it + resp = await server_client.get("/api/v1/terminal/approvals?status=pending") + data = resp.json() + assert data["total"] == 1 + assert data["approvals"][0]["id"] == approval_id + + # 3. Approve it + resp = await server_client.post( + f"/api/v1/terminal/approvals/{approval_id}/approve", + json={"note": "OK to proceed"}, + ) + assert resp.status_code == 200 + assert resp.json()["status"] == "approved" + + # 4. List pending — should be empty now + resp = await server_client.get("/api/v1/terminal/approvals?status=pending") + assert resp.json()["total"] == 0 + + # 5. List approved — should see it + resp = await server_client.get("/api/v1/terminal/approvals?status=approved") + data = resp.json() + assert data["total"] == 1 + assert data["approvals"][0]["id"] == approval_id + + @pytest.mark.asyncio + async def test_reject_then_cannot_approve( + self, server_client: AsyncClient, server_db_path: Path + ): + """Once rejected, cannot approve the same request.""" + approval_id = "reject-then-approve" + async with aiosqlite.connect(str(server_db_path)) as db: + await db.execute( + """INSERT INTO terminal_approvals + (id, user_id, username, session_id, command, status, + created_at, expires_at) + VALUES (?, ?, ?, ?, ?, 'pending', ?, ?)""", + ( + approval_id, + "user-1", + "alice", + "session-1", + "mkfs /dev/sda", + "2026-01-01T00:00:00Z", + _future_iso(), + ), + ) + await db.commit() + + # Reject it + resp = await server_client.post( + f"/api/v1/terminal/approvals/{approval_id}/reject" + ) + assert resp.status_code == 200 + assert resp.json()["status"] == "rejected" + + # Try to approve — should fail (already reviewed) + resp = await server_client.post( + f"/api/v1/terminal/approvals/{approval_id}/approve" + ) + assert resp.status_code == 404 + + +# ── Approval future notification test ───────────────────────────────── + + +class TestApprovalFutureNotification: + """Test that approving/rejecting via API notifies waiting futures.""" + + @pytest.mark.asyncio + async def test_approve_notifies_future( + self, server_client: AsyncClient, server_db_path: Path + ): + """When admin approves via API, the waiting future is resolved.""" + from agentkit.server.routes.terminal_server import _pending_approvals + + approval_id = "future-test-1" + + # Create approval in DB + async with aiosqlite.connect(str(server_db_path)) as db: + await db.execute( + """INSERT INTO terminal_approvals + (id, user_id, username, session_id, command, status, + created_at, expires_at) + VALUES (?, ?, ?, ?, ?, 'pending', ?, ?)""", + ( + approval_id, + "user-1", + "alice", + "session-1", + "rm -rf /tmp", + "2026-01-01T00:00:00Z", + _future_iso(), + ), + ) + await db.commit() + + # Register a future (simulating a waiting WebSocket) + loop = asyncio.get_running_loop() + future: asyncio.Future[bool] = loop.create_future() + _pending_approvals[approval_id] = future + + # Approve via API + resp = await server_client.post( + f"/api/v1/terminal/approvals/{approval_id}/approve" + ) + assert resp.status_code == 200 + + # The future should be resolved with True + result = await asyncio.wait_for(future, timeout=1.0) + assert result is True + + # Cleanup + _pending_approvals.pop(approval_id, None) + + @pytest.mark.asyncio + async def test_reject_notifies_future( + self, server_client: AsyncClient, server_db_path: Path + ): + """When admin rejects via API, the waiting future is resolved with False.""" + from agentkit.server.routes.terminal_server import _pending_approvals + + approval_id = "future-test-2" + + async with aiosqlite.connect(str(server_db_path)) as db: + await db.execute( + """INSERT INTO terminal_approvals + (id, user_id, username, session_id, command, status, + created_at, expires_at) + VALUES (?, ?, ?, ?, ?, 'pending', ?, ?)""", + ( + approval_id, + "user-1", + "alice", + "session-1", + "mkfs /dev/sda", + "2026-01-01T00:00:00Z", + _future_iso(), + ), + ) + await db.commit() + + loop = asyncio.get_running_loop() + future: asyncio.Future[bool] = loop.create_future() + _pending_approvals[approval_id] = future + + resp = await server_client.post( + f"/api/v1/terminal/approvals/{approval_id}/reject" + ) + assert resp.status_code == 200 + + result = await asyncio.wait_for(future, timeout=1.0) + assert result is False + + _pending_approvals.pop(approval_id, None) diff --git a/tests/unit/test_terminal_whitelist.py b/tests/unit/test_terminal_whitelist.py new file mode 100644 index 0000000..2f4f28b --- /dev/null +++ b/tests/unit/test_terminal_whitelist.py @@ -0,0 +1,760 @@ +"""Tests for U6: terminal three-layer whitelist + audit logging. + +Covers: + - terminal_security.py: pattern matching, builtin whitelist, DB-backed + user whitelist, blocklist, audit log writing, top-level + check_command_safety_v2 + - terminal_whitelist.py routes: user whitelist CRUD, blocklist CRUD, + audit log query +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient + +from agentkit.server.auth.models import init_auth_db +from agentkit.server.auth.terminal_security import ( + DECISION_BLOCKED, + DECISION_CONFIRMED, + DECISION_DENIED, + DECISION_EXECUTED, + _matches_any_pattern, + _matches_builtin_whitelist, + _matches_pattern, + check_command_safety_v2, + compute_whitelist_entry, + write_audit_log, +) +from agentkit.server.routes import terminal_whitelist + + +# ── Pattern matching unit tests ─────────────────────────────────────── + + +class TestPatternMatching: + """Test the pattern matching helpers.""" + + def test_matches_pattern_single_word_matches_binary(self): + assert _matches_pattern("ls -la", "ls") is True + assert _matches_pattern("git status", "git") is True + + def test_matches_pattern_single_word_does_not_match_different_binary(self): + assert _matches_pattern("ls -la", "cat") is False + + def test_matches_pattern_multi_word_prefix(self): + assert _matches_pattern("git push origin main", "git push") is True + assert _matches_pattern("npm install --save", "npm install") is True + + def test_matches_pattern_multi_word_not_prefix(self): + assert _matches_pattern("git status", "git push") is False + + def test_matches_pattern_case_insensitive(self): + assert _matches_pattern("LS -la", "ls") is True + assert _matches_pattern("Git Push", "git push") is True + + def test_matches_pattern_empty_pattern_returns_false(self): + assert _matches_pattern("ls", "") is False + assert _matches_pattern("ls", " ") is False + + def test_matches_pattern_unparseable_command(self): + # Unbalanced quote → shlex fails → binary is None → no match + assert _matches_pattern('echo "unbalanced', "echo") is False + + def test_matches_any_pattern_returns_first_match(self): + matched, pattern = _matches_any_pattern("git push", ["ls", "git", "cat"]) + assert matched is True + assert pattern == "git" + + def test_matches_any_pattern_no_match(self): + matched, pattern = _matches_any_pattern("rm -rf /", ["ls", "git"]) + assert matched is False + assert pattern is None + + +class TestBuiltinWhitelist: + """Test the built-in safe whitelist matching.""" + + def test_builtin_whitelist_matches_ls(self): + assert _matches_builtin_whitelist("ls -la") is True + + def test_builtin_whitelist_matches_cat(self): + assert _matches_builtin_whitelist("cat /etc/hosts") is True + + def test_builtin_whitelist_matches_git_status(self): + assert _matches_builtin_whitelist("git status") is True + + def test_builtin_whitelist_matches_docker_ps(self): + assert _matches_builtin_whitelist("docker ps") is True + + def test_builtin_whitelist_does_not_match_rm(self): + assert _matches_builtin_whitelist("rm -rf /tmp/test") is False + + def test_builtin_whitelist_does_not_match_unknown_command(self): + assert _matches_builtin_whitelist("somecmd --flag") is False + + def test_builtin_whitelist_empty_command(self): + assert _matches_builtin_whitelist("") is False + + def test_builtin_whitelist_case_insensitive(self): + assert _matches_builtin_whitelist("LS -LA") is True + + +class TestComputeWhitelistEntry: + """Test the whitelist entry computation for confirmed commands.""" + + def test_compute_entry_for_safe_binary(self): + # Non-dangerous binary (not in _DANGEROUS_BINARY_FLAGS) → just the binary name + assert compute_whitelist_entry("ls -la /tmp") == "ls" + + def test_compute_entry_for_dangerous_binary(self): + # Dangerous binary (in _DANGEROUS_BINARY_FLAGS) → None (never whitelisted, + # because whitelisting the binary name would bypass danger detection for + # dangerous flag variants like "rm -rf") + assert compute_whitelist_entry("rm -rf /tmp/test") is None + + def test_compute_entry_for_binary_with_dangerous_variants(self): + # Binary in _DANGEROUS_BINARY_FLAGS (e.g. docker) → None even for safe + # invocations, because whitelisting "docker" would auto-execute "docker rm" + assert compute_whitelist_entry("docker ps") is None + + def test_compute_entry_for_unparseable_command(self): + assert compute_whitelist_entry('echo "unbalanced') is None + + def test_compute_entry_for_empty_command(self): + assert compute_whitelist_entry("") is None + + +# ── DB-backed tests (using temp SQLite) ─────────────────────────────── + + +@pytest.fixture +async def temp_db_path(tmp_path: Path) -> Path: + """Create a temporary auth DB with the terminal tables.""" + db_path = tmp_path / "test_auth.db" + await init_auth_db(db_path) + return db_path + + +class TestCheckCommandSafetyV2: + """Test the top-level three-layer whitelist check.""" + + @pytest.mark.asyncio + async def test_builtin_whitelist_command_passes(self, temp_db_path: Path): + """Built-in safe commands execute without confirmation.""" + decision = await check_command_safety_v2( + "ls -la", + session_id="test-session", + session_whitelist=set(), + user_id="user-1", + db_path=temp_db_path, + ) + assert decision.safe is True + assert decision.decision == DECISION_EXECUTED + assert decision.matched_layer == "builtin" + + @pytest.mark.asyncio + async def test_dangerous_command_requires_confirmation(self, temp_db_path: Path): + """Non-whitelisted dangerous commands require confirmation.""" + decision = await check_command_safety_v2( + "rm -rf /tmp/test", + session_id="test-session", + session_whitelist=set(), + user_id="user-1", + db_path=temp_db_path, + ) + assert decision.safe is False + assert decision.decision == DECISION_DENIED + assert decision.reason is not None + + @pytest.mark.asyncio + async def test_session_whitelist_allows_command(self, temp_db_path: Path): + """Session whitelist allows previously-confirmed commands.""" + decision = await check_command_safety_v2( + "rm -rf /tmp/test", + session_id="test-session", + session_whitelist={"rm"}, # rm is in session whitelist + user_id="user-1", + db_path=temp_db_path, + ) + assert decision.safe is True + assert decision.matched_layer == "session_whitelist" + + @pytest.mark.asyncio + async def test_user_whitelist_allows_command(self, temp_db_path: Path): + """User whitelist (DB-backed) allows commands.""" + import aiosqlite + + # Add a user whitelist entry + async with aiosqlite.connect(str(temp_db_path)) as db: + await db.execute( + """INSERT INTO terminal_whitelist_user + (id, user_id, command_pattern, scope, created_at) + VALUES (?, ?, ?, 'user', ?)""", + ("wl-1", "user-1", "rm", "2026-01-01T00:00:00Z"), + ) + await db.commit() + + decision = await check_command_safety_v2( + "rm -rf /tmp/test", + session_id="test-session", + session_whitelist=set(), + user_id="user-1", + db_path=temp_db_path, + ) + assert decision.safe is True + assert decision.matched_layer == "user_whitelist" + + @pytest.mark.asyncio + async def test_blocklist_rejects_command(self, temp_db_path: Path): + """Blocklist rejects commands regardless of other whitelists.""" + import aiosqlite + + # Add a blocklist entry for "rm" + async with aiosqlite.connect(str(temp_db_path)) as db: + await db.execute( + """INSERT INTO terminal_blocklist + (id, command_pattern, reason, created_at) + VALUES (?, ?, ?, ?)""", + ("bl-1", "rm", "rm is blocked by admin", "2026-01-01T00:00:00Z"), + ) + await db.commit() + + # Even though "rm" is in session whitelist, blocklist takes priority + decision = await check_command_safety_v2( + "rm -rf /tmp/test", + session_id="test-session", + session_whitelist={"rm"}, # session whitelist has rm + user_id="user-1", + db_path=temp_db_path, + ) + assert decision.safe is False + assert decision.decision == DECISION_BLOCKED + assert decision.matched_layer == "blocklist" + assert "rm is blocked by admin" in (decision.reason or "") + + @pytest.mark.asyncio + async def test_blocklist_takes_priority_over_builtin(self, temp_db_path: Path): + """Blocklist rejects even built-in safe commands.""" + import aiosqlite + + async with aiosqlite.connect(str(temp_db_path)) as db: + await db.execute( + """INSERT INTO terminal_blocklist + (id, command_pattern, reason, created_at) + VALUES (?, ?, ?, ?)""", + ("bl-1", "ls", "ls is blocked", "2026-01-01T00:00:00Z"), + ) + await db.commit() + + decision = await check_command_safety_v2( + "ls -la", + session_id="test-session", + session_whitelist=set(), + user_id="user-1", + db_path=temp_db_path, + ) + assert decision.safe is False + assert decision.decision == DECISION_BLOCKED + + @pytest.mark.asyncio + async def test_empty_command_denied(self, temp_db_path: Path): + """Empty commands are denied.""" + decision = await check_command_safety_v2( + " ", + session_id="test-session", + session_whitelist=set(), + user_id="user-1", + db_path=temp_db_path, + ) + assert decision.safe is False + assert decision.decision == DECISION_DENIED + + @pytest.mark.asyncio + async def test_dev_mode_no_db(self): + """When db_path is None, DB-backed layers are skipped.""" + # A command that's not in builtin whitelist and is dangerous + decision = await check_command_safety_v2( + "rm -rf /tmp/test", + session_id="test-session", + session_whitelist=set(), + user_id=None, + db_path=None, # No DB + ) + # Should fall through to danger detection + assert decision.safe is False + assert decision.decision == DECISION_DENIED + + @pytest.mark.asyncio + async def test_non_dangerous_non_whitelisted_command_passes(self, temp_db_path: Path): + """Commands that pass the danger check (not in any whitelist) execute.""" + # "man ls" is not in the builtin whitelist but is not dangerous + # per the _is_dangerous logic (man is not in _DANGEROUS_BINARIES) + # Actually, man is not in _SAFE_COMMAND_PREFIXES, so it would be + # considered dangerous by the default logic. Let's use a command + # that's definitely not dangerous but not in the whitelist. + # Looking at _is_single_command_dangerous: if binary is not in + # _SAFE_COMMAND_PREFIXES and not in _DANGEROUS_BINARIES, it returns + # True (dangerous). So any unknown binary is dangerous. + # This means the "danger_check_passed" layer is rarely reached. + # Let's test with a piped command where all parts are safe. + decision = await check_command_safety_v2( + "ls | grep test", + session_id="test-session", + session_whitelist=set(), + user_id="user-1", + db_path=temp_db_path, + ) + # ls and grep are both in builtin whitelist, but the pipe makes it + # go through _is_dangerous which checks each part. Since both parts + # are safe, the whole command is safe. + assert decision.safe is True + + +class TestWriteAuditLog: + """Test the audit log writer.""" + + @pytest.mark.asyncio + async def test_write_audit_log_success(self, temp_db_path: Path): + """Audit log entry is written to the DB.""" + await write_audit_log( + temp_db_path, + user_id="user-1", + username="alice", + session_id="session-1", + command="ls -la", + decision=DECISION_EXECUTED, + reason="matched_layer=builtin", + cwd="/tmp", + exit_code=0, + terminal_mode="local", + ) + + import aiosqlite + + async with aiosqlite.connect(str(temp_db_path)) as db: + db.row_factory = aiosqlite.Row + cursor = await db.execute( + "SELECT * FROM terminal_audit_logs WHERE session_id = ?", + ("session-1",), + ) + row = await cursor.fetchone() + + assert row is not None + assert row["user_id"] == "user-1" + assert row["username"] == "alice" + assert row["command"] == "ls -la" + assert row["decision"] == DECISION_EXECUTED + assert row["exit_code"] == 0 + assert row["terminal_mode"] == "local" + + @pytest.mark.asyncio + async def test_write_audit_log_null_user(self, temp_db_path: Path): + """Audit log accepts None user_id (dev mode).""" + await write_audit_log( + temp_db_path, + user_id=None, + username=None, + session_id="session-dev", + command="rm -rf /tmp", + decision=DECISION_CONFIRMED, + terminal_mode="local", + ) + + import aiosqlite + + async with aiosqlite.connect(str(temp_db_path)) as db: + cursor = await db.execute( + "SELECT user_id, username FROM terminal_audit_logs WHERE session_id = ?", + ("session-dev",), + ) + row = await cursor.fetchone() + + assert row is not None + assert row[0] is None + assert row[1] is None + + @pytest.mark.asyncio + async def test_write_audit_log_best_effort(self, tmp_path: Path): + """Audit log failures are swallowed (never raise).""" + # Non-existent DB path — should not raise + await write_audit_log( + tmp_path / "nonexistent" / "auth.db", # Invalid path + user_id="user-1", + username="alice", + session_id="session-1", + command="ls", + decision=DECISION_EXECUTED, + ) + + @pytest.mark.asyncio + async def test_write_audit_log_truncates_long_command(self, temp_db_path: Path): + """Commands longer than 4096 chars are truncated.""" + long_command = "echo " + "a" * 5000 + await write_audit_log( + temp_db_path, + user_id="user-1", + username="alice", + session_id="session-1", + command=long_command, + decision=DECISION_EXECUTED, + ) + + import aiosqlite + + async with aiosqlite.connect(str(temp_db_path)) as db: + cursor = await db.execute( + "SELECT command FROM terminal_audit_logs WHERE session_id = ?", + ("session-1",), + ) + row = await cursor.fetchone() + + assert row is not None + assert len(row[0]) <= 4096 + + +# ── Whitelist management API tests ──────────────────────────────────── + + +@pytest.fixture +async def whitelist_app(tmp_path: Path) -> FastAPI: + """Build a FastAPI app with the whitelist routes + dev-admin middleware.""" + db_path = tmp_path / "test_auth.db" + await init_auth_db(db_path) + + app = FastAPI() + app.state.auth_db_path = str(db_path) + app.include_router(terminal_whitelist.router, prefix="/api/v1") + + # Dev-admin middleware: injects an admin user so permission checks pass + @app.middleware("http") + async def _set_dev_admin_user(request, call_next): + request.state.current_user = { + "user_id": "dev-admin-id", + "username": "dev-admin", + "role": "admin", + "dev_mode": False, # Use real DB path + } + return await call_next(request) + + return app + + +@pytest.fixture +async def whitelist_client(whitelist_app: FastAPI): + """HTTP client for the whitelist app.""" + transport = ASGITransport(app=whitelist_app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + yield client + + +class TestUserWhitelistAPI: + """Test the user whitelist CRUD endpoints.""" + + @pytest.mark.asyncio + async def test_list_empty_user_whitelist(self, whitelist_client: AsyncClient): + resp = await whitelist_client.get("/api/v1/terminal/whitelist/user") + assert resp.status_code == 200 + data = resp.json() + assert data["entries"] == [] + + @pytest.mark.asyncio + async def test_add_user_whitelist_entry(self, whitelist_client: AsyncClient): + resp = await whitelist_client.post( + "/api/v1/terminal/whitelist/user", + json={"command_pattern": "docker"}, + ) + assert resp.status_code == 201 + data = resp.json() + assert data["command_pattern"] == "docker" + assert data["scope"] == "user" + assert "id" in data + + @pytest.mark.asyncio + async def test_add_duplicate_user_whitelist_returns_409( + self, whitelist_client: AsyncClient + ): + # Add once + await whitelist_client.post( + "/api/v1/terminal/whitelist/user", + json={"command_pattern": "git push"}, + ) + # Add again → 409 + resp = await whitelist_client.post( + "/api/v1/terminal/whitelist/user", + json={"command_pattern": "git push"}, + ) + assert resp.status_code == 409 + + @pytest.mark.asyncio + async def test_list_user_whitelist_after_add(self, whitelist_client: AsyncClient): + await whitelist_client.post( + "/api/v1/terminal/whitelist/user", + json={"command_pattern": "docker"}, + ) + await whitelist_client.post( + "/api/v1/terminal/whitelist/user", + json={"command_pattern": "git push"}, + ) + + resp = await whitelist_client.get("/api/v1/terminal/whitelist/user") + assert resp.status_code == 200 + entries = resp.json()["entries"] + assert len(entries) == 2 + patterns = {e["command_pattern"] for e in entries} + assert patterns == {"docker", "git push"} + + @pytest.mark.asyncio + async def test_delete_user_whitelist_entry(self, whitelist_client: AsyncClient): + add_resp = await whitelist_client.post( + "/api/v1/terminal/whitelist/user", + json={"command_pattern": "docker"}, + ) + entry_id = add_resp.json()["id"] + + del_resp = await whitelist_client.delete( + f"/api/v1/terminal/whitelist/user/{entry_id}" + ) + assert del_resp.status_code == 204 + + # Verify it's gone + list_resp = await whitelist_client.get("/api/v1/terminal/whitelist/user") + assert list_resp.json()["entries"] == [] + + @pytest.mark.asyncio + async def test_delete_nonexistent_user_whitelist_returns_404( + self, whitelist_client: AsyncClient + ): + resp = await whitelist_client.delete( + "/api/v1/terminal/whitelist/user/nonexistent-id" + ) + assert resp.status_code == 404 + + @pytest.mark.asyncio + async def test_add_user_whitelist_invalid_pattern_rejected( + self, whitelist_client: AsyncClient + ): + # Pattern with shell separator + resp = await whitelist_client.post( + "/api/v1/terminal/whitelist/user", + json={"command_pattern": "ls; rm -rf /"}, + ) + assert resp.status_code == 422 + + @pytest.mark.asyncio + async def test_add_user_whitelist_empty_pattern_rejected( + self, whitelist_client: AsyncClient + ): + resp = await whitelist_client.post( + "/api/v1/terminal/whitelist/user", + json={"command_pattern": ""}, + ) + assert resp.status_code == 422 + + +class TestBlocklistAPI: + """Test the blocklist CRUD endpoints (admin only).""" + + @pytest.mark.asyncio + async def test_list_empty_blocklist(self, whitelist_client: AsyncClient): + resp = await whitelist_client.get("/api/v1/terminal/blocklist") + assert resp.status_code == 200 + assert resp.json()["entries"] == [] + + @pytest.mark.asyncio + async def test_add_blocklist_entry(self, whitelist_client: AsyncClient): + resp = await whitelist_client.post( + "/api/v1/terminal/blocklist", + json={"command_pattern": "mkfs", "reason": "formatting is dangerous"}, + ) + assert resp.status_code == 201 + data = resp.json() + assert data["command_pattern"] == "mkfs" + assert data["reason"] == "formatting is dangerous" + + @pytest.mark.asyncio + async def test_add_duplicate_blocklist_returns_409(self, whitelist_client: AsyncClient): + await whitelist_client.post( + "/api/v1/terminal/blocklist", + json={"command_pattern": "mkfs"}, + ) + resp = await whitelist_client.post( + "/api/v1/terminal/blocklist", + json={"command_pattern": "mkfs"}, + ) + assert resp.status_code == 409 + + @pytest.mark.asyncio + async def test_delete_blocklist_entry(self, whitelist_client: AsyncClient): + add_resp = await whitelist_client.post( + "/api/v1/terminal/blocklist", + json={"command_pattern": "mkfs"}, + ) + entry_id = add_resp.json()["id"] + + del_resp = await whitelist_client.delete(f"/api/v1/terminal/blocklist/{entry_id}") + assert del_resp.status_code == 204 + + # Verify deleted + list_resp = await whitelist_client.get("/api/v1/terminal/blocklist") + assert list_resp.json()["entries"] == [] + + @pytest.mark.asyncio + async def test_delete_nonexistent_blocklist_returns_404( + self, whitelist_client: AsyncClient + ): + resp = await whitelist_client.delete("/api/v1/terminal/blocklist/nonexistent") + assert resp.status_code == 404 + + +class TestAuditLogAPI: + """Test the audit log query endpoint.""" + + @pytest.mark.asyncio + async def test_list_empty_audit_logs(self, whitelist_client: AsyncClient): + resp = await whitelist_client.get("/api/v1/terminal/audit-logs") + assert resp.status_code == 200 + data = resp.json() + assert data["entries"] == [] + assert data["total"] == 0 + + @pytest.mark.asyncio + async def test_list_audit_logs_after_writes( + self, whitelist_client: AsyncClient, whitelist_app: FastAPI + ): + db_path = whitelist_app.state.auth_db_path + await write_audit_log( + db_path, + user_id="user-1", + username="alice", + session_id="session-1", + command="ls -la", + decision=DECISION_EXECUTED, + exit_code=0, + ) + await write_audit_log( + db_path, + user_id="user-1", + username="alice", + session_id="session-1", + command="rm -rf /tmp", + decision=DECISION_CONFIRMED, + exit_code=0, + ) + await write_audit_log( + db_path, + user_id="user-2", + username="bob", + session_id="session-2", + command="mkfs /dev/sda", + decision=DECISION_BLOCKED, + ) + + # List all + resp = await whitelist_client.get("/api/v1/terminal/audit-logs") + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 3 + assert len(data["entries"]) == 3 + + # Filter by user_id + resp = await whitelist_client.get( + "/api/v1/terminal/audit-logs?user_id=user-1" + ) + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 2 + for entry in data["entries"]: + assert entry["user_id"] == "user-1" + + # Filter by decision + resp = await whitelist_client.get( + "/api/v1/terminal/audit-logs?decision=confirmed" + ) + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 1 + assert data["entries"][0]["decision"] == "confirmed" + + # Filter by session_id + resp = await whitelist_client.get( + "/api/v1/terminal/audit-logs?session_id=session-2" + ) + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 1 + assert data["entries"][0]["session_id"] == "session-2" + + @pytest.mark.asyncio + async def test_audit_logs_pagination( + self, whitelist_client: AsyncClient, whitelist_app: FastAPI + ): + db_path = whitelist_app.state.auth_db_path + for i in range(5): + await write_audit_log( + db_path, + user_id="user-1", + username="alice", + session_id=f"session-{i}", + command=f"ls {i}", + decision=DECISION_EXECUTED, + ) + + # Page 1: limit=2, offset=0 + resp = await whitelist_client.get( + "/api/v1/terminal/audit-logs?limit=2&offset=0" + ) + data = resp.json() + assert data["total"] == 5 + assert len(data["entries"]) == 2 + + # Page 2: limit=2, offset=2 + resp = await whitelist_client.get( + "/api/v1/terminal/audit-logs?limit=2&offset=2" + ) + data = resp.json() + assert data["total"] == 5 + assert len(data["entries"]) == 2 + + # Page 3: limit=2, offset=4 + resp = await whitelist_client.get( + "/api/v1/terminal/audit-logs?limit=2&offset=4" + ) + data = resp.json() + assert data["total"] == 5 + assert len(data["entries"]) == 1 + + +# ── Integration: blocklist + safety check ───────────────────────────── + + +class TestBlocklistIntegration: + """Integration test: blocklist entry blocks command in safety check.""" + + @pytest.mark.asyncio + async def test_blocklist_blocks_command_in_safety_check( + self, whitelist_client: AsyncClient, whitelist_app: FastAPI + ): + """Add a blocklist entry via API, then verify safety check blocks it.""" + # Add "rm" to blocklist via API + resp = await whitelist_client.post( + "/api/v1/terminal/blocklist", + json={"command_pattern": "rm", "reason": "rm disabled by policy"}, + ) + assert resp.status_code == 201 + + # Now check command safety — rm should be blocked + db_path = whitelist_app.state.auth_db_path + decision = await check_command_safety_v2( + "rm -rf /tmp/test", + session_id="test-session", + session_whitelist={"rm"}, # Even with session whitelist + user_id="user-1", + db_path=db_path, + ) + assert decision.safe is False + assert decision.decision == DECISION_BLOCKED + assert decision.matched_layer == "blocklist"