diff --git a/.gitignore b/.gitignore index 6386f3a..be0054e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,28 @@ +# Dependencies node_modules/ -.idea/ + +# Config config.js +.env + +# Logs logs/ +*.log + +# Auth / Secrets session.json refresh.token + +# Build +dist/ + +# Test coverage +coverage/ + +# IDE +.idea/ .codex +.vscode/ + +# OS +.DS_Store diff --git a/AGENTS.md b/AGENTS.md index 45d2b07..28fb466 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,907 @@ -# Project Notes +# Steam Chat - Project Knowledge Base -- WebSocket payloads with `type: "message"` must set `data.name` to the sender's nickname. -- For incoming friend messages, `data.name` is the friend's nickname. -- For self-sent echo messages, `data.name` is the current Steam account nickname. +**Generated:** 2026-05-06 +**Stack:** Node.js (CommonJS), ES6 Frontend Modules, WebSocket, Steam API + +## OVERVIEW +Real-time Steam chat service with HTTP/WebSocket API and built-in responsive web UI. Supports messaging, images, stickers, and emoticons. + +## STRUCTURE +``` +./ +├── client.js # Steam client wrapper, user cache +├── chat.js # HTTP server + WebSocket (core, 1800+ lines) +├── steam-lifecycle.js # Steam connection lifecycle with retry logic +├── logger.js # Winston logger + chat history logging +├── config.js # Runtime configuration +├── config.example.js # Configuration template +├── package.json # Dependencies: steam-user, ws, winston, axios +├── public/ # Frontend assets +│ ├── index.html # Main page (Chinese UI) +│ ├── app.js # Frontend entry (ES6 modules) +│ ├── app/ # Modular frontend components +│ └── styles/ # Modular CSS +├── logs/ # chat.jsonl, image/sticker cache +├── test/ # Node.js built-in test runner +└── scripts/ # Utility scripts +``` + +## WHERE TO LOOK +| Task | Location | Notes | +|------|----------|-------| +| Steam login/auth | `steam-lifecycle.js` | Refresh token support, retry with backoff | +| HTTP API routes | `chat.js` (lines 1-100) | `/message`, `/image`, `/history`, `/conversations` | +| WebSocket handlers | `chat.js` (search `wss.on`) | Types: send_message, get_history, get_conversations | +| Frontend state | `public/app/session.js` | Active conversation, friends, groups | +| WebSocket client | `public/app/websocket.js` | Auto-reconnect, request IDs | +| Message composer | `public/app/composer.js` | Input, attachments, emoticon picker | +| Message rendering | `public/app/messages.js`, `message-bubble.js` | Bubbles, separators, rich content | +| Chat logging | `logger.js` | JSONL format, imports history on first load | + +## CONVENTIONS + +### Backend (Node.js - CommonJS) +- `type: "commonjs"` in package.json +- `require()` for imports, `module.exports` for exports +- Async/await preferred over callbacks +- Winston logger with structured metadata: `logger.info("msg", { key: value })` +- Steam result codes: Use `EResult` from `steam-user/enums/EResult` + +### Frontend (Browser - ES6 Modules) +- Native ES6 modules: `import/export` (type="module" in script tag) +- Factory pattern: `createXController({ deps })` returns public API +- Event delegation over individual listeners +- DOM refs centralized in `dom.js` + +### File Naming +- Backend: camelCase (e.g., `steam-lifecycle.js`) +- Frontend: camelCase, descriptive (e.g., `message-bubble.js`) +- CSS: matches component name (e.g., `composer.css`) + +### Configuration +- `config.js` required at runtime (not in repo) +- `config.example.js` as template +- Environment: `STEAM_CHAT_DISABLE_AUTOSTART=1` for testing + +## ANTI-PATTERNS (THIS PROJECT) +- **DO NOT** mix CommonJS and ES6 modules within same file +- **DO NOT** use global variables in frontend (use module-scoped) +- **NEVER** commit `config.js` with real credentials +- **AVOID** modifying `chat.js` without checking WebSocket message type contracts + +## SPECIAL RULES + +### WebSocket Payload Naming (CRITICAL) +- `type: "message"` payloads MUST set `data.name` to sender nickname +- Incoming friend messages: `data.name` = friend's nickname +- Self-sent echo messages: `data.name` = current Steam account nickname + +### Steam Lifecycle +- Supports refresh token auth (auto-saves to `refresh.token`) +- Exponential backoff retry: starts 5s, max 5min +- Distinguishes recoverable vs non-recoverable errors (see `steam-lifecycle.js` sets) + +### Chat Log Format +- JSONL file: `logs/chat.jsonl` +- Fields: `date`, `echo` (boolean), `id` (SteamID64), `name`, `message`, `ordinal` +- Image cache: `logs/images/`, Sticker cache: `logs/stickers/` + +## COMMANDS +```bash +# Install dependencies +npm install + +# Run (requires config.js) +node client.js + +# Test (disables auto-start) +npm test +# or +STEAM_CHAT_DISABLE_AUTOSTART=1 node --test + +# Setup config +cp config.example.js config.js +# Edit config.js with your Steam credentials +``` + +## NOTES +- Default port: 3000 (configurable in `config.chat.port`) +- WebSocket path: `/ws` (configurable) +- HTTP Basic Auth optional but recommended for production +- Frontend is Chinese language (zh-CN) +- Mobile responsive: breakpoint at 900px +- Image proxy caches remote images locally +- Uses Node.js native test runner (no jest/mocha) +- Large files: `chat.js` (~1800 lines), `composer.js` (~600 lines) + +--- + +# Plan Mode 工作流 + +> 先计划、后执行。每次非平凡任务必须经过 P → R → A → E 四阶段闭环。 +> 建立日期:2026-05-09 + +--- + +## 1. 触发方式 + +### 方式 A — 显式触发 + +``` +ulw plan <需求描述> +``` + +### 方式 B — 隐式触发 + +以下条件任一满足时自动进入 Plan Mode: +- 涉及 2+ 个文件的改动 +- 涉及架构决策 +- 涉及跨服务修改 +- 涉及新的业务流程 + +--- + +## 2. 四阶段工作流 + +``` +P 阶段 (Plan) ──→ R 阶段 (Review) ──→ A 阶段 (Approve) ──→ E 阶段 (Execute) + │ │ │ │ + │ 我出计划 │ 你审反馈 │ 你签字审批 │ 我执行 + │ 结构化文档 │ 逐条过 │ 无遗留问题 │ 按计划推进 + └───────────────────┴────────────────────┴────────────────────┴────────── +``` + +--- + +## 3. P 阶段:计划制定 + +### 3.1 产出物 + +计划文档,写入 `.sisyphus/plans/{task-name}.md`,包含 6 个章节: + +| 章节 | 内容 | +|------|------| +| 1. 需求理解 | 我对需求的重新表述 + 待确认疑问 | +| 2. 范围界定 | In Scope / Out of Scope 清单 | +| 3. 技术方案 | 架构决策、修改文件、技术路线 | +| 4. 任务分解 | 按 Wave(并行批次)拆分的任务列表,每任务含:文件、改动描述、依赖 | +| 5. 风险点 | 潜在风险、回滚方案、需要关注的点 | +| 6. 验证计划 | 如何验证每个改动正确 | + +### 3.2 内部流程 + +``` +P0: 需求澄清(如有歧义 → 提问) +P1: 探索代码库(explore agent 并行搜索相关代码、模式、依赖) +P2: 复杂架构 → 咨询 Oracle(技术方案评审) +P3: 撰写计划文档(上述 6 个章节) +P4: 输出给用户审核 +``` + +### 3.3 执行完成标准(通用门禁) + +``` +- [ ] 所有任务标记完成 +- [ ] 项目构建通过(按项目实际工具:go build / npm run build / cargo build / 等) +- [ ] 项目测试通过(按项目实际工具:go test / npm test / pytest / 等) +- [ ] LSP 诊断无新增错误 +- [ ] 改动的代码通过了手动验证(见验证计划各条) +``` + +--- + +### 3.4 计划文档模板 + +```markdown +# Plan: {任务名称} + +## 1. 需求理解 +{我对需求的重新表述} + +## 2. 范围界定 +### In Scope +- {list} +### Out of Scope +- {list} + +## 3. 技术方案 +{架构图/决策说明/文件清单} + +## 4. 任务分解 + +| Wave | Task ID | 描述 | 文件 | 依赖 | 预期产出 | +|------|---------|------|------|------|----------| +| 1 | T1 | ... | ... | 无 | ... | +| 1 | T2 | ... | ... | 无 | ... | +| 2 | T3 | ... | ... | T1,T2 | ... | + +## 5. 风险点 +| 风险 | 概率 | 影响 | 缓解措施 | +|------|------|------|----------| +| ... | 高/中/低 | ... | ... | + +## 6. 验证计划 +| 验证项 | 方法(按项目实际工具填写) | 预期结果 | +|--------|---------------------------|----------| +| 编译 | {go build / npm run build / cargo build / ...} | exit 0 | +| 单元测试 | {go test / npm test / pytest / ...} | 全部通过 | +| Lint/类型检查 | {golangci-lint / tsc / mypy / ...} | 无新增问题 | +| 手动验证 | {按功能描述执行的操作步骤} | 符合预期 | +``` + +--- + +## 4. R 阶段:审核反馈 + +### 角色分工 + +- **你**:逐章节审阅计划,给出修改意见,指出遗漏,调整优先级 +- **我**:根据反馈更新计划文档,对每条反馈回应(接受/解释/替代方案),方案重大变更则重新咨询 Oracle + +### 你说话的格式 + +``` +plan 反馈: +1. [章节名] 第X条:建议改为... +2. [风险] 漏掉了YY场景 +3. [任务分解] Wave 2 应该先于 Wave 1 +``` + +### 我回应的格式 + +``` +计划更新 v2: +[章节] 已修改:... +[章节] 已采纳反馈:... +[章节] 关于第X点,我的考虑是... 是否保持原方案? +``` + +### 轮次 + +不限,直至你满意。 + +--- + +## 5. A 阶段:审批通过 + +### 触发词 + +你说以下之一即表示审批通过: + +``` +plan 通过 +审核通过 +批准执行 +Plan Approved +``` + +### 审批门禁(前置条件) + +- [ ] 所有疑问已澄清 +- [ ] 需求理解无误 +- [ ] 技术方案没有明显漏洞 +- [ ] 任务分解完整(没有遗漏步骤) +- [ ] 验证计划合理 + +审批后,计划文档进入**锁定状态**。执行阶段严格按计划推进。 + +--- + +## 6. E 阶段:执行 + +### 执行原则 + +1. 严格按照任务分解的 **Wave 顺序** 执行 +2. 每个 Wave 内任务**并行**执行 +3. 每个任务完成后:`lsp_diagnostics` + 验证步骤 +4. 每完成一个 Wave:运行一次 build + test +5. 每完成一个 Wave:向你更新进度 + +### 执行偏差处理 + +| 情况 | 处理方式 | +|------|----------| +| 执行中发现计划遗漏 | 暂停 → 报告偏差 → 等你决策 | +| 执行中遇到计划错误的假设 | 暂停 → 分析根因 → 提交方案变更请求 | +| 执行中发现更好方案 | 暂停 → 说明理由 → 等你决定是否改用新方案 | +| 执行一切顺利 | 继续推进,直到全部完成 | + +### 变更请求格式 + +``` +变更请求 #1: +- 原计划:[Wave X, Task Y] +- 发现的问题:[具体问题] +- 建议修改为:[新方案] +- 理由:[为什么不按原计划] +- 影响:[对其他任务的影响] +请确认是否批准此变更。 +``` + +--- + +## 7. Plan Mode 与直接执行模式的选择 + +| 维度 | 直接执行 | Plan Mode | +|------|----------|-----------| +| 开始前 | 直接开始编码 | 先出计划文档 | +| 用户参与 | 执行中提反馈 | 计划阶段先审核 | +| 变更处理 | 随时改 | 正式变更请求 | +| 范围控制 | 容易 scope creep | 严格按计划执行 | +| 风险把控 | 走一步看一步 | 提前识别风险 | +| 适用场景 | 1 个文件的简单修改 | 2+ 文件、新功能、架构变更 | + +--- + +## 8. 执行完成标准(通用) + +所有执行任务完成后,必须逐条验证: + +- [ ] 所有任务标记完成 +- [ ] 项目构建通过(按项目实际工具填写命令) +- [ ] 项目测试通过(按项目实际工具填写命令) +- [ ] LSP 诊断无新增错误 +- [ ] 改动的代码通过了手动验证(见验证计划各条) + +**以上门禁全部通过才算任务完成。** + +--- + +# 通用功能模块自评审流程 + +> 一键触发:`ulw 启动自评审流程,目标:<包路径 | 功能模块描述>` +> 交互触发:`ulw 我要自评审`(逐步问答,无需记参数) +> 示例:`ulw 启动自评审流程,目标:core/helper/search` +> 示例:`ulw 启动自评审流程,目标:订单结算流程` +> 示例:`ulw 启动自评审流程,目标:库存同步与发货` + +## 触发方式 + +支持两种触发模式: + +### 模式 1:直接触发(参数完整) + +适合熟悉格式、一次性写全的场景。 + +Sisyphus 将根据输入自动判断目标类型: + +- **Go 包路径**(包含 `/` 或 `.`,如 `core/helper/search`)→ 走包发现模式 +- **功能模块描述**(自然语言,如 `订单结算流程`)→ 走功能模块发现模式 +- **手动指定文件清单**:在上述参数后追加 `|` 分隔的文件路径,跳过自动发现 + +``` +ulw 启动自评审流程,目标: [| file1.go,file2.go] [可选: 额外上下文] +``` + +额外上下文示例: +- `该模块负责订单结算,依赖 MySQL + Redis` +- `该模块是新增的,需要特别注意错误处理` +- `重点审查并发安全性` +- `涉及多服务交互:go-game-trade-serve → go-goods-serve` + +### 模式 2:交互式触发(推荐) + +记不住格式、或者想一步步来的时候用。无需记忆任何参数。 + +``` +ulw 我要自评审 +``` + +Sisyphus 收到后将通过对话逐项询问: + +1. **评审目标** — 包路径?功能模块描述?还是直接给文件列表? +2. **额外上下文** — 业务背景、关注重点、涉及的服务等 +3. **确认** — 展示理解到的目标,让用户确认后再启动 + +相当于把一次性参数填写变成了问答式引导,降低心智负担。 + +## Sisyphus 自动执行流程 + +Sisyphus 收到此请求后将执行: + +### Phase 0: 文件发现 + +**模式 A — 按包路径发现:** +- 解析目标包路径 +- `glob` + `grep` 发现包内所有 `.go` 文件 +- `grep` 发现包间引用关系 +- **反向发现调用者**:grep 搜索服务目录下哪些文件调用了目标包的导出函数/类型(如 `Start`、`TryPublish`、`Handle` 等入口),将这些调用者文件加入覆盖清单 + - 例:审查 `consumer/goods` 包时,发现调用它的 `game_trade.go`(API handler)和 producer 定时任务文件,加入审查范围 + - 反向发现确保端到端链路完整:调用者的日志级别、返回信息是否与模块实际行为一致 +- 汇总为「覆盖文件清单」 + +**模式 B — 按功能模块描述发现:** +- 启动 2 个 `explore` Agent 并行探索: + - Agent 1:根据功能描述在相关服务目录下搜索关键词、结构体、函数 + - Agent 2:根据功能描述搜索配置、路由注册、API 入口等外围文件 +- 合并结果去重,形成「覆盖文件清单」 +- 如发现跨服务调用,在报告中注明涉及的外部服务 + +**模式 C — 手动指定文件清单:** +- 跳过自动发现,直接使用用户提供的文件路径列表 +- 对每个文件做存在性验证,不存在的文件报告警告 + +### Phase 1: 基线检查 + +- 若覆盖文件清单归属单一服务或单一包 → `go build ./...` + `go test ./...` +- 若跨多个包 → 对每个涉及的独立包分别运行 build + test +- 失败则先不进入审查,报告用户 + +### Phase 2: 审查循环 (直至质量达标) + +循环核心原则:**不设轮次上限,只以质量门禁是否全部通过为终止条件。** + +#### 质量门禁(必须全部通过) + +| # | 门禁 | 判定方式 | 一票否决 | +|---|------|----------|----------| +| G1 | 代码正确性审查 verdict = PASS | Oracle Agent 1 | 是 | +| G2 | 安全+边界审查 verdict = PASS | Oracle Agent 2 | 是 | +| G3 | 架构+模式审查 verdict = PASS | Oracle Agent 3 | 是 | +| G4 | 攻击者测试 verdict = PASS | Oracle Agent 4 | 是 | +| G5 | 交叉验证 verdict = PASS | Sisyphus 对比 4 个 Agent 发现 | 是 | +| G6 | 误判检测 verdict = NO_MISJUDGMENT | Sisyphus 逐条复审 findings | 是 | +| G7 | 零 CRITICAL/MAJOR 残留 | 汇总所有 findings 检查 | 是 | +| G8 | 零回归问题 | 对比上一轮 findings,新引入的算回归 | 是 | +| G9 | go build 通过 | bash 执行 | 是 | +| G10 | go test 通过 | bash 执行 | 是 | + +所有门禁通过(PASS)才算质量达标,否则继续循环。 + +#### 循环流程 + +``` +第 N 轮: + ├─ Phase 2a: 并行审查 + │ ├─ 启动 4 个 Oracle 并行审查(使用 Phase 0 发现的文件清单): + │ │ bg_1: 代码正确性 (oracle, Agent 1) + │ │ bg_2: 安全 + 边界条件 (oracle, Agent 2) + │ │ bg_3: 架构 + 模式 (oracle, Agent 3) + │ │ bg_4: 攻击者测试 (oracle, Agent 4) + │ └─ 等待全部完成 + ├─ Phase 2b: 交叉验证 + │ ├─ Agent 4 审查 Agent 1-3 的发现补充遗漏 + │ ├─ Agent 1-3 审查 Agent 4 的发现去重 + │ └─ Sisyphus 汇总生成完整 findings 清单 + ├─ Phase 2c: 质量门禁检查 + │ ├─ 全部 10 项 PASS → 输出最终报告,循环终止 + │ └─ 有 FAIL 项 → 进入修复流程 + ├─ 修复流程: + │ ├─ 回归问题优先修复(先还旧债,再修新债) + │ ├─ 误判优先复查(G6 FAIL 说明某条 finding 被错误判定,先纠正) + │ ├─ 按类型分流到修复 Agent: + │ │ 单文件修改 → category="quick" + │ │ 多文件/复杂 → category="deep" + │ └─ 修复后执行 build + test + ├─ 如果连续 3 轮同一门禁 FAIL(僵局处理): + │ ├─ 启动 Oracle 深度诊断,分析为什么反复修不好 + │ ├─ 输出根因分析 + 替代方案 + │ └─ 上报用户决策:继续修 / 接受现状 / 改方案 + └─ 进入第 N+1 轮 +``` + +#### 僵局处理(Escalation) + +当连续 3 轮同一门禁 FAIL 时,说明常规修复手段无效。此时: + +1. **暂停修复**,不自欺欺人继续打补丁 +2. **启动 Oracle 深度诊断**,分析根因: + - 是设计缺陷导致修不好?(如:当前架构本身就不安全) + - 是修复引入了新问题?(如:为了修 A 破坏了 B) + - 是审查标准不合理?(如:过于理想化,与现有代码风格冲突) +3. **输出根因分析报告**,给出 2-3 个可选方案 +4. **上报用户**,由用户决策下一步方向 + +### Phase 3: 输出报告 + +## 审查 Agent 提示词模板 + +Sisyphus 将「覆盖文件清单」和模块上下文代入以下模板。 + +**所有 Agent 的 prompt 均包含四个通用要求(在模板中已内置):** + +``` +通用要求(对所有维度均适用): +R1. 时序场景模拟: + - 识别模块中所有共享状态(全局变量、sync.Map、channel、atomic 操作等) + - 列出每个共享状态的 所有读写操作 及其所在的函数/goroutine + - 模拟 2-3 个 goroutine 交错时序,找出可能的竞态窗口 + - 特别关注"先释放某资源 → 其他 goroutine 获取 → 原 goroutine 再次操作该资源"的模式 + +R2. 逐 return 路径 cleanup 验证: + - 对每个包含资源获取的函数,列出其所有 return 路径(正常结束、错误、超时、取消) + - 逐路径验证 cleanup 完整性:每个 return 是否释放了该路径上已获取的所有资源 + - 比较对称路径的 cleanup 是否一致(如 if/else 分支、循环内 break vs continue vs return) + +R3. 跨函数/跨文件共享状态生命周期追踪: + - 如果一个共享状态的生命周期跨越多个函数/文件(如 producer 写入 → event bus → consumer 读取并释放) + - 追踪该状态的完整路径,检查每个跳转点的一致性 + - 特别关注状态通过事件/参数传递时,中间件或错误路径是否会中断传递 + +R4. 隐式假设陷阱扫描: + - 代码中的每个硬编码值(超时时长、重试次数/间隔、批大小、并发数、缓冲区容量、轮询频率)都隐含了一个对业务场景的假设。 + 对这些假设逐条问: + a) 这个值假定外部系统(AI API、DB、下游服务)的响应多快?负载多高?该假设在最坏情况下还成立吗? + b) 这个值假定同时存在多少个并发操作?如果同时有 100 个而不是 1 个,还成立吗? + c) 如果假设不成立,代码是"优雅退化"还是"直接中断"?中断后是否有补偿机制? + d) 代码是否将"正常情况"和"边界情况"用了同一个值?(例如:重获取锁超时 = 5 秒,假设 priority 在 5 秒内跑完;但 priority 实际耗时可达 30 分钟——正常路径和边界路径混用了同一套超时) + e) 是否存在"读取时看起来安全,写入时暴露假设"的代码?(例如:`LoadInt64 > 0` 的检查与后续操作之间假设了状态不变) + - 除了硬编码值,还有一类更隐蔽的隐式假设——**代码结构本身隐含的对系统行为的假设**。对以下每类逐条排查: + f) **事件/消息投递假设**: 是否假设"发布成功 = 一定被执行"?发布后的链路是否有超时/取消/panic 导致静默丢弃的路径?调用方的"成功日志"和实际执行之间有 gap 吗? + g) **并发与 goroutine 模型假设**: 是否假设其他 goroutine 一定活着?是否假设 state 在读和写之间不变?是否假设信号量/buffer 永远不会满? + h) **错误传播假设**: 是否用 `ctx.Err()` 代替了被包裹的底层 err?是否假设错误一定是某种特定类型?错误链路上是否有被吞没的中间错误? + i) **key/标识符空间隔离假设**: 不同用途的 key(如 `game:` vs `game:batch:`)是否可能碰撞?token/ID 的生成方式是否保证全局唯一? + j) **外部系统行为假设**: 是否假设外部 API 稳定返回特定格式?是否假设失败原因可被 binary split 重试解决?是否假设外部系统不会永久性失败? + k) **defer 注册时序假设**: 所有资源释放(锁、token、channel close、连接归还等)的 defer 是否在 `ctx.Done()` 检查之前注册?如果 defer 注册在 ctx 检查之后,ctx 恰好在这两步之间取消时,defer 不会执行,资源永久泄漏。审阅每条 early‑return 路径:确认 defer 注册 → 确认 ctx 检查在 defer 之后。 + l) **契约边界假设**: 找出所有仅靠"当前代码路径唯一"维持的不变量(如单次订阅、单点初始化、单消费者语义)。对每个不变量,追问:如果路径倍增(重复订阅/重入/串跑/重启),系统是快速失败还是静默损坏?是否有恢复机制?是否存在调用方已假定"约束永远成立"但实现层没有任何防御的断裂点? + m) **状态信息不完备假设**: 系统通过有限的状态信息(计数器、字段、信号量、缓存值)来代表真实世界。任何状态信息都可能因聚合粒度、传递损耗或更新延迟而与真实状态不符。对每个状态信息,追问:它在什么场景下会失准?失准时系统的行为是快速失败还是静默输出错误结果? + +``` + +### Agent 1: 代码正确性 + +``` +task(subagent_type="oracle", load_skills=[], run_in_background=true, + description="Review correctness of MODULE_NAME", + prompt=""" +CODE CORRECTNESS + QUALITY REVIEW +{MODULE_NAME} +{NEWLINE_SEPARATED_FILE_LIST_WITH_FULL_CONTENT} +{MODULE_SPECIFIC_CONTEXT_FROM_USER} + +通用要求: +R1. 时序场景模拟: + - 识别模块中所有共享状态(全局变量、sync.Map、channel、atomic 操作等) + - 列出每个共享状态的 所有读写操作 及其所在的函数/goroutine + - 模拟 2-3 个 goroutine 交错时序,找出可能的竞态窗口 + - 特别关注"先释放某资源 → 其他 goroutine 获取 → 原 goroutine 再次操作该资源"的模式 + +R2. 逐 return 路径 cleanup 验证: + - 对每个包含资源获取的函数,列出其所有 return 路径(正常结束、错误、超时、取消) + - 逐路径验证 cleanup 完整性:每个 return 是否释放了该路径上已获取的所有资源 + - 比较对称路径的 cleanup 是否一致(如 if/else 分支、循环内 break vs continue vs return) + +R3. 跨函数/跨文件共享状态生命周期追踪: + - 如果一个共享状态的生命周期跨越多个函数/文件 + - 追踪该状态的完整路径,检查每个跳转点的一致性 + - 特别关注状态通过事件/参数传递时,中间件或错误路径是否会中断传递 + +R4. 隐式假设陷阱扫描: + - 代码中的每个硬编码值(超时时长、重试次数/间隔、批大小、并发数、缓冲区容量、轮询频率)都隐含了一个对业务场景的假设。 + 对这些假设逐条问: + a) 这个值假定外部系统(AI API、DB、下游服务)的响应多快?负载多高?该假设在最坏情况下还成立吗? + b) 这个值假定同时存在多少个并发操作?如果同时有 100 个而不是 1 个,还成立吗? + c) 如果假设不成立,代码是"优雅退化"还是"直接中断"?中断后是否有补偿机制? + d) 代码是否将"正常情况"和"边界情况"用了同一个值? + e) 是否存在"读取时看起来安全,写入时暴露假设"的代码? + - 除了硬编码值,还有一类更隐蔽的隐式假设——**代码结构本身隐含的对系统行为的假设**。对以下每类逐条排查: + f) **事件/消息投递假设**: 是否假设"发布成功 = 一定被执行"?发布后的链路是否有超时/取消/panic 导致静默丢弃的路径?调用方的"成功日志"和实际执行之间有 gap 吗? + g) **并发与 goroutine 模型假设**: 是否假设其他 goroutine 一定活着?是否假设 state 在读和写之间不变?是否假设信号量/buffer 永远不会满? + h) **错误传播假设**: 是否用 `ctx.Err()` 代替了被包裹的底层 err?是否假设错误一定是某种特定类型?错误链路上是否有被吞没的中间错误? + i) **key/标识符空间隔离假设**: 不同用途的 key(如 `game:` vs `game:batch:`)是否可能碰撞?token/ID 的生成方式是否保证全局唯一? + j) **外部系统行为假设**: 是否假设外部 API 稳定返回特定格式?是否假设失败原因可被 binary split 重试解决?是否假设外部系统不会永久性失败? + k) **defer 注册时序假设**: 所有资源释放(锁、token、channel close、连接归还等)的 defer 是否在 `ctx.Done()` 检查之前注册?如果 defer 注册在 ctx 检查之后,ctx 恰好在这两步之间取消时,defer 不会执行,资源永久泄漏。审阅每条 early‑return 路径:确认 defer 注册 → 确认 ctx 检查在 defer 之后。 + l) **契约边界假设**: 找出所有仅靠"当前代码路径唯一"维持的不变量(如单次订阅、单点初始化、单消费者语义)。对每个不变量,追问:如果路径倍增(重复订阅/重入/串跑/重启),系统是快速失败还是静默损坏?是否有恢复机制?是否存在调用方已假定"约束永远成立"但实现层没有任何防御的断裂点? + m) **状态信息不完备假设**: 系统通过有限的状态信息(计数器、字段、信号量、缓存值)来代表真实世界。任何状态信息都可能因聚合粒度、传递损耗或更新延迟而与真实状态不符。对每个状态信息,追问:它在什么场景下会失准?失准时系统的行为是快速失败还是静默输出错误结果? +Review for: +- Logic errors +- Concurrency issues: 死锁、活锁、竞态条件、双释放、ABA 问题、原子操作误用 +- 共享的 sync.Map/atomic.Pointer 等原语:区分"操作本身安全"和"业务语义安全" + (例如:sync.Map.Delete 是幂等的,但如果当前存储的是其他 goroutine 的 token, + 删除它就破坏了其他持有者的锁 — 这种"跨 goroutine 的语义安全性") +- defer cleanup 的覆盖完整性:所有获取操作是否有对应的 defer/手动释放 +- Error handling gaps: 错误被吞没、错误类型误判(如用 ctx.Err() 代替被包裹的 err) +- Data integrity risks +- Nil pointer dereference potential +- Dead code + +OUTPUT: PASS or FAIL each with CRITICAL/MAJOR/MINOR severity, file:line reference, and concrete explanation +""") +``` + +### Agent 2: 安全 + 边界条件 + +``` +task(subagent_type="oracle", load_skills=[], run_in_background=true, + description="Review security of MODULE_NAME", + prompt=""" +SECURITY + EDGE CASE REVIEW +{MODULE_NAME} +{FILE_LIST} + +通用要求: +R1. 时序场景模拟: + - 识别模块中所有共享状态(全局变量、sync.Map、channel、atomic 操作等) + - 列出每个共享状态的 所有读写操作 及其所在的函数/goroutine + - 模拟 2-3 个 goroutine 交错时序,找出可能的竞态窗口 + - 特别关注"先释放某资源 → 其他 goroutine 获取 → 原 goroutine 再次操作该资源"的模式 + +R2. 逐 return 路径 cleanup 验证: + - 对每个包含资源获取的函数,列出其所有 return 路径(正常结束、错误、超时、取消) + - 逐路径验证 cleanup 完整性:每个 return 是否释放了该路径上已获取的所有资源 + - 比较对称路径的 cleanup 是否一致(如 if/else 分支、循环内 break vs continue vs return) + +R3. 跨函数/跨文件共享状态生命周期追踪: + - 如果一个共享状态的生命周期跨越多个函数/文件 + - 追踪该状态的完整路径,检查每个跳转点的一致性 + - 特别关注状态通过事件/参数传递时,中间件或错误路径是否会中断传递 + +R4. 隐式假设陷阱扫描: + - 代码中的每个硬编码值(超时时长、重试次数/间隔、批大小、并发数、缓冲区容量、轮询频率)都隐含了一个对业务场景的假设。 + 对这些假设逐条问: + a) 这个值假定外部系统(AI API、DB、下游服务)的响应多快?负载多高?该假设在最坏情况下还成立吗? + b) 这个值假定同时存在多少个并发操作?如果同时有 100 个而不是 1 个,还成立吗? + c) 如果假设不成立,代码是"优雅退化"还是"直接中断"?中断后是否有补偿机制? + d) 代码是否将"正常情况"和"边界情况"用了同一个值? + e) 是否存在"读取时看起来安全,写入时暴露假设"的代码? + - 除了硬编码值,还有一类更隐蔽的隐式假设——**代码结构本身隐含的对系统行为的假设**。对以下每类逐条排查: + f) **事件/消息投递假设**: 是否假设"发布成功 = 一定被执行"?发布后的链路是否有超时/取消/panic 导致静默丢弃的路径?调用方的"成功日志"和实际执行之间有 gap 吗? + g) **并发与 goroutine 模型假设**: 是否假设其他 goroutine 一定活着?是否假设 state 在读和写之间不变?是否假设信号量/buffer 永远不会满? + h) **错误传播假设**: 是否用 `ctx.Err()` 代替了被包裹的底层 err?是否假设错误一定是某种特定类型?错误链路上是否有被吞没的中间错误? + i) **key/标识符空间隔离假设**: 不同用途的 key(如 `game:` vs `game:batch:`)是否可能碰撞?token/ID 的生成方式是否保证全局唯一? + j) **外部系统行为假设**: 是否假设外部 API 稳定返回特定格式?是否假设失败原因可被 binary split 重试解决?是否假设外部系统不会永久性失败? + k) **defer 注册时序假设**: 所有资源释放(锁、token、channel close、连接归还等)的 defer 是否在 `ctx.Done()` 检查之前注册?如果 defer 注册在 ctx 检查之后,ctx 恰好在这两步之间取消时,defer 不会执行,资源永久泄漏。审阅每条 early‑return 路径:确认 defer 注册 → 确认 ctx 检查在 defer 之后。 + l) **契约边界假设**: 找出所有仅靠"当前代码路径唯一"维持的不变量(如单次订阅、单点初始化、单消费者语义)。对每个不变量,追问:如果路径倍增(重复订阅/重入/串跑/重启),系统是快速失败还是静默损坏?是否有恢复机制?是否存在调用方已假定"约束永远成立"但实现层没有任何防御的断裂点? + m) **状态信息不完备假设**: 系统通过有限的状态信息(计数器、字段、信号量、缓存值)来代表真实世界。任何状态信息都可能因聚合粒度、传递损耗或更新延迟而与真实状态不符。对每个状态信息,追问:它在什么场景下会失准?失准时系统的行为是快速失败还是静默输出错误结果? + +Review for: +- Input validation 的覆盖面和充分性 +- Injection risks (SQL/命令/prompt injection 等) +- Secrets exposure (API key 是否被意外记入日志) +- DoS vectors(无界 goroutine、内存爆炸、死循环、无穷递归) +- 操作幂等性 vs 业务语义安全性的区别 + (例如:db.Delete 不报错 ≠ 业务语义正确;map.Delete 不 panic ≠ 没破坏其他持有者的状态) +- Edge cases: empty inputs, max-size, unicode, zero values, negative values, context cancelled during write +- Panic paths: 是否有未 recover 的 panic 点 +- Resource leaks: HTTP body, goroutine, channel, semaphore +- Timeout handling: 内外层超时不匹配、超时后状态不一致 +- 攻击者视角:假设调用者可以控制输入参数,找到所有利用路径 + +OUTPUT: PASS or FAIL each with CRITICAL/HIGH/MEDIUM/LOW severity, file:line reference, and concrete explanation +""") +``` + +### Agent 3: 架构 + 模式 + +``` +task(subagent_type="oracle", load_skills=[], run_in_background=true, + description="Review architecture of MODULE_NAME", + prompt=""" +ARCHITECTURE + PATTERN REVIEW +{MODULE_NAME} +{FILE_LIST} + +通用要求: +R1. 时序场景模拟: + - 识别模块中所有共享状态(全局变量、sync.Map、channel、atomic 操作等) + - 列出每个共享状态的 所有读写操作 及其所在的函数/goroutine + - 模拟 2-3 个 goroutine 交错时序,找出可能的竞态窗口 + - 特别关注"先释放某资源 → 其他 goroutine 获取 → 原 goroutine 再次操作该资源"的模式 + +R2. 逐 return 路径 cleanup 验证: + - 对每个包含资源获取的函数,列出其所有 return 路径(正常结束、错误、超时、取消) + - 逐路径验证 cleanup 完整性:每个 return 是否释放了该路径上已获取的所有资源 + - 比较对称路径的 cleanup 是否一致(如 if/else 分支、循环内 break vs continue vs return) + +R3. 跨函数/跨文件共享状态生命周期追踪: + - 如果一个共享状态的生命周期跨越多个函数/文件 + - 追踪该状态的完整路径,检查每个跳转点的一致性 + - 特别关注状态通过事件/参数传递时,中间件或错误路径是否会中断传递 + +R4. 隐式假设陷阱扫描: + - 代码中的每个硬编码值(超时时长、重试次数/间隔、批大小、并发数、缓冲区容量、轮询频率)都隐含了一个对业务场景的假设。 + 对这些假设逐条问: + a) 这个值假定外部系统(AI API、DB、下游服务)的响应多快?负载多高?该假设在最坏情况下还成立吗? + b) 这个值假定同时存在多少个并发操作?如果同时有 100 个而不是 1 个,还成立吗? + c) 如果假设不成立,代码是"优雅退化"还是"直接中断"?中断后是否有补偿机制? + d) 代码是否将"正常情况"和"边界情况"用了同一个值? + e) 是否存在"读取时看起来安全,写入时暴露假设"的代码? + - 除了硬编码值,还有一类更隐蔽的隐式假设——**代码结构本身隐含的对系统行为的假设**。对以下每类逐条排查: + f) **事件/消息投递假设**: 是否假设"发布成功 = 一定被执行"?发布后的链路是否有超时/取消/panic 导致静默丢弃的路径?调用方的"成功日志"和实际执行之间有 gap 吗? + g) **并发与 goroutine 模型假设**: 是否假设其他 goroutine 一定活着?是否假设 state 在读和写之间不变?是否假设信号量/buffer 永远不会满? + h) **错误传播假设**: 是否用 `ctx.Err()` 代替了被包裹的底层 err?是否假设错误一定是某种特定类型?错误链路上是否有被吞没的中间错误? + i) **key/标识符空间隔离假设**: 不同用途的 key(如 `game:` vs `game:batch:`)是否可能碰撞?token/ID 的生成方式是否保证全局唯一? + j) **外部系统行为假设**: 是否假设外部 API 稳定返回特定格式?是否假设失败原因可被 binary split 重试解决?是否假设外部系统不会永久性失败? + k) **defer 注册时序假设**: 所有资源释放(锁、token、channel close、连接归还等)的 defer 是否在 `ctx.Done()` 检查之前注册?如果 defer 注册在 ctx 检查之后,ctx 恰好在这两步之间取消时,defer 不会执行,资源永久泄漏。审阅每条 early‑return 路径:确认 defer 注册 → 确认 ctx 检查在 defer 之后。 + l) **契约边界假设**: 找出所有仅靠"当前代码路径唯一"维持的不变量(如单次订阅、单点初始化、单消费者语义)。对每个不变量,追问:如果路径倍增(重复订阅/重入/串跑/重启),系统是快速失败还是静默损坏?是否有恢复机制?是否存在调用方已假定"约束永远成立"但实现层没有任何防御的断裂点? + m) **状态信息不完备假设**: 系统通过有限的状态信息(计数器、字段、信号量、缓存值)来代表真实世界。任何状态信息都可能因聚合粒度、传递损耗或更新延迟而与真实状态不符。对每个状态信息,追问:它在什么场景下会失准?失准时系统的行为是快速失败还是静默输出错误结果? + +Review for: +- Package structure: 依赖方向是否清晰,是否存在循环依赖 +- 对称性检查: 是否有类似的代码路径(如 A vs B、game vs price、producer vs consumer) + 它们的 cleanup/错误处理是否一致?不一致的差异是否有正当理由? +- 代码重复: 哪些可以抽象复用,哪些是必要差异 +- Over-engineering: 是否存在不必要复杂度 +- Dead code: 未使用的函数、类型、字段、常量 +- 常量组织: 分散或重复的常量、硬编码值 +- Interface design: 是否利于测试(mockable)、扩展 +- 全局状态依赖: 是否过度依赖全局变量,影响可测试性和并发安全性 +- 已知设计约束记录: 如无法避免的依赖倒置,标注为已知约束 + +OUTPUT: PASS or FAIL each with CRITICAL/MAJOR/MINOR severity, file:line reference, and concrete explanation +""") +``` + +### Agent 4: 攻击者测试 + +这是新增角色,专门从"破坏系统"角度审查。它不检查"代码好不好看",只检查"有什么方式能让系统坏掉"。 + +``` +task(subagent_type="oracle", load_skills=[], run_in_background=true, + description="Adversarial review of MODULE_NAME", + prompt=""" +ADVERSARIAL + DESTRUCTIVE TESTING +{MODULE_NAME} +{FILE_LIST} + +角色: 你是恶意攻击者/系统破坏者。你的目标是找到所有方式让这个模块出错、崩溃、数据损坏、或行为异常。 +你不关心代码风格或架构优雅性。只关心:**我怎么搞坏它?** + +通用要求: +R1. 时序场景模拟: + - 识别模块中所有共享状态(全局变量、sync.Map、channel、atomic 操作等) + - 列出每个共享状态的 所有读写操作 及其所在的函数/goroutine + - 模拟 2-3 个 goroutine 交错时序,找出可能的竞态窗口 + - 特别关注"先释放某资源 → 其他 goroutine 获取 → 原 goroutine 再次操作该资源"的模式 + +R2. 逐 return 路径 cleanup 验证: + - 对每个包含资源获取的函数,列出其所有 return 路径(正常结束、错误、超时、取消) + - 逐路径验证 cleanup 完整性:每个 return 是否释放了该路径上已获取的所有资源 + - 比较对称路径的 cleanup 是否一致(如 if/else 分支、循环内 break vs continue vs return) + +R3. 跨函数/跨文件共享状态生命周期追踪: + - 如果一个共享状态的生命周期跨越多个函数/文件 + - 追踪该状态的完整路径,检查每个跳转点的一致性 + - 特别关注状态通过事件/参数传递时,中间件或错误路径是否会中断传递 + +R4. 隐式假设陷阱扫描: + - 代码中的每个硬编码值(超时时长、重试次数/间隔、批大小、并发数、缓冲区容量、轮询频率)都隐含了一个对业务场景的假设。 + 对这些假设逐条问: + a) 这个值假定外部系统(AI API、DB、下游服务)的响应多快?负载多高?该假设在最坏情况下还成立吗? + b) 这个值假定同时存在多少个并发操作?如果同时有 100 个而不是 1 个,还成立吗? + c) 如果假设不成立,代码是"优雅退化"还是"直接中断"?中断后是否有补偿机制? + d) 代码是否将"正常情况"和"边界情况"用了同一个值? + e) 是否存在"读取时看起来安全,写入时暴露假设"的代码? + - 除了硬编码值,还有一类更隐蔽的隐式假设——**代码结构本身隐含的对系统行为的假设**。对以下每类逐条排查: + f) **事件/消息投递假设**: 是否假设"发布成功 = 一定被执行"?发布后的链路是否有超时/取消/panic 导致静默丢弃的路径?调用方的"成功日志"和实际执行之间有 gap 吗? + g) **并发与 goroutine 模型假设**: 是否假设其他 goroutine 一定活着?是否假设 state 在读和写之间不变?是否假设信号量/buffer 永远不会满? + h) **错误传播假设**: 是否用 `ctx.Err()` 代替了被包裹的底层 err?是否假设错误一定是某种特定类型?错误链路上是否有被吞没的中间错误? + i) **key/标识符空间隔离假设**: 不同用途的 key(如 `game:` vs `game:batch:`)是否可能碰撞?token/ID 的生成方式是否保证全局唯一? + j) **外部系统行为假设**: 是否假设外部 API 稳定返回特定格式?是否假设失败原因可被 binary split 重试解决?是否假设外部系统不会永久性失败? + k) **defer 注册时序假设**: 所有资源释放(锁、token、channel close、连接归还等)的 defer 是否在 `ctx.Done()` 检查之前注册?如果 defer 注册在 ctx 检查之后,ctx 恰好在这两步之间取消时,defer 不会执行,资源永久泄漏。审阅每条 early‑return 路径:确认 defer 注册 → 确认 ctx 检查在 defer 之后。 + l) **契约边界假设**: 找出所有仅靠"当前代码路径唯一"维持的不变量(如单次订阅、单点初始化、单消费者语义)。对每个不变量,追问:如果路径倍增(重复订阅/重入/串跑/重启),系统是快速失败还是静默损坏?是否有恢复机制?是否存在调用方已假定"约束永远成立"但实现层没有任何防御的断裂点? + m) **状态信息不完备假设**: 系统通过有限的状态信息(计数器、字段、信号量、缓存值)来代表真实世界。任何状态信息都可能因聚合粒度、传递损耗或更新延迟而与真实状态不符。对每个状态信息,追问:它在什么场景下会失准?失准时系统的行为是快速失败还是静默输出错误结果? + +找以下类别的破坏路径(每个类别给出具体时序): + +1. 并发破坏: + - 双释放:同一个资源被释放两次,第二次释放时已被其他人持有 + - 释放后使用:资源被释放后仍有代码路径访问它 + - 先读后写竞态:TOC/TOU (time-of-check vs time-of-use) + - 死锁/活锁/自旋:循环等待条件永远不满足 + - 优先级反转:高优先级任务被低优先级任务阻塞超过预期 + - ABA 问题:atomic.CompareAndSwap 的经典陷阱 + +2. 状态泄露: + - Defer/resource 泄露:某个 return 路径遗漏了资源释放 + - 对称性违反:A 路径有 cleanup,B 路径没有 + - 永久残留:某个 key/token 写入 map 后没有删除路径 + +3. 数据损坏: + - 并发写入同一条记录 + - 部分更新:一批操作中部分成功部分失败 + - 脏读:读到不完整的状态 + +4. 静默失败: + - 错误被 log 后继续执行(错误被吞没) + - 返回成功但实际未执行任何操作 + - 条件竞争导致跳过执行 + +5. 超时/取消不一致: + - 外层超时比内层短,导致内层操作被无故终止 + - 取消后状态未回滚 + - 超时后仍有 goroutine 在后台运行 + +OUTPUT: PASS or FAIL each with CRITICAL/HIGH/MEDIUM/LOW severity, file:line reference, concrete exploit scenario, and expected impact +""") +``` + +## 交叉验证 + +所有 Agent 返回 findings 后,Sisyphus 执行交叉验证: + +``` +交叉验证步骤: + +1. 收集 4 个 Agent 的所有 findings,去重合并 + +2. 让 Agent 4 审查 Agent 1-3 的 findings: + - 是否有 Agent 1-3 判定为 PASS 但 Agent 4 持怀疑态度的? + - 是否有 Agent 1-3 标记为 MINOR 但 Agent 4 认为可能是 MAJOR/CRITICAL 的? + - 输出: 补充遗漏、严重度修正建议 + +3. 让 Agent 1-3 审查 Agent 4 的 findings: + - 是否与 Agent 1-3 的已有发现重叠? + - 是否有 Agent 4 发现但其他 Agent 确实遗漏的关键问题? + - 输出: 去重后的新增 findings + +4. Sisyphus 逐条检查 findings 的判定质量(误判检测): + - 对每条 finding,检查是否有 Agent 给出了"底层操作安全,无实际 bug"类判定 + - 追问:该操作在 业务语义 上是否安全?(即是否可能破坏其他 goroutine 的状态) + - 如果发现误判 → 标注 MISJUDGMENT,该轮 G6 门禁 FAIL +``` + +交叉验证的输出格式: +``` + + + 严重度从 MINOR 修正为 MAJOR: ... + Agent 1-3 未发现的路径: ... + + + 与 agent1_finding_7 重复,合并 + 确认遗漏,补充到主清单 + + + + 原判定: "sync.Map.Delete 幂等安全,无实际 bug" + 纠正: 虽然 Delete 不 panic,但此时 map 中存的是其他 goroutine 的 token,删除它导致该 goroutine 状态泄露 + + + +``` + +## 修复 Agent 分流规则 + +| 问题规模 | Agent | 策略 | +|----------|-------|------| +| 单文件简单修改 | `category="quick"` | 逐一给出文件路径+行号+精确修改内容 | +| 多文件协调修改 | `category="quick"` 分批 | 按「修改的文件不重叠」原则并行 | +| 复杂逻辑重写 | `category="deep"` | 给出完整上下文和期望结果 | + +## 退出条件 + +循环终止条件(按优先级): + +| 优先级 | 条件 | 说明 | +|--------|------|------| +| 1 | **全部质量门禁通过** | 正常退出 — 质量达标 | +| 2 | **用户手动终止** | `stop` / `终止` / `暂停` | +| 3 | **僵局经用户决策终止** | 上报后用户选择「接受现状」或「改方案」 | + +**不存在「无新发现就自动停止」这条退路。** 只要门禁没全过,就继续循环。 +只有质量达标、用户叫停、或用户决策接受现状这三种情况才能终止。 + +## 最终报告模板 + +```markdown +# {MODULE_NAME} 自评审报告 + +## 总览 +- 模块: {MODULE_NAME} +- 发现模式: [包路径 / 功能模块探索 / 手动指定] +- 涉及包/服务: {PACKAGES / SERVICES} +- 轮次: {ROUNDS} +- 最终判定: PASS / FAIL +- 已修复: {FIXED_COUNT} 项 +- 已知设计约束: {CONSTRAINT_COUNT} 项 +- 审查 Agent: 4 个(正确性/安全+边界/架构+模式/攻击者测试) +- 交叉验证: [已执行 / 跳过] +- 误判检测: [无误判 / 发现 {N} 条误判并纠正] + +## 已修复问题 +| # | 严重度 | 描述 | 文件 | 修复方式 | +|---|--------|------|------|----------| + +## 误判纠正记录 +| # | 原判定 | 纠正后 | 描述 | +|---|--------|--------|------| + +## 已知设计约束 +| # | 描述 | 原因 | +|---|------|------| + +## 验证 +- build: {STATUS} +- test: {PASSED}/{TOTAL} +``` diff --git a/agents/SELF_REVIEW.md b/agents/SELF_REVIEW.md new file mode 100644 index 0000000..3e951e9 --- /dev/null +++ b/agents/SELF_REVIEW.md @@ -0,0 +1,558 @@ +# 通用功能模块自评审流程 + +> 一键触发:`ulw 启动自评审流程,目标:<包路径 | 功能模块描述>` +> 交互触发:`ulw 我要自评审`(逐步问答,无需记参数) +> 示例:`ulw 启动自评审流程,目标:core/helper/search` +> 示例:`ulw 启动自评审流程,目标:订单结算流程` +> 示例:`ulw 启动自评审流程,目标:库存同步与发货` + +## 触发方式 + +支持两种触发模式: + +### 模式 1:直接触发(参数完整) + +适合熟悉格式、一次性写全的场景。 + +Sisyphus 将根据输入自动判断目标类型: + +- **Go 包路径**(包含 `/` 或 `.`,如 `core/helper/search`)→ 走包发现模式 +- **功能模块描述**(自然语言,如 `订单结算流程`)→ 走功能模块发现模式 +- **手动指定文件清单**:在上述参数后追加 `|` 分隔的文件路径,跳过自动发现 + +``` +ulw 启动自评审流程,目标: [| file1.go,file2.go] [可选: 额外上下文] +``` + +额外上下文示例: +- `该模块负责订单结算,依赖 MySQL + Redis` +- `该模块是新增的,需要特别注意错误处理` +- `重点审查并发安全性` +- `涉及多服务交互:go-game-trade-serve → go-goods-serve` + +### 模式 2:交互式触发(推荐) + +记不住格式、或者想一步步来的时候用。无需记忆任何参数。 + +``` +ulw 我要自评审 +``` + +Sisyphus 收到后将通过对话逐项询问: + +1. **评审目标** — 包路径?功能模块描述?还是直接给文件列表? +2. **额外上下文** — 业务背景、关注重点、涉及的服务等 +3. **确认** — 展示理解到的目标,让用户确认后再启动 + +相当于把一次性参数填写变成了问答式引导,降低心智负担。 + +## Sisyphus 自动执行流程 + +Sisyphus 收到此请求后将执行: + +### Phase 0: 文件发现 + +**模式 A — 按包路径发现:** +- 解析目标包路径 +- `glob` + `grep` 发现包内所有 `.go` 文件 +- `grep` 发现包间引用关系 +- **反向发现调用者**:grep 搜索服务目录下哪些文件调用了目标包的导出函数/类型(如 `Start`、`TryPublish`、`Handle` 等入口),将这些调用者文件加入覆盖清单 + - 例:审查 `consumer/goods` 包时,发现调用它的 `game_trade.go`(API handler)和 producer 定时任务文件,加入审查范围 + - 反向发现确保端到端链路完整:调用者的日志级别、返回信息是否与模块实际行为一致 +- 汇总为「覆盖文件清单」 + +**模式 B — 按功能模块描述发现:** +- 启动 2 个 `explore` Agent 并行探索: + - Agent 1:根据功能描述在相关服务目录下搜索关键词、结构体、函数 + - Agent 2:根据功能描述搜索配置、路由注册、API 入口等外围文件 +- 合并结果去重,形成「覆盖文件清单」 +- 如发现跨服务调用,在报告中注明涉及的外部服务 + +**模式 C — 手动指定文件清单:** +- 跳过自动发现,直接使用用户提供的文件路径列表 +- 对每个文件做存在性验证,不存在的文件报告警告 + +### Phase 1: 基线检查 + +- 若覆盖文件清单归属单一服务或单一包 → `go build ./...` + `go test ./...` +- 若跨多个包 → 对每个涉及的独立包分别运行 build + test +- 失败则先不进入审查,报告用户 + +### Phase 2: 审查循环 (直至质量达标) + +循环核心原则:**不设轮次上限,只以质量门禁是否全部通过为终止条件。** + +#### 质量门禁(必须全部通过) + +| # | 门禁 | 判定方式 | 一票否决 | +|---|------|----------|----------| +| G1 | 代码正确性审查 verdict = PASS | Oracle Agent 1 | 是 | +| G2 | 安全+边界审查 verdict = PASS | Oracle Agent 2 | 是 | +| G3 | 架构+模式审查 verdict = PASS | Oracle Agent 3 | 是 | +| G4 | 攻击者测试 verdict = PASS | Oracle Agent 4 | 是 | +| G5 | 交叉验证 verdict = PASS | Sisyphus 对比 4 个 Agent 发现 | 是 | +| G6 | 误判检测 verdict = NO_MISJUDGMENT | Sisyphus 逐条复审 findings | 是 | +| G7 | 零 CRITICAL/MAJOR 残留 | 汇总所有 findings 检查 | 是 | +| G8 | 零回归问题 | 对比上一轮 findings,新引入的算回归 | 是 | +| G9 | go build 通过 | bash 执行 | 是 | +| G10 | go test 通过 | bash 执行 | 是 | + +所有门禁通过(PASS)才算质量达标,否则继续循环。 + +#### 循环流程 + +``` +第 N 轮: + ├─ Phase 2a: 并行审查 + │ ├─ 启动 4 个 Oracle 并行审查(使用 Phase 0 发现的文件清单): + │ │ bg_1: 代码正确性 (oracle, Agent 1) + │ │ bg_2: 安全 + 边界条件 (oracle, Agent 2) + │ │ bg_3: 架构 + 模式 (oracle, Agent 3) + │ │ bg_4: 攻击者测试 (oracle, Agent 4) + │ └─ 等待全部完成 + ├─ Phase 2b: 交叉验证 + │ ├─ Agent 4 审查 Agent 1-3 的发现补充遗漏 + │ ├─ Agent 1-3 审查 Agent 4 的发现去重 + │ └─ Sisyphus 汇总生成完整 findings 清单 + ├─ Phase 2c: 质量门禁检查 + │ ├─ 全部 10 项 PASS → 输出最终报告,循环终止 + │ └─ 有 FAIL 项 → 进入修复流程 + ├─ 修复流程: + │ ├─ 回归问题优先修复(先还旧债,再修新债) + │ ├─ 误判优先复查(G6 FAIL 说明某条 finding 被错误判定,先纠正) + │ ├─ 按类型分流到修复 Agent: + │ │ 单文件修改 → category="quick" + │ │ 多文件/复杂 → category="deep" + │ └─ 修复后执行 build + test + ├─ 如果连续 3 轮同一门禁 FAIL(僵局处理): + │ ├─ 启动 Oracle 深度诊断,分析为什么反复修不好 + │ ├─ 输出根因分析 + 替代方案 + │ └─ 上报用户决策:继续修 / 接受现状 / 改方案 + └─ 进入第 N+1 轮 +``` + +#### 僵局处理(Escalation) + +当连续 3 轮同一门禁 FAIL 时,说明常规修复手段无效。此时: + +1. **暂停修复**,不自欺欺人继续打补丁 +2. **启动 Oracle 深度诊断**,分析根因: + - 是设计缺陷导致修不好?(如:当前架构本身就不安全) + - 是修复引入了新问题?(如:为了修 A 破坏了 B) + - 是审查标准不合理?(如:过于理想化,与现有代码风格冲突) +3. **输出根因分析报告**,给出 2-3 个可选方案 +4. **上报用户**,由用户决策下一步方向 + +### Phase 3: 输出报告 + +## 审查 Agent 提示词模板 + +Sisyphus 将「覆盖文件清单」和模块上下文代入以下模板。 + +**所有 Agent 的 prompt 均包含四个通用要求(在模板中已内置):** + +``` +通用要求(对所有维度均适用): +R1. 时序场景模拟: + - 识别模块中所有共享状态(全局变量、sync.Map、channel、atomic 操作等) + - 列出每个共享状态的 所有读写操作 及其所在的函数/goroutine + - 模拟 2-3 个 goroutine 交错时序,找出可能的竞态窗口 + - 特别关注"先释放某资源 → 其他 goroutine 获取 → 原 goroutine 再次操作该资源"的模式 + +R2. 逐 return 路径 cleanup 验证: + - 对每个包含资源获取的函数,列出其所有 return 路径(正常结束、错误、超时、取消) + - 逐路径验证 cleanup 完整性:每个 return 是否释放了该路径上已获取的所有资源 + - 比较对称路径的 cleanup 是否一致(如 if/else 分支、循环内 break vs continue vs return) + +R3. 跨函数/跨文件共享状态生命周期追踪: + - 如果一个共享状态的生命周期跨越多个函数/文件(如 producer 写入 → event bus → consumer 读取并释放) + - 追踪该状态的完整路径,检查每个跳转点的一致性 + - 特别关注状态通过事件/参数传递时,中间件或错误路径是否会中断传递 + +R4. 隐式假设陷阱扫描: + - 代码中的每个硬编码值(超时时长、重试次数/间隔、批大小、并发数、缓冲区容量、轮询频率)都隐含了一个对业务场景的假设。 + 对这些假设逐条问: + a) 这个值假定外部系统(AI API、DB、下游服务)的响应多快?负载多高?该假设在最坏情况下还成立吗? + b) 这个值假定同时存在多少个并发操作?如果同时有 100 个而不是 1 个,还成立吗? + c) 如果假设不成立,代码是"优雅退化"还是"直接中断"?中断后是否有补偿机制? + d) 代码是否将"正常情况"和"边界情况"用了同一个值?(例如:重获取锁超时 = 5 秒,假设 priority 在 5 秒内跑完;但 priority 实际耗时可达 30 分钟——正常路径和边界路径混用了同一套超时) + e) 是否存在"读取时看起来安全,写入时暴露假设"的代码?(例如:`LoadInt64 > 0` 的检查与后续操作之间假设了状态不变) + - 除了硬编码值,还有一类更隐蔽的隐式假设——**代码结构本身隐含的对系统行为的假设**。对以下每类逐条排查: + f) **事件/消息投递假设**: 是否假设"发布成功 = 一定被执行"?发布后的链路是否有超时/取消/panic 导致静默丢弃的路径?调用方的"成功日志"和实际执行之间有 gap 吗? + g) **并发与 goroutine 模型假设**: 是否假设其他 goroutine 一定活着?是否假设 state 在读和写之间不变?是否假设信号量/buffer 永远不会满? + h) **错误传播假设**: 是否用 `ctx.Err()` 代替了被包裹的底层 err?是否假设错误一定是某种特定类型?错误链路上是否有被吞没的中间错误? + i) **key/标识符空间隔离假设**: 不同用途的 key(如 `game:` vs `game:batch:`)是否可能碰撞?token/ID 的生成方式是否保证全局唯一? + j) **外部系统行为假设**: 是否假设外部 API 稳定返回特定格式?是否假设失败原因可被 binary split 重试解决?是否假设外部系统不会永久性失败? + k) **defer 注册时序假设**: 所有资源释放(锁、token、channel close、连接归还等)的 defer 是否在 `ctx.Done()` 检查之前注册?如果 defer 注册在 ctx 检查之后,ctx 恰好在这两步之间取消时,defer 不会执行,资源永久泄漏。审阅每条 early‑return 路径:确认 defer 注册 → 确认 ctx 检查在 defer 之后。 + l) **契约边界假设**: 找出所有仅靠"当前代码路径唯一"维持的不变量(如单次订阅、单点初始化、单消费者语义)。对每个不变量,追问:如果路径倍增(重复订阅/重入/串跑/重启),系统是快速失败还是静默损坏?是否有恢复机制?是否存在调用方已假定"约束永远成立"但实现层没有任何防御的断裂点? + m) **状态信息不完备假设**: 系统通过有限的状态信息(计数器、字段、信号量、缓存值)来代表真实世界。任何状态信息都可能因聚合粒度、传递损耗或更新延迟而与真实状态不符。对每个状态信息,追问:它在什么场景下会失准?失准时系统的行为是快速失败还是静默输出错误结果? + +``` + +### Agent 1: 代码正确性 + +``` +task(subagent_type="oracle", load_skills=[], run_in_background=true, + description="Review correctness of MODULE_NAME", + prompt=""" +CODE CORRECTNESS + QUALITY REVIEW +{MODULE_NAME} +{NEWLINE_SEPARATED_FILE_LIST_WITH_FULL_CONTENT} +{MODULE_SPECIFIC_CONTEXT_FROM_USER} + +通用要求: +R1. 时序场景模拟: + - 识别模块中所有共享状态(全局变量、sync.Map、channel、atomic 操作等) + - 列出每个共享状态的 所有读写操作 及其所在的函数/goroutine + - 模拟 2-3 个 goroutine 交错时序,找出可能的竞态窗口 + - 特别关注"先释放某资源 → 其他 goroutine 获取 → 原 goroutine 再次操作该资源"的模式 + +R2. 逐 return 路径 cleanup 验证: + - 对每个包含资源获取的函数,列出其所有 return 路径(正常结束、错误、超时、取消) + - 逐路径验证 cleanup 完整性:每个 return 是否释放了该路径上已获取的所有资源 + - 比较对称路径的 cleanup 是否一致(如 if/else 分支、循环内 break vs continue vs return) + +R3. 跨函数/跨文件共享状态生命周期追踪: + - 如果一个共享状态的生命周期跨越多个函数/文件 + - 追踪该状态的完整路径,检查每个跳转点的一致性 + - 特别关注状态通过事件/参数传递时,中间件或错误路径是否会中断传递 + +R4. 隐式假设陷阱扫描: + - 代码中的每个硬编码值(超时时长、重试次数/间隔、批大小、并发数、缓冲区容量、轮询频率)都隐含了一个对业务场景的假设。 + 对这些假设逐条问: + a) 这个值假定外部系统(AI API、DB、下游服务)的响应多快?负载多高?该假设在最坏情况下还成立吗? + b) 这个值假定同时存在多少个并发操作?如果同时有 100 个而不是 1 个,还成立吗? + c) 如果假设不成立,代码是"优雅退化"还是"直接中断"?中断后是否有补偿机制? + d) 代码是否将"正常情况"和"边界情况"用了同一个值? + e) 是否存在"读取时看起来安全,写入时暴露假设"的代码? + - 除了硬编码值,还有一类更隐蔽的隐式假设——**代码结构本身隐含的对系统行为的假设**。对以下每类逐条排查: + f) **事件/消息投递假设**: 是否假设"发布成功 = 一定被执行"?发布后的链路是否有超时/取消/panic 导致静默丢弃的路径?调用方的"成功日志"和实际执行之间有 gap 吗? + g) **并发与 goroutine 模型假设**: 是否假设其他 goroutine 一定活着?是否假设 state 在读和写之间不变?是否假设信号量/buffer 永远不会满? + h) **错误传播假设**: 是否用 `ctx.Err()` 代替了被包裹的底层 err?是否假设错误一定是某种特定类型?错误链路上是否有被吞没的中间错误? + i) **key/标识符空间隔离假设**: 不同用途的 key(如 `game:` vs `game:batch:`)是否可能碰撞?token/ID 的生成方式是否保证全局唯一? + j) **外部系统行为假设**: 是否假设外部 API 稳定返回特定格式?是否假设失败原因可被 binary split 重试解决?是否假设外部系统不会永久性失败? + k) **defer 注册时序假设**: 所有资源释放(锁、token、channel close、连接归还等)的 defer 是否在 `ctx.Done()` 检查之前注册?如果 defer 注册在 ctx 检查之后,ctx 恰好在这两步之间取消时,defer 不会执行,资源永久泄漏。审阅每条 early‑return 路径:确认 defer 注册 → 确认 ctx 检查在 defer 之后。 + l) **契约边界假设**: 找出所有仅靠"当前代码路径唯一"维持的不变量(如单次订阅、单点初始化、单消费者语义)。对每个不变量,追问:如果路径倍增(重复订阅/重入/串跑/重启),系统是快速失败还是静默损坏?是否有恢复机制?是否存在调用方已假定"约束永远成立"但实现层没有任何防御的断裂点? + m) **状态信息不完备假设**: 系统通过有限的状态信息(计数器、字段、信号量、缓存值)来代表真实世界。任何状态信息都可能因聚合粒度、传递损耗或更新延迟而与真实状态不符。对每个状态信息,追问:它在什么场景下会失准?失准时系统的行为是快速失败还是静默输出错误结果? +Review for: +- Logic errors +- Concurrency issues: 死锁、活锁、竞态条件、双释放、ABA 问题、原子操作误用 +- 共享的 sync.Map/atomic.Pointer 等原语:区分"操作本身安全"和"业务语义安全" + (例如:sync.Map.Delete 是幂等的,但如果当前存储的是其他 goroutine 的 token, + 删除它就破坏了其他持有者的锁 — 这种"跨 goroutine 的语义安全性") +- defer cleanup 的覆盖完整性:所有获取操作是否有对应的 defer/手动释放 +- Error handling gaps: 错误被吞没、错误类型误判(如用 ctx.Err() 代替被包裹的 err) +- Data integrity risks +- Nil pointer dereference potential +- Dead code + +OUTPUT: PASS or FAIL each with CRITICAL/MAJOR/MINOR severity, file:line reference, and concrete explanation +""") +``` + +### Agent 2: 安全 + 边界条件 + +``` +task(subagent_type="oracle", load_skills=[], run_in_background=true, + description="Review security of MODULE_NAME", + prompt=""" +SECURITY + EDGE CASE REVIEW +{MODULE_NAME} +{FILE_LIST} + +通用要求: +R1. 时序场景模拟: + - 识别模块中所有共享状态(全局变量、sync.Map、channel、atomic 操作等) + - 列出每个共享状态的 所有读写操作 及其所在的函数/goroutine + - 模拟 2-3 个 goroutine 交错时序,找出可能的竞态窗口 + - 特别关注"先释放某资源 → 其他 goroutine 获取 → 原 goroutine 再次操作该资源"的模式 + +R2. 逐 return 路径 cleanup 验证: + - 对每个包含资源获取的函数,列出其所有 return 路径(正常结束、错误、超时、取消) + - 逐路径验证 cleanup 完整性:每个 return 是否释放了该路径上已获取的所有资源 + - 比较对称路径的 cleanup 是否一致(如 if/else 分支、循环内 break vs continue vs return) + +R3. 跨函数/跨文件共享状态生命周期追踪: + - 如果一个共享状态的生命周期跨越多个函数/文件 + - 追踪该状态的完整路径,检查每个跳转点的一致性 + - 特别关注状态通过事件/参数传递时,中间件或错误路径是否会中断传递 + +R4. 隐式假设陷阱扫描: + - 代码中的每个硬编码值(超时时长、重试次数/间隔、批大小、并发数、缓冲区容量、轮询频率)都隐含了一个对业务场景的假设。 + 对这些假设逐条问: + a) 这个值假定外部系统(AI API、DB、下游服务)的响应多快?负载多高?该假设在最坏情况下还成立吗? + b) 这个值假定同时存在多少个并发操作?如果同时有 100 个而不是 1 个,还成立吗? + c) 如果假设不成立,代码是"优雅退化"还是"直接中断"?中断后是否有补偿机制? + d) 代码是否将"正常情况"和"边界情况"用了同一个值? + e) 是否存在"读取时看起来安全,写入时暴露假设"的代码? + - 除了硬编码值,还有一类更隐蔽的隐式假设——**代码结构本身隐含的对系统行为的假设**。对以下每类逐条排查: + f) **事件/消息投递假设**: 是否假设"发布成功 = 一定被执行"?发布后的链路是否有超时/取消/panic 导致静默丢弃的路径?调用方的"成功日志"和实际执行之间有 gap 吗? + g) **并发与 goroutine 模型假设**: 是否假设其他 goroutine 一定活着?是否假设 state 在读和写之间不变?是否假设信号量/buffer 永远不会满? + h) **错误传播假设**: 是否用 `ctx.Err()` 代替了被包裹的底层 err?是否假设错误一定是某种特定类型?错误链路上是否有被吞没的中间错误? + i) **key/标识符空间隔离假设**: 不同用途的 key(如 `game:` vs `game:batch:`)是否可能碰撞?token/ID 的生成方式是否保证全局唯一? + j) **外部系统行为假设**: 是否假设外部 API 稳定返回特定格式?是否假设失败原因可被 binary split 重试解决?是否假设外部系统不会永久性失败? + k) **defer 注册时序假设**: 所有资源释放(锁、token、channel close、连接归还等)的 defer 是否在 `ctx.Done()` 检查之前注册?如果 defer 注册在 ctx 检查之后,ctx 恰好在这两步之间取消时,defer 不会执行,资源永久泄漏。审阅每条 early‑return 路径:确认 defer 注册 → 确认 ctx 检查在 defer 之后。 + l) **契约边界假设**: 找出所有仅靠"当前代码路径唯一"维持的不变量(如单次订阅、单点初始化、单消费者语义)。对每个不变量,追问:如果路径倍增(重复订阅/重入/串跑/重启),系统是快速失败还是静默损坏?是否有恢复机制?是否存在调用方已假定"约束永远成立"但实现层没有任何防御的断裂点? + m) **状态信息不完备假设**: 系统通过有限的状态信息(计数器、字段、信号量、缓存值)来代表真实世界。任何状态信息都可能因聚合粒度、传递损耗或更新延迟而与真实状态不符。对每个状态信息,追问:它在什么场景下会失准?失准时系统的行为是快速失败还是静默输出错误结果? + +Review for: +- Input validation 的覆盖面和充分性 +- Injection risks (SQL/命令/prompt injection 等) +- Secrets exposure (API key 是否被意外记入日志) +- DoS vectors(无界 goroutine、内存爆炸、死循环、无穷递归) +- 操作幂等性 vs 业务语义安全性的区别 + (例如:db.Delete 不报错 ≠ 业务语义正确;map.Delete 不 panic ≠ 没破坏其他持有者的状态) +- Edge cases: empty inputs, max-size, unicode, zero values, negative values, context cancelled during write +- Panic paths: 是否有未 recover 的 panic 点 +- Resource leaks: HTTP body, goroutine, channel, semaphore +- Timeout handling: 内外层超时不匹配、超时后状态不一致 +- 攻击者视角:假设调用者可以控制输入参数,找到所有利用路径 + +OUTPUT: PASS or FAIL each with CRITICAL/HIGH/MEDIUM/LOW severity, file:line reference, and concrete explanation +""") +``` + +### Agent 3: 架构 + 模式 + +``` +task(subagent_type="oracle", load_skills=[], run_in_background=true, + description="Review architecture of MODULE_NAME", + prompt=""" +ARCHITECTURE + PATTERN REVIEW +{MODULE_NAME} +{FILE_LIST} + +通用要求: +R1. 时序场景模拟: + - 识别模块中所有共享状态(全局变量、sync.Map、channel、atomic 操作等) + - 列出每个共享状态的 所有读写操作 及其所在的函数/goroutine + - 模拟 2-3 个 goroutine 交错时序,找出可能的竞态窗口 + - 特别关注"先释放某资源 → 其他 goroutine 获取 → 原 goroutine 再次操作该资源"的模式 + +R2. 逐 return 路径 cleanup 验证: + - 对每个包含资源获取的函数,列出其所有 return 路径(正常结束、错误、超时、取消) + - 逐路径验证 cleanup 完整性:每个 return 是否释放了该路径上已获取的所有资源 + - 比较对称路径的 cleanup 是否一致(如 if/else 分支、循环内 break vs continue vs return) + +R3. 跨函数/跨文件共享状态生命周期追踪: + - 如果一个共享状态的生命周期跨越多个函数/文件 + - 追踪该状态的完整路径,检查每个跳转点的一致性 + - 特别关注状态通过事件/参数传递时,中间件或错误路径是否会中断传递 + +R4. 隐式假设陷阱扫描: + - 代码中的每个硬编码值(超时时长、重试次数/间隔、批大小、并发数、缓冲区容量、轮询频率)都隐含了一个对业务场景的假设。 + 对这些假设逐条问: + a) 这个值假定外部系统(AI API、DB、下游服务)的响应多快?负载多高?该假设在最坏情况下还成立吗? + b) 这个值假定同时存在多少个并发操作?如果同时有 100 个而不是 1 个,还成立吗? + c) 如果假设不成立,代码是"优雅退化"还是"直接中断"?中断后是否有补偿机制? + d) 代码是否将"正常情况"和"边界情况"用了同一个值? + e) 是否存在"读取时看起来安全,写入时暴露假设"的代码? + - 除了硬编码值,还有一类更隐蔽的隐式假设——**代码结构本身隐含的对系统行为的假设**。对以下每类逐条排查: + f) **事件/消息投递假设**: 是否假设"发布成功 = 一定被执行"?发布后的链路是否有超时/取消/panic 导致静默丢弃的路径?调用方的"成功日志"和实际执行之间有 gap 吗? + g) **并发与 goroutine 模型假设**: 是否假设其他 goroutine 一定活着?是否假设 state 在读和写之间不变?是否假设信号量/buffer 永远不会满? + h) **错误传播假设**: 是否用 `ctx.Err()` 代替了被包裹的底层 err?是否假设错误一定是某种特定类型?错误链路上是否有被吞没的中间错误? + i) **key/标识符空间隔离假设**: 不同用途的 key(如 `game:` vs `game:batch:`)是否可能碰撞?token/ID 的生成方式是否保证全局唯一? + j) **外部系统行为假设**: 是否假设外部 API 稳定返回特定格式?是否假设失败原因可被 binary split 重试解决?是否假设外部系统不会永久性失败? + k) **defer 注册时序假设**: 所有资源释放(锁、token、channel close、连接归还等)的 defer 是否在 `ctx.Done()` 检查之前注册?如果 defer 注册在 ctx 检查之后,ctx 恰好在这两步之间取消时,defer 不会执行,资源永久泄漏。审阅每条 early‑return 路径:确认 defer 注册 → 确认 ctx 检查在 defer 之后。 + l) **契约边界假设**: 找出所有仅靠"当前代码路径唯一"维持的不变量(如单次订阅、单点初始化、单消费者语义)。对每个不变量,追问:如果路径倍增(重复订阅/重入/串跑/重启),系统是快速失败还是静默损坏?是否有恢复机制?是否存在调用方已假定"约束永远成立"但实现层没有任何防御的断裂点? + m) **状态信息不完备假设**: 系统通过有限的状态信息(计数器、字段、信号量、缓存值)来代表真实世界。任何状态信息都可能因聚合粒度、传递损耗或更新延迟而与真实状态不符。对每个状态信息,追问:它在什么场景下会失准?失准时系统的行为是快速失败还是静默输出错误结果? + +Review for: +- Package structure: 依赖方向是否清晰,是否存在循环依赖 +- 对称性检查: 是否有类似的代码路径(如 A vs B、game vs price、producer vs consumer) + 它们的 cleanup/错误处理是否一致?不一致的差异是否有正当理由? +- 代码重复: 哪些可以抽象复用,哪些是必要差异 +- Over-engineering: 是否存在不必要复杂度 +- Dead code: 未使用的函数、类型、字段、常量 +- 常量组织: 分散或重复的常量、硬编码值 +- Interface design: 是否利于测试(mockable)、扩展 +- 全局状态依赖: 是否过度依赖全局变量,影响可测试性和并发安全性 +- 已知设计约束记录: 如无法避免的依赖倒置,标注为已知约束 + +OUTPUT: PASS or FAIL each with CRITICAL/MAJOR/MINOR severity, file:line reference, and concrete explanation +""") +``` + +### Agent 4: 攻击者测试 + +这是新增角色,专门从"破坏系统"角度审查。它不检查"代码好不好看",只检查"有什么方式能让系统坏掉"。 + +``` +task(subagent_type="oracle", load_skills=[], run_in_background=true, + description="Adversarial review of MODULE_NAME", + prompt=""" +ADVERSARIAL + DESTRUCTIVE TESTING +{MODULE_NAME} +{FILE_LIST} + +角色: 你是恶意攻击者/系统破坏者。你的目标是找到所有方式让这个模块出错、崩溃、数据损坏、或行为异常。 +你不关心代码风格或架构优雅性。只关心:**我怎么搞坏它?** + +通用要求: +R1. 时序场景模拟: + - 识别模块中所有共享状态(全局变量、sync.Map、channel、atomic 操作等) + - 列出每个共享状态的 所有读写操作 及其所在的函数/goroutine + - 模拟 2-3 个 goroutine 交错时序,找出可能的竞态窗口 + - 特别关注"先释放某资源 → 其他 goroutine 获取 → 原 goroutine 再次操作该资源"的模式 + +R2. 逐 return 路径 cleanup 验证: + - 对每个包含资源获取的函数,列出其所有 return 路径(正常结束、错误、超时、取消) + - 逐路径验证 cleanup 完整性:每个 return 是否释放了该路径上已获取的所有资源 + - 比较对称路径的 cleanup 是否一致(如 if/else 分支、循环内 break vs continue vs return) + +R3. 跨函数/跨文件共享状态生命周期追踪: + - 如果一个共享状态的生命周期跨越多个函数/文件 + - 追踪该状态的完整路径,检查每个跳转点的一致性 + - 特别关注状态通过事件/参数传递时,中间件或错误路径是否会中断传递 + +R4. 隐式假设陷阱扫描: + - 代码中的每个硬编码值(超时时长、重试次数/间隔、批大小、并发数、缓冲区容量、轮询频率)都隐含了一个对业务场景的假设。 + 对这些假设逐条问: + a) 这个值假定外部系统(AI API、DB、下游服务)的响应多快?负载多高?该假设在最坏情况下还成立吗? + b) 这个值假定同时存在多少个并发操作?如果同时有 100 个而不是 1 个,还成立吗? + c) 如果假设不成立,代码是"优雅退化"还是"直接中断"?中断后是否有补偿机制? + d) 代码是否将"正常情况"和"边界情况"用了同一个值? + e) 是否存在"读取时看起来安全,写入时暴露假设"的代码? + - 除了硬编码值,还有一类更隐蔽的隐式假设——**代码结构本身隐含的对系统行为的假设**。对以下每类逐条排查: + f) **事件/消息投递假设**: 是否假设"发布成功 = 一定被执行"?发布后的链路是否有超时/取消/panic 导致静默丢弃的路径?调用方的"成功日志"和实际执行之间有 gap 吗? + g) **并发与 goroutine 模型假设**: 是否假设其他 goroutine 一定活着?是否假设 state 在读和写之间不变?是否假设信号量/buffer 永远不会满? + h) **错误传播假设**: 是否用 `ctx.Err()` 代替了被包裹的底层 err?是否假设错误一定是某种特定类型?错误链路上是否有被吞没的中间错误? + i) **key/标识符空间隔离假设**: 不同用途的 key(如 `game:` vs `game:batch:`)是否可能碰撞?token/ID 的生成方式是否保证全局唯一? + j) **外部系统行为假设**: 是否假设外部 API 稳定返回特定格式?是否假设失败原因可被 binary split 重试解决?是否假设外部系统不会永久性失败? + k) **defer 注册时序假设**: 所有资源释放(锁、token、channel close、连接归还等)的 defer 是否在 `ctx.Done()` 检查之前注册?如果 defer 注册在 ctx 检查之后,ctx 恰好在这两步之间取消时,defer 不会执行,资源永久泄漏。审阅每条 early‑return 路径:确认 defer 注册 → 确认 ctx 检查在 defer 之后。 + l) **契约边界假设**: 找出所有仅靠"当前代码路径唯一"维持的不变量(如单次订阅、单点初始化、单消费者语义)。对每个不变量,追问:如果路径倍增(重复订阅/重入/串跑/重启),系统是快速失败还是静默损坏?是否有恢复机制?是否存在调用方已假定"约束永远成立"但实现层没有任何防御的断裂点? + m) **状态信息不完备假设**: 系统通过有限的状态信息(计数器、字段、信号量、缓存值)来代表真实世界。任何状态信息都可能因聚合粒度、传递损耗或更新延迟而与真实状态不符。对每个状态信息,追问:它在什么场景下会失准?失准时系统的行为是快速失败还是静默输出错误结果? + +找以下类别的破坏路径(每个类别给出具体时序): + +1. 并发破坏: + - 双释放:同一个资源被释放两次,第二次释放时已被其他人持有 + - 释放后使用:资源被释放后仍有代码路径访问它 + - 先读后写竞态:TOC/TOU (time-of-check vs time-of-use) + - 死锁/活锁/自旋:循环等待条件永远不满足 + - 优先级反转:高优先级任务被低优先级任务阻塞超过预期 + - ABA 问题:atomic.CompareAndSwap 的经典陷阱 + +2. 状态泄露: + - Defer/resource 泄露:某个 return 路径遗漏了资源释放 + - 对称性违反:A 路径有 cleanup,B 路径没有 + - 永久残留:某个 key/token 写入 map 后没有删除路径 + +3. 数据损坏: + - 并发写入同一条记录 + - 部分更新:一批操作中部分成功部分失败 + - 脏读:读到不完整的状态 + +4. 静默失败: + - 错误被 log 后继续执行(错误被吞没) + - 返回成功但实际未执行任何操作 + - 条件竞争导致跳过执行 + +5. 超时/取消不一致: + - 外层超时比内层短,导致内层操作被无故终止 + - 取消后状态未回滚 + - 超时后仍有 goroutine 在后台运行 + +OUTPUT: PASS or FAIL each with CRITICAL/HIGH/MEDIUM/LOW severity, file:line reference, concrete exploit scenario, and expected impact +""") +``` + +## 交叉验证 + +所有 Agent 返回 findings 后,Sisyphus 执行交叉验证: + +``` +交叉验证步骤: + +1. 收集 4 个 Agent 的所有 findings,去重合并 + +2. 让 Agent 4 审查 Agent 1-3 的 findings: + - 是否有 Agent 1-3 判定为 PASS 但 Agent 4 持怀疑态度的? + - 是否有 Agent 1-3 标记为 MINOR 但 Agent 4 认为可能是 MAJOR/CRITICAL 的? + - 输出: 补充遗漏、严重度修正建议 + +3. 让 Agent 1-3 审查 Agent 4 的 findings: + - 是否与 Agent 1-3 的已有发现重叠? + - 是否有 Agent 4 发现但其他 Agent 确实遗漏的关键问题? + - 输出: 去重后的新增 findings + +4. Sisyphus 逐条检查 findings 的判定质量(误判检测): + - 对每条 finding,检查是否有 Agent 给出了"底层操作安全,无实际 bug"类判定 + - 追问:该操作在 业务语义 上是否安全?(即是否可能破坏其他 goroutine 的状态) + - 如果发现误判 → 标注 MISJUDGMENT,该轮 G6 门禁 FAIL +``` + +交叉验证的输出格式: +``` + + + 严重度从 MINOR 修正为 MAJOR: ... + Agent 1-3 未发现的路径: ... + + + 与 agent1_finding_7 重复,合并 + 确认遗漏,补充到主清单 + + + + 原判定: "sync.Map.Delete 幂等安全,无实际 bug" + 纠正: 虽然 Delete 不 panic,但此时 map 中存的是其他 goroutine 的 token,删除它导致该 goroutine 状态泄露 + + + +``` + +## 修复 Agent 分流规则 + +| 问题规模 | Agent | 策略 | +|----------|-------|------| +| 单文件简单修改 | `category="quick"` | 逐一给出文件路径+行号+精确修改内容 | +| 多文件协调修改 | `category="quick"` 分批 | 按「修改的文件不重叠」原则并行 | +| 复杂逻辑重写 | `category="deep"` | 给出完整上下文和期望结果 | + +## 退出条件 + +循环终止条件(按优先级): + +| 优先级 | 条件 | 说明 | +|--------|------|------| +| 1 | **全部质量门禁通过** | 正常退出 — 质量达标 | +| 2 | **用户手动终止** | `stop` / `终止` / `暂停` | +| 3 | **僵局经用户决策终止** | 上报后用户选择「接受现状」或「改方案」 | + +**不存在「无新发现就自动停止」这条退路。** 只要门禁没全过,就继续循环。 +只有质量达标、用户叫停、或用户决策接受现状这三种情况才能终止。 + +## 最终报告模板 + +```markdown +# {MODULE_NAME} 自评审报告 + +## 总览 +- 模块: {MODULE_NAME} +- 发现模式: [包路径 / 功能模块探索 / 手动指定] +- 涉及包/服务: {PACKAGES / SERVICES} +- 轮次: {ROUNDS} +- 最终判定: PASS / FAIL +- 已修复: {FIXED_COUNT} 项 +- 已知设计约束: {CONSTRAINT_COUNT} 项 +- 审查 Agent: 4 个(正确性/安全+边界/架构+模式/攻击者测试) +- 交叉验证: [已执行 / 跳过] +- 误判检测: [无误判 / 发现 {N} 条误判并纠正] + +## 已修复问题 +| # | 严重度 | 描述 | 文件 | 修复方式 | +|---|--------|------|------|----------| + +## 误判纠正记录 +| # | 原判定 | 纠正后 | 描述 | +|---|--------|--------|------| + +## 已知设计约束 +| # | 描述 | 原因 | +|---|------|------| + +## 验证 +- build: {STATUS} +- test: {PASSED}/{TOTAL} +``` diff --git a/agents/plan-mode.md b/agents/plan-mode.md new file mode 100644 index 0000000..f7209d3 --- /dev/null +++ b/agents/plan-mode.md @@ -0,0 +1,228 @@ +# Plan Mode 工作流 + +> 先计划、后执行。每次非平凡任务必须经过 P → R → A → E 四阶段闭环。 +> 建立日期:2026-05-09 + +--- + +## 1. 触发方式 + +### 方式 A — 显式触发 + +``` +ulw plan <需求描述> +``` + +### 方式 B — 隐式触发 + +以下条件任一满足时自动进入 Plan Mode: +- 涉及 2+ 个文件的改动 +- 涉及架构决策 +- 涉及跨服务修改 +- 涉及新的业务流程 + +--- + +## 2. 四阶段工作流 + +``` +P 阶段 (Plan) ──→ R 阶段 (Review) ──→ A 阶段 (Approve) ──→ E 阶段 (Execute) + │ │ │ │ + │ 我出计划 │ 你审反馈 │ 你签字审批 │ 我执行 + │ 结构化文档 │ 逐条过 │ 无遗留问题 │ 按计划推进 + └───────────────────┴────────────────────┴────────────────────┴────────── +``` + +--- + +## 3. P 阶段:计划制定 + +### 3.1 产出物 + +计划文档,写入 `.sisyphus/plans/{task-name}.md`,包含 6 个章节: + +| 章节 | 内容 | +|------|------| +| 1. 需求理解 | 我对需求的重新表述 + 待确认疑问 | +| 2. 范围界定 | In Scope / Out of Scope 清单 | +| 3. 技术方案 | 架构决策、修改文件、技术路线 | +| 4. 任务分解 | 按 Wave(并行批次)拆分的任务列表,每任务含:文件、改动描述、依赖 | +| 5. 风险点 | 潜在风险、回滚方案、需要关注的点 | +| 6. 验证计划 | 如何验证每个改动正确 | + +### 3.2 内部流程 + +``` +P0: 需求澄清(如有歧义 → 提问) +P1: 探索代码库(explore agent 并行搜索相关代码、模式、依赖) +P2: 复杂架构 → 咨询 Oracle(技术方案评审) +P3: 撰写计划文档(上述 6 个章节) +P4: 输出给用户审核 +``` + +### 3.3 执行完成标准(通用门禁) + +``` +- [ ] 所有任务标记完成 +- [ ] 项目构建通过(按项目实际工具:go build / npm run build / cargo build / 等) +- [ ] 项目测试通过(按项目实际工具:go test / npm test / pytest / 等) +- [ ] LSP 诊断无新增错误 +- [ ] 改动的代码通过了手动验证(见验证计划各条) +``` + +--- + +### 3.4 计划文档模板 + +```markdown +# Plan: {任务名称} + +## 1. 需求理解 +{我对需求的重新表述} + +## 2. 范围界定 +### In Scope +- {list} +### Out of Scope +- {list} + +## 3. 技术方案 +{架构图/决策说明/文件清单} + +## 4. 任务分解 + +| Wave | Task ID | 描述 | 文件 | 依赖 | 预期产出 | +|------|---------|------|------|------|----------| +| 1 | T1 | ... | ... | 无 | ... | +| 1 | T2 | ... | ... | 无 | ... | +| 2 | T3 | ... | ... | T1,T2 | ... | + +## 5. 风险点 +| 风险 | 概率 | 影响 | 缓解措施 | +|------|------|------|----------| +| ... | 高/中/低 | ... | ... | + +## 6. 验证计划 +| 验证项 | 方法(按项目实际工具填写) | 预期结果 | +|--------|---------------------------|----------| +| 编译 | {go build / npm run build / cargo build / ...} | exit 0 | +| 单元测试 | {go test / npm test / pytest / ...} | 全部通过 | +| Lint/类型检查 | {golangci-lint / tsc / mypy / ...} | 无新增问题 | +| 手动验证 | {按功能描述执行的操作步骤} | 符合预期 | +``` + +--- + +## 4. R 阶段:审核反馈 + +### 角色分工 + +- **你**:逐章节审阅计划,给出修改意见,指出遗漏,调整优先级 +- **我**:根据反馈更新计划文档,对每条反馈回应(接受/解释/替代方案),方案重大变更则重新咨询 Oracle + +### 你说话的格式 + +``` +plan 反馈: +1. [章节名] 第X条:建议改为... +2. [风险] 漏掉了YY场景 +3. [任务分解] Wave 2 应该先于 Wave 1 +``` + +### 我回应的格式 + +``` +计划更新 v2: +[章节] 已修改:... +[章节] 已采纳反馈:... +[章节] 关于第X点,我的考虑是... 是否保持原方案? +``` + +### 轮次 + +不限,直至你满意。 + +--- + +## 5. A 阶段:审批通过 + +### 触发词 + +你说以下之一即表示审批通过: + +``` +plan 通过 +审核通过 +批准执行 +Plan Approved +``` + +### 审批门禁(前置条件) + +- [ ] 所有疑问已澄清 +- [ ] 需求理解无误 +- [ ] 技术方案没有明显漏洞 +- [ ] 任务分解完整(没有遗漏步骤) +- [ ] 验证计划合理 + +审批后,计划文档进入**锁定状态**。执行阶段严格按计划推进。 + +--- + +## 6. E 阶段:执行 + +### 执行原则 + +1. 严格按照任务分解的 **Wave 顺序** 执行 +2. 每个 Wave 内任务**并行**执行 +3. 每个任务完成后:`lsp_diagnostics` + 验证步骤 +4. 每完成一个 Wave:运行一次 build + test +5. 每完成一个 Wave:向你更新进度 + +### 执行偏差处理 + +| 情况 | 处理方式 | +|------|----------| +| 执行中发现计划遗漏 | 暂停 → 报告偏差 → 等你决策 | +| 执行中遇到计划错误的假设 | 暂停 → 分析根因 → 提交方案变更请求 | +| 执行中发现更好方案 | 暂停 → 说明理由 → 等你决定是否改用新方案 | +| 执行一切顺利 | 继续推进,直到全部完成 | + +### 变更请求格式 + +``` +变更请求 #1: +- 原计划:[Wave X, Task Y] +- 发现的问题:[具体问题] +- 建议修改为:[新方案] +- 理由:[为什么不按原计划] +- 影响:[对其他任务的影响] +请确认是否批准此变更。 +``` + +--- + +## 7. Plan Mode 与直接执行模式的选择 + +| 维度 | 直接执行 | Plan Mode | +|------|----------|-----------| +| 开始前 | 直接开始编码 | 先出计划文档 | +| 用户参与 | 执行中提反馈 | 计划阶段先审核 | +| 变更处理 | 随时改 | 正式变更请求 | +| 范围控制 | 容易 scope creep | 严格按计划执行 | +| 风险把控 | 走一步看一步 | 提前识别风险 | +| 适用场景 | 1 个文件的简单修改 | 2+ 文件、新功能、架构变更 | + +--- + +## 8. 执行完成标准(通用) + +所有执行任务完成后,必须逐条验证: + +- [ ] 所有任务标记完成 +- [ ] 项目构建通过(按项目实际工具填写命令) +- [ ] 项目测试通过(按项目实际工具填写命令) +- [ ] LSP 诊断无新增错误 +- [ ] 改动的代码通过了手动验证(见验证计划各条) + +**以上门禁全部通过才算任务完成。** diff --git a/chat.js b/chat.js index fbfd499..a043431 100644 --- a/chat.js +++ b/chat.js @@ -3,6 +3,7 @@ const fs = require('fs'); const crypto = require('crypto'); const path = require('path'); const { once } = require('node:events'); +const readline = require('readline'); const axios = require('axios'); const dateformat = require('@matteo.collina/dateformat'); @@ -11,6 +12,9 @@ const WebSocket = require('ws'); const CHAT_LOG_FILE = './logs/chat.jsonl'; const STICKER_CACHE_DIR = './logs/stickers'; const IMAGE_CACHE_DIR = './logs/images'; +const HISTORY_DEFAULT_LIMIT = 100; +const HISTORY_MAX_LIMIT = 500; +const PREVIEW_MAX_LENGTH = 60; function normalizeAuthConfig(rawAuth) { const defaultConfig = { @@ -127,7 +131,6 @@ function getClientIp(req, trustProxy = false) { return normalizeIpAddress( (req && req.socket && req.socket.remoteAddress) - || (req && req.connection && req.connection.remoteAddress) || '', ); } @@ -307,12 +310,12 @@ function normalizeHistoryEntry(entry) { }; } -function sanitizeLimit(limit, fallback = 100) { +function sanitizeLimit(limit, fallback = HISTORY_DEFAULT_LIMIT) { const value = Number.parseInt(limit, 10); if (!Number.isFinite(value) || value <= 0) { return fallback; } - return Math.min(value, 500); + return Math.min(value, HISTORY_MAX_LIMIT); } function extractStickerType(message) { @@ -520,7 +523,7 @@ function buildConversationPreview(entry) { return `[表情] ${emoticonNames.join(' ')}`; } - return String(entry.message || '').trim().replace(/\s+/g, ' ').slice(0, 60); + return String(entry.message || '').trim().replace(/\s+/g, ' ').slice(0, PREVIEW_MAX_LENGTH); } function sortHistoryItems(items) { @@ -655,9 +658,15 @@ function createChatService(customDeps = {}) { const recentSelfImageUrls = new Map(); const pendingStickerFetches = new Map(); const pendingImageFetches = new Map(); + let pendingWebSessionRefresh = null; let started = false; - fsModule.mkdir('./logs', { recursive: true }, (err) => { + const MSG_DEDUP_TTL_MS = 15000; + const WS_SESSION_TIMEOUT_MS = 15000; + const EMOTICON_LIST_TIMEOUT_MS = 10000; + const LOG_DIR = './logs'; + + fsModule.mkdir(LOG_DIR, { recursive: true }, (err) => { if (err) { logger.error('an error occurred while creating the logs directory: ' + err); } @@ -690,12 +699,22 @@ function createChatService(customDeps = {}) { }); async function readRequestBody(req) { - let body = ''; - req.on('data', (chunk) => { - body += chunk.toString(); + return new Promise((resolve, reject) => { + let body = ''; + const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10MB + + req.on('data', (chunk) => { + body += chunk.toString(); + if (body.length > MAX_BODY_SIZE) { + req.destroy(); + const error = new Error('Request body too large'); + error.code = 413; + reject(error); + } + }); + req.on('end', () => resolve(body)); + req.on('error', reject); }); - await onceFn(req, 'end'); - return body; } async function readJsonBody(req) { @@ -834,6 +853,8 @@ function createChatService(customDeps = {}) { message: response.modified_message, ordinal: response.ordinal, }); + }).catch((err) => { + logger.error('failed to append outgoing log', { id: uid, error: err.message }); }); } @@ -870,10 +891,10 @@ function createChatService(customDeps = {}) { function rememberSelfMessage(message) { const key = buildMessageKey(message); - recentSelfMessages.set(key, Date.now() + 15000); + recentSelfMessages.set(key, Date.now() + MSG_DEDUP_TTL_MS); const timer = setTimeout(() => { recentSelfMessages.delete(key); - }, 15000); + }, MSG_DEDUP_TTL_MS); if (typeof timer.unref === 'function') { timer.unref(); @@ -930,7 +951,7 @@ function createChatService(customDeps = {}) { const timeout = setTimeout(() => { removeHandler(handler); reject(new Error('getEmoticonList timed out')); - }, 10000); + }, EMOTICON_LIST_TIMEOUT_MS); if (typeof timeout.unref === 'function') { timeout.unref(); } @@ -1008,29 +1029,78 @@ function createChatService(customDeps = {}) { return groups; } - function sendFriendMessage(uid, msg) { - return new Promise((resolve, reject) => { - if (!uid || typeof msg !== 'string') { - reject(new Error('id and msg are required')); - return; - } + async function sendFriendMessage(uid, msg) { + if (!uid || typeof msg !== 'string') { + throw new Error('id and msg are required'); + } - steamUser.chat.sendFriendMessage(uid, msg, (err, response) => { - if (err) { - reject(err); - return; - } + async function doSend() { + return new Promise((resolve, reject) => { + steamUser.chat.sendFriendMessage(uid, msg, (err, response) => { + if (err) { + reject(err); + return; + } - appendOutgoingLog(uid, response); + appendOutgoingLog(uid, response); - resolve({ - server_timestamp: response.server_timestamp, - steamid_friend: uid, - message: response.modified_message, - ordinal: response.ordinal, + resolve({ + server_timestamp: response.server_timestamp, + steamid_friend: uid, + message: response.modified_message, + ordinal: response.ordinal, + }); }); }); - }); + } + + try { + return await doSend(); + } catch (err) { + let sendError = err; + + if (isTransientNetworkError(sendError)) { + logger.warn('temporary network error while sending message, retrying once', { + id: uid, + error: sendError.message, + code: sendError.code || null, + }); + + try { + return await doSend(); + } catch (retryErr) { + sendError = retryErr; + } + } + + if (!isLikelyExpiredWebSessionError(sendError) && !isTransientNetworkError(sendError)) { + throw sendError; + } + + logger.warn('failed to send message, trying to refresh web session', { + id: uid, + error: sendError.message, + code: sendError.code || null, + }); + + try { + if (!pendingWebSessionRefresh) { + pendingWebSessionRefresh = (async () => { + steamUser.webLogOn(); + await waitForFreshWebSession(); + })(); + } + try { + await pendingWebSessionRefresh; + } finally { + pendingWebSessionRefresh = null; + } + return await doSend(); + } catch (retryErr) { + logger.error('an error occurred while sending message', retryErr); + throw retryErr; + } + } } async function ensureWebSession() { @@ -1076,24 +1146,26 @@ function createChatService(customDeps = {}) { || message.includes('forbidden'); } - async function waitForFreshWebSession(timeoutMs = 15000) { + async function waitForFreshWebSession(timeoutMs = WS_SESSION_TIMEOUT_MS) { + const ac = new AbortController(); let timer = null; try { await Promise.race([ - onceFn(steamUser, 'webSession'), + onceFn(steamUser, 'webSession', { signal: ac.signal }).catch((err) => { + if (err.name !== 'AbortError') throw err; + // Swallow AbortError — timeout already handled the race + }), new Promise((_, reject) => { timer = setTimeout(() => { - const error = new Error(`Timed out after ${timeoutMs}ms while waiting for Steam web session`); - error.code = 'WEB_SESSION_TIMEOUT'; - reject(error); + ac.abort(); + reject(new Error(`Timed out after ${timeoutMs}ms while waiting for Steam web session`)); }, timeoutMs); }), ]); } finally { - if (timer) { - clearTimeout(timer); - } + if (timer) clearTimeout(timer); + ac.abort(); } } @@ -1178,8 +1250,17 @@ function createChatService(customDeps = {}) { }); try { - steamUser.webLogOn(); - await waitForFreshWebSession(); + if (!pendingWebSessionRefresh) { + pendingWebSessionRefresh = (async () => { + steamUser.webLogOn(); + await waitForFreshWebSession(); + })(); + } + try { + await pendingWebSessionRefresh; + } finally { + pendingWebSessionRefresh = null; + } return await uploadImageToUser(uid, imageBuffer); } catch (retryErr) { logger.error('an error occurred while sending image', retryErr); @@ -1205,37 +1286,47 @@ function createChatService(customDeps = {}) { } async function readChatHistory({ id, limit } = {}) { - const lines = await new Promise((resolve, reject) => { - fsModule.readFile(CHAT_LOG_FILE, 'utf8', (err, content) => { - if (err) { - if (err.code === 'ENOENT') { - resolve(''); - return; - } - reject(err); - return; - } + const maxItems = sanitizeLimit(limit, HISTORY_DEFAULT_LIMIT); + const items = []; - resolve(content); + try { + const rl = readline.createInterface({ + input: fsModule.createReadStream(CHAT_LOG_FILE, { encoding: 'utf8' }), + crlfDelay: Infinity, }); - }); - const maxItems = sanitizeLimit(limit, 100); - let items = parseLogLines(lines); + for await (const line of rl) { + const trimmed = line.trim(); + if (!trimmed) continue; - if (id) { - items = items.filter((entry) => entry.id === id); + try { + const entry = normalizeHistoryEntry(JSON.parse(trimmed)); + if (entry && (!id || entry.id === id)) { + items.push(entry); + } + } catch (parseErr) { + logger.warn('skip invalid chat log line', { line: trimmed }); + } + } + } catch (err) { + if (err && err.code === 'ENOENT') { + return []; + } + if (err && err.message && err.message.includes('ENOENT')) { + return []; + } + throw err; } if (items.length > maxItems) { - items = items.slice(-maxItems); + items.splice(0, items.length - maxItems); } return sortHistoryItems(items); } async function readConversationSummaries({ limit } = {}) { - const items = await readChatHistory({ limit: sanitizeLimit(limit, 500) }); + const items = await readChatHistory({ limit: sanitizeLimit(limit, HISTORY_MAX_LIMIT) }); const summaries = buildConversationSummaries(items); for (const summary of summaries) { @@ -1313,6 +1404,27 @@ function createChatService(customDeps = {}) { throw error; } + // SSRF protection: reject private/internal addresses + let parsedUrl; + try { + parsedUrl = new URL(normalizedUrl); + } catch (_) { + const error = new Error('Invalid image URL'); + error.code = 400; + throw error; + } + const hostname = parsedUrl.hostname; + if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname === '[::1]' || hostname === '0.0.0.0') { + const error = new Error('Invalid image URL'); + error.code = 400; + throw error; + } + if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(hostname) && isLanIp(hostname)) { + const error = new Error('Invalid image URL'); + error.code = 400; + throw error; + } + const { dataPath, metaPath } = buildImageCachePaths(normalizedUrl); const cachedData = await readFileIfExists(dataPath); if (cachedData) { @@ -1403,18 +1515,19 @@ function createChatService(customDeps = {}) { function rememberSelfImageUrl(uid, imageUrl) { const urlKey = `${uid}:${imageUrl}`; - recentSelfImageUrls.set(urlKey, Date.now() + 15000); - - // Also remember that we sent *any* image to this uid recently, - // so we can suppress echo messages that contain image URLs even - // if Steam transforms the URL format. const uidKey = `img:${uid}`; - recentSelfImageUrls.set(uidKey, Date.now() + 15000); + const expiresAt = Date.now() + MSG_DEDUP_TTL_MS; + + recentSelfImageUrls.set(urlKey, expiresAt); + recentSelfImageUrls.set(uidKey, expiresAt); const timer = setTimeout(() => { recentSelfImageUrls.delete(urlKey); - recentSelfImageUrls.delete(uidKey); - }, 15000); + // Only delete uidKey if it hasn't been refreshed by a newer call + if (recentSelfImageUrls.get(uidKey) === expiresAt) { + recentSelfImageUrls.delete(uidKey); + } + }, MSG_DEDUP_TTL_MS); if (typeof timer.unref === 'function') { timer.unref(); @@ -1708,6 +1821,14 @@ function createChatService(customDeps = {}) { } function handleWs(ws) { + const MAX_WS_CONNECTIONS = 100; + if (wss.clients.size >= MAX_WS_CONNECTIONS) { + ws.close(1013, 'Too many connections'); + logger.warn('WebSocket connection rejected: too many connections', { + max: MAX_WS_CONNECTIONS, + }); + return; + } logger.info('WebSocket connection established'); sendWs(ws, { type: 'ready', @@ -1755,37 +1876,43 @@ function createChatService(customDeps = {}) { } started = true; - await client.steamLoginPromise; + try { + await client.steamLoginPromise; - steamUser.chat.on('friendMessage', (message) => { - broadcastSteamMessage(message, false).catch((err) => { - logger.error('failed to broadcast friend message', err); - }); - }); - - steamUser.chat.on('friendMessageEcho', (message) => { - broadcastSteamMessage(message, true, { dedupe: true }).catch((err) => { - logger.error('failed to broadcast echoed friend message', err); - }); - }); - - wss.on('connection', handleWs); - - await new Promise((resolve, reject) => { - server.listen(chatConfig.port, chatConfig.host, (err) => { - if (err) { - reject(err); - return; - } - - logger.info('chat server started', { - host: chatConfig.host, - port: chatConfig.port, - wsPath: chatConfig.wsPath, + steamUser.chat.on('friendMessage', (message) => { + broadcastSteamMessage(message, false).catch((err) => { + logger.error('failed to broadcast friend message', err); }); - resolve(); }); - }); + + steamUser.chat.on('friendMessageEcho', (message) => { + broadcastSteamMessage(message, true, { dedupe: true }).catch((err) => { + logger.error('failed to broadcast echoed friend message', err); + }); + }); + + wss.on('connection', handleWs); + + await new Promise((resolve, reject) => { + server.listen(chatConfig.port, chatConfig.host, (err) => { + if (err) { + reject(err); + return; + } + + logger.info('chat server started', { + host: chatConfig.host, + port: chatConfig.port, + wsPath: chatConfig.wsPath, + }); + resolve(); + }); + }); + } catch (err) { + started = false; + logger.error('chat service failed to start', err); + throw err; + } } return { diff --git a/client.js b/client.js index 8a28eeb..de192e8 100644 --- a/client.js +++ b/client.js @@ -64,3 +64,5 @@ module.exports = { steamLoginPromise: steamLoginPromise, steamWebLoginPromise: steamWebLoginPromise, } + +require('./logger'); diff --git a/logger.js b/logger.js index 6d832df..7562110 100644 --- a/logger.js +++ b/logger.js @@ -50,13 +50,13 @@ async function logMessage(date, steamID, message, echo, ordinal) { client.steamLoginPromise.then(() => { steamUser.chat.on("friendMessage", (message) => { - // noinspection JSIgnoredPromiseFromCall - logMessage(dateToString(message.server_timestamp), message.steamid_friend, message.message, false, message.ordinal); + logMessage(dateToString(message.server_timestamp), message.steamid_friend, message.message, false, message.ordinal) + .catch((err) => logger.error('failed to log friend message', err)); }); steamUser.chat.on("friendMessageEcho", (message) => { - // noinspection JSIgnoredPromiseFromCall - logMessage(dateToString(message.server_timestamp), message.steamid_friend, message.message, true, message.ordinal); + logMessage(dateToString(message.server_timestamp), message.steamid_friend, message.message, true, message.ordinal) + .catch((err) => logger.error('failed to log echoed friend message', err)); }); }); @@ -96,7 +96,7 @@ function importChatHistory(steamID) { steamID, message.message, message.sender.getSteamID64() === steamUser.steamID.getSteamID64(), message.ordinal, - ); + ).catch((err) => logger.error("failed to log imported chat history", err)); } }); } diff --git a/public/app/AGENTS.md b/public/app/AGENTS.md new file mode 100644 index 0000000..7c6803a --- /dev/null +++ b/public/app/AGENTS.md @@ -0,0 +1,81 @@ +# Frontend Modules (public/app/) + +**Domain:** Browser-side ES6 modules for Steam Chat UI + +## STRUCTURE +``` +app/ +├── bootstrap.js # Page init, event binding, config fetch +├── session.js # Active conversation state, sidebar data +├── websocket.js # WebSocket client, auto-reconnect, request IDs +├── composer.js # Message input, attachments, emoticon/sticker picker +├── messages.js # Message list rendering, history, separators +├── message-bubble.js # Individual bubble rendering (text/image/sticker) +├── sidebar.js # Conversations/friends/groups tabs +├── lightbox.js # Image preview modal with zoom +├── managed-images.js # Lazy loading, image lifecycle +├── rich-content.js # Emoticons, link cards, BBCode parsing +├── notifications.js # Desktop notifications, unread count +├── preferences.js # localStorage for target ID, history limit +├── layout.js # Responsive layout, mobile sidebar toggle +├── status.js # Connection status chip +├── dom.js # DOM element references +└── utils.js # Date formatting, Steam URL builders, parsers +``` + +## WHERE TO LOOK +| Task | Location | +|------|----------| +| Add new WebSocket message type | `websocket.js` switch statement | +| Change message rendering | `message-bubble.js` | +| Add emoticon/sticker support | `composer.js` + `rich-content.js` | +| Modify layout breakpoints | `layout.js` (900px mobile) | +| Add keyboard shortcuts | `bootstrap.js` or `composer.js` | +| Change date/time format | `utils.js` | +| Mobile sidebar behavior | `layout.js`, `sidebar.js` | + +## CONVENTIONS + +### Module Pattern +```javascript +export function createXController({ dep1, dep2 }) { + // private state + let state = {}; + + function privateFn() {} + + function publicFn() {} + + return { + publicFn, + // only expose necessary methods + }; +} +``` + +### Event Handling +- Prefer delegation: `container.addEventListener('click', handler)` +- Check targets: `if (event.target.matches('.class'))` +- One-time warmup: `{ once: true }` option + +### DOM References +- Centralized in `dom.js` via `getAppDomRefs(document)` +- Passed as dependencies to controllers +- Avoid querying DOM repeatedly + +### WebSocket Communication +- Request IDs: `${prefix}-${counter++}` format +- Types: `send_message`, `get_history`, `get_conversations`, `get_friends`, `get_groups`, `get_emoticons` +- Auto-reconnect on disconnect (3s delay) + +## ANTI-PATTERNS +- **DO NOT** use inline event handlers (onclick="...") +- **DO NOT** import Node.js modules (fs, path, etc.) +- **AVOID** direct DOM manipulation outside controllers +- **NEVER** store sensitive data in localStorage + +## DEPENDENCIES +- Pure vanilla JS, no frameworks +- Native WebSocket API +- Native Fetch API (for `/api/config`) +- `window.visualViewport` for mobile keyboard handling diff --git a/steam-lifecycle.js b/steam-lifecycle.js index 66c96c2..712151a 100644 --- a/steam-lifecycle.js +++ b/steam-lifecycle.js @@ -254,6 +254,13 @@ function createSteamLifecycle({ message: msg, autoRelogin: steamUser.options && steamUser.options.autoRelogin, }); + + // Schedule a retry as fallback — if autoRelogin succeeds, + // loggedOn will clear this timer. This ensures reconnection + // even if autoRelogin is disabled or fails silently. + const err = new Error(msg || `Steam disconnected (eresult: ${eresult})`); + err.eresult = eresult; + scheduleRetry(err); }); steamUser.on('error', (err) => { diff --git a/test/chat.test.js b/test/chat.test.js index a4bb50c..7fb922c 100644 --- a/test/chat.test.js +++ b/test/chat.test.js @@ -2,6 +2,7 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const { EventEmitter } = require('node:events'); const { once } = require('node:events'); +const { Readable } = require('node:stream'); process.env.STEAM_CHAT_DISABLE_AUTOSTART = '1'; @@ -157,6 +158,13 @@ function createService(overrides = {}) { extraFiles.set(path, data); cb(null); }, + createReadStream(path, options) { + assert.equal(path, CHAT_LOG_FILE); + const stream = new Readable(); + stream.push(logContent); + stream.push(null); + return stream; + }, }; const server = {