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
- **进化**`evolution/`Reflector、PromptOptimizer遗传算法、PitfallDetector、ABTester
- **工具**`tools/`21 个内置 + MCP 扩展组合SequentialChain/ParallelFanOut/DynamicSelector
@ -136,7 +136,7 @@ HandoffTransportInProcessasyncio.Queue+ Redis Pub/Sub — 仅用于事
- **总线**`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
### 服务端路由22 个模块)
### 服务端路由28 个模块)
| 前缀 | 模块 | 用途 |
| ------------------------- | -------------------------------------- | ------------------------- |
@ -162,6 +162,12 @@ HandoffTransportInProcessasyncio.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\_drivenvs `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/`

View File

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

View File

@ -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-2568-10 次),再对哈希拼接再做一次 SHA-256CPU 浪费且无安全增益。
@ -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 缓存隔离威胁模型不同但可对照阅读。

View File

@ -309,11 +309,22 @@ class SqliteConversationStore:
row = await cursor.fetchone()
if row is None:
return False
await db.execute(
"DELETE FROM messages WHERE conversation_id = ?", (conversation_id,)
)
await db.execute("DELETE FROM conversations WHERE id = ?", (conversation_id,))
await db.commit()
# 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

View File

@ -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 (

View File

@ -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)

View File

@ -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) {

View File

@ -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()
})

View File

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

View File

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

View File

@ -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 {

View File

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

View File

@ -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',

View File

@ -114,9 +114,10 @@ function closeDetail(): void {
}
onMounted(() => {
if (skillsStore.skills.length === 0) {
skillsStore.fetchSkills()
}
// 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>

View File

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

View File

@ -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;

View File

@ -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)

View File

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

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