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:
chiguyong 2026-06-28 15:06:41 +08:00
parent 8ae8ed4e9b
commit c9ce15fa4b
19 changed files with 385 additions and 55 deletions

View File

@ -128,7 +128,7 @@ HandoffTransportInProcessasyncio.Queue+ Redis Pub/Sub — 仅用于事
### 关键子系统 ### 关键子系统
- **LLM 网关**`llm/`6 个 providerOpenAI/Anthropic/Gemini/Doubao/Wenxin/Yuanbaofallback、语义缓存、用量追踪、RemoteLLMProviderclient→server 代理,带 401 刷新重试) - **LLM 网关**`llm/`基于 LiteLLM 统一适配层U15 迁移),保留原 6 个 provider 命名作为别名OpenAI/Anthropic/Gemini/Doubao/Wenxin/Yuanbao,支持任意 OpenAI 兼容 API如 DashScope/DeepSeekfallback、语义缓存、用量追踪、RemoteLLMProviderclient→server 代理,带 401 刷新重试)
- **记忆**`memory/`4 层SOUL/USER/MEMORY/DAILY、WorkingMemoryRedis、EpisodicMemoryPG+pgvector、SemanticMemoryHTTP RAG - **记忆**`memory/`4 层SOUL/USER/MEMORY/DAILY、WorkingMemoryRedis、EpisodicMemoryPG+pgvector、SemanticMemoryHTTP RAG
- **进化**`evolution/`Reflector、PromptOptimizer遗传算法、PitfallDetector、ABTester - **进化**`evolution/`Reflector、PromptOptimizer遗传算法、PitfallDetector、ABTester
- **工具**`tools/`21 个内置 + MCP 扩展组合SequentialChain/ParallelFanOut/DynamicSelector - **工具**`tools/`21 个内置 + MCP 扩展组合SequentialChain/ParallelFanOut/DynamicSelector
@ -136,7 +136,7 @@ HandoffTransportInProcessasyncio.Queue+ Redis Pub/Sub — 仅用于事
- **总线**`bus/`MemoryBus进程内、RedisBus分布式 - **总线**`bus/`MemoryBus进程内、RedisBus分布式
- **认证**`server/auth/`JWTaccess 15min + refresh 7dHS256、API Key恒定时间比较、3 级 RBACmember/operator/admin + 权限位、6 层终端安全blocklist→shell-ops→builtin→global→user→session→danger、bcrypt 密码哈希rounds=12 - **认证**`server/auth/`JWTaccess 15min + refresh 7dHS256、API Key恒定时间比较、3 级 RBACmember/operator/admin + 权限位、6 层终端安全blocklist→shell-ops→builtin→global→user→session→danger、bcrypt 密码哈希rounds=12
### 服务端路由22 个模块) ### 服务端路由28 个模块)
| 前缀 | 模块 | 用途 | | 前缀 | 模块 | 用途 |
| ------------------------- | -------------------------------------- | ------------------------- | | ------------------------- | -------------------------------------- | ------------------------- |
@ -162,6 +162,12 @@ HandoffTransportInProcessasyncio.Queue+ Redis Pub/Sub — 仅用于事
| `/api/v1/auth` | auth.py | 登录/刷新/登出/me | | `/api/v1/auth` | auth.py | 登录/刷新/登出/me |
| `/api/v1/system` | system.py | 系统资源(需 SYSTEM\_CONFIG 权限) | | `/api/v1/system` | system.py | 系统资源(需 SYSTEM\_CONFIG 权限) |
| `/api/v1/config` | config\_sync.py | 配置版本 + 同步(轮询) | | `/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 协议 ### WebSocket Chat 协议
@ -182,7 +188,7 @@ Server -> Client`connected`、`token`、`thinking`、`step`、`final_answer`
CLI 参数 > `agentkit.yaml` > 环境变量(`${VAR:-default}`> `.env` > 硬编码默认值 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\_drivenvs `business_skill`(领域技能)。通过 `server/routes/skill_management.py` 中的 `_ENGINE_TEMPLATE_NAMES` 分类。前端按 `category` 字段分组 — `SkillsView` 双栏布局,`SkillCard`/`SkillsTab` 显示类型标签(引擎/技能)和基于分类的图标 - 技能分类:`agent_template`执行引擎react/direct/rewoo/reflexion/plan\_exec/goal\_drivenvs `business_skill`(领域技能)。通过 `server/routes/skill_management.py` 中的 `_ENGINE_TEMPLATE_NAMES` 分类。前端按 `category` 字段分组 — `SkillsView` 双栏布局,`SkillCard`/`SkillsTab` 显示类型标签(引擎/技能)和基于分类的图标
- LLM 配置:`agentkit.yaml` llm 段(与服务端配置统一) - LLM 配置:`agentkit.yaml` llm 段(与服务端配置统一)
- 流水线配置:`configs/pipelines/*.yaml` - 流水线配置:`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 - 团队模板:`bound_skills` 字段存储成员列表(如 `dev_team.yaml` 列出 tech\_lead、frontend\_engineer、backend\_engineer、qa\_engineer、code\_reviewer
- 所有 Pydantic 模型使用 `model_config = ConfigDict(...)` 而非 `class Config` - 所有 Pydantic 模型使用 `model_config = ConfigDict(...)` 而非 `class Config`
- 测试文件:`tests/unit/` 和 `tests/integration/` - 测试文件:`tests/unit/` 和 `tests/integration/`

View File

@ -676,7 +676,7 @@ gateway.register_provider("dashscope", OpenAIProvider(
app = create_app(llm_gateway=gateway) app = create_app(llm_gateway=gateway)
if __name__ == "__main__": 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 注册 Skill
```bash ```bash
curl -X POST http://localhost:8000/api/v1/skills \ curl -X POST http://localhost:8001/api/v1/skills \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{ -d '{
"config": { "config": {
@ -915,7 +915,7 @@ curl -X POST http://localhost:8000/api/v1/skills \
提交任务(指定 Skill 提交任务(指定 Skill
```bash ```bash
curl -X POST http://localhost:8000/api/v1/tasks \ curl -X POST http://localhost:8001/api/v1/tasks \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{ -d '{
"skill_name": "content_generator", "skill_name": "content_generator",
@ -926,7 +926,7 @@ curl -X POST http://localhost:8000/api/v1/tasks \
提交任务(意图路由自动匹配): 提交任务(意图路由自动匹配):
```bash ```bash
curl -X POST http://localhost:8000/api/v1/tasks \ curl -X POST http://localhost:8001/api/v1/tasks \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{ -d '{
"input_data": {"query": "帮我生成一篇文章"} "input_data": {"query": "帮我生成一篇文章"}
@ -936,7 +936,7 @@ curl -X POST http://localhost:8000/api/v1/tasks \
创建 Agent 创建 Agent
```bash ```bash
curl -X POST http://localhost:8000/api/v1/agents \ curl -X POST http://localhost:8001/api/v1/agents \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"skill_name": "content_generator"}' -d '{"skill_name": "content_generator"}'
``` ```
@ -944,13 +944,13 @@ curl -X POST http://localhost:8000/api/v1/agents \
查询 LLM 用量: 查询 LLM 用量:
```bash ```bash
curl http://localhost:8000/api/v1/llm/usage curl http://localhost:8001/api/v1/llm/usage
``` ```
健康检查: 健康检查:
```bash ```bash
curl http://localhost:8000/api/v1/health curl http://localhost:8001/api/v1/health
``` ```
#### Python SDK 调用 #### Python SDK 调用
@ -960,7 +960,7 @@ import asyncio
from agentkit.server.client import AgentKitClient from agentkit.server.client import AgentKitClient
async def main(): async def main():
async with AgentKitClient("http://localhost:8000") as client: async with AgentKitClient("http://localhost:8001") as client:
# 注册 Skill # 注册 Skill
await client.register_skill({ await client.register_skill({
"name": "content_generator", "name": "content_generator",

View File

@ -1,11 +1,12 @@
--- ---
title: "Portal platform security & reliability fixes — channels, LLM cache, server shutdown" title: "Portal 平台安全与可靠性修复 — 通道、LLM 缓存、服务端关闭"
date: 2026-06-26 date: 2026-06-26
category: docs/solutions/security-issues category: docs/solutions/security-issues
module: channels/llm/server module: channels/llm/server
problem_type: security_issue problem_type: security_issue
component: service_object component: service_object
severity: high severity: mixed
priority_breakdown: {p1: 4, p2: 5, p3: 1}
symptoms: symptoms:
- "WeCom webhook accepted replayed requests within an unlimited time window (no timestamp freshness check)" - "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)" - "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 - 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 ## Problem
@ -38,7 +47,7 @@ related_components:
- **LLM 缓存跨用户泄漏**`should_cache()` 对 `user_id` 参数执行 `_ = user_id` 直接丢弃,在 `per_user_namespace` 开启时,`user_id=None` 的请求仍会命中其他用户的缓存。 - **LLM 缓存跨用户泄漏**`should_cache()` 对 `user_id` 参数执行 `_ = user_id` 直接丢弃,在 `per_user_namespace` 开启时,`user_id=None` 的请求仍会命中其他用户的缓存。
- **Webhook 异常触发 500 重试风暴**`receive_message()` 解析失败直接抛出未捕获异常,平台收到 500 后按退避策略无限重试,造成异常放大。 - **Webhook 异常触发 500 重试风暴**`receive_message()` 解析失败直接抛出未捕获异常,平台收到 500 后按退避策略无限重试,造成异常放大。
- **应用关闭资源泄漏**shutdown 流程未调用 `close_all_adapters()`、未 await `_pending_webhook_tasks`,导致 httpx 连接泄漏与 IM 回复丢失。 - **应用关闭资源泄漏**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 ## What Didn't Work
@ -50,7 +59,7 @@ related_components:
### P1 #1 — WeCom webhook 重放攻击修复 ### 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()` 只比对签名,不校验时间戳新鲜度,重放窗口无限大。 **问题**`verify_signature()` 只比对签名,不校验时间戳新鲜度,重放窗口无限大。
@ -77,7 +86,7 @@ if abs(now - ts_int) > _SIGNATURE_MAX_AGE_SECONDS:
### P1 #2 — LiteLLM 缓存跨用户泄漏修复 ### 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` 的请求仍会命中缓存,造成跨用户数据泄漏。 **问题**`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 异常风暴防御 ### 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平台按重试策略无限重试形成异常风暴。 **问题**`receive_message()` 抛出异常时,路由返回 500平台按重试策略无限重试形成异常风暴。
@ -113,7 +122,7 @@ except Exception as exc: # noqa: BLE001 — 防止 receive_message 异常导致
### P1 #4 — 应用关闭泄漏修复 ### 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 回复丢失。 **问题**shutdown 流程未关闭 channel adapters、未等待后台 webhook 任务,导致 httpx 连接泄漏与 IM 回复丢失。
@ -136,7 +145,7 @@ except Exception:
### P2 #8 — Feishu token TTL 修正 ### 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。 **问题**`_TOKEN_CACHE_TTL = 300.0` 比实际有效期2h短 24 倍,造成每 5 分钟强制刷新一次 token无谓增加 QPS。
@ -148,7 +157,7 @@ _TOKEN_CACHE_TTL = 6900.0 # 2h - 5min 余量,避免临界点失效
### P2 #10 — 无界 webhook 任务集 ### 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 在高负载下无上限增长,可能耗尽内存与协程。 **问题**`_pending_webhook_tasks` set 在高负载下无上限增长,可能耗尽内存与协程。
@ -164,7 +173,7 @@ if len(_pending_webhook_tasks) >= _WEBHOOK_MAX_CONCURRENT * 2:
### P2 #12 — 配额检查 N+1 查询消除 ### 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 各一次,但实际可合并为一次查询),重复查询放大数据库压力。 **问题**:配额检查对每个部门每个周期调用 4 次 `get_usage()`token 与 cost 各一次,但实际可合并为一次查询),重复查询放大数据库压力。
@ -191,7 +200,7 @@ def _get_usage_summary(self, department_id: str, period: str) -> UsageSummary:
### P2 #13 — 缓存键冗余 SHA-256 消除 ### 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-2568-10 次),再对哈希拼接再做一次 SHA-256CPU 浪费且无安全增益。 **问题**`generate_cache_key` 对每个组件单独 SHA-2568-10 次),再对哈希拼接再做一次 SHA-256CPU 浪费且无安全增益。
@ -219,7 +228,7 @@ return hashlib.sha256(combined.encode()).hexdigest()
### P2 #18 — 移除未使用的 secrets_store 参数 ### 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。 **问题**`get_api_key()` 接受 `secrets_store` 形参但从未使用(同步方法无法 await 异步 `get_secret`),属于误导性 API。
@ -237,7 +246,7 @@ def get_api_key(self) -> str:
### P3 #21 — DIRECT_CHAT 路径去重 ### 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 回退路径中重复实现,维护时易漂移。 **问题**DIRECT_CHAT 逻辑在主路径与 ReAct 回退路径中重复实现,维护时易漂移。
@ -258,13 +267,32 @@ async def _direct_chat(llm_gateway: Any, routing: Any) -> str:
修复完成后按以下方式验证: 修复完成后按以下方式验证:
- **ruff check**`src/` 干净,仅剩预存在的 `gui_mode` F821与本次修复无关 - **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_config_migration.py` — 全部通过。
- **配额强制测试**`pytest tests/unit/llm/test_quota_enforcement.py` — 11 passed。 - **配额强制测试**`pytest tests/unit/llm/test_quota_enforcement.py` — 11 passed。
- **litellm 相关测试**:因 `litellm` 未安装被 skip环境问题非代码问题 - **litellm 相关测试**:因 `litellm` 未安装被 skip环境问题非代码问题
- **WeCom 测试调整**:将固定时间戳 `1609459200` 改为 `int(time.time())` 动态生成后,新鲜度校验测试通过。 - **WeCom 测试调整**:将固定时间戳 `1609459200` 改为 `int(time.time())` 动态生成后,新鲜度校验测试通过。
- **缓存测试调整**:显式传入 `user_id` 或关闭 `per_user_namespace` 后,`should_cache()` 测试通过。 - **缓存测试调整**:显式传入 `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 ## Prevention
### 安全 ### 安全
@ -292,5 +320,5 @@ async def _direct_chat(llm_gateway: Any, routing: Any) -> str:
## Related Docs ## 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 修复系列,可在遇到类似可靠性问题时交叉参考。 - [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](file:///Users/Chiguyong/Code/Fischer/fischer-agentkit/docs/solutions/architecture-patterns/bitable-companion-service-security-reliability-patterns.md) — Bitable 伴生服务的安全/可靠性架构模式SSRF、SQL 注入、IDOR、缓存失效等与本批次的 LLM 缓存隔离威胁模型不同但可对照阅读。 - [bitable-companion-service-security-reliability-patterns.md](docs/solutions/architecture-patterns/bitable-companion-service-security-reliability-patterns.md) — Bitable 伴生服务的安全/可靠性架构模式SSRF、SQL 注入、IDOR、缓存失效等与本批次的 LLM 缓存隔离威胁模型不同但可对照阅读。

View File

@ -309,11 +309,22 @@ class SqliteConversationStore:
row = await cursor.fetchone() row = await cursor.fetchone()
if row is None: if row is None:
return False return False
await db.execute( # ponytail: wrap both DELETEs in a try/except with rollback —
"DELETE FROM messages WHERE conversation_id = ?", (conversation_id,) # previously the second DELETE failure would leave orphaned
) # messages (conversation row gone, messages lingering) because
await db.execute("DELETE FROM conversations WHERE id = ?", (conversation_id,)) # the first DELETE already auto-committed in autocommit mode.
await db.commit() # 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) self._cache.pop(conversation_id, None)
return True return True

View File

@ -453,7 +453,9 @@ async def lifespan(app: FastAPI):
try: try:
default_agent._tool_registry.register(calendar_tool) default_agent._tool_registry.register(calendar_tool)
except Exception: 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 # Strip any existing "## 可用工具" section to avoid
# duplicate tool blocks in the system prompt. # duplicate tool blocks in the system prompt.
base_prompt = getattr(default_agent, "_system_prompt", None) or ( base_prompt = getattr(default_agent, "_system_prompt", None) or (

View File

@ -193,6 +193,11 @@ class AuthMiddleware(BaseHTTPMiddleware):
"user_id": payload.get("sub"), "user_id": payload.get("sub"),
"username": payload.get("username"), "username": payload.get("username"),
"role": payload.get("role"), "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) return await call_next(request)

View File

@ -103,7 +103,8 @@ function onEdit(event: ICalendarEvent): void {
.calendar-drawer__tabs :deep(.ant-tabs-content-holder) { .calendar-drawer__tabs :deep(.ant-tabs-content-holder) {
flex: 1; 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) { .calendar-drawer__tabs :deep(.ant-tabs-content) {

View File

@ -67,7 +67,17 @@ function handleEventClick(arg: EventClickArg): void {
} }
onMounted(() => { 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(() => { nextTick(() => {
calendarRef.value?.getApi()?.updateSize() calendarRef.value?.getApi()?.updateSize()
}) })

View File

@ -29,6 +29,7 @@
<button <button
class="chat-sidebar__item-delete" class="chat-sidebar__item-delete"
title="删除对话" title="删除对话"
aria-label="删除对话"
@click.stop @click.stop
> >
<DeleteOutlined /> <DeleteOutlined />

View File

@ -6,6 +6,7 @@
<button <button
class="right-panel__toggle" class="right-panel__toggle"
:title="collapsed ? '展开右侧面板' : '收起右侧面板'" :title="collapsed ? '展开右侧面板' : '收起右侧面板'"
:aria-label="collapsed ? '展开右侧面板' : '收起右侧面板'"
@click="toggle" @click="toggle"
> >
<RightOutlined v-if="!collapsed" /> <RightOutlined v-if="!collapsed" />

View File

@ -79,9 +79,11 @@
<CalendarTab v-else-if="activeTab === 'calendar'" /> <CalendarTab v-else-if="activeTab === 'calendar'" />
<SystemTab v-else-if="activeTab === 'system'" /> <SystemTab v-else-if="activeTab === 'system'" />
<KnowledgeTab v-else-if="activeTab === 'knowledge'" /> <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> </div>
<nav class="system-monitor__tabs" role="tablist" aria-label="右侧功能导航"> <nav class="system-monitor__tabs" role="tablist" aria-label="右侧功能导航" @keydown="onTabKeydown">
<a-tooltip <a-tooltip
v-for="tab in tabs" v-for="tab in tabs"
:key="tab.key" :key="tab.key"
@ -150,6 +152,19 @@ const tabs: Tab[] = [
const activeTab = ref('monitor') const activeTab = ref('monitor')
const loading = ref(false) const loading = ref(false)
const error = ref('') 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 health = ref<IHealthCheck>({ status: 'unknown', version: '', checks: {} })
const metrics = ref<IMetricsData>({ const metrics = ref<IMetricsData>({
tasks: { total_tasks: 0, completed_tasks: 0, failed_tasks: 0, pending_tasks: 0 }, tasks: { total_tasks: 0, completed_tasks: 0, failed_tasks: 0, pending_tasks: 0 },
@ -305,6 +320,8 @@ onUnmounted(() => {
.system-monitor__tab--active { .system-monitor__tab--active {
color: var(--accent-team); color: var(--accent-team);
background: var(--color-primary-light); 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 { .system-monitor__tab-icon {

View File

@ -27,12 +27,16 @@
/> />
</div> </div>
<a-tooltip title="多维表格"> <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 /> <TableOutlined />
</button> </button>
</a-tooltip> </a-tooltip>
<a-tooltip :title="isDark ? '切换亮色模式' : '切换暗色模式'"> <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-if="isDark" />
<BulbOutlined v-else style="opacity: 0.5" /> <BulbOutlined v-else style="opacity: 0.5" />
</button> </button>

View File

@ -62,6 +62,9 @@ const simpleImage = Empty.PRESENTED_IMAGE_SIMPLE
function formatTime(iso: string): string { function formatTime(iso: string): string {
if (!iso) return '' if (!iso) return ''
const d = new Date(iso) 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', { return d.toLocaleString('zh-CN', {
month: '2-digit', month: '2-digit',
day: '2-digit', day: '2-digit',

View File

@ -114,9 +114,10 @@ function closeDetail(): void {
} }
onMounted(() => { onMounted(() => {
if (skillsStore.skills.length === 0) { // ponytail: previously only fetched when store was empty, causing stale
skillsStore.fetchSkills() // data after skills were modified elsewhere. Always fetch on mount
} // the store handles deduplication and this tab is not mounted often.
skillsStore.fetchSkills()
}) })
</script> </script>

View File

@ -23,19 +23,28 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref, onUnmounted } from 'vue'
import { MessageShell, UserBubble, ErrorCard } from '@/components/chat/messages' import { MessageShell, UserBubble, ErrorCard } from '@/components/chat/messages'
const errorDetail = ref('连接被拒绝:无法连接到本地 MCP 服务器localhost:8080。请检查服务是否已启动。') const errorDetail = ref('连接被拒绝:无法连接到本地 MCP 服务器localhost:8080。请检查服务是否已启动。')
const retried = ref(false) 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 { function handleRetry(): void {
retried.value = true retried.value = true
errorDetail.value = '正在重试…' errorDetail.value = '正在重试…'
setTimeout(() => { if (retryTimer) clearTimeout(retryTimer)
retryTimer = setTimeout(() => {
errorDetail.value = '连接被拒绝:无法连接到本地 MCP 服务器localhost:8080。请检查服务是否已启动。' errorDetail.value = '连接被拒绝:无法连接到本地 MCP 服务器localhost:8080。请检查服务是否已启动。'
retryTimer = null
}, 1500) }, 1500)
} }
onUnmounted(() => {
if (retryTimer) clearTimeout(retryTimer)
})
</script> </script>
<style scoped> <style scoped>

View File

@ -370,6 +370,12 @@ export const useChatStore = defineStore("chat", () => {
} }
conversations.value = conversations.value.filter((c) => c.id !== id); conversations.value = conversations.value.filter((c) => c.id !== id);
streamingStepsByConv.value.delete(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); markConversationDone(id);
if (currentConversationId.value === id) { if (currentConversationId.value === id) {
currentConversationId.value = null; currentConversationId.value = null;

View File

@ -7,7 +7,6 @@ import uuid
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any
from fastapi import ( from fastapi import (
APIRouter, APIRouter,
@ -192,12 +191,29 @@ class PortalConnectionManager:
features) to deliver real-time messages to a user's open chat tab(s). 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: def __init__(self) -> None:
# user_id -> list of active WebSocket connections # user_id -> list of active WebSocket connections
self._connections: dict[str, list[WebSocket]] = {} self._connections: dict[str, list[WebSocket]] = {}
def add(self, user_id: str, ws: WebSocket) -> None: 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: def remove(self, user_id: str, ws: WebSocket) -> None:
conns = self._connections.get(user_id) conns = self._connections.get(user_id)
@ -207,7 +223,7 @@ class PortalConnectionManager:
if not self._connections[user_id]: if not self._connections[user_id]:
del 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*. """Broadcast a JSON message to all connections for *user_id*.
Removes stale connections that fail to send. Removes stale connections that fail to send.
@ -219,7 +235,10 @@ class PortalConnectionManager:
for ws in conns: for ws in conns:
try: try:
await ws.send_json(message) 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) stale.append(ws)
for ws in stale: for ws in stale:
self.remove(user_id, ws) self.remove(user_id, ws)
@ -228,7 +247,7 @@ class PortalConnectionManager:
portal_connection_manager = 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.""" """Public helper to push a message to all portal WebSockets for a user."""
await portal_connection_manager.send_json(user_id, message) await portal_connection_manager.send_json(user_id, message)
@ -791,7 +810,15 @@ async def get_conversation(
@router.delete("/portal/conversations/{conversation_id}") @router.delete("/portal/conversations/{conversation_id}")
async def delete_conversation(conversation_id: str, _auth: None = Depends(_verify_api_key)): 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) deleted = await _conversation_store.delete_conversation(conversation_id)
if not deleted: if not deleted:
raise HTTPException(status_code=404, detail=f"Conversation '{conversation_id}' not found") 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.""" """Real-time chat WebSocket endpoint."""
await websocket.accept() 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) # Authentication (after accept, since FastAPI requires accept before close)
configured_api_key: str | None = None configured_api_key: str | None = None
if hasattr(websocket.app.state, "server_config") and websocket.app.state.server_config: 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 # Track authenticated portal connections for user-scoped push (calendar
# reminders, etc.). user_id is None for API-key / dev-mode clients. # reminders, etc.). user_id is None for API-key / dev-mode clients.
current_user = getattr(websocket.state, "current_user", None) or {} 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: if ws_user_id:
portal_connection_manager.add(ws_user_id, websocket) portal_connection_manager.add(ws_user_id, websocket)

View File

@ -11,6 +11,7 @@ user_id; it does not perform auth (same pattern as DocumentTool).
from __future__ import annotations from __future__ import annotations
from datetime import datetime
from typing import Any from typing import Any
from agentkit.calendar.models import ReminderRule from agentkit.calendar.models import ReminderRule
@ -18,6 +19,35 @@ from agentkit.calendar.service import CalendarService
from agentkit.tools.base import Tool 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): class CalendarTool(Tool):
"""Agent tool for calendar event management. """Agent tool for calendar event management.
@ -30,6 +60,8 @@ class CalendarTool(Tool):
description=( description=(
"Create, query, update, and delete calendar events and reminders. " "Create, query, update, and delete calendar events and reminders. "
"Use create_event to schedule events and set reminders (e.g. 'remind me Monday morning'). " "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." "Actions: create_event, query_events, update_event, delete_event."
), ),
input_schema={ input_schema={
@ -153,6 +185,22 @@ class CalendarTool(Tool):
if not end_time: if not end_time:
return {"success": False, "error": "Missing required field: 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", "") description = kwargs.get("description", "")
location = kwargs.get("location", "") location = kwargs.get("location", "")
is_all_day = kwargs.get("is_all_day", False) is_all_day = kwargs.get("is_all_day", False)
@ -161,6 +209,30 @@ class CalendarTool(Tool):
reminder_offset = kwargs.get("reminder_offset_minutes") reminder_offset = kwargs.get("reminder_offset_minutes")
reminder_channels = kwargs.get("reminder_channels") or ["client"] 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 # Build explicit reminder rules if requested
reminder_rules: list[ReminderRule] | None = None reminder_rules: list[ReminderRule] | None = None
if reminder_offset is not None: if reminder_offset is not None:
@ -171,10 +243,13 @@ class CalendarTool(Tool):
"success": False, "success": False,
"error": f"reminder_offset_minutes must be an integer, got {reminder_offset!r}", "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 { return {
"success": False, "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 = [ reminder_rules = [
ReminderRule( ReminderRule(
@ -285,11 +360,20 @@ class CalendarTool(Tool):
return {"success": False, "error": "Permission denied"} return {"success": False, "error": "Permission denied"}
# Build fields dict from updatable params (only those explicitly provided) # 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] = {} fields: dict[str, Any] = {}
for key in updatable: for key in updatable:
if key in kwargs and kwargs[key] is not None: if key in kwargs and kwargs[key] is not None:
fields[key] = kwargs[key] 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: if not fields:
return {"success": False, "error": "No fields to update"} return {"success": False, "error": "No fields to update"}

View File

@ -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"]