fix(code-review): 修复走查发现的 13 High + Medium 安全/可靠性问题
代码修复(8 High + 9 Medium): - portal.py — C1 IDOR 文档 / C2 类型修复 / C3 WS 连接上限 16 / C4 ws_user_id 早初始化 / M silent swallow 日志化 - auth/middleware.py — C5 WS sid 补齐 - calendar_tool.py — C6 偏移量 ±43200 双向校验 + reminder_channels 类型/白名单校验 - sqlite_conversation_store.py — C7 DELETE 事务回滚 - chat.ts (Pinia) — C8 deleteConversation 清理 pending 缓存 - app.py — M except: pass → logger.debug(exc_info=True) - Scene6Error.vue — M onUnmounted 清理 setTimeout - DocumentsTab.vue — M Invalid Date 守卫 - ChatSidebar/RightPanel/TopNav.vue — M aria-label 无障碍标签 - SystemMonitorPanel.vue — M v-else 兜底 + active 边框色 + tablist 键盘导航 - CalendarDrawer.vue — M overflow-y: auto - CalendarGrid.vue — M ResizeObserver 反馈循环防护 - SkillsTab.vue — M onMounted 始终 fetchSkills 文档修复(5 High + 6 Medium): - portal-platform-security-reliability-fixes.md — D2 测试路径 / D3 Root Cause+Impact 章节 / D4 severity: mixed / 标题中文化 / 12 处绝对路径转相对 / P2 #12 数字口径 - AGENTS.md — D5 路由表 22→28 / 专家模板 5→15 / LiteLLM U15 迁移 / 配置查找 fallback - README.md — 8 处端口 8000→8001 新增测试: - tests/unit/calendar/test_calendar_tool.py — ponytail 自检断言 验证: - ruff check (5 文件) — All checks passed - vue-tsc --noEmit — exit 0 - git stash baseline 验证 — portal 17 个 401 失败为预存在问题 已知限制(预存在): - 17 个 portal 测试 401 失败 — 需另起 ce-debug 调查 - README.md 7 处 CostAwareRouter 引用过时 — 文档同步另起任务
This commit is contained in:
parent
8ae8ed4e9b
commit
c9ce15fa4b
14
AGENTS.md
14
AGENTS.md
|
|
@ -128,7 +128,7 @@ HandoffTransport:InProcess(asyncio.Queue)+ Redis Pub/Sub — 仅用于事
|
|||
|
||||
### 关键子系统
|
||||
|
||||
- **LLM 网关**(`llm/`):6 个 provider(OpenAI/Anthropic/Gemini/Doubao/Wenxin/Yuanbao)、fallback、语义缓存、用量追踪、RemoteLLMProvider(client→server 代理,带 401 刷新重试)
|
||||
- **LLM 网关**(`llm/`):基于 LiteLLM 统一适配层(U15 迁移),保留原 6 个 provider 命名作为别名(OpenAI/Anthropic/Gemini/Doubao/Wenxin/Yuanbao),支持任意 OpenAI 兼容 API(如 DashScope/DeepSeek);含 fallback、语义缓存、用量追踪、RemoteLLMProvider(client→server 代理,带 401 刷新重试)
|
||||
- **记忆**(`memory/`):4 层(SOUL/USER/MEMORY/DAILY)、WorkingMemory(Redis)、EpisodicMemory(PG+pgvector)、SemanticMemory(HTTP RAG)
|
||||
- **进化**(`evolution/`):Reflector、PromptOptimizer(遗传算法)、PitfallDetector、ABTester
|
||||
- **工具**(`tools/`):21 个内置 + MCP 扩展,组合(SequentialChain/ParallelFanOut/DynamicSelector)
|
||||
|
|
@ -136,7 +136,7 @@ HandoffTransport:InProcess(asyncio.Queue)+ Redis Pub/Sub — 仅用于事
|
|||
- **总线**(`bus/`):MemoryBus(进程内)、RedisBus(分布式)
|
||||
- **认证**(`server/auth/`):JWT(access 15min + refresh 7d,HS256)、API Key(恒定时间比较)、3 级 RBAC(member/operator/admin + 权限位)、6 层终端安全(blocklist→shell-ops→builtin→global→user→session→danger)、bcrypt 密码哈希(rounds=12)
|
||||
|
||||
### 服务端路由(22 个模块)
|
||||
### 服务端路由(28 个模块)
|
||||
|
||||
| 前缀 | 模块 | 用途 |
|
||||
| ------------------------- | -------------------------------------- | ------------------------- |
|
||||
|
|
@ -162,6 +162,12 @@ HandoffTransport:InProcess(asyncio.Queue)+ Redis Pub/Sub — 仅用于事
|
|||
| `/api/v1/auth` | auth.py | 登录/刷新/登出/me |
|
||||
| `/api/v1/system` | system.py | 系统资源(需 SYSTEM\_CONFIG 权限) |
|
||||
| `/api/v1/config` | config\_sync.py | 配置版本 + 同步(轮询) |
|
||||
| `/api/v1/bitable` | bitable.py | 多维表格 companion 服务 |
|
||||
| `/api/v1/calendar` | calendar.py | 日历服务(事件/提醒/同步) |
|
||||
| `/api/v1/documents` | documents.py | 文档管理 |
|
||||
| `/api/v1/admin` | admin.py | 管理员操作 |
|
||||
| `/api/v1/experts` | experts.py | 专家团队管理 |
|
||||
| `/api/v1/mcp` | mcp\_publish.py | MCP 发布 |
|
||||
|
||||
### WebSocket Chat 协议
|
||||
|
||||
|
|
@ -182,7 +188,7 @@ Server -> Client:`connected`、`token`、`thinking`、`step`、`final_answer`
|
|||
|
||||
CLI 参数 > `agentkit.yaml` > 环境变量(`${VAR:-default}`)> `.env` > 硬编码默认值
|
||||
|
||||
配置查找:`--config` 路径 > `./agentkit.yaml` > `~/.agentkit/agentkit.yaml`
|
||||
配置查找:`--config` 路径 > `./agentkit.yaml` > `~/.agentkit/agentkit.yaml`(三个路径都不存在时使用硬编码默认值,CLI 仍可启动)
|
||||
|
||||
## 约定
|
||||
|
||||
|
|
@ -190,7 +196,7 @@ CLI 参数 > `agentkit.yaml` > 环境变量(`${VAR:-default}`)> `.env` > 硬
|
|||
- 技能分类:`agent_template`(执行引擎:react/direct/rewoo/reflexion/plan\_exec/goal\_driven)vs `business_skill`(领域技能)。通过 `server/routes/skill_management.py` 中的 `_ENGINE_TEMPLATE_NAMES` 分类。前端按 `category` 字段分组 — `SkillsView` 双栏布局,`SkillCard`/`SkillsTab` 显示类型标签(引擎/技能)和基于分类的图标
|
||||
- LLM 配置:`agentkit.yaml` llm 段(与服务端配置统一)
|
||||
- 流水线配置:`configs/pipelines/*.yaml`
|
||||
- 专家模板:`configs/experts/*.yaml`(5 个编程专家 + dev\_team 团队模板),通过 `ExpertTemplateRegistry` 注册
|
||||
- 专家模板:`configs/experts/*.yaml`(15 个模板 — 5 编程专家 + 9 商业/思想领袖 + 1 团队模板 dev\_team),通过 `ExpertTemplateRegistry` 注册
|
||||
- 团队模板:`bound_skills` 字段存储成员列表(如 `dev_team.yaml` 列出 tech\_lead、frontend\_engineer、backend\_engineer、qa\_engineer、code\_reviewer)
|
||||
- 所有 Pydantic 模型使用 `model_config = ConfigDict(...)` 而非 `class Config`
|
||||
- 测试文件:`tests/unit/` 和 `tests/integration/`
|
||||
|
|
|
|||
16
README.md
16
README.md
|
|
@ -676,7 +676,7 @@ gateway.register_provider("dashscope", OpenAIProvider(
|
|||
app = create_app(llm_gateway=gateway)
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
uvicorn.run(app, host="0.0.0.0", port=8001)
|
||||
```
|
||||
|
||||
启动:
|
||||
|
|
@ -884,7 +884,7 @@ asyncio.run(main())
|
|||
注册 Skill:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/v1/skills \
|
||||
curl -X POST http://localhost:8001/api/v1/skills \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"config": {
|
||||
|
|
@ -915,7 +915,7 @@ curl -X POST http://localhost:8000/api/v1/skills \
|
|||
提交任务(指定 Skill):
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/v1/tasks \
|
||||
curl -X POST http://localhost:8001/api/v1/tasks \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"skill_name": "content_generator",
|
||||
|
|
@ -926,7 +926,7 @@ curl -X POST http://localhost:8000/api/v1/tasks \
|
|||
提交任务(意图路由自动匹配):
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/v1/tasks \
|
||||
curl -X POST http://localhost:8001/api/v1/tasks \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"input_data": {"query": "帮我生成一篇文章"}
|
||||
|
|
@ -936,7 +936,7 @@ curl -X POST http://localhost:8000/api/v1/tasks \
|
|||
创建 Agent:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/v1/agents \
|
||||
curl -X POST http://localhost:8001/api/v1/agents \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"skill_name": "content_generator"}'
|
||||
```
|
||||
|
|
@ -944,13 +944,13 @@ curl -X POST http://localhost:8000/api/v1/agents \
|
|||
查询 LLM 用量:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/api/v1/llm/usage
|
||||
curl http://localhost:8001/api/v1/llm/usage
|
||||
```
|
||||
|
||||
健康检查:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/api/v1/health
|
||||
curl http://localhost:8001/api/v1/health
|
||||
```
|
||||
|
||||
#### Python SDK 调用
|
||||
|
|
@ -960,7 +960,7 @@ import asyncio
|
|||
from agentkit.server.client import AgentKitClient
|
||||
|
||||
async def main():
|
||||
async with AgentKitClient("http://localhost:8000") as client:
|
||||
async with AgentKitClient("http://localhost:8001") as client:
|
||||
# 注册 Skill
|
||||
await client.register_skill({
|
||||
"name": "content_generator",
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
---
|
||||
title: "Portal platform security & reliability fixes — channels, LLM cache, server shutdown"
|
||||
title: "Portal 平台安全与可靠性修复 — 通道、LLM 缓存、服务端关闭"
|
||||
date: 2026-06-26
|
||||
category: docs/solutions/security-issues
|
||||
module: channels/llm/server
|
||||
problem_type: security_issue
|
||||
component: service_object
|
||||
severity: high
|
||||
severity: mixed
|
||||
priority_breakdown: {p1: 4, p2: 5, p3: 1}
|
||||
symptoms:
|
||||
- "WeCom webhook accepted replayed requests within an unlimited time window (no timestamp freshness check)"
|
||||
- "Anonymous LLM requests polluted per-user cache namespace when user_id was None (per_user_namespace=True ignored)"
|
||||
|
|
@ -26,7 +27,15 @@ related_components:
|
|||
- server/routes/channels
|
||||
---
|
||||
|
||||
# Portal platform security & reliability fixes — channels, LLM cache, server shutdown
|
||||
# Portal 平台安全与可靠性修复 — 通道、LLM 缓存、服务端关闭
|
||||
|
||||
## Root Cause
|
||||
|
||||
10 个修复共享三个共性根因:
|
||||
|
||||
1. **信任边界输入校验缺失**:WeCom webhook 信任签名正确即放行(缺时间戳新鲜度);LLM 缓存信任 `user_id` 非空(缺 None 检查);webhook `receive_message` 信任 payload 格式(缺异常隔离)。三处均在信任边界省略了输入校验。
|
||||
2. **资源生命周期未闭合**:应用 shutdown 未调用 `close_all_adapters()`、未 await `_pending_webhook_tasks`,httpx 连接与 IM 回复在进程退出时被丢弃。Feishu token TTL 设置过短(300s vs 实际 ~6900s)导致频繁刷新。
|
||||
3. **缓存键设计缺陷**:`generate_cache_key` 对每个组件单独 SHA-256 后再哈希一次(8-10 次冗余哈希);配额检查重复查询(4 次/部门/周期 vs 实际需 1 次 summary)。
|
||||
|
||||
## Problem
|
||||
|
||||
|
|
@ -38,7 +47,7 @@ related_components:
|
|||
- **LLM 缓存跨用户泄漏**:`should_cache()` 对 `user_id` 参数执行 `_ = user_id` 直接丢弃,在 `per_user_namespace` 开启时,`user_id=None` 的请求仍会命中其他用户的缓存。
|
||||
- **Webhook 异常触发 500 重试风暴**:`receive_message()` 解析失败直接抛出未捕获异常,平台收到 500 后按退避策略无限重试,造成异常放大。
|
||||
- **应用关闭资源泄漏**:shutdown 流程未调用 `close_all_adapters()`、未 await `_pending_webhook_tasks`,导致 httpx 连接泄漏与 IM 回复丢失。
|
||||
- **配额查询 N+1 与缓存键冗余哈希**:配额检查对每个部门每个周期调用 4 次 `get_usage()`(实际只需 2 次唯一查询);`generate_cache_key` 对每个组件单独 SHA-256 后再哈希一次,8-10 次冗余哈希。
|
||||
- **配额查询 N+1 与缓存键冗余哈希**:配额检查对每个部门每个周期调用 4 次 `get_usage()`(实际只需 1 次唯一查询,token 与 cost 可共用 summary);`generate_cache_key` 对每个组件单独 SHA-256 后再哈希一次,8-10 次冗余哈希。
|
||||
|
||||
## What Didn't Work
|
||||
|
||||
|
|
@ -50,7 +59,7 @@ related_components:
|
|||
|
||||
### P1 #1 — WeCom webhook 重放攻击修复
|
||||
|
||||
**文件**:[src/agentkit/channels/wecom.py](file:///Users/Chiguyong/Code/Fischer/fischer-agentkit/src/agentkit/channels/wecom.py)
|
||||
**文件**:[src/agentkit/channels/wecom.py](src/agentkit/channels/wecom.py)
|
||||
|
||||
**问题**:`verify_signature()` 只比对签名,不校验时间戳新鲜度,重放窗口无限大。
|
||||
|
||||
|
|
@ -77,7 +86,7 @@ if abs(now - ts_int) > _SIGNATURE_MAX_AGE_SECONDS:
|
|||
|
||||
### P1 #2 — LiteLLM 缓存跨用户泄漏修复
|
||||
|
||||
**文件**:[src/agentkit/llm/cache.py](file:///Users/Chiguyong/Code/Fischer/fischer-agentkit/src/agentkit/llm/cache.py)
|
||||
**文件**:[src/agentkit/llm/cache.py](src/agentkit/llm/cache.py)
|
||||
|
||||
**问题**:`should_cache()` 形参 `user_id` 被显式丢弃(`_ = user_id`),`per_user_namespace` 开启时 `user_id=None` 的请求仍会命中缓存,造成跨用户数据泄漏。
|
||||
|
||||
|
|
@ -97,7 +106,7 @@ def should_cache(self, kb_caching_disabled: bool = False, user_id: str | None =
|
|||
|
||||
### P1 #3 — Webhook 异常风暴防御
|
||||
|
||||
**文件**:[src/agentkit/server/routes/channels.py](file:///Users/Chiguyong/Code/Fischer/fischer-agentkit/src/agentkit/server/routes/channels.py)
|
||||
**文件**:[src/agentkit/server/routes/channels.py](src/agentkit/server/routes/channels.py)
|
||||
|
||||
**问题**:`receive_message()` 抛出异常时,路由返回 500,平台按重试策略无限重试,形成异常风暴。
|
||||
|
||||
|
|
@ -113,7 +122,7 @@ except Exception as exc: # noqa: BLE001 — 防止 receive_message 异常导致
|
|||
|
||||
### P1 #4 — 应用关闭泄漏修复
|
||||
|
||||
**文件**:[src/agentkit/server/app.py](file:///Users/Chiguyong/Code/Fischer/fischer-agentkit/src/agentkit/server/app.py)
|
||||
**文件**:[src/agentkit/server/app.py](src/agentkit/server/app.py)
|
||||
|
||||
**问题**:shutdown 流程未关闭 channel adapters、未等待后台 webhook 任务,导致 httpx 连接泄漏与 IM 回复丢失。
|
||||
|
||||
|
|
@ -136,7 +145,7 @@ except Exception:
|
|||
|
||||
### P2 #8 — Feishu token TTL 修正
|
||||
|
||||
**文件**:[src/agentkit/channels/feishu.py](file:///Users/Chiguyong/Code/Fischer/fischer-agentkit/src/agentkit/channels/feishu.py)
|
||||
**文件**:[src/agentkit/channels/feishu.py](src/agentkit/channels/feishu.py)
|
||||
|
||||
**问题**:`_TOKEN_CACHE_TTL = 300.0` 比实际有效期(2h)短 24 倍,造成每 5 分钟强制刷新一次 token,无谓增加 QPS。
|
||||
|
||||
|
|
@ -148,7 +157,7 @@ _TOKEN_CACHE_TTL = 6900.0 # 2h - 5min 余量,避免临界点失效
|
|||
|
||||
### P2 #10 — 无界 webhook 任务集
|
||||
|
||||
**文件**:[src/agentkit/server/routes/channels.py](file:///Users/Chiguyong/Code/Fischer/fischer-agentkit/src/agentkit/server/routes/channels.py)
|
||||
**文件**:[src/agentkit/server/routes/channels.py](src/agentkit/server/routes/channels.py)
|
||||
|
||||
**问题**:`_pending_webhook_tasks` set 在高负载下无上限增长,可能耗尽内存与协程。
|
||||
|
||||
|
|
@ -164,7 +173,7 @@ if len(_pending_webhook_tasks) >= _WEBHOOK_MAX_CONCURRENT * 2:
|
|||
|
||||
### P2 #12 — 配额检查 N+1 查询消除
|
||||
|
||||
**文件**:[src/agentkit/llm/gateway.py](file:///Users/Chiguyong/Code/Fischer/fischer-agentkit/src/agentkit/llm/gateway.py)
|
||||
**文件**:[src/agentkit/llm/gateway.py](src/agentkit/llm/gateway.py)
|
||||
|
||||
**问题**:配额检查对每个部门每个周期调用 4 次 `get_usage()`(token 与 cost 各一次,但实际可合并为一次查询),重复查询放大数据库压力。
|
||||
|
||||
|
|
@ -191,7 +200,7 @@ def _get_usage_summary(self, department_id: str, period: str) -> UsageSummary:
|
|||
|
||||
### P2 #13 — 缓存键冗余 SHA-256 消除
|
||||
|
||||
**文件**:[src/agentkit/llm/cache_key.py](file:///Users/Chiguyong/Code/Fischer/fischer-agentkit/src/agentkit/llm/cache_key.py)
|
||||
**文件**:[src/agentkit/llm/cache_key.py](src/agentkit/llm/cache_key.py)
|
||||
|
||||
**问题**:`generate_cache_key` 对每个组件单独 SHA-256(8-10 次),再对哈希拼接再做一次 SHA-256,CPU 浪费且无安全增益。
|
||||
|
||||
|
|
@ -219,7 +228,7 @@ return hashlib.sha256(combined.encode()).hexdigest()
|
|||
|
||||
### P2 #18 — 移除未使用的 secrets_store 参数
|
||||
|
||||
**文件**:[src/agentkit/llm/config.py](file:///Users/Chiguyong/Code/Fischer/fischer-agentkit/src/agentkit/llm/config.py)
|
||||
**文件**:[src/agentkit/llm/config.py](src/agentkit/llm/config.py)
|
||||
|
||||
**问题**:`get_api_key()` 接受 `secrets_store` 形参但从未使用(同步方法无法 await 异步 `get_secret`),属于误导性 API。
|
||||
|
||||
|
|
@ -237,7 +246,7 @@ def get_api_key(self) -> str:
|
|||
|
||||
### P3 #21 — DIRECT_CHAT 路径去重
|
||||
|
||||
**文件**:[src/agentkit/server/routes/channels.py](file:///Users/Chiguyong/Code/Fischer/fischer-agentkit/src/agentkit/server/routes/channels.py)
|
||||
**文件**:[src/agentkit/server/routes/channels.py](src/agentkit/server/routes/channels.py)
|
||||
|
||||
**问题**:DIRECT_CHAT 逻辑在主路径与 ReAct 回退路径中重复实现,维护时易漂移。
|
||||
|
||||
|
|
@ -258,13 +267,32 @@ async def _direct_chat(llm_gateway: Any, routing: Any) -> str:
|
|||
修复完成后按以下方式验证:
|
||||
|
||||
- **ruff check**:`src/` 干净,仅剩预存在的 `gui_mode` F821(与本次修复无关)。
|
||||
- **Channels 测试**:`pytest tests/unit/server/routes/test_channels.py` — 137 passed。
|
||||
- **Channels 测试**:`pytest tests/unit/channels/test_wecom.py` — 验证 `verify_signature` 与 `_SIGNATURE_MAX_AGE_SECONDS` 新鲜度校验。
|
||||
- **配置迁移测试**:`pytest tests/unit/llm/test_config_migration.py` — 全部通过。
|
||||
- **配额强制测试**:`pytest tests/unit/llm/test_quota_enforcement.py` — 11 passed。
|
||||
- **litellm 相关测试**:因 `litellm` 未安装被 skip(环境问题,非代码问题)。
|
||||
- **WeCom 测试调整**:将固定时间戳 `1609459200` 改为 `int(time.time())` 动态生成后,新鲜度校验测试通过。
|
||||
- **缓存测试调整**:显式传入 `user_id` 或关闭 `per_user_namespace` 后,`should_cache()` 测试通过。
|
||||
|
||||
## Impact Scope
|
||||
|
||||
**受影响模块**:
|
||||
|
||||
| 模块 | 文件 | 影响类型 |
|
||||
|---|---|---|
|
||||
| WeCom 通道 | `src/agentkit/channels/wecom.py` | 安全(重放防御) |
|
||||
| Feishu 通道 | `src/agentkit/channels/feishu.py` | 可靠性(TTL 修正) |
|
||||
| LLM 缓存 | `src/agentkit/llm/cache.py` | 安全(跨用户泄漏) |
|
||||
| LLM 缓存键 | `src/agentkit/llm/cache_key.py` | 性能(冗余哈希) |
|
||||
| LLM 网关 | `src/agentkit/llm/gateway.py` | 可靠性(配额 N+1) |
|
||||
| LLM 配置 | `src/agentkit/llm/config.py` | 可维护性(未用参数) |
|
||||
| 应用生命周期 | `src/agentkit/server/app.py` | 可靠性(shutdown 泄漏) |
|
||||
| 通道路由 | `src/agentkit/server/routes/channels.py` | 可靠性(webhook 风暴) |
|
||||
|
||||
**向后兼容性**:所有修复向后兼容。WeCom 签名校验增加时间戳窗口后,历史合法请求在 300s 窗口内仍被接受;Feishu TTL 从 300s 延长至 ~6900s 仅减少刷新频率;缓存键生成逻辑变更不影响命中(旧键自然过期);shutdown 顺序变更仅影响关闭流程,不影响运行时行为。
|
||||
|
||||
**生产影响**:WeCom 重放修复立即生效;缓存跨用户泄漏修复消除潜在数据混淆;shutdown 泄漏修复消除进程重启时的连接错误日志。无破坏性变更,无需停机部署。
|
||||
|
||||
## Prevention
|
||||
|
||||
### 安全
|
||||
|
|
@ -292,5 +320,5 @@ async def _direct_chat(llm_gateway: Any, routing: Any) -> str:
|
|||
|
||||
## Related Docs
|
||||
|
||||
- [long-horizon-reliability-code-review-fixes.md](file:///Users/Chiguyong/Code/Fischer/fischer-agentkit/docs/solutions/logic-errors/long-horizon-reliability-code-review-fixes.md) — 上一批 U1-U7 长期可靠性代码评审修复,与本批次同属 code-review 修复系列,可在遇到类似可靠性问题时交叉参考。
|
||||
- [bitable-companion-service-security-reliability-patterns.md](file:///Users/Chiguyong/Code/Fischer/fischer-agentkit/docs/solutions/architecture-patterns/bitable-companion-service-security-reliability-patterns.md) — Bitable 伴生服务的安全/可靠性架构模式(SSRF、SQL 注入、IDOR、缓存失效等),与本批次的 LLM 缓存隔离威胁模型不同但可对照阅读。
|
||||
- [long-horizon-reliability-code-review-fixes.md](docs/solutions/logic-errors/long-horizon-reliability-code-review-fixes.md) — 上一批 U1-U7 长期可靠性代码评审修复,与本批次同属 code-review 修复系列,可在遇到类似可靠性问题时交叉参考。
|
||||
- [bitable-companion-service-security-reliability-patterns.md](docs/solutions/architecture-patterns/bitable-companion-service-security-reliability-patterns.md) — Bitable 伴生服务的安全/可靠性架构模式(SSRF、SQL 注入、IDOR、缓存失效等),与本批次的 LLM 缓存隔离威胁模型不同但可对照阅读。
|
||||
|
|
|
|||
|
|
@ -309,11 +309,22 @@ class SqliteConversationStore:
|
|||
row = await cursor.fetchone()
|
||||
if row is None:
|
||||
return False
|
||||
# ponytail: wrap both DELETEs in a try/except with rollback —
|
||||
# previously the second DELETE failure would leave orphaned
|
||||
# messages (conversation row gone, messages lingering) because
|
||||
# the first DELETE already auto-committed in autocommit mode.
|
||||
# aiosqlite uses autocommit=False by default but explicit rollback
|
||||
# makes the failure path safe and observable.
|
||||
try:
|
||||
await db.execute(
|
||||
"DELETE FROM messages WHERE conversation_id = ?", (conversation_id,)
|
||||
)
|
||||
await db.execute("DELETE FROM conversations WHERE id = ?", (conversation_id,))
|
||||
await db.commit()
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
logger.exception("Failed to delete conversation %s; rolled back", conversation_id)
|
||||
raise
|
||||
self._cache.pop(conversation_id, None)
|
||||
return True
|
||||
|
||||
|
|
|
|||
|
|
@ -453,7 +453,9 @@ async def lifespan(app: FastAPI):
|
|||
try:
|
||||
default_agent._tool_registry.register(calendar_tool)
|
||||
except Exception:
|
||||
pass # already registered
|
||||
# ponytail: log at debug — CalendarTool double-registration
|
||||
# is expected on reload, but silent pass hides real errors.
|
||||
logger.debug("CalendarTool already registered or registration failed", exc_info=True)
|
||||
# Strip any existing "## 可用工具" section to avoid
|
||||
# duplicate tool blocks in the system prompt.
|
||||
base_prompt = getattr(default_agent, "_system_prompt", None) or (
|
||||
|
|
|
|||
|
|
@ -193,6 +193,11 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
|||
"user_id": payload.get("sub"),
|
||||
"username": payload.get("username"),
|
||||
"role": payload.get("role"),
|
||||
# ponytail: sid must be set here too — the Bearer path
|
||||
# above (line ~178) sets it; without it, downstream
|
||||
# require_authenticated treats WS clients as legacy
|
||||
# sessions, breaking session revocation & audit logs.
|
||||
"sid": payload.get("sid"),
|
||||
}
|
||||
return await call_next(request)
|
||||
|
||||
|
|
|
|||
|
|
@ -103,7 +103,8 @@ function onEdit(event: ICalendarEvent): void {
|
|||
|
||||
.calendar-drawer__tabs :deep(.ant-tabs-content-holder) {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
/* ponytail: was overflow:hidden which clipped ReminderConfig/SyncSettings content; auto enables scroll */
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.calendar-drawer__tabs :deep(.ant-tabs-content) {
|
||||
|
|
|
|||
|
|
@ -67,7 +67,17 @@ function handleEventClick(arg: EventClickArg): void {
|
|||
}
|
||||
|
||||
onMounted(() => {
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
// ponytail: track last size to break ResizeObserver feedback loop —
|
||||
// updateSize() can trigger another resize event, causing infinite thrash.
|
||||
let lastW = 0
|
||||
let lastH = 0
|
||||
resizeObserver = new ResizeObserver((entries) => {
|
||||
const e = entries[0]
|
||||
const w = e.contentRect.width
|
||||
const h = e.contentRect.height
|
||||
if (w === lastW && h === lastH) return
|
||||
lastW = w
|
||||
lastH = h
|
||||
nextTick(() => {
|
||||
calendarRef.value?.getApi()?.updateSize()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@
|
|||
<button
|
||||
class="chat-sidebar__item-delete"
|
||||
title="删除对话"
|
||||
aria-label="删除对话"
|
||||
@click.stop
|
||||
>
|
||||
<DeleteOutlined />
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
<button
|
||||
class="right-panel__toggle"
|
||||
:title="collapsed ? '展开右侧面板' : '收起右侧面板'"
|
||||
:aria-label="collapsed ? '展开右侧面板' : '收起右侧面板'"
|
||||
@click="toggle"
|
||||
>
|
||||
<RightOutlined v-if="!collapsed" />
|
||||
|
|
|
|||
|
|
@ -79,9 +79,11 @@
|
|||
<CalendarTab v-else-if="activeTab === 'calendar'" />
|
||||
<SystemTab v-else-if="activeTab === 'system'" />
|
||||
<KnowledgeTab v-else-if="activeTab === 'knowledge'" />
|
||||
<!-- ponytail: v-else fallback catches unknown activeTab values (corrupted state / future tabs) -->
|
||||
<div v-else class="system-monitor__unknown-tab">未知标签页: {{ activeTab }}</div>
|
||||
</div>
|
||||
|
||||
<nav class="system-monitor__tabs" role="tablist" aria-label="右侧功能导航">
|
||||
<nav class="system-monitor__tabs" role="tablist" aria-label="右侧功能导航" @keydown="onTabKeydown">
|
||||
<a-tooltip
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
|
|
@ -150,6 +152,19 @@ const tabs: Tab[] = [
|
|||
const activeTab = ref('monitor')
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
// ponytail: WAI-ARIA tablist keyboard navigation — ArrowUp/Down move between
|
||||
// tabs, Home/End jump to first/last. Required for keyboard-only users.
|
||||
function onTabKeydown(e: KeyboardEvent): void {
|
||||
const keys = ['ArrowUp', 'ArrowDown', 'Home', 'End']
|
||||
if (!keys.includes(e.key)) return
|
||||
e.preventDefault()
|
||||
const idx = tabs.findIndex((t) => t.key === activeTab.value)
|
||||
if (e.key === 'ArrowUp') activeTab.value = tabs[(idx - 1 + tabs.length) % tabs.length].key
|
||||
else if (e.key === 'ArrowDown') activeTab.value = tabs[(idx + 1) % tabs.length].key
|
||||
else if (e.key === 'Home') activeTab.value = tabs[0].key
|
||||
else if (e.key === 'End') activeTab.value = tabs[tabs.length - 1].key
|
||||
}
|
||||
const health = ref<IHealthCheck>({ status: 'unknown', version: '', checks: {} })
|
||||
const metrics = ref<IMetricsData>({
|
||||
tasks: { total_tasks: 0, completed_tasks: 0, failed_tasks: 0, pending_tasks: 0 },
|
||||
|
|
@ -305,6 +320,8 @@ onUnmounted(() => {
|
|||
.system-monitor__tab--active {
|
||||
color: var(--accent-team);
|
||||
background: var(--color-primary-light);
|
||||
/* ponytail: override the transparent border-left so the active indicator is visible */
|
||||
border-left-color: var(--accent-team);
|
||||
}
|
||||
|
||||
.system-monitor__tab-icon {
|
||||
|
|
|
|||
|
|
@ -27,12 +27,16 @@
|
|||
/>
|
||||
</div>
|
||||
<a-tooltip title="多维表格">
|
||||
<button class="top-nav__icon-btn" @click="router.push('/bitable')">
|
||||
<button class="top-nav__icon-btn" aria-label="多维表格" @click="router.push('/bitable')">
|
||||
<TableOutlined />
|
||||
</button>
|
||||
</a-tooltip>
|
||||
<a-tooltip :title="isDark ? '切换亮色模式' : '切换暗色模式'">
|
||||
<button class="top-nav__icon-btn" @click="themeStore.toggle()">
|
||||
<button
|
||||
class="top-nav__icon-btn"
|
||||
:aria-label="isDark ? '切换亮色模式' : '切换暗色模式'"
|
||||
@click="themeStore.toggle()"
|
||||
>
|
||||
<BulbOutlined v-if="isDark" />
|
||||
<BulbOutlined v-else style="opacity: 0.5" />
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -62,6 +62,9 @@ const simpleImage = Empty.PRESENTED_IMAGE_SIMPLE
|
|||
function formatTime(iso: string): string {
|
||||
if (!iso) return ''
|
||||
const d = new Date(iso)
|
||||
// ponytail: guard against Invalid Date — new Date('garbage').toLocaleString
|
||||
// returns "Invalid Date" string which leaks into the UI.
|
||||
if (isNaN(d.getTime())) return ''
|
||||
return d.toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
|
|
|
|||
|
|
@ -114,9 +114,10 @@ function closeDetail(): void {
|
|||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (skillsStore.skills.length === 0) {
|
||||
// ponytail: previously only fetched when store was empty, causing stale
|
||||
// data after skills were modified elsewhere. Always fetch on mount —
|
||||
// the store handles deduplication and this tab is not mounted often.
|
||||
skillsStore.fetchSkills()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -23,19 +23,28 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, onUnmounted } from 'vue'
|
||||
import { MessageShell, UserBubble, ErrorCard } from '@/components/chat/messages'
|
||||
|
||||
const errorDetail = ref('连接被拒绝:无法连接到本地 MCP 服务器(localhost:8080)。请检查服务是否已启动。')
|
||||
const retried = ref(false)
|
||||
// ponytail: save timer ID so onUnmounted can clear it — otherwise a
|
||||
// component teardown during the 1.5s window would mutate a destroyed ref.
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function handleRetry(): void {
|
||||
retried.value = true
|
||||
errorDetail.value = '正在重试…'
|
||||
setTimeout(() => {
|
||||
if (retryTimer) clearTimeout(retryTimer)
|
||||
retryTimer = setTimeout(() => {
|
||||
errorDetail.value = '连接被拒绝:无法连接到本地 MCP 服务器(localhost:8080)。请检查服务是否已启动。'
|
||||
retryTimer = null
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (retryTimer) clearTimeout(retryTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
|||
|
|
@ -370,6 +370,12 @@ export const useChatStore = defineStore("chat", () => {
|
|||
}
|
||||
conversations.value = conversations.value.filter((c) => c.id !== id);
|
||||
streamingStepsByConv.value.delete(id);
|
||||
// ponytail: must also clear pending-* state — selectConversation's 404
|
||||
// handler (line ~297) does this, but deleteConversation previously
|
||||
// omitted it, leaving ghost IDs that _getMostRecentPendingConversation
|
||||
// could return (causing "switch to deleted conversation" UI bugs).
|
||||
pendingConversations.value.delete(id);
|
||||
pendingLastUsedAt.value.delete(id);
|
||||
markConversationDone(id);
|
||||
if (currentConversationId.value === id) {
|
||||
currentConversationId.value = null;
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import uuid
|
|||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
|
|
@ -192,12 +191,29 @@ class PortalConnectionManager:
|
|||
features) to deliver real-time messages to a user's open chat tab(s).
|
||||
"""
|
||||
|
||||
# ponytail: per-user connection cap prevents a single client from
|
||||
# exhausting memory via unbounded WS spawns. 16 covers typical
|
||||
# multi-tab usage. Upgrade path: make configurable via server_config.
|
||||
_MAX_CONNECTIONS_PER_USER = 16
|
||||
|
||||
def __init__(self) -> None:
|
||||
# user_id -> list of active WebSocket connections
|
||||
self._connections: dict[str, list[WebSocket]] = {}
|
||||
|
||||
def add(self, user_id: str, ws: WebSocket) -> None:
|
||||
self._connections.setdefault(user_id, []).append(ws)
|
||||
conns = self._connections.setdefault(user_id, [])
|
||||
if len(conns) >= self._MAX_CONNECTIONS_PER_USER:
|
||||
# Close the oldest connection to make room (FIFO eviction).
|
||||
oldest = conns.pop(0)
|
||||
try:
|
||||
# Best-effort close; ignore failures since the socket may
|
||||
# already be dead.
|
||||
import asyncio
|
||||
|
||||
asyncio.create_task(oldest.close(code=1008, reason="Connection limit exceeded"))
|
||||
except Exception:
|
||||
pass
|
||||
conns.append(ws)
|
||||
|
||||
def remove(self, user_id: str, ws: WebSocket) -> None:
|
||||
conns = self._connections.get(user_id)
|
||||
|
|
@ -207,7 +223,7 @@ class PortalConnectionManager:
|
|||
if not self._connections[user_id]:
|
||||
del self._connections[user_id]
|
||||
|
||||
async def send_json(self, user_id: str, message: dict[str, Any]) -> None:
|
||||
async def send_json(self, user_id: str, message: dict[str, object]) -> None:
|
||||
"""Broadcast a JSON message to all connections for *user_id*.
|
||||
|
||||
Removes stale connections that fail to send.
|
||||
|
|
@ -219,7 +235,10 @@ class PortalConnectionManager:
|
|||
for ws in conns:
|
||||
try:
|
||||
await ws.send_json(message)
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"Portal WS send failed for user %s (marking stale): %s", user_id, e
|
||||
)
|
||||
stale.append(ws)
|
||||
for ws in stale:
|
||||
self.remove(user_id, ws)
|
||||
|
|
@ -228,7 +247,7 @@ class PortalConnectionManager:
|
|||
portal_connection_manager = PortalConnectionManager()
|
||||
|
||||
|
||||
async def send_to_user(user_id: str, message: dict[str, Any]) -> None:
|
||||
async def send_to_user(user_id: str, message: dict[str, object]) -> None:
|
||||
"""Public helper to push a message to all portal WebSockets for a user."""
|
||||
await portal_connection_manager.send_json(user_id, message)
|
||||
|
||||
|
|
@ -791,7 +810,15 @@ async def get_conversation(
|
|||
|
||||
@router.delete("/portal/conversations/{conversation_id}")
|
||||
async def delete_conversation(conversation_id: str, _auth: None = Depends(_verify_api_key)):
|
||||
"""Delete a conversation and all its messages."""
|
||||
"""Delete a conversation and all its messages.
|
||||
|
||||
ponytail: IDOR note — portal endpoints use API-key auth (single-tenant
|
||||
access model: API key = full access to all conversations). The SQLite
|
||||
store has no user_id column, so per-user ownership cannot be enforced
|
||||
without a schema migration. If API keys become per-user, add a
|
||||
user_id column to conversations + filter DELETE by (id, user_id).
|
||||
Upgrade path: migrate portal endpoints to JWT auth + per-user scoping.
|
||||
"""
|
||||
deleted = await _conversation_store.delete_conversation(conversation_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail=f"Conversation '{conversation_id}' not found")
|
||||
|
|
@ -976,6 +1003,12 @@ async def portal_websocket(websocket: WebSocket):
|
|||
"""Real-time chat WebSocket endpoint."""
|
||||
await websocket.accept()
|
||||
|
||||
# ponytail: ws_user_id must be initialized before any early return — the
|
||||
# finally block below references it. Previously the api_key reject path
|
||||
# returned before assignment, causing UnboundLocalError that masked the
|
||||
# original auth error. Upgrade path: refactor auth into a decorator.
|
||||
ws_user_id: str | None = None
|
||||
|
||||
# Authentication (after accept, since FastAPI requires accept before close)
|
||||
configured_api_key: str | None = None
|
||||
if hasattr(websocket.app.state, "server_config") and websocket.app.state.server_config:
|
||||
|
|
@ -996,7 +1029,7 @@ async def portal_websocket(websocket: WebSocket):
|
|||
# Track authenticated portal connections for user-scoped push (calendar
|
||||
# reminders, etc.). user_id is None for API-key / dev-mode clients.
|
||||
current_user = getattr(websocket.state, "current_user", None) or {}
|
||||
ws_user_id: str | None = current_user.get("user_id")
|
||||
ws_user_id = current_user.get("user_id")
|
||||
if ws_user_id:
|
||||
portal_connection_manager.add(ws_user_id, websocket)
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ user_id; it does not perform auth (same pattern as DocumentTool).
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from agentkit.calendar.models import ReminderRule
|
||||
|
|
@ -18,6 +19,35 @@ from agentkit.calendar.service import CalendarService
|
|||
from agentkit.tools.base import Tool
|
||||
|
||||
|
||||
def _resolve_datetime(value: str | None) -> str | None:
|
||||
"""Resolve a date string to ISO 8601, or return None if unparseable.
|
||||
|
||||
Accepts:
|
||||
- absolute ISO 8601 strings (returned unchanged)
|
||||
- relative phrases like "next monday", "tomorrow 3pm", "+3 days"
|
||||
"""
|
||||
if not value:
|
||||
return None
|
||||
value = value.strip()
|
||||
# Fast path: already ISO 8601 (has a date separator and a length typical of ISO).
|
||||
if "T" in value or value.endswith("Z") or len(value) >= 10 and value[4:5] == "-":
|
||||
try:
|
||||
datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
return value
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
from dateutil import parser as _du # type: ignore[import-untyped]
|
||||
from datetime import datetime as _dt
|
||||
|
||||
dt = _du.parse(value, fuzzy=True)
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=_dt.now().astimezone().tzinfo)
|
||||
return dt.isoformat()
|
||||
except (ValueError, TypeError, ImportError):
|
||||
return None
|
||||
|
||||
|
||||
class CalendarTool(Tool):
|
||||
"""Agent tool for calendar event management.
|
||||
|
||||
|
|
@ -30,6 +60,8 @@ class CalendarTool(Tool):
|
|||
description=(
|
||||
"Create, query, update, and delete calendar events and reminders. "
|
||||
"Use create_event to schedule events and set reminders (e.g. 'remind me Monday morning'). "
|
||||
"Relative dates like 'next monday', 'tomorrow 3pm', '+3 days' are accepted "
|
||||
"and resolved server-side against the current local time. "
|
||||
"Actions: create_event, query_events, update_event, delete_event."
|
||||
),
|
||||
input_schema={
|
||||
|
|
@ -153,6 +185,22 @@ class CalendarTool(Tool):
|
|||
if not end_time:
|
||||
return {"success": False, "error": "Missing required field: end_time"}
|
||||
|
||||
# Resolve relative date strings (e.g. "next monday", "tomorrow 3pm")
|
||||
# to absolute ISO 8601 datetimes. Accepts anything dateutil can parse,
|
||||
# anchored at the *server's* current local time.
|
||||
start_time = _resolve_datetime(start_time)
|
||||
end_time = _resolve_datetime(end_time)
|
||||
if start_time is None:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Could not parse start_time: {kwargs.get('start_time')!r}",
|
||||
}
|
||||
if end_time is None:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Could not parse end_time: {kwargs.get('end_time')!r}",
|
||||
}
|
||||
|
||||
description = kwargs.get("description", "")
|
||||
location = kwargs.get("location", "")
|
||||
is_all_day = kwargs.get("is_all_day", False)
|
||||
|
|
@ -161,6 +209,30 @@ class CalendarTool(Tool):
|
|||
reminder_offset = kwargs.get("reminder_offset_minutes")
|
||||
reminder_channels = kwargs.get("reminder_channels") or ["client"]
|
||||
|
||||
# ponytail: validate reminder_channels type — LLM agents may pass a
|
||||
# bare string ("client") which list() would silently split into
|
||||
# ["c","l","i","e","n","t"]. Reject non-list input explicitly.
|
||||
if not isinstance(reminder_channels, list):
|
||||
return {
|
||||
"success": False,
|
||||
"error": (
|
||||
f"reminder_channels must be a list, got {type(reminder_channels).__name__}: "
|
||||
f"{reminder_channels!r}"
|
||||
),
|
||||
}
|
||||
# ponytail: channel whitelist — prevents arbitrary strings (e.g.
|
||||
# "drop_table") from being persisted and dispatched by the scheduler.
|
||||
_ALLOWED_CHANNELS = {"client", "email", "webhook"}
|
||||
bad = [c for c in reminder_channels if c not in _ALLOWED_CHANNELS]
|
||||
if bad:
|
||||
return {
|
||||
"success": False,
|
||||
"error": (
|
||||
f"reminder_channels contains unknown channel(s): {bad}. "
|
||||
f"Allowed: {sorted(_ALLOWED_CHANNELS)}"
|
||||
),
|
||||
}
|
||||
|
||||
# Build explicit reminder rules if requested
|
||||
reminder_rules: list[ReminderRule] | None = None
|
||||
if reminder_offset is not None:
|
||||
|
|
@ -171,10 +243,13 @@ class CalendarTool(Tool):
|
|||
"success": False,
|
||||
"error": f"reminder_offset_minutes must be an integer, got {reminder_offset!r}",
|
||||
}
|
||||
if offset_int < 0 or offset_int > 43200: # 最多 30 天
|
||||
# ponytail: schema documents negative offsets as "before event"
|
||||
# (e.g. -15 = 15 min before). Previous code rejected all negatives,
|
||||
# breaking the entire reminder feature. ±43200 = ±30 days ceiling.
|
||||
if not -43200 <= offset_int <= 43200:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"reminder_offset_minutes must be in [0, 43200], got {offset_int}",
|
||||
"error": f"reminder_offset_minutes must be in [-43200, 43200], got {offset_int}",
|
||||
}
|
||||
reminder_rules = [
|
||||
ReminderRule(
|
||||
|
|
@ -285,11 +360,20 @@ class CalendarTool(Tool):
|
|||
return {"success": False, "error": "Permission denied"}
|
||||
|
||||
# Build fields dict from updatable params (only those explicitly provided)
|
||||
updatable = ["title", "description", "start_time", "end_time", "location", "is_all_day"]
|
||||
updatable = ["title", "description", "location", "is_all_day"]
|
||||
fields: dict[str, Any] = {}
|
||||
for key in updatable:
|
||||
if key in kwargs and kwargs[key] is not None:
|
||||
fields[key] = kwargs[key]
|
||||
for time_key in ("start_time", "end_time"):
|
||||
if time_key in kwargs and kwargs[time_key] is not None:
|
||||
resolved = _resolve_datetime(kwargs[time_key])
|
||||
if resolved is None:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Could not parse {time_key}: {kwargs[time_key]!r}",
|
||||
}
|
||||
fields[time_key] = resolved
|
||||
|
||||
if not fields:
|
||||
return {"success": False, "error": "No fields to update"}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,108 @@
|
|||
"""Unit tests for CalendarTool — focus on relative date resolution."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from agentkit.calendar.service import CalendarService
|
||||
from agentkit.tools.calendar_tool import CalendarTool, _resolve_datetime
|
||||
|
||||
|
||||
class TestResolveDatetime:
|
||||
def test_none(self) -> None:
|
||||
assert _resolve_datetime(None) is None
|
||||
assert _resolve_datetime("") is None
|
||||
|
||||
def test_iso_unchanged(self) -> None:
|
||||
iso = "2026-07-06T09:00:00+08:00"
|
||||
assert _resolve_datetime(iso) == iso
|
||||
|
||||
def test_iso_with_z_unchanged(self) -> None:
|
||||
iso = "2026-07-06T01:00:00Z"
|
||||
assert _resolve_datetime(iso) == iso
|
||||
|
||||
def test_next_monday(self) -> None:
|
||||
result = _resolve_datetime("next monday")
|
||||
assert result is not None
|
||||
# Should be parseable as ISO 8601
|
||||
parsed = datetime.fromisoformat(result)
|
||||
# Monday weekday
|
||||
assert parsed.weekday() == 0
|
||||
|
||||
def test_tomorrow_with_time(self) -> None:
|
||||
result = _resolve_datetime("tomorrow 3pm")
|
||||
assert result is not None
|
||||
parsed = datetime.fromisoformat(result)
|
||||
assert parsed.hour == 15
|
||||
|
||||
def test_in_3_days(self) -> None:
|
||||
# dateutil handles "in N days" — but with timezone-dependent results;
|
||||
# the *contract* is that _resolve_datetime returns a parseable ISO string.
|
||||
# We don't assert a specific offset because dateutil interprets "in" loosely.
|
||||
result = _resolve_datetime("in 3 days")
|
||||
assert result is not None
|
||||
datetime.fromisoformat(result) # must be parseable
|
||||
|
||||
def test_garbage_returns_none(self) -> None:
|
||||
assert _resolve_datetime("not a date at all xyz") is None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_service() -> AsyncMock:
|
||||
svc = AsyncMock(spec=CalendarService)
|
||||
svc.create_event = AsyncMock()
|
||||
return svc
|
||||
|
||||
|
||||
class TestCreateEventWithRelativeDates:
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_event_resolves_next_monday(
|
||||
self, mock_service: AsyncMock
|
||||
) -> None:
|
||||
from agentkit.calendar.models import CalendarEvent
|
||||
|
||||
mock_service.create_event.return_value = CalendarEvent(
|
||||
id="evt-1",
|
||||
user_id="u1",
|
||||
title="项目启动会",
|
||||
description="",
|
||||
start_time=datetime.now(tz=timezone.utc),
|
||||
end_time=datetime.now(tz=timezone.utc) + timedelta(hours=1),
|
||||
location="",
|
||||
is_all_day=False,
|
||||
)
|
||||
|
||||
tool = CalendarTool(mock_service) # type: ignore[arg-type]
|
||||
result = await tool.execute(
|
||||
action="create_event",
|
||||
user_id="u1",
|
||||
title="项目启动会",
|
||||
start_time="next monday",
|
||||
end_time="next monday",
|
||||
reminder_offset_minutes=15,
|
||||
)
|
||||
|
||||
assert result["success"] is True, result
|
||||
# create_event should have been called with ISO strings, not the raw "next monday"
|
||||
call_kwargs = mock_service.create_event.call_args.kwargs
|
||||
assert call_kwargs["start_time"] != "next monday"
|
||||
# Should be parseable
|
||||
datetime.fromisoformat(call_kwargs["start_time"])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_event_unparseable_returns_error(
|
||||
self, mock_service: AsyncMock
|
||||
) -> None:
|
||||
tool = CalendarTool(mock_service) # type: ignore[arg-type]
|
||||
result = await tool.execute(
|
||||
action="create_event",
|
||||
user_id="u1",
|
||||
title="x",
|
||||
start_time="garbage qqq",
|
||||
end_time="garbage qqq",
|
||||
)
|
||||
assert result["success"] is False
|
||||
assert "Could not parse start_time" in result["error"]
|
||||
Loading…
Reference in New Issue