diff --git a/.claude/skills/pr-fixup/SKILL.md b/.claude/skills/pr-fixup/SKILL.md new file mode 100644 index 00000000..c5555501 --- /dev/null +++ b/.claude/skills/pr-fixup/SKILL.md @@ -0,0 +1,160 @@ +--- +name: pr-fixup +description: Wait for PR review action to complete, fix valid issues or resolve false positives, loop until PR is clean +argument-hint: "[PR number, default: current branch's PR]" +--- + +# PR Fixup: Review → Fix/Dispute → Re-review Loop + +等待 PR review action 完成,分析 review 评论,修复真实问题或反驳误报,循环直到 PR 无阻塞问题。 + +## 前置信息收集 + +1. **获取仓库信息**: `gh repo view --json nameWithOwner -q .nameWithOwner` → 得到 `OWNER/REPO`,再拆分出 OWNER 和 REPO +2. **确定 PR 号**: + - 如果 `$ARGUMENTS` 提供了 PR 号或 URL(`https://github.com/.../pull/N`),提取编号使用 + - 否则: `gh pr view --json number -q .number` 自动检测当前分支 PR + - 如果没有 PR,告知用户并停止 +3. **获取当前分支**: `git branch --show-current` +4. **读取 PR 信息**: `gh pr view PR_NUMBER` 了解 PR 意图 + +## 主循环 + +重复以下步骤,直到所有 review 问题解决。**最多 5 轮**,超过后提醒用户手动介入。 + +--- + +### Step 1: 等待 Review Action 完成 + +先获取 PR 最新 commit SHA: + +```bash +gh pr view PR_NUMBER --json headRefOid -q .headRefOid +``` + +然后轮询检查该 commit 对应的 pr-review workflow 运行状态: + +```bash +gh run list --workflow=pr-review.yml -b BRANCH -L 5 --json status,conclusion,databaseId,headSha +``` + +从结果中筛选 `headSha` 匹配最新 commit 的运行。 + +- 如果**没有匹配的运行**,等待 30 秒后重试(action 可能还没触发) +- 如果 `status` 不是 `"completed"`,每 30 秒轮询一次,最多等待 20 分钟 +- 如果 `conclusion` 是 `"failure"`,用 `gh run view ID --log-failed` 查看失败原因,告知用户并停止 +- 如果 `conclusion` 是 `"success"`,继续下一步 + +### Step 2: 获取未解决的 Review 评论 + +通过 GraphQL 获取所有 review threads: + +```bash +gh api graphql -f query='{ + repository(owner:"OWNER", name:"REPO") { + pullRequest(number:PR_NUMBER) { + reviewThreads(first:100) { + nodes { + id + isResolved + comments(first:10) { + nodes { + databaseId + body + author { login } + path + line + } + } + } + } + } + } +}' +``` + +过滤条件: +- `isResolved == false`(未解决) +- 发起评论(第一条 comment)的 `author.login` 是 `claude[bot]` + +如果**没有未解决的 claude[bot] 评论** → 输出 "✅ PR review 通过,无阻塞问题" 并结束循环。 + +### Step 3: 分析每个评论 + +对于每个未解决的评论: + +1. **读取完整源文件**:用 Read 工具读取评论所在的 `path` 文件 +2. **理解评论内容**:仔细阅读 `body` 中指出的具体问题 +3. **结合上下文判断**:评论是否正确? + +分类标准: + +| 分类 | 条件 | 举例 | +|------|------|------| +| **真实问题** | 代码确实存在 reviewer 描述的缺陷 | 逻辑错误、安全漏洞、资源泄漏、类型不安全 | +| **误报** | 代码是正确的,reviewer 的分析有误 | 忽略了上下文、误解了控制流、不了解框架行为、过度保守 | + +**判断原则**: +- 如果你不确定,**倾向于修复**而不是反驳——宁可多修一个不必要的问题,也不要放过一个真实 bug +- 反驳误报时必须有**明确的理由**,能指出 reviewer 具体哪里判断错了 + +### Step 4: 处理问题 + +**对于真实问题:** +- 修复代码,使用最小改动,不做不相关的重构 +- `git add` 修改的文件 + +**对于误报:** + +1. 回复评论说明原因: + +```bash +gh api repos/OWNER/REPO/pulls/PR_NUMBER/comments/COMMENT_DATABASE_ID/replies \ + -f body="Not an issue — <具体解释,引用代码说明 reviewer 的判断为什么不适用于此场景>" +``` + +2. Resolve 该 thread: + +```bash +gh api graphql -f query='mutation { + resolveReviewThread(input:{threadId:"THREAD_NODE_ID"}) { + thread { isResolved } + } +}' +``` + +### Step 5: 提交推送或结束 + +统计本轮处理结果。 + +**如果有代码修复:** +- `git commit`,message 遵循项目风格: `fix: address PR review feedback`(如果能更具体则写具体内容,如 `fix: 修复 session cleanup 竞态条件`) +- `git push` +- 输出 "🔄 第 N 轮:修复 X 个问题,反驳 Y 个误报,等待新一轮 review..." +- 回到 Step 1 + +**如果只有误报被 resolve(无代码修复):** +- 输出 "✅ 第 N 轮:反驳 Y 个误报并 resolve,PR review 通过" +- 结束循环 + +--- + +## 完成汇总 + +循环结束时,输出汇总报告: + +``` +## 📋 PR Fixup 完成 + +- **总轮数**: N +- **修复问题**: X 个 +- **反驳误报**: Y 个 +- **PR 状态**: ✅ 无阻塞问题 +``` + +## 注意事项 + +- 只处理 `claude[bot]` 的评论,不处理人类 reviewer 的评论 +- 反驳评论时给出**具体、有理据的解释**,引用代码上下文,不要笼统地说"这没问题" +- commit message 遵循项目风格: `fix: <中文描述>` +- 如果同一个问题反复出现(修了又被报),在第 3 轮后停下来让用户介入 diff --git a/.claude/skills/ship/SKILL.md b/.claude/skills/ship/SKILL.md index 9b59aab8..e1a9f3f7 100644 --- a/.claude/skills/ship/SKILL.md +++ b/.claude/skills/ship/SKILL.md @@ -50,3 +50,4 @@ argument-hint: "[commit message or description of changes]" - **绝不**提交 `.env`、credentials 等敏感文件 - 如果没有任何变更,告知用户而不是创建空提交 - 每一步都展示结果,出错时停下来说明原因 +- PR 创建成功后,提示用户:可以运行 `/pr-fixup` 自动等待 review 并修复问题 diff --git a/.github/workflows/pr-review.yml b/.github/workflows/pr-review.yml index 68dd9904..c95c9a84 100644 --- a/.github/workflows/pr-review.yml +++ b/.github/workflows/pr-review.yml @@ -47,10 +47,14 @@ jobs: 2. Run `gh pr diff ${{ github.event.pull_request.number }}` to get the current diff. 3. For each previous comment you left: - Read the CURRENT version of the file at the commented line to check if the issue is fixed. - - If FIXED: reply to that comment via `gh api repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/comments/{comment_id}/replies -f body="✅ Fixed."` + - If FIXED: reply to that comment via `gh api repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/comments/{comment_id}/replies -f body="✅ Fixed. {brief description of the fix}"`, then resolve the conversation (see step 5). - If STILL EXISTS: reply noting it persists, do NOT create a duplicate inline comment for the same issue. - If PARTIALLY FIXED: reply explaining what remains. 4. Only create NEW inline comments for genuinely new issues not already covered by previous comments. + 5. After replying to ALL fixed comments, resolve their conversation threads: + a. Get review thread IDs: `gh api graphql -f query='{ repository(owner:"${{ github.repository_owner }}", name:"${{ github.event.repository.name }}") { pullRequest(number:${{ github.event.pull_request.number }}) { reviewThreads(first:100) { nodes { id isResolved comments(first:1) { nodes { databaseId body } } } } } } }'` + b. Match each fixed comment's databaseId to find the thread node ID. + c. Resolve each thread: `gh api graphql -f query='mutation { resolveReviewThread(input:{threadId:"THREAD_NODE_ID"}) { thread { isResolved } } }'` ## Step 2: Setup diff --git a/docs/openclaw-analysis.md b/docs/openclaw-analysis.md new file mode 100644 index 00000000..7bc182e6 --- /dev/null +++ b/docs/openclaw-analysis.md @@ -0,0 +1,425 @@ +# OpenClaw 项目分析:Anywhere-Code 可借鉴的实现 + +> 分析日期: 2026-02-17 +> OpenClaw 版本: 2026.2.16 (145k+ stars) +> Anywhere-Code 当前状态: 单渠道 (飞书) + Claude Agent SDK 桥接 + +--- + +## 一、两个项目的定位差异 + +| 维度 | OpenClaw | Anywhere-Code | +|------|----------|---------------| +| 定位 | 通用 AI 助手平台 | Claude Code 飞书桥接 | +| 渠道 | 40+ (Telegram/Discord/Slack/WhatsApp/Signal/iMessage...) | 仅飞书 | +| AI 后端 | 多模型 (Claude/GPT/Gemini) + Pi Agent 运行时 | 仅 Claude Agent SDK | +| 代码量 | ~52 个 src 子模块 + 40+ 扩展 | ~2000 行应用代码 | +| 架构 | WebSocket Gateway 控制平面 + 插件系统 | Express + 飞书 SDK 直连 | + +**核心判断: Anywhere-Code 不需要也不应该成为 OpenClaw。** 但 OpenClaw 在以下几个维度的工程实现,值得有选择地借鉴。 + +--- + +## 二、高价值可借鉴实现 + +### 1. Channel 抽象层 (最高优先级) + +**OpenClaw 的做法:** + +``` +extensions/telegram/ → ChannelPlugin 接口 +extensions/discord/ → ChannelPlugin 接口 +extensions/slack/ → ChannelPlugin 接口 +...40+ 渠道 +``` + +每个渠道是一个独立 npm workspace 包,通过 `package.json` 的 `openclaw.extensions` 字段注册: + +```json +{ + "name": "@openclaw/telegram", + "openclaw": { "extensions": ["./index.ts"] } +} +``` + +核心是 **ChannelPlugin 接口**: + +```typescript +type ChannelPlugin = { + id: string + meta: ChannelMeta // 元数据 (名称/图标/能力) + messaging?: MessagingAdapter // 收发消息 + outbound?: OutboundAdapter // 输出适配 + auth?: AuthAdapter // 认证 + security?: SecurityAdapter // 安全策略 + threading?: ThreadingAdapter // 话题/线程 + tools?: AgentTool[] // 渠道专属工具 +} +``` + +加上轻量的 **ChannelDock** 元数据注册: + +```typescript +// dock.ts — 不加载任何重依赖,只声明能力 +{ id: "telegram", chatTypes: ["direct","group"], blockStreaming: true, polls: true } +{ id: "discord", chatTypes: ["direct","channel","thread"], inlineButtons: true } +``` + +**Anywhere-Code 现状的问题:** +- `FeishuClient`、`EventHandler`、`MessageBuilder` 全部硬编码飞书逻辑 +- 添加 Slack/钉钉/企微需要大量复制粘贴 + +**建议借鉴方案:** + +``` +src/channels/ + types.ts # ChannelAdapter 接口定义 + registry.ts # 渠道注册表 + feishu/ # 飞书实现 (现有代码迁入) + adapter.ts + client.ts + event-handler.ts + message-builder.ts + dingtalk/ # 未来: 钉钉 + wecom/ # 未来: 企业微信 + slack/ # 未来: Slack +``` + +核心接口 (精简版,不需要 OpenClaw 那么庞大): + +```typescript +interface ChannelAdapter { + id: string + // 接收消息 → 统一格式 + onMessage(raw: unknown): IncomingMessage | null + // 发送文本 + sendText(target: MessageTarget, text: string): Promise + // 发送进度卡片 (可选) + sendProgress?(target: MessageTarget, status: ProgressStatus): Promise + // 更新已发送消息 (可选) + updateMessage?(messageId: string, content: unknown): Promise +} + +interface IncomingMessage { + channelId: string + chatId: string + userId: string + text: string + threadId?: string + mentions?: string[] +} +``` + +**工作量估计: 1-2 天重构现有飞书代码到此结构。** 之后添加新渠道只需实现 `ChannelAdapter`。 + +--- + +### 2. 会话路由与 Session Key 组合 (高优先级) + +**OpenClaw 的做法:** + +```typescript +// 会话 key 格式: agent:{agentId}:{mainKey} +// mainKey = channel:account:peer (确定性、层次化) +// 支持多种 DM scope 模式: +// main → 所有 DM 共享一个会话 +// per-peer → 每个联系人独立会话 +// per-channel-peer → 跨渠道同一联系人独立 +``` + +Session key 设计特点: +- **确定性**: 相同输入总是生成相同 key (重启后恢复) +- **归一化**: 字母数字 + 连字符,最长 64 字符 +- **层次化**: 支持从 key 反向解析出 agent/channel/user + +**Anywhere-Code 现状:** + +```typescript +// 简单拼接: chatId (群聊) 或 chatId:userId (私聊) +const key = this.makeKey(chatId, userId); +``` + +**建议借鉴:** +- 当前 key 方案在单渠道下够用 +- **但一旦加入第二个渠道,必须在 key 中包含 channel 标识** +- 建议预留: `{channel}:{chatId}:{userId}` 格式 +- 保持归一化 (去掉特殊字符,限制长度) + +--- + +### 3. 插件式 MCP 工具注册 (中等优先级) + +**OpenClaw 的做法:** + +工具通过 Plugin SDK 注册,每个插件可以声明自己的 Agent 工具: + +```typescript +type ChannelPlugin = { + tools?: ChannelAgentTool[] // 渠道贡献的工具 +} +``` + +工具策略管道 (composable): + +```typescript +applyToolPolicyPipeline([ + globalPolicy, // 全局配置 + agentPolicy, // 当前 agent 策略 + groupPolicy, // 当前群组策略 + senderPolicy, // 当前用户策略 + workspaceGuard, // 工作区限制 +]) +``` + +**Anywhere-Code 现状:** +- 只有一个 `setup_workspace` MCP 工具 +- 工具权限是全局 `canUseTool: () => ({ behavior: 'allow' })` + +**建议借鉴:** +- 将 MCP 工具注册改为声明式,方便扩展: + +```typescript +// src/tools/registry.ts +const tools = [ + createWorkspaceTool(), // 现有 + createSearchTool(), // 未来: 代码搜索 + createDeployTool(), // 未来: 部署 +]; + +// 注入到 executor +mcpServers: { 'tool-registry': createToolRegistry(tools) } +``` + +- 工具权限从全部放行改为 **按类别策略**: + +```typescript +canUseTool: async (toolName, input) => { + if (DANGEROUS_TOOLS.has(toolName)) { + return { behavior: 'deny', message: '需要管理员确认' }; + } + return { behavior: 'allow' }; +} +``` + +--- + +### 4. 混合记忆系统 (中等优先级,长期) + +**OpenClaw 的做法:** + +``` +Memory System +├── Vector Search (sqlite-vec, embeddings) +├── BM25 Full-text Search +├── Hybrid Ranking (configurable weights) +├── MMR Reranking (多样性) +├── Query Expansion (多语言关键词) +├── MEMORY.md 文件监听 + 增量同步 +└── Temporal Decay (时间衰减) +``` + +- 支持多种 embedding 提供商: OpenAI / Gemini / Voyage / 本地 llama +- 每个 agent 独立记忆索引 +- 优雅降级: embedding 失败 → 退回纯 BM25 + +**Anywhere-Code 现状:** +- 仅依赖 Claude Agent SDK 的 `resume` 参数续接会话 +- 无跨会话记忆 +- 无文档/知识库检索 + +**建议借鉴 (分阶段):** + +**Phase 1 — 会话摘要持久化 (低成本):** +```typescript +// 每次会话结束时,让 Claude 生成一句摘要 +// 存入 SQLite sessions 表的 summary 字段 +// 下次会话在 systemPrompt.append 中注入历史摘要 +``` + +**Phase 2 — MEMORY.md 文件支持:** +```typescript +// 读取工作区的 MEMORY.md,注入到 system prompt +// Claude Agent SDK 的 settingSources: ['project'] 已支持 CLAUDE.md +// 额外追加项目级记忆 +``` + +**Phase 3 — 向量检索 (可选):** +```typescript +// 如果需求明确,用 sqlite-vec 做简单 RAG +// 但注意: Claude Agent SDK 本身已有文件读取能力 +// 大部分场景不需要独立 RAG +``` + +--- + +### 5. Hook / 事件系统 (中等优先级) + +**OpenClaw 的做法:** + +```typescript +// 事件驱动 hook 系统 +type HookEvent = + | "command:new" // 新命令到达 + | "session:start" // 会话开始 + | "session:end" // 会话结束 + | "webhook:received" // 外部 webhook + | "tool:before" // 工具调用前 + | "tool:after" // 工具调用后 + +// Hook 来源 +type HookSource = + | "openclaw-bundled" // 内置 + | "openclaw-managed" // 平台管理 + | "openclaw-workspace" // 工作区自定义 + | "openclaw-plugin" // 插件提供 + +// Frontmatter 元数据 (requirements) +// bins: ["git", "docker"] — 依赖的外部命令 +// env: ["GITHUB_TOKEN"] — 依赖的环境变量 +// os: ["darwin"] — 操作系统限制 +``` + +**Anywhere-Code 可借鉴的简化版:** + +```typescript +// src/hooks/types.ts +type HookEvent = 'message:received' | 'task:start' | 'task:complete' | 'task:error'; + +interface Hook { + event: HookEvent + handler: (context: HookContext) => Promise +} + +// 使用场景: +// - task:complete → 发送钉钉/飞书通知 +// - task:error → 触发告警 +// - message:received → 审计日志 +// - task:start → 记录开始时间 (metrics) +``` + +**价值:** 不需要改核心代码就能扩展行为。当前的 `EventHandler` 是同步管道,加 hook 点成本很低。 + +--- + +### 6. Block Streaming (流式分块推送) (低优先级) + +**OpenClaw 的做法:** + +针对支持渐进式文本的渠道 (Telegram、Discord 等): +- 设置 `blockStreaming: true` + 最小字符数 + 空闲超时 +- Claude 每输出一段文字就推送给用户,而非等全部完成 +- 不同渠道有不同的 coalesce (合并) 策略 + +**Anywhere-Code 现状:** +- 等 Claude 全部执行完毕后一次性返回结果 +- 进度通过 interactive card 状态更新 ("执行中...") +- 但实际文本内容不流式推送 + +**建议借鉴:** +- 利用 Claude Agent SDK 的 `onProgress` 回调: + +```typescript +for await (const message of q) { + if (message.type === 'assistant') { + // 每累积 200 字符或 3 秒,更新一次飞书卡片 + accumulatedText += extractText(message); + if (shouldFlush(accumulatedText, lastFlushTime)) { + await feishuClient.updateCard(cardId, accumulatedText); + lastFlushTime = Date.now(); + } + } +} +``` + +- 飞书的 interactive card 支持 `update`,天然适合流式更新 +- **注意:** 飞书 API 有速率限制,不能每秒更新,需要合理 coalesce + +--- + +### 7. 安全模型 (DM Pairing) (按需) + +**OpenClaw 的做法:** + +``` +默认模式: "pairing" + 1. 未知用户发消息 → 返回配对码 + 2. 管理员执行: openclaw pairing approve + 3. 用户加入 allowlist → 正常使用 + +开放模式: allowFrom: ["*"] + - 需显式配置,不是默认值 +``` + +**Anywhere-Code 现状:** +- `ALLOWED_USER_IDS` 环境变量,空 = 允许所有人 +- 无配对流程 + +**建议借鉴:** +- 当部署到公开环境时,空 allowlist = 允许所有人 是危险的 +- 可以添加简单的审批流: + +```typescript +// 首次消息 → 通知管理员 +// 管理员回复 /approve @user → 加入白名单 +// 不需要 OpenClaw 那样复杂的 QR/配对码 +``` + +--- + +## 三、不建议借鉴的部分 + +| OpenClaw 特性 | 不借鉴原因 | +|---------------|-----------| +| Pi Agent 运行时 | Anywhere-Code 直接用 Claude Agent SDK,更轻量更直接 | +| 多模型切换 | 项目定位就是 Claude Code 桥接,不需要 GPT/Gemini | +| Gateway WebSocket 控制平面 | 过度工程,单进程 Express 足够当前规模 | +| macOS/iOS/Android 原生 App | 完全不同的产品形态 | +| A2UI Canvas | UI 工作台,与 IM 桥接场景不符 | +| 完整 Plugin SDK (460+ 导出类型) | 太重,简化的 adapter 接口足够 | +| Cron 调度系统 | 当前无此需求 | +| 浏览器自动化 (Playwright) | Claude Agent SDK 自己管理工具 | + +--- + +## 四、推荐实施路线图 + +``` +Phase 1 — 渠道抽象 (1-2 天) + ├── 定义 ChannelAdapter 接口 + ├── 将飞书代码迁入 src/channels/feishu/ + ├── 统一 IncomingMessage 类型 + └── Session key 加入 channel 前缀 + +Phase 2 — 工具策略 (0.5 天) + ├── canUseTool 从全放行改为分类策略 + ├── MCP 工具注册改为声明式 + └── 添加危险工具拦截 + +Phase 3 — 流式卡片更新 (0.5 天) + ├── 利用 onProgress 回调 + ├── 合并策略 (200字符 / 3秒) + └── 飞书 card update API + +Phase 4 — 事件 Hook (0.5 天) + ├── 定义 4-5 个核心事件点 + ├── 简单的 hook 注册机制 + └── 内置: 审计日志 hook + +Phase 5 — 会话记忆 (1 天) + ├── 会话结束摘要 + ├── 摘要注入到新会话 system prompt + └── MEMORY.md 支持 + +Phase 6 — 新渠道 (按需) + ├── 钉钉 adapter + ├── 企业微信 adapter + └── Slack adapter +``` + +--- + +## 五、总结 + +OpenClaw 是一个成熟的、面向消费者的通用 AI 助手平台,Anywhere-Code 是一个聚焦的、面向开发者的 Claude Code 桥接工具。两者定位不同,但 OpenClaw 在**渠道抽象**、**工具策略管道**、**混合记忆搜索**三个方面的工程设计,对 Anywhere-Code 的后续演进有直接参考价值。 + +最核心的一条: **现在就做渠道抽象层**。当前只有飞书,但中国团队环境下钉钉和企微是必然需求。提前抽象的成本很低 (1-2 天),事后补做的成本很高 (涉及所有已有代码的重构)。 diff --git a/docs/workspace-cache-and-restart.md b/docs/workspace-cache-and-restart.md new file mode 100644 index 00000000..a6dab1a5 --- /dev/null +++ b/docs/workspace-cache-and-restart.md @@ -0,0 +1,427 @@ +# 工作区缓存与 Agent 自动重启方案 + +## 背景与问题 + +当前系统中,用户通过飞书发消息触发 Claude Agent 执行任务。Agent 通过 `setup_workspace` MCP tool 在运行时 clone 仓库并切换工作目录。这一机制存在以下问题: + +### 问题 1:CLAUDE.md 无法在当前 query 中生效 + +Agent SDK 在 `query()` 启动时根据 `cwd` + `settingSources: ['project']` 加载项目配置(CLAUDE.md)。当 `setup_workspace` 在 query 执行过程中切换了工作目录,当前子进程的 `cwd` 不会改变,新仓库的 CLAUDE.md 不会被加载。只有下一次 query 才能正确加载。 + +**影响**:首次访问某个仓库时,Claude 缺少该项目的上下文指导(代码规范、架构说明、命令约定等),可能产出不符合项目规范的结果。 + +### 问题 2:只读查询也要走完整 clone 流程 + +用户问"看看 foo/bar 的架构",当前流程仍然要完整 clone 仓库并创建 feature 分支,耗时且不必要。 + +### 问题 3:同一仓库被反复 clone + +不同用户、不同会话访问同一仓库,每次都从远程 clone,浪费时间和带宽。 + +### 问题 4(已有缺陷):taskQueue 未被集成 + +当前 `executeClaudeTask()` 没有通过 `taskQueue` 串行化执行。如果两条消息快速到达同一个 chat,会并发执行两个 query,导致 `runningQueries` Map 中同一 key 被覆盖、session 状态竞态等问题。本方案的 restart 机制会放大此问题(restart 期间更容易有第二条消息到达)。 + +**前置要求**:实施本方案前应先修复 `taskQueue` 集成,确保同一 chat 的 query 串行执行。 + +## 方案概述 + +引入 **仓库缓存层** 和 **Agent 自动重启机制**,统一解决上述问题: + +1. **仓库缓存目录**:维护一组本地 bare clone 镜像,作为快速 clone 源 +2. **setup_workspace 增加 readonly/writable 模式**:Claude 根据语义自主判断访问模式 +3. **工作区变更后自动重启 query**:确保新 query 以正确的 `cwd` 启动,CLAUDE.md 从一开始就生效 + +### 曾考虑但放弃的替代方案 + +- **预解析 CLAUDE.md 方案**:`setup_workspace` 完成后由 MCP tool 读取新仓库 CLAUDE.md 内容注入给 Claude。绕过了 SDK 标准加载机制,可能丢失 `.claude/` 目录下的其他配置。 +- **System prompt 动态拼接**:检测到 URL 后先 clone 再拼接 CLAUDE.md 到 system prompt。与 Claude 自主判断是否需要 workspace 的设计理念冲突,且正则检测缺乏语义理解能力。 +- **缓存使用普通 clone(非 bare)共享工作树**:多 session 共享同一工作树存在写入污染和 `git checkout` 并发冲突的根本性问题,详见"风险与考量"。 + +## 分阶段实施计划 + +建议分两阶段交付,降低一次性变更的风险: + +- **Phase 1(核心价值)**:实现 restart 机制 + git 安全参数,解决 CLAUDE.md 不生效问题。不引入缓存层,仍然每次从远程 clone。 +- **Phase 2(性能优化)**:引入 bare clone 缓存层 + readonly/writable 模式,优化 clone 速度和只读查询体验。 + +## 详细设计 + +### 1. 目录结构 + +``` +/repos/cache/ # 仓库缓存根目录 (可配置) + github.com/ + foo/bar.git/ # bare clone,无工作树 + baz/qux.git/ + gitlab.com/ + org/group/project.git/ # 支持多级 group 路径 + +/workspaces/ # 隔离工作区根目录 (可配置) + {session-key}/ # 按 session 隔离 + bar/ # 从缓存 local clone,带工作树和 feature branch +``` + +### 2. 仓库缓存管理 + +#### 2.1 缓存策略 + +缓存采用 **bare clone**(`git clone --bare`),不维护工作树。这从根本上消除了多 session 共享工作树带来的写入污染和并发切分支冲突。readonly 和 writable 模式都通过从 bare cache 做 local clone 获得独立的工作树。 + +| 操作 | 触发时机 | 说明 | +|------|---------|------| +| 创建缓存 | 首次访问某仓库时 | `git clone --bare ` | +| 更新缓存 | 每次使用前 | `git fetch --all`(如最近 N 分钟内已 fetch 则跳过) | +| 清理缓存 | 定时任务 | 超过 `REPO_CACHE_MAX_AGE_DAYS` 未访问或总大小超过 `REPO_CACHE_MAX_SIZE_GB` 时按 LRU 清理 | + +所有 git 操作必须携带安全参数: + +```bash +# clone 时 +git clone --bare \ + --config core.hooksPath=/dev/null \ + --no-recurse-submodules \ + -c protocol.file.allow=never \ + + +# fetch 时 +git -C fetch --all \ + --no-recurse-submodules \ + -c protocol.file.allow=never +``` + +#### 2.2 缓存路径映射 + +从仓库 URL 到缓存路径的映射规则: + +``` +https://github.com/foo/bar.git → {REPO_CACHE_DIR}/github.com/foo/bar.git +git@github.com:foo/bar.git → {REPO_CACHE_DIR}/github.com/foo/bar.git +https://gitlab.com/org/sub/proj → {REPO_CACHE_DIR}/gitlab.com/org/sub/proj.git +https://git.corp.com:8443/org/repo → {REPO_CACHE_DIR}/git.corp.com:8443/org/repo.git +``` + +**解析规则:** + +- 使用 Node.js `URL` 类解析 HTTP(S) URL,用专用逻辑解析 `git@host:path` 格式 +- 剥离认证信息(userinfo 部分),只保留 `host[:port]/path` +- 去除 `.git` 后缀后再统一追加 `.git`,确保一致性 +- **路径段统一转为小写**(GitHub/GitLab URL 大小写不敏感,但 Linux 文件系统敏感) +- 每个路径段禁止 `..`、空段、以 `.` 开头的段名 + +**路径穿越防护**:最终生成的缓存路径用 `path.resolve()` 解析后,校验 `resolvedPath.startsWith(REPO_CACHE_DIR)`,不满足则拒绝。 + +#### 2.3 并发安全 + +由于缓存为 bare clone(无工作树),并发风险大幅降低: + +- **local clone 并发读取 bare cache**:Git 原生支持,多个 `git clone ` 可安全并发 +- **`git fetch` 与 local clone 的竞态**:`git fetch` 更新 refs 和 pack 文件期间,`git clone` 可能获得不一致状态。使用文件锁(`flock`)互斥 fetch 和 clone 操作 +- **多个 fetch 并发**:通过 flock 串行化,同一缓存目录同一时间只有一个 fetch + +#### 2.4 原子性与故障恢复 + +缓存创建和工作区创建使用临时目录 + rename 策略,确保目录要么完整存在、要么不存在: + +``` +1. git clone --bare .tmp-{uuid} +2. rename .tmp-{uuid} → # 同一文件系统上原子操作 +``` + +- clone 失败时清理 `.tmp-*` 残留目录 +- 服务启动时扫描并清理 `REPO_CACHE_DIR` 和 `WORKSPACE_ROOT_DIR` 下的 `.tmp-*` 目录 + +### 3. setup_workspace MCP tool 改造 + +#### 3.1 接口变更 + +新增 `mode` 参数: + +```typescript +{ + repo_url?: string, // 远程仓库 URL + local_path?: string, // 本地仓库路径 + mode: 'readonly' | 'writable', // 新增:访问模式 + source_branch?: string, // 源分支 + feature_branch?: string, // feature 分支名 (仅 writable 模式有效) +} +``` + +#### 3.2 执行逻辑 + +**readonly 模式**: + +``` +1. 解析 repo_url → 生成缓存路径 (含路径穿越校验) +2. 缓存不存在?→ git clone --bare (带安全参数,原子创建) +3. 缓存已存在?→ git fetch --all (带 flock,近期已 fetch 则跳过) +4. git clone (local clone,秒级完成) +5. 如指定 source_branch → git checkout +6. 设置 cwd = workspace-path +7. 触发 onWorkspaceChanged 回调 +``` + +**writable 模式**: + +``` +1. 解析 repo_url → 生成缓存路径 (含路径穿越校验) +2. 缓存不存在?→ git clone --bare (带安全参数,原子创建) +3. 缓存已存在?→ git fetch --all (带 flock,近期已 fetch 则跳过) +4. git clone (local clone,秒级完成) +5. 设置远程 URL 为原始远程地址 (剥离认证信息): git remote set-url origin +6. git checkout -b [source_branch] +7. 设置 cwd = workspace-path +8. 触发 onWorkspaceChanged 回调 +``` + +> **注意**:`local_path` 参数在 readonly 模式下直接将 cwd 指向该路径(无需缓存/clone),writable 模式下从该路径 local clone 到隔离工作区(与现有行为一致)。 + +#### 3.3 Claude 的 system prompt 引导 + +更新 `WORKSPACE_SYSTEM_PROMPT`,让 Claude 理解两种模式的区别: + +``` +**模式选择:** +- mode='readonly': 只需要阅读、分析、理解代码时使用。不会创建 feature 分支。 +- mode='writable': 需要修改代码、提交变更时使用。会创建隔离工作区和 feature 分支。 + +**重要:** 调用 setup_workspace 后,系统将自动重启以加载项目配置。 +请在调用后仅输出简短确认(如"工作区已就绪"),不要继续执行后续任务。 +``` + +### 4. Agent 自动重启机制 + +#### 4.1 核心流程 + +``` +用户消息 + │ + ▼ +executeClaudeTask(prompt, workingDir) + │ + ▼ +query() 启动,cwd = 当前 workingDir + │ 使用默认 maxTurns / maxBudgetUsd(不限制第一次 query, + │ 确保不需要 setup_workspace 时也能完整执行任务) + │ + ├─ Claude 判断不需要切换仓库 → 正常执行 → 返回结果 + │ + └─ Claude 调用 setup_workspace → onWorkspaceChanged 触发 + │ + ▼ + 设置 workspaceChanged = true,记录 newWorkingDir + │ + ▼ + 当前 query 自然结束(Claude 输出 "工作区已就绪") + │ + ▼ + executor 返回结果,携带 restart 信号 + │ + ▼ + event-handler 检测到 restart 信号 + │ + ▼ + 清空 session.conversationId(避免残留指向短 session) + │ + ▼ + 更新进度卡片("正在加载项目配置...") + │ + ▼ + 发起新 query: + prompt = 原始用户请求 + cwd = newWorkingDir + 不提供 setup_workspace MCP tool(防止循环) + 不传 resumeSessionId(全新 session) + 使用正常的 maxTurns / maxBudgetUsd + │ + ▼ + 新 query 加载新仓库的 CLAUDE.md ✓ → 正常执行 → 返回最终结果 + │ + ▼ + 更新同一张进度卡片为最终结果 +``` + +#### 4.2 executor 改造 + +`ClaudeExecutor.execute()` 签名变更: + +```typescript +async execute( + sessionKey: string, + prompt: string, + workingDir: string, + resumeSessionId?: string, + onProgress?: ProgressCallback, + onWorkspaceChanged?: (newDir: string) => void, + options?: { + maxTurns?: number; // 覆盖默认的 50 + maxBudgetUsd?: number; // 覆盖默认的 5 + disableWorkspaceTool?: boolean; // 不注入 setup_workspace MCP tool + }, +): Promise +``` + +返回值增加 restart 相关字段: + +```typescript +interface ClaudeResult { + // ... 现有字段 + needsRestart?: boolean; // 是否需要重启 + newWorkingDir?: string; // 新的工作目录 +} +``` + +在 `onWorkspaceChanged` 回调中记录状态: + +```typescript +let workspaceChanged = false; +let newWorkingDir: string | undefined; + +const onWorkspaceChangedWrapped = (newDir: string) => { + workspaceChanged = true; + newWorkingDir = newDir; + onWorkspaceChanged?.(newDir); // 仍然更新 session +}; +``` + +MCP server 注入逻辑: + +```typescript +const mcpServers = options?.disableWorkspaceTool + ? {} + : { 'workspace-manager': createWorkspaceMcpServer(onWorkspaceChangedWrapped) }; +``` + +#### 4.3 event-handler 改造 + +在 `executeClaudeTask` 中处理 restart: + +```typescript +const result = await claudeExecutor.execute( + sessionKey, prompt, session.workingDir, session.conversationId, + onProgress, onWorkspaceChanged, +); + +if (result.needsRestart && result.newWorkingDir) { + // 清空残留的 conversationId + sessionManager.setConversationId(chatId, userId, ''); + + // 更新进度卡片 + await feishuClient.updateCard(progressMsgId, buildProgressCard(prompt, '正在加载项目配置...')); + + // 以新工作目录重新执行 + const restartResult = await claudeExecutor.execute( + sessionKey, + prompt, // 原始用户请求 + result.newWorkingDir, // 新的工作目录 + undefined, // 不 resume,全新 session + onProgress, + undefined, // 不传 onWorkspaceChanged + { disableWorkspaceTool: true }, // 不注入 setup_workspace MCP tool + ); + + // 用 restartResult 更新卡片(流程与现有逻辑相同) + // ... + return; +} + +// 无 restart,正常更新卡片(现有逻辑) +``` + +#### 4.4 防止无限循环 + +三层防护,确保 restart 最多发生一次: + +1. **语义层**:restart 后 `cwd` 已是目标仓库,Claude 不会再判断需要 clone +2. **工具层**:restart query 中通过 `disableWorkspaceTool: true` 完全移除 `setup_workspace` MCP tool,即使 Claude 想调用也找不到该工具 +3. **回调层**:不传 `onWorkspaceChanged`,即使意外触发也不会设置 `needsRestart` + +#### 4.5 restart 期间的 abort 处理 + +用户可能在第一次 query 结束和 restart query 开始之间发送 `/stop` 命令。在发起 restart query 前检查 session 状态: + +```typescript +if (result.needsRestart && result.newWorkingDir) { + // 检查是否被用户中断 + const currentSession = sessionManager.get(chatId, userId); + if (!currentSession || currentSession.status !== 'busy') { + logger.info({ chatId, userId }, 'Restart cancelled: session no longer busy'); + return; + } + // ... 继续 restart +} +``` + +### 5. 配置项 + +新增环境变量: + +```bash +# 仓库缓存 +REPO_CACHE_DIR=/repos/cache # 缓存根目录 +REPO_CACHE_MAX_AGE_DAYS=30 # 缓存最大保留天数 +REPO_CACHE_MAX_SIZE_GB=50 # 缓存最大总大小,超过按 LRU 清理 +REPO_CACHE_FETCH_INTERVAL_MIN=10 # 同一仓库两次 fetch 的最小间隔(分钟) + +# 隔离工作区 +WORKSPACE_ROOT_DIR=/workspaces # 工作区根目录 (现有 DEFAULT_WORK_DIR 的替代) +``` + +### 6. 对现有功能的影响 + +| 功能 | 影响 | 说明 | +|------|------|------| +| `/project` 命令 | 无变化 | 手动切换目录,下次 query 自然生效 | +| `/workspace` 命令 | 改为使用缓存层 | 速度提升,行为不变 | +| `/reset` 命令 | 无变化 | 重置 session,清除 conversationId | +| session resume | 行为变化 | restart 前清空 conversationId,restart 后保存新 session 的 ID | +| 多用户并发 | 需先修复 taskQueue | 缓存层通过 bare clone + flock 保证安全,工作区按 session 隔离 | + +## 实现步骤 + +### Phase 1:restart 机制(核心价值) + +1. **前置:集成 taskQueue** — 确保 `executeClaudeTask` 通过 taskQueue 串行化执行 +2. **新增配置项** — `WORKSPACE_ROOT_DIR` 等 restart 相关配置 +3. **改造 `src/claude/executor.ts`** — `execute()` 增加 `options` 参数、返回值增加 `needsRestart` / `newWorkingDir`、包装 `onWorkspaceChanged` 回调、支持 `disableWorkspaceTool` +4. **改造 `src/feishu/event-handler.ts`** — `executeClaudeTask` 增加 restart 逻辑(清空 conversationId → 更新卡片 → 重新执行) +5. **更新 system prompt** — 引导 Claude 在调用 `setup_workspace` 后立即结束 +6. **增加 git 安全参数** — 在 `src/workspace/manager.ts` 的所有 git 操作中添加 `core.hooksPath=/dev/null`、`--no-recurse-submodules`、`-c protocol.file.allow=never` + +### Phase 2:缓存层 + readonly/writable 模式 + +7. **新增配置项** — `REPO_CACHE_DIR`、`REPO_CACHE_MAX_AGE_DAYS`、`REPO_CACHE_MAX_SIZE_GB`、`REPO_CACHE_FETCH_INTERVAL_MIN` +8. **新增 `src/workspace/cache.ts`** — bare clone 缓存管理(URL 解析与路径穿越校验、缓存创建/更新/清理、flock 并发控制、原子目录创建) +9. **改造 `src/workspace/manager.ts`** — `setupWorkspace` 增加 `mode` 参数,接入缓存层 +10. **改造 `src/workspace/tool.ts`** — MCP tool schema 增加 `mode` 参数 +11. **更新 system prompt** — 增加 readonly/writable 模式选择引导 +12. **缓存清理** — 在现有的 30 分钟 cleanup interval 中加入过期缓存清理、服务启动时清理 `.tmp-*` 残留、session 过期时联动删除磁盘上的工作区目录 + +> 步骤 3-4(executor/event-handler restart 改造)与步骤 8(cache 模块)相互独立,可并行开发。 + +## 风险与考量 + +### 磁盘空间 + +缓存目录会持续增长。通过 `REPO_CACHE_MAX_AGE_DAYS` 和 `REPO_CACHE_MAX_SIZE_GB` 双重控制,清理任务定期执行。Session 过期时应联动删除磁盘上的工作区目录(当前的 `sessionManager.cleanup()` 只删除数据库记录)。大型 mono-repo 可考虑 `--depth=1` shallow clone 作为缓存。 + +### Git 安全 + +对用户提供的任意仓库 URL 执行 git 操作存在风险: + +- **Git hooks**:恶意仓库可通过 hooks 在 clone 时执行任意命令。通过 `core.hooksPath=/dev/null` 禁用。 +- **Submodules**:恶意仓库可通过 `.gitmodules` 指向内网地址(SSRF)或触发递归 clone。通过 `--no-recurse-submodules` 和 `-c protocol.file.allow=never` 禁用。 +- **认证信息泄露**:`git remote set-url origin` 时必须剥离 URL 中的 userinfo 部分,避免凭据写入 `.git/config` 被 Claude 读取。 +- **可选加固**:仓库 URL 主机名白名单(只允许 `github.com`、`gitlab.com` 及配置的私有实例)。 + +### 重启带来的额外耗时 + +restart 意味着两次 query 调用。第一次 query 使用默认的 turns/budget 限制(不人为降低),如果 Claude 不需要 setup_workspace 则在第一次 query 中完整执行任务,不会触发 restart。system prompt 引导 Claude 在调用 setup_workspace 后尽快结束,使 restart 额外开销可控。进度卡片分阶段更新("正在加载项目配置..." → 最终结果),让用户了解进展。 + +### 缓存一致性 + +缓存仓库可能不是最新的。每次使用前执行 `git fetch --all` 可以缓解,通过 `REPO_CACHE_FETCH_INTERVAL_MIN` 控制 fetch 频率避免大型仓库的重复 fetch 开销。对于大多数使用场景(代码分析、bug 修复),短暂的不一致窗口是可以接受的。 + +### CLAUDE.md prompt injection + +恶意仓库的 CLAUDE.md 可能包含 prompt injection 内容。这是 Claude Code Agent SDK `settingSources: ['project']` 机制的固有信任边界问题,并非本方案引入。restart 机制使该风险更为显式(restart 的明确目的就是加载目标仓库的 CLAUDE.md),但不改变风险的性质。确保 `canUseTool` 安全策略在 restart query 中同样生效即可。 diff --git a/src/claude/__tests__/executor.test.ts b/src/claude/__tests__/executor.test.ts new file mode 100644 index 00000000..c3460442 --- /dev/null +++ b/src/claude/__tests__/executor.test.ts @@ -0,0 +1,227 @@ +// @ts-nocheck — test file, vitest uses esbuild transform +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ============================================================ +// Mocks +// ============================================================ + +vi.mock('node:fs', () => ({ + existsSync: vi.fn(() => true), + mkdirSync: vi.fn(), +})); + +vi.mock('../../config.js', () => ({ + config: { + claude: { defaultWorkDir: '/tmp/work' }, + }, +})); + +vi.mock('../../utils/logger.js', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +// Mock the workspace tool module +const mockCreateWorkspaceMcpServer = vi.fn(() => ({ type: 'mock-mcp-server' })); +vi.mock('../../workspace/tool.js', () => ({ + createWorkspaceMcpServer: (...args: unknown[]) => mockCreateWorkspaceMcpServer(...args), +})); + +// Mock the SDK query function — returns an async iterable of messages +const mockQueryInstance = { + close: vi.fn(), + [Symbol.asyncIterator]: vi.fn(), +}; +const mockQuery = vi.fn(() => mockQueryInstance); + +vi.mock('@anthropic-ai/claude-agent-sdk', () => ({ + query: (...args: unknown[]) => mockQuery(...args), +})); + +import { ClaudeExecutor } from '../executor.js'; + +// ============================================================ +// Helpers +// ============================================================ + +/** Create a mock async iterator that yields given messages */ +function setupMessages(messages: Array>) { + const iter = messages[Symbol.iterator](); + mockQueryInstance[Symbol.asyncIterator].mockReturnValue({ + next: () => { + const { value, done } = iter.next(); + return Promise.resolve({ value, done: done ?? false }); + }, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + // Default: yield a simple success result + setupMessages([ + { type: 'system', subtype: 'init', session_id: 'sess-1', model: 'claude', tools: [] }, + { type: 'result', subtype: 'success', session_id: 'sess-1', result: 'hello', duration_ms: 100 }, + ]); +}); + +// ============================================================ +// Tests +// ============================================================ + +describe('ClaudeExecutor', () => { + let executor: ClaudeExecutor; + + beforeEach(() => { + executor = new ClaudeExecutor(); + }); + + describe('restart signal', () => { + it('should set needsRestart when onWorkspaceChanged is called', async () => { + // 模拟 workspace tool 在 query 执行中触发 onWorkspaceChanged + let capturedOnWorkspaceChanged: ((dir: string) => void) | undefined; + mockCreateWorkspaceMcpServer.mockImplementation((cb: (dir: string) => void) => { + capturedOnWorkspaceChanged = cb; + return { type: 'mock-mcp-server' }; + }); + + setupMessages([ + { type: 'system', subtype: 'init', session_id: 'sess-1', model: 'claude', tools: [] }, + { type: 'result', subtype: 'success', session_id: 'sess-1', result: 'workspace ready', duration_ms: 50 }, + ]); + + const externalCallback = vi.fn(); + const resultPromise = executor.execute( + 'chat1:user1', 'test prompt', '/tmp/work', + undefined, undefined, externalCallback, + ); + + // 模拟 MCP tool 在迭代过程中调用 onWorkspaceChanged + // 由于 mock 的 async iterator 是同步 resolve 的,这里需要在 query 构建后触发 + // 实际上 capturedOnWorkspaceChanged 会在 createWorkspaceMcpServer 调用时被捕获 + // 手动触发 + if (capturedOnWorkspaceChanged) { + capturedOnWorkspaceChanged('/new/workspace'); + } + + const result = await resultPromise; + + expect(result.needsRestart).toBe(true); + expect(result.newWorkingDir).toBe('/new/workspace'); + // 外部回调也应被调用 + expect(externalCallback).toHaveBeenCalledWith('/new/workspace'); + }); + + it('should not set needsRestart when workspace does not change', async () => { + const result = await executor.execute( + 'chat1:user1', 'test prompt', '/tmp/work', + undefined, undefined, vi.fn(), + ); + + expect(result.needsRestart).toBeFalsy(); + expect(result.newWorkingDir).toBeUndefined(); + }); + + it('should include needsRestart in error results', async () => { + let capturedCb: ((dir: string) => void) | undefined; + mockCreateWorkspaceMcpServer.mockImplementation((cb: (dir: string) => void) => { + capturedCb = cb; + return { type: 'mock-mcp-server' }; + }); + + setupMessages([ + { type: 'system', subtype: 'init', session_id: 'sess-1', model: 'claude', tools: [] }, + { type: 'result', subtype: 'error', session_id: 'sess-1', errors: ['something failed'], duration_ms: 50 }, + ]); + + const promise = executor.execute( + 'chat1:user1', 'test', '/tmp/work', + undefined, undefined, vi.fn(), + ); + capturedCb?.('/new/dir'); + const result = await promise; + + expect(result.success).toBe(false); + expect(result.needsRestart).toBe(true); + expect(result.newWorkingDir).toBe('/new/dir'); + }); + }); + + describe('disableWorkspaceTool', () => { + it('should not create MCP server when disableWorkspaceTool is true', async () => { + await executor.execute( + 'chat1:user1', 'test', '/tmp/work', + undefined, undefined, undefined, + { disableWorkspaceTool: true }, + ); + + // createWorkspaceMcpServer should NOT be called + expect(mockCreateWorkspaceMcpServer).not.toHaveBeenCalled(); + + // query should be called with mcpServers: undefined + const queryCallOptions = mockQuery.mock.calls[0][0].options; + expect(queryCallOptions.mcpServers).toBeUndefined(); + }); + + it('should create MCP server when disableWorkspaceTool is not set', async () => { + await executor.execute( + 'chat1:user1', 'test', '/tmp/work', + undefined, undefined, vi.fn(), + ); + + expect(mockCreateWorkspaceMcpServer).toHaveBeenCalledTimes(1); + const queryCallOptions = mockQuery.mock.calls[0][0].options; + expect(queryCallOptions.mcpServers).toHaveProperty('workspace-manager'); + }); + }); + + describe('options overrides', () => { + it('should use default maxTurns and maxBudgetUsd', async () => { + await executor.execute('chat1:user1', 'test', '/tmp/work'); + + const opts = mockQuery.mock.calls[0][0].options; + expect(opts.maxTurns).toBe(50); + expect(opts.maxBudgetUsd).toBe(5); + }); + + it('should override maxTurns and maxBudgetUsd from options', async () => { + await executor.execute( + 'chat1:user1', 'test', '/tmp/work', + undefined, undefined, undefined, + { maxTurns: 5, maxBudgetUsd: 0.5 }, + ); + + const opts = mockQuery.mock.calls[0][0].options; + expect(opts.maxTurns).toBe(5); + expect(opts.maxBudgetUsd).toBe(0.5); + }); + }); + + describe('workspace changed callback wrapping', () => { + it('should not wrap when onWorkspaceChanged is undefined', async () => { + await executor.execute( + 'chat1:user1', 'test', '/tmp/work', + undefined, undefined, undefined, + ); + + // createWorkspaceMcpServer should be called with undefined (no wrapping) + expect(mockCreateWorkspaceMcpServer).toHaveBeenCalledWith(undefined); + }); + + it('should wrap when onWorkspaceChanged is provided', async () => { + const cb = vi.fn(); + await executor.execute( + 'chat1:user1', 'test', '/tmp/work', + undefined, undefined, cb, + ); + + // createWorkspaceMcpServer should be called with a wrapper function (not the original cb) + const passedCb = mockCreateWorkspaceMcpServer.mock.calls[0][0]; + expect(passedCb).toBeDefined(); + expect(passedCb).not.toBe(cb); // It's a wrapper + }); + }); +}); diff --git a/src/claude/executor.ts b/src/claude/executor.ts index 068e606e..556a6abb 100644 --- a/src/claude/executor.ts +++ b/src/claude/executor.ts @@ -4,7 +4,7 @@ import { mkdirSync, existsSync } from 'node:fs'; import { config } from '../config.js'; import { logger } from '../utils/logger.js'; import { createWorkspaceMcpServer } from '../workspace/tool.js'; -import type { ClaudeResult, ProgressCallback } from './types.js'; +import type { ClaudeResult, ExecuteOptions, ProgressCallback } from './types.js'; // ============================================================ // Claude Agent SDK 执行器 @@ -19,22 +19,29 @@ const WORKSPACE_SYSTEM_PROMPT = `你正在通过飞书消息与用户交互。 ## 工作区管理 -你有一个 setup_workspace 工具可用,用于为代码修改任务创建隔离工作区。 +你有一个 setup_workspace 工具可用,用于为代码任务创建隔离工作区。 **何时使用 setup_workspace:** -- 当用户提供了 GitHub/GitLab 等远程仓库 URL,需要 clone 并修改代码时 -- 当用户指定了本地仓库路径,需要在隔离环境中修改代码时(避免影响原始仓库) -- 当用户的请求涉及对某个仓库的代码修改,且当前工作目录不是该仓库时 +- 当用户提供了 GitHub/GitLab 等远程仓库 URL,需要 clone 代码时 +- 当用户指定了本地仓库路径,需要在隔离环境中操作时 +- 当用户的请求涉及某个仓库,且当前工作目录不是该仓库时 + +**模式选择 (mode 参数):** +- mode="readonly": 只需要阅读、分析、理解代码时使用。不会创建 feature 分支。 +- mode="writable": 需要修改代码、提交变更时使用。会创建隔离工作区和 feature 分支。 **如何使用:** - 远程仓库: 使用 repo_url 参数传入仓库 URL - 本地仓库: 使用 local_path 参数传入仓库绝对路径 -- 可选指定 source_branch (源分支) 和 feature_branch (自定义分支名) +- 根据意图选择 mode (readonly 或 writable) +- 可选指定 source_branch (源分支) 和 feature_branch (自定义分支名, 仅 writable) **无需使用的场景:** -- 用户只是询问问题、不涉及代码修改 - 当前工作目录已经是目标仓库 -- 用户明确表示要在当前目录操作`; +- 用户明确表示要在当前目录操作 + +**重要:调用 setup_workspace 后,系统将自动重启以加载项目配置(CLAUDE.md 等)。 +请在调用后仅输出简短确认(如"工作区已就绪,正在重新加载项目配置..."),不要继续执行后续任务。**`; export class ClaudeExecutor { /** 运行中的 query 实例 (用于 abort) */ @@ -49,6 +56,7 @@ export class ClaudeExecutor { * @param resumeSessionId 可选:恢复之前的会话 * @param onProgress 进度回调 * @param onWorkspaceChanged 工作区变更回调 (MCP 工具 clone 后更新 session) + * @param options 可选参数 (maxTurns, maxBudgetUsd, disableWorkspaceTool) */ async execute( sessionKey: string, @@ -57,6 +65,7 @@ export class ClaudeExecutor { resumeSessionId?: string, onProgress?: ProgressCallback, onWorkspaceChanged?: (newDir: string) => void, + options?: ExecuteOptions, ): Promise { const startTime = Date.now(); const abortController = new AbortController(); @@ -72,9 +81,24 @@ export class ClaudeExecutor { 'Executing Claude Agent SDK query', ); + // 跟踪 workspace 变更,用于 restart 信号 + let workspaceChanged = false; + let newWorkingDir: string | undefined; + + const onWorkspaceChangedWrapped = onWorkspaceChanged + ? (newDir: string) => { + workspaceChanged = true; + newWorkingDir = newDir; + onWorkspaceChanged(newDir); + } + : undefined; + // 每次 query 创建独立的 MCP 服务器实例,通过闭包绑定当前 session 的回调 // 确保多 chat 并发执行时互不干扰 - const workspaceMcpServer = createWorkspaceMcpServer(onWorkspaceChanged); + // restart 时通过 disableWorkspaceTool 完全移除 setup_workspace,防止无限循环 + const mcpServers = options?.disableWorkspaceTool + ? undefined + : { 'workspace-manager': createWorkspaceMcpServer(onWorkspaceChangedWrapped) }; // 构建 SDK query const q = query({ @@ -100,8 +124,8 @@ export class ClaudeExecutor { }, // 预算和限制 - maxTurns: 50, - maxBudgetUsd: 5, + maxTurns: options?.maxTurns ?? 50, + maxBudgetUsd: options?.maxBudgetUsd ?? 5, // 会话续接 ...(resumeSessionId ? { resume: resumeSessionId } : {}), @@ -116,10 +140,8 @@ export class ClaudeExecutor { // 加载项目设置 (CLAUDE.md 等) settingSources: ['project'], - // MCP 服务器:工作区管理工具 - mcpServers: { - 'workspace-manager': workspaceMcpServer, - }, + // MCP 服务器:工作区管理工具 (restart 时为空对象,不注入 setup_workspace) + mcpServers, }, }); @@ -203,6 +225,8 @@ export class ClaudeExecutor { durationApiMs: resultMessage.duration_api_ms, costUsd: resultMessage.total_cost_usd, numTurns: resultMessage.num_turns, + needsRestart: workspaceChanged, + newWorkingDir, }; } else { // 错误结果 @@ -216,6 +240,8 @@ export class ClaudeExecutor { durationApiMs: resultMessage.duration_api_ms, costUsd: resultMessage.total_cost_usd, numTurns: resultMessage.num_turns, + needsRestart: workspaceChanged, + newWorkingDir, }; } } @@ -226,6 +252,8 @@ export class ClaudeExecutor { output: output || '(无输出)', sessionId, durationMs, + needsRestart: workspaceChanged, + newWorkingDir, }; } diff --git a/src/claude/types.ts b/src/claude/types.ts index 2412a16f..2b162064 100644 --- a/src/claude/types.ts +++ b/src/claude/types.ts @@ -34,6 +34,20 @@ export interface ClaudeResult { costUsd?: number; /** 总轮数 */ numTurns?: number; + /** 是否需要重启 (workspace 变更后) */ + needsRestart?: boolean; + /** 重启目标工作目录 */ + newWorkingDir?: string; +} + +/** executor.execute() 的可选参数 */ +export interface ExecuteOptions { + /** 覆盖默认 maxTurns (默认 50) */ + maxTurns?: number; + /** 覆盖默认 maxBudgetUsd (默认 5) */ + maxBudgetUsd?: number; + /** 不注入 setup_workspace MCP tool (restart 时使用) */ + disableWorkspaceTool?: boolean; } /** 执行进度回调 — 接收 SDK 的 SDKMessage */ diff --git a/src/config.ts b/src/config.ts index a576523a..f52c7e6e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -35,6 +35,18 @@ export const config = { branchPrefix: process.env.WORKSPACE_BRANCH_PREFIX || 'feat/claude-session', }, + // 仓库缓存配置 + repoCache: { + /** 缓存根目录 (bare clone 存放位置) */ + dir: process.env.REPO_CACHE_DIR || '/repos/cache', + /** 缓存最大保留天数 */ + maxAgeDays: parseInt(process.env.REPO_CACHE_MAX_AGE_DAYS || '30', 10), + /** 缓存最大总大小 (GB),超过按 LRU 清理 — TODO: 尚未实现,当前仅按过期时间清理 */ + maxSizeGb: parseInt(process.env.REPO_CACHE_MAX_SIZE_GB || '50', 10), + /** 同一仓库两次 fetch 的最小间隔 (分钟) */ + fetchIntervalMin: parseInt(process.env.REPO_CACHE_FETCH_INTERVAL_MIN || '10', 10), + }, + // 数据库配置 db: { sessionDbPath: process.env.SESSION_DB_PATH || './data/sessions.db', diff --git a/src/feishu/__tests__/event-handler.test.ts b/src/feishu/__tests__/event-handler.test.ts new file mode 100644 index 00000000..8524f400 --- /dev/null +++ b/src/feishu/__tests__/event-handler.test.ts @@ -0,0 +1,315 @@ +// @ts-nocheck — test file, vitest uses esbuild transform +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { ClaudeResult } from '../../claude/types.js'; + +// ============================================================ +// Mocks +// ============================================================ + +const mockExecute = vi.fn<(...args: unknown[]) => Promise>(); + +vi.mock('../../claude/executor.js', () => ({ + claudeExecutor: { + execute: (...args: unknown[]) => mockExecute(...args), + killSession: vi.fn(), + }, +})); + +const mockSessionGet = vi.fn(); +const mockSessionGetOrCreate = vi.fn(); +const mockSessionSetWorkingDir = vi.fn(); +const mockSessionSetStatus = vi.fn(); +const mockSessionSetConversationId = vi.fn(); +const mockSessionSetThread = vi.fn(); + +vi.mock('../../session/manager.js', () => ({ + sessionManager: { + get: (...args: unknown[]) => mockSessionGet(...args), + getOrCreate: (...args: unknown[]) => mockSessionGetOrCreate(...args), + setWorkingDir: (...args: unknown[]) => mockSessionSetWorkingDir(...args), + setStatus: (...args: unknown[]) => mockSessionSetStatus(...args), + setConversationId: (...args: unknown[]) => mockSessionSetConversationId(...args), + setThread: (...args: unknown[]) => mockSessionSetThread(...args), + reset: vi.fn(), + }, +})); + +vi.mock('../../session/queue.js', () => { + // 简化版 TaskQueue 用于测试 + const queues = new Map void; reject: (e: Error) => void }>>(); + return { + taskQueue: { + enqueue: vi.fn((_chatId: string, _userId: string, _msg: string, _msgId: string) => { + return new Promise((resolve, reject) => { + // 不实际入队,测试中直接由 processQueue 驱动 + }); + }), + dequeue: vi.fn(), + complete: vi.fn(), + pendingCount: vi.fn(() => 0), + cancelPending: vi.fn(() => 0), + isBusy: vi.fn(() => false), + }, + }; +}); + +const mockReplyText = vi.fn(); +const mockReplyInThread = vi.fn(() => Promise.resolve({ messageId: 'bot-msg-1', threadId: 'thread-1' })); +const mockSendCard = vi.fn(() => Promise.resolve('card-msg-1')); +const mockUpdateCard = vi.fn(); +const mockReplyCardInThread = vi.fn(() => Promise.resolve('card-msg-2')); +const mockReplyTextInThread = vi.fn(); +const mockSendText = vi.fn(); + +vi.mock('../client.js', () => ({ + feishuClient: { + replyText: (...args: unknown[]) => mockReplyText(...args), + replyInThread: (...args: unknown[]) => mockReplyInThread(...args), + sendCard: (...args: unknown[]) => mockSendCard(...args), + updateCard: (...args: unknown[]) => mockUpdateCard(...args), + replyCardInThread: (...args: unknown[]) => mockReplyCardInThread(...args), + replyTextInThread: (...args: unknown[]) => mockReplyTextInThread(...args), + sendText: (...args: unknown[]) => mockSendText(...args), + }, +})); + +vi.mock('../message-builder.js', () => ({ + buildProgressCard: vi.fn((prompt: string, status?: string) => ({ + type: 'progress', prompt, status: status || '正在处理...', + })), + buildResultCard: vi.fn((_prompt: string, output: string, success: boolean) => ({ + type: 'result', output, success, + })), + buildStatusCard: vi.fn(), +})); + +vi.mock('../../utils/logger.js', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +vi.mock('../../utils/security.js', () => ({ + isUserAllowed: vi.fn(() => true), + containsDangerousCommand: vi.fn(() => false), +})); + +vi.mock('../../config.js', () => ({ + config: { + feishu: { encryptKey: '', verifyToken: '' }, + security: { allowedUserIds: [] }, + claude: { defaultWorkDir: '/tmp/work' }, + workspace: { baseDir: '/tmp/workspaces', branchPrefix: 'feat/test' }, + }, +})); + +vi.mock('../../workspace/manager.js', () => ({ + setupWorkspace: vi.fn(), +})); + +// ============================================================ +// 由于 event-handler 中 executeClaudeTask 是私有函数, +// 我们通过模拟完整的消息处理流程来测试 restart 逻辑。 +// 但 event-handler 导出的是 createEventDispatcher,不方便直接测试。 +// 所以我们提取关键的 restart 逻辑进行单元测试。 +// +// 这里测试的核心逻辑: +// 1. 第一次 execute 返回 needsRestart → 触发第二次 execute +// 2. 第二次 execute 使用 newWorkingDir + disableWorkspaceTool +// 3. restart 前清空 conversationId +// 4. restart 前检查 session 是否仍为 busy +// ============================================================ + +/** + * 模拟 executeClaudeTask 的核心 restart 逻辑 + * (从 event-handler.ts 提取的逻辑,用于可测试性) + */ +async function simulateExecuteClaudeTask( + prompt: string, + chatId: string, + userId: string, +) { + const { claudeExecutor } = await import('../../claude/executor.js'); + const { sessionManager } = await import('../../session/manager.js'); + + const session = sessionManager.getOrCreate(chatId, userId); + const sessionKey = `${chatId}:${userId}`; + + sessionManager.setStatus(chatId, userId, 'busy'); + + const onWorkspaceChanged = (newDir: string) => { + sessionManager.setWorkingDir(chatId, userId, newDir); + }; + + const result = await claudeExecutor.execute( + sessionKey, prompt, session.workingDir, + session.conversationId, undefined, onWorkspaceChanged, + ); + + if (result.needsRestart && result.newWorkingDir) { + const currentSession = sessionManager.get(chatId, userId); + if (!currentSession || currentSession.status !== 'busy') { + return { restarted: false, reason: 'session_not_busy' }; + } + + sessionManager.setConversationId(chatId, userId, ''); + + const restartResult = await claudeExecutor.execute( + sessionKey, prompt, result.newWorkingDir, + undefined, undefined, undefined, + { disableWorkspaceTool: true }, + ); + + if (restartResult.sessionId) { + sessionManager.setConversationId(chatId, userId, restartResult.sessionId); + } + + return { restarted: true, result: restartResult }; + } + + if (result.sessionId) { + sessionManager.setConversationId(chatId, userId, result.sessionId); + } + + return { restarted: false, result }; +} + +// ============================================================ +// Tests +// ============================================================ + +describe('executeClaudeTask restart logic', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockSessionGetOrCreate.mockReturnValue({ + chatId: 'chat1', + userId: 'user1', + workingDir: '/tmp/work', + status: 'idle', + conversationId: 'old-conv-id', + }); + mockSessionGet.mockReturnValue({ + chatId: 'chat1', + userId: 'user1', + workingDir: '/tmp/work', + status: 'busy', + }); + }); + + it('should not restart when needsRestart is false', async () => { + mockExecute.mockResolvedValueOnce({ + success: true, + output: 'done', + sessionId: 'sess-1', + durationMs: 100, + }); + + const outcome = await simulateExecuteClaudeTask('fix the bug', 'chat1', 'user1'); + + expect(outcome.restarted).toBe(false); + expect(mockExecute).toHaveBeenCalledTimes(1); + expect(mockSessionSetConversationId).toHaveBeenCalledWith('chat1', 'user1', 'sess-1'); + }); + + it('should restart with new cwd when needsRestart is true', async () => { + // 第一次 execute: workspace changed + mockExecute.mockResolvedValueOnce({ + success: true, + output: 'workspace ready', + sessionId: 'sess-setup', + durationMs: 50, + needsRestart: true, + newWorkingDir: '/workspaces/my-repo', + }); + // 第二次 execute: 正常执行 + mockExecute.mockResolvedValueOnce({ + success: true, + output: 'bug fixed', + sessionId: 'sess-main', + durationMs: 200, + }); + + const outcome = await simulateExecuteClaudeTask('fix the bug', 'chat1', 'user1'); + + expect(outcome.restarted).toBe(true); + expect(mockExecute).toHaveBeenCalledTimes(2); + + // 第二次 execute 的参数检查 + const secondCall = mockExecute.mock.calls[1]; + expect(secondCall[0]).toBe('chat1:user1'); // sessionKey + expect(secondCall[1]).toBe('fix the bug'); // 原始 prompt + expect(secondCall[2]).toBe('/workspaces/my-repo'); // 新 cwd + expect(secondCall[3]).toBeUndefined(); // 不 resume + expect(secondCall[5]).toBeUndefined(); // 不传 onWorkspaceChanged + expect(secondCall[6]).toEqual({ disableWorkspaceTool: true }); // 禁用 workspace tool + }); + + it('should clear conversationId before restart', async () => { + mockExecute.mockResolvedValueOnce({ + success: true, output: 'ready', durationMs: 50, + needsRestart: true, newWorkingDir: '/new/dir', + }); + mockExecute.mockResolvedValueOnce({ + success: true, output: 'done', sessionId: 'sess-new', durationMs: 100, + }); + + await simulateExecuteClaudeTask('test', 'chat1', 'user1'); + + // conversationId 应先被清空,再被设置为新的 + const setCalls = mockSessionSetConversationId.mock.calls; + expect(setCalls[0]).toEqual(['chat1', 'user1', '']); // 清空 + expect(setCalls[1]).toEqual(['chat1', 'user1', 'sess-new']); // 设置新值 + }); + + it('should cancel restart if session is no longer busy', async () => { + mockExecute.mockResolvedValueOnce({ + success: true, output: 'ready', durationMs: 50, + needsRestart: true, newWorkingDir: '/new/dir', + }); + + // 模拟用户在 restart 前发了 /stop + mockSessionGet.mockReturnValue({ + chatId: 'chat1', userId: 'user1', workingDir: '/tmp/work', status: 'idle', + }); + + const outcome = await simulateExecuteClaudeTask('test', 'chat1', 'user1'); + + expect(outcome.restarted).toBe(false); + expect(outcome.reason).toBe('session_not_busy'); + expect(mockExecute).toHaveBeenCalledTimes(1); // 没有第二次 execute + }); + + it('should cancel restart if session is not found', async () => { + mockExecute.mockResolvedValueOnce({ + success: true, output: 'ready', durationMs: 50, + needsRestart: true, newWorkingDir: '/new/dir', + }); + + // 模拟 session 被 /reset 删除 + mockSessionGet.mockReturnValue(undefined); + + const outcome = await simulateExecuteClaudeTask('test', 'chat1', 'user1'); + + expect(outcome.restarted).toBe(false); + expect(outcome.reason).toBe('session_not_busy'); + }); + + it('should save restart result sessionId for future resume', async () => { + mockExecute.mockResolvedValueOnce({ + success: true, output: 'ready', durationMs: 50, + needsRestart: true, newWorkingDir: '/new/dir', + }); + mockExecute.mockResolvedValueOnce({ + success: true, output: 'done', sessionId: 'sess-restart', durationMs: 100, + }); + + await simulateExecuteClaudeTask('test', 'chat1', 'user1'); + + // 最后设置的 conversationId 应该是 restart query 的 + const lastCall = mockSessionSetConversationId.mock.calls.at(-1); + expect(lastCall).toEqual(['chat1', 'user1', 'sess-restart']); + }); +}); diff --git a/src/feishu/event-handler.ts b/src/feishu/event-handler.ts index 461377d6..a5e0cd11 100644 --- a/src/feishu/event-handler.ts +++ b/src/feishu/event-handler.ts @@ -79,6 +79,24 @@ export function createCardActionHandler(): lark.CardActionHandler { return handler; } +// ============================================================ +// 队列驱动:确保同一 chat 的 query 串行执行 +// ============================================================ + +function processQueue(chatId: string): void { + const task = taskQueue.dequeue(chatId); + if (!task) return; + + executeClaudeTask(task.message, task.chatId, task.userId, task.messageId, task.rootId) + .then(() => task.resolve('done')) + .catch((err) => task.reject(err instanceof Error ? err : new Error(String(err)))) + .finally(() => { + taskQueue.complete(chatId); + // 处理队列中的下一个任务 + processQueue(chatId); + }); +} + // ============================================================ // 消息处理逻辑 // ============================================================ @@ -166,8 +184,10 @@ async function handleMessageEvent(data: MessageEventData): Promise { return; } - // 执行 Claude Agent - await executeClaudeTask(text, chatId, userId, messageId, rootId); + // 通过 taskQueue 串行化执行,确保同一 chat 同一时间只有一个 query + // enqueue 返回的 Promise 的错误处理在 processQueue/executeClaudeTask 中完成 + taskQueue.enqueue(chatId, userId, text, messageId, rootId).catch(() => {}); + processQueue(chatId); } /** @@ -190,9 +210,35 @@ async function handleSlashCommand( // /project - 切换工作目录 if (trimmed.startsWith('/project ')) { const dir = trimmed.slice('/project '.length).trim(); + // 安全校验:路径必须在允许的基目录下(用 realpathSync 跟踪 symlink) + const { resolve } = await import('node:path'); + const { existsSync, realpathSync } = await import('node:fs'); + const resolved = resolve(dir); + if (!existsSync(resolved)) { + const reply = `⚠️ 路径不存在: ${dir}`; + if (threadRootMsgId) { + await feishuClient.replyTextInThread(threadRootMsgId, reply); + } else { + await feishuClient.replyText(messageId, reply); + } + return true; + } + const realResolved = realpathSync(resolved); + const allowedBase = existsSync(resolve(config.claude.defaultWorkDir)) + ? realpathSync(resolve(config.claude.defaultWorkDir)) + : resolve(config.claude.defaultWorkDir); + if (!realResolved.startsWith(allowedBase + '/') && realResolved !== allowedBase) { + const reply = `⚠️ 路径不在允许的目录范围内 (允许: ${allowedBase})`; + if (threadRootMsgId) { + await feishuClient.replyTextInThread(threadRootMsgId, reply); + } else { + await feishuClient.replyText(messageId, reply); + } + return true; + } sessionManager.getOrCreate(chatId, userId); - sessionManager.setWorkingDir(chatId, userId, dir); - const reply = `📂 工作目录已切换到: ${dir}`; + sessionManager.setWorkingDir(chatId, userId, realResolved); + const reply = `📂 工作目录已切换到: ${realResolved}`; if (threadRootMsgId) { await feishuClient.replyTextInThread(threadRootMsgId, reply); } else { @@ -368,6 +414,8 @@ async function ensureThread( /** * 执行 Claude Agent SDK 任务 + * 支持 workspace 变更后自动 restart:第一次 query 触发 setup_workspace 后, + * 自动以新 cwd 发起第二次 query,确保 CLAUDE.md 正确加载。 */ async function executeClaudeTask( prompt: string, @@ -394,61 +442,110 @@ async function executeClaudeTask( // 标记会话为忙碌 sessionManager.setStatus(chatId, userId, 'busy'); + // workspace 变更回调: MCP 工具 clone 后自动更新 session.workingDir + const onWorkspaceChanged = (newDir: string) => { + sessionManager.setWorkingDir(chatId, userId, newDir); + logger.info({ chatId, userId, newDir }, 'Workspace changed via MCP tool'); + }; + + const onProgress = (message: import('@anthropic-ai/claude-agent-sdk').SDKMessage) => { + logger.debug({ messageType: message.type }, 'Claude SDK message'); + }; + try { - // 调用 Claude Agent SDK + // 第一次 query:可能触发 workspace setup const result = await claudeExecutor.execute( sessionKey, prompt, session.workingDir, session.conversationId, - (message) => { - logger.debug({ messageType: message.type }, 'Claude SDK message'); - }, - // 工作区变更回调: MCP 工具 clone 后自动更新 session.workingDir - (newDir: string) => { - sessionManager.setWorkingDir(chatId, userId, newDir); - logger.info({ chatId, userId, newDir }, 'Workspace changed via MCP tool'); - }, + onProgress, + onWorkspaceChanged, ); - // 保存 SDK session_id 用于下次续接 - if (result.sessionId) { - sessionManager.setConversationId(chatId, userId, result.sessionId); - } + // 检测是否需要 restart(workspace 变更后重新执行以加载 CLAUDE.md) + if (result.needsRestart && result.newWorkingDir) { + logger.info( + { chatId, userId, newWorkingDir: result.newWorkingDir }, + 'Workspace changed, restarting query with new cwd', + ); + + // 检查 session 是否已被用户 /stop 中断 + const currentSession = sessionManager.get(chatId, userId); + if (!currentSession || currentSession.status !== 'busy') { + logger.info({ chatId, userId }, 'Restart cancelled: session no longer busy'); + return; + } - // 格式化耗时和花费 - const durationStr = formatDuration(result.durationMs); - const costInfo = result.costUsd - ? ` | 💰 $${result.costUsd.toFixed(4)}` - : ''; + // 验证新工作目录确实存在 + const { existsSync: dirExists } = await import('node:fs'); + if (!dirExists(result.newWorkingDir)) { + logger.error({ newWorkingDir: result.newWorkingDir }, 'Restart cancelled: newWorkingDir does not exist'); + await sendResultCard( + prompt, { ...result, success: false, output: '', error: '工作区准备失败,目录不存在' }, + result.durationMs, result.costUsd, + progressMsgId, threadRootMsgId, chatId, + ); + return; + } - // 更新卡片为结果 - const resultCard = buildResultCard( - prompt, - result.output || result.error || '(无输出)', - result.success, - durationStr + costInfo, - ); + // 清空残留的 conversationId,避免指向只做了 workspace setup 的短 session + sessionManager.setConversationId(chatId, userId, ''); - if (progressMsgId) { - await feishuClient.updateCard(progressMsgId, resultCard); - } else if (threadRootMsgId) { - await feishuClient.replyCardInThread(threadRootMsgId, resultCard); - } else { - await feishuClient.sendCard(chatId, resultCard); - } + // 更新进度卡片 + if (progressMsgId) { + await feishuClient.updateCard(progressMsgId, buildProgressCard(prompt, '正在加载项目配置...')); + } - // 如果输出特别长,额外发送完整文本 - if (result.output && result.output.length > 3000) { - if (threadRootMsgId) { - await feishuClient.replyTextInThread(threadRootMsgId, result.output); - } else { - await feishuClient.sendText(chatId, result.output); + // 第二次 query:以新 cwd 执行,CLAUDE.md 正确加载 + // - 不传 resumeSessionId(全新 session) + // - 不传 onWorkspaceChanged(不触发二次 restart) + // - disableWorkspaceTool: 完全移除 setup_workspace MCP tool,防止无限循环 + const restartResult = await claudeExecutor.execute( + sessionKey, + prompt, + result.newWorkingDir, + undefined, + onProgress, + undefined, + { disableWorkspaceTool: true }, + ); + + // 保存 restart query 的 session_id 用于下次续接 + // 如果 restart query 失败未返回 sessionId,用第一次 query 的作为 fallback + const finalSessionId = restartResult.sessionId || result.sessionId; + if (finalSessionId) { + sessionManager.setConversationId(chatId, userId, finalSessionId); } + + // 合并两次 query 的耗时和花费 + const totalDurationMs = result.durationMs + restartResult.durationMs; + const totalCostUsd = (result.costUsd ?? 0) + (restartResult.costUsd ?? 0); + + await sendResultCard( + prompt, restartResult, totalDurationMs, totalCostUsd, + progressMsgId, threadRootMsgId, chatId, + ); + return; } + + // 无 restart,正常流程 + if (result.sessionId) { + sessionManager.setConversationId(chatId, userId, result.sessionId); + } + + await sendResultCard( + prompt, result, result.durationMs, result.costUsd, + progressMsgId, threadRootMsgId, chatId, + ); } catch (err) { logger.error({ err }, 'Error executing Claude Agent SDK query'); - await feishuClient.replyText(messageId, `❌ 执行出错: ${(err as Error).message}`); + const errorReply = `❌ 执行出错: ${(err as Error).message}`; + if (threadRootMsgId) { + await feishuClient.replyTextInThread(threadRootMsgId, errorReply); + } else { + await feishuClient.replyText(messageId, errorReply); + } } finally { try { sessionManager.setStatus(chatId, userId, 'idle'); @@ -458,6 +555,48 @@ async function executeClaudeTask( } } +/** + * 发送结果卡片(提取为独立函数,避免 restart 和正常流程重复代码) + */ +async function sendResultCard( + prompt: string, + result: import('../claude/types.js').ClaudeResult, + totalDurationMs: number, + totalCostUsd: number | undefined, + progressMsgId: string | undefined, + threadRootMsgId: string | undefined, + chatId: string, +): Promise { + const durationStr = formatDuration(totalDurationMs); + const costInfo = totalCostUsd + ? ` | 💰 $${totalCostUsd.toFixed(4)}` + : ''; + + const resultCard = buildResultCard( + prompt, + result.output || result.error || '(无输出)', + result.success, + durationStr + costInfo, + ); + + if (progressMsgId) { + await feishuClient.updateCard(progressMsgId, resultCard); + } else if (threadRootMsgId) { + await feishuClient.replyCardInThread(threadRootMsgId, resultCard); + } else { + await feishuClient.sendCard(chatId, resultCard); + } + + // 如果输出特别长,额外发送完整文本 + if (result.output && result.output.length > 3000) { + if (threadRootMsgId) { + await feishuClient.replyTextInThread(threadRootMsgId, result.output); + } else { + await feishuClient.sendText(chatId, result.output); + } + } +} + /** * 解析飞书消息 (使用 SDK 类型化的事件数据) */ diff --git a/src/index.ts b/src/index.ts index d6712560..b6ef39a9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ import { logger } from './utils/logger.js'; import { startServer } from './server.js'; import { sessionManager } from './session/manager.js'; import { claudeExecutor } from './claude/executor.js'; +import { cleanupTmpDirs, cleanupExpiredCaches } from './workspace/cache.js'; function main(): void { logger.info('Starting Feishu Claude Code Bridge...'); @@ -22,13 +23,17 @@ function main(): void { timeoutSeconds: config.claude.timeoutSeconds, }, 'Configuration loaded'); + // 启动时清理残留的 .tmp-* 临时目录 + cleanupTmpDirs(); + // 启动 HTTP 服务 startServer(); - // 定时清理过期会话和 Claude Code 进程 (每 30 分钟) + // 定时清理过期会话、Claude Code 进程和缓存 (每 30 分钟) setInterval(() => { sessionManager.cleanup(); claudeExecutor.cleanup(); + cleanupExpiredCaches(); }, 30 * 60 * 1000); // 优雅退出 diff --git a/src/session/queue.ts b/src/session/queue.ts index 62a04c10..ff39e16a 100644 --- a/src/session/queue.ts +++ b/src/session/queue.ts @@ -18,6 +18,7 @@ export class TaskQueue { userId: string, message: string, messageId: string, + rootId?: string, ): Promise { return new Promise((resolve, reject) => { const task: QueueTask = { @@ -26,6 +27,7 @@ export class TaskQueue { userId, message, messageId, + rootId, resolve, reject, createdAt: new Date(), diff --git a/src/session/types.ts b/src/session/types.ts index 530ee985..9dd22ec3 100644 --- a/src/session/types.ts +++ b/src/session/types.ts @@ -30,6 +30,7 @@ export interface QueueTask { userId: string; message: string; messageId: string; + rootId?: string; resolve: (result: string) => void; reject: (error: Error) => void; createdAt: Date; diff --git a/src/workspace/__tests__/cache.test.ts b/src/workspace/__tests__/cache.test.ts new file mode 100644 index 00000000..7a92dd9b --- /dev/null +++ b/src/workspace/__tests__/cache.test.ts @@ -0,0 +1,311 @@ +// @ts-nocheck — test file, vitest uses esbuild transform +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('node:child_process', () => ({ + execFileSync: vi.fn(), +})); + +vi.mock('node:fs', () => ({ + existsSync: vi.fn(() => false), + mkdirSync: vi.fn(), + renameSync: vi.fn(), + rmSync: vi.fn(), + readdirSync: vi.fn(() => []), + statSync: vi.fn(), +})); + +vi.mock('node:crypto', () => ({ + randomBytes: vi.fn(() => Buffer.from('deadbeef', 'hex')), +})); + +vi.mock('../../config.js', () => ({ + config: { + repoCache: { + dir: '/repos/cache', + maxAgeDays: 30, + maxSizeGb: 50, + fetchIntervalMin: 10, + }, + workspace: { + baseDir: '/tmp/workspaces', + }, + }, +})); + +vi.mock('../../utils/logger.js', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +import { execFileSync } from 'node:child_process'; +import { existsSync, renameSync, rmSync, readdirSync, statSync } from 'node:fs'; +import { repoUrlToCachePath, sanitizeRepoUrl, ensureBareCache, cleanupTmpDirs, cleanupExpiredCaches } from '../cache.js'; + +const mockExecFileSync = vi.mocked(execFileSync); +const mockExistsSync = vi.mocked(existsSync); +const mockRenameSync = vi.mocked(renameSync); +const mockRmSync = vi.mocked(rmSync); +const mockReaddirSync = vi.mocked(readdirSync); +const mockStatSync = vi.mocked(statSync); + +beforeEach(() => { + vi.clearAllMocks(); + mockExistsSync.mockReturnValue(false); +}); + +// ============================================================ +// repoUrlToCachePath +// ============================================================ + +describe('repoUrlToCachePath', () => { + it('should parse HTTPS URL', () => { + expect(repoUrlToCachePath('https://github.com/foo/bar.git')) + .toBe('github.com/foo/bar.git'); + }); + + it('should parse HTTPS URL without .git suffix', () => { + expect(repoUrlToCachePath('https://github.com/foo/bar')) + .toBe('github.com/foo/bar.git'); + }); + + it('should parse SSH shorthand (git@host:path)', () => { + expect(repoUrlToCachePath('git@github.com:foo/bar.git')) + .toBe('github.com/foo/bar.git'); + }); + + it('should parse ssh:// URL', () => { + expect(repoUrlToCachePath('ssh://git@github.com/foo/bar.git')) + .toBe('github.com/foo/bar.git'); + }); + + it('should handle multi-level GitLab groups', () => { + expect(repoUrlToCachePath('https://gitlab.com/org/subgroup/project')) + .toBe('gitlab.com/org/subgroup/project.git'); + }); + + it('should preserve port in host', () => { + expect(repoUrlToCachePath('https://git.corp.com:8443/org/repo')) + .toBe('git.corp.com:8443/org/repo.git'); + }); + + it('should strip authentication info from URL', () => { + expect(repoUrlToCachePath('https://user:token@github.com/foo/bar')) + .toBe('github.com/foo/bar.git'); + }); + + it('should normalize to lowercase', () => { + expect(repoUrlToCachePath('https://GitHub.com/Foo/Bar.git')) + .toBe('github.com/foo/bar.git'); + }); + + it('should reject URL with path traversal (..)', () => { + // URL class auto-resolves ".." so we test via git@ format which doesn't + expect(() => repoUrlToCachePath('git@github.com:foo/../../etc/passwd')) + .toThrow('非法路径段'); + }); + + it('should reject URL with dot-prefixed segment', () => { + expect(() => repoUrlToCachePath('https://github.com/.hidden/repo')) + .toThrow('非法路径段'); + }); + + it('should reject unparseable URL', () => { + expect(() => repoUrlToCachePath('not-a-url')) + .toThrow('无法解析仓库 URL'); + }); + + it('should reject URL with empty path', () => { + expect(() => repoUrlToCachePath('https://github.com')) + .toThrow('无法解析仓库 URL'); + }); +}); + +// ============================================================ +// sanitizeRepoUrl +// ============================================================ + +describe('sanitizeRepoUrl', () => { + it('should strip credentials from HTTPS URL', () => { + const result = sanitizeRepoUrl('https://user:token@github.com/foo/bar.git'); + expect(result).not.toContain('user'); + expect(result).not.toContain('token'); + expect(result).toContain('github.com/foo/bar.git'); + }); + + it('should return SSH URL unchanged', () => { + expect(sanitizeRepoUrl('git@github.com:foo/bar.git')) + .toBe('git@github.com:foo/bar.git'); + }); + + it('should handle URL without credentials', () => { + const url = 'https://github.com/foo/bar.git'; + expect(sanitizeRepoUrl(url)).toContain('github.com/foo/bar.git'); + }); +}); + +// ============================================================ +// ensureBareCache +// ============================================================ + +describe('ensureBareCache', () => { + it('should create bare clone when cache does not exist', () => { + mockExistsSync.mockImplementation((p) => { + // parent dir exists, cache path does not + if (String(p).endsWith('foo')) return true; + return false; + }); + + const result = ensureBareCache('https://github.com/foo/bar.git'); + + expect(result).toContain('/repos/cache/github.com/foo/bar.git'); + + // Should call git clone --bare + expect(mockExecFileSync).toHaveBeenCalledTimes(1); + const args = mockExecFileSync.mock.calls[0][1]; + expect(args).toContain('clone'); + expect(args).toContain('--bare'); + expect(args).toContain('--config'); + expect(args).toContain('core.hooksPath=/dev/null'); + expect(args).toContain('--no-recurse-submodules'); + + // Should rename tmp dir + expect(mockRenameSync).toHaveBeenCalledTimes(1); + }); + + it('should fetch when cache exists and is stale', () => { + mockExistsSync.mockReturnValue(true); + + ensureBareCache('https://github.com/foo/bar.git'); + + // Should call git fetch --all + expect(mockExecFileSync).toHaveBeenCalledTimes(1); + const args = mockExecFileSync.mock.calls[0][1]; + expect(args).toContain('fetch'); + expect(args).toContain('--all'); + }); + + it('should skip fetch when recently fetched', () => { + // Cache exists for both calls + mockExistsSync.mockReturnValue(true); + + // First call: fetches + ensureBareCache('https://github.com/foo/qux.git'); + const fetchCalls = mockExecFileSync.mock.calls.filter( + c => (c[1] as string[]).includes('fetch'), + ); + expect(fetchCalls).toHaveLength(1); + + mockExecFileSync.mockClear(); + + // Second call with same URL: should skip fetch (within interval) + ensureBareCache('https://github.com/foo/qux.git'); + expect(mockExecFileSync).not.toHaveBeenCalled(); + }); + + it('should cleanup tmp dir on clone failure', () => { + mockExistsSync.mockImplementation((p) => { + if (String(p).endsWith('foo')) return true; + // tmp dir exists for cleanup + if (String(p).includes('.tmp-')) return true; + return false; + }); + + mockExecFileSync.mockImplementation(() => { + throw new Error('clone failed'); + }); + + expect(() => ensureBareCache('https://github.com/foo/bar.git')) + .toThrow('bare clone 失败'); + + // Should attempt to clean up tmp dir + expect(mockRmSync).toHaveBeenCalled(); + }); +}); + +// ============================================================ +// cleanupTmpDirs +// ============================================================ + +describe('cleanupTmpDirs', () => { + it('should remove .tmp-* directories', () => { + mockExistsSync.mockReturnValue(true); + + // First call: /repos/cache entries, second call: /tmp/workspaces entries + // Subsequent calls for recursion into normal-dir: return empty + let callCount = 0; + mockReaddirSync.mockImplementation(() => { + callCount++; + if (callCount === 1) { + // /repos/cache + return [ + { name: 'repo.git.tmp-abc123', isDirectory: () => true }, + { name: 'file.txt', isDirectory: () => false }, + ]; + } + if (callCount === 2) { + // /tmp/workspaces + return [ + { name: 'workspace.tmp-def456', isDirectory: () => true }, + ]; + } + return []; + }); + + const cleaned = cleanupTmpDirs(); + + expect(cleaned).toBe(2); + expect(mockRmSync).toHaveBeenCalledTimes(2); + }); +}); + +// ============================================================ +// cleanupExpiredCaches +// ============================================================ + +describe('cleanupExpiredCaches', () => { + it('should remove .git caches older than maxAgeDays', () => { + mockExistsSync.mockReturnValue(true); + + // 3-level structure: cacheDir → github.com → foo → bar.git + let callCount = 0; + mockReaddirSync.mockImplementation(() => { + callCount++; + if (callCount === 1) return [{ name: 'github.com', isDirectory: () => true }]; // cacheDir + if (callCount === 2) return [{ name: 'foo', isDirectory: () => true }]; // host + if (callCount === 3) return [{ name: 'bar.git', isDirectory: () => true }]; // owner + return []; // empty checks after cleanup + }); + + const oldTime = Date.now() - (31 * 24 * 60 * 60 * 1000); // 31 days ago + mockStatSync.mockReturnValue({ mtimeMs: oldTime }); + + const cleaned = cleanupExpiredCaches(); + + expect(cleaned).toBe(1); + expect(mockRmSync).toHaveBeenCalled(); + }); + + it('should keep recent .git caches', () => { + mockExistsSync.mockReturnValue(true); + + let callCount = 0; + mockReaddirSync.mockImplementation(() => { + callCount++; + if (callCount === 1) return [{ name: 'github.com', isDirectory: () => true }]; + if (callCount === 2) return [{ name: 'foo', isDirectory: () => true }]; + if (callCount === 3) return [{ name: 'bar.git', isDirectory: () => true }]; + return [{ name: 'bar.git', isDirectory: () => true }]; // not empty + }); + + const recentTime = Date.now() - (1 * 24 * 60 * 60 * 1000); // 1 day ago + mockStatSync.mockReturnValue({ mtimeMs: recentTime }); + + const cleaned = cleanupExpiredCaches(); + + expect(cleaned).toBe(0); + }); +}); diff --git a/src/workspace/__tests__/manager.test.ts b/src/workspace/__tests__/manager.test.ts index a7137f15..bf88a113 100644 --- a/src/workspace/__tests__/manager.test.ts +++ b/src/workspace/__tests__/manager.test.ts @@ -1,3 +1,4 @@ +// @ts-nocheck — test file, vitest uses esbuild transform import { describe, it, expect, vi, beforeEach } from 'vitest'; vi.mock('node:child_process', () => ({ @@ -7,6 +8,7 @@ vi.mock('node:child_process', () => ({ vi.mock('node:fs', () => ({ existsSync: vi.fn(), mkdirSync: vi.fn(), + realpathSync: vi.fn((p: string) => p), })); vi.mock('node:crypto', () => ({ @@ -19,6 +21,15 @@ vi.mock('../../config.js', () => ({ baseDir: '/tmp/workspaces', branchPrefix: 'feat/claude-session', }, + claude: { + defaultWorkDir: '/home/user/projects', + }, + repoCache: { + dir: '/repos/cache', + maxAgeDays: 30, + maxSizeGb: 50, + fetchIntervalMin: 10, + }, }, })); @@ -27,9 +38,18 @@ vi.mock('../../utils/logger.js', () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), + debug: vi.fn(), }, })); +// Mock cache module +const mockEnsureBareCache = vi.fn(() => '/repos/cache/github.com/user/repo.git'); +const mockSanitizeRepoUrl = vi.fn((url: string) => url); +vi.mock('../cache.js', () => ({ + ensureBareCache: (...args: unknown[]) => mockEnsureBareCache(...args), + sanitizeRepoUrl: (...args: unknown[]) => mockSanitizeRepoUrl(...args), +})); + import { execFileSync } from 'node:child_process'; import { existsSync, mkdirSync } from 'node:fs'; import { deriveRepoName, setupWorkspace } from '../manager.js'; @@ -45,6 +65,8 @@ beforeEach(() => { if (p === '/tmp/workspaces') return true; return false; }); + mockEnsureBareCache.mockReturnValue('/repos/cache/github.com/user/repo.git'); + mockSanitizeRepoUrl.mockImplementation((url) => url); }); // ============================================================ @@ -94,65 +116,139 @@ describe('setupWorkspace', () => { expect(() => setupWorkspace({})).toThrow('必须提供 repo_url 或 local_path'); }); - it('should clone remote repo and create feature branch', () => { - const result = setupWorkspace({ repoUrl: 'https://github.com/user/repo.git' }); + describe('writable mode (default)', () => { + it('should use bare cache for remote repo and create feature branch', () => { + const result = setupWorkspace({ repoUrl: 'https://github.com/user/repo.git' }); - expect(result.repoName).toBe('repo'); - expect(result.branch).toMatch(/^feat\/claude-session-/); - expect(result.workspacePath).toContain('/tmp/workspaces/repo-feat-claude-session-'); + expect(result.repoName).toBe('repo'); + expect(result.branch).toMatch(/^feat\/claude-session-/); - // 验证 execFileSync 调用了 git clone 和 git checkout - expect(mockExecFileSync).toHaveBeenCalledTimes(2); + // Should call ensureBareCache + expect(mockEnsureBareCache).toHaveBeenCalledWith('https://github.com/user/repo.git'); - const cloneCall = mockExecFileSync.mock.calls[0]; - expect(cloneCall[0]).toBe('git'); - expect(cloneCall[1]).toContain('clone'); - expect(cloneCall[1]).toContain('https://github.com/user/repo.git'); - - const checkoutCall = mockExecFileSync.mock.calls[1]; - expect(checkoutCall[0]).toBe('git'); - expect(checkoutCall[1]![0]).toBe('checkout'); - expect(checkoutCall[1]![1]).toBe('-b'); + // Should call git clone (from cache) and git checkout -b + expect(mockExecFileSync).toHaveBeenCalledTimes(3); // clone + set-url + checkout + + const cloneCall = mockExecFileSync.mock.calls[0]; + expect(cloneCall[0]).toBe('git'); + expect(cloneCall[1]).toContain('clone'); + // Clone source should be the cache path + expect(cloneCall[1]).toContain('/repos/cache/github.com/user/repo.git'); + + // set-url call + const setUrlCall = mockExecFileSync.mock.calls[1]; + expect(setUrlCall[1]).toContain('set-url'); + + // checkout call + const checkoutCall = mockExecFileSync.mock.calls[2]; + expect(checkoutCall[1][0]).toBe('checkout'); + expect(checkoutCall[1][1]).toBe('-b'); + }); + + it('should sanitize remote URL when setting origin', () => { + mockSanitizeRepoUrl.mockReturnValue('https://github.com/user/repo.git'); + + setupWorkspace({ repoUrl: 'https://token:x@github.com/user/repo.git' }); + + expect(mockSanitizeRepoUrl).toHaveBeenCalledWith('https://token:x@github.com/user/repo.git'); + + const setUrlCall = mockExecFileSync.mock.calls[1]; + expect(setUrlCall[1]).toContain('https://github.com/user/repo.git'); + }); + + it('should use custom featureBranch when specified', () => { + const result = setupWorkspace({ + repoUrl: 'https://github.com/user/repo', + featureBranch: 'fix/my-bug', + }); + + expect(result.branch).toBe('fix/my-bug'); + }); }); - it('should clone local repo when path exists', () => { - // localPath 存在性检查需要返回 true - mockExistsSync.mockImplementation((p) => { - if (p === '/tmp/workspaces') return true; - if (p === '/home/user/projects/my-app') return true; - return false; + describe('readonly mode', () => { + it('should clone from cache without creating feature branch', () => { + const result = setupWorkspace({ + repoUrl: 'https://github.com/user/repo.git', + mode: 'readonly', + }); + + expect(result.repoName).toBe('repo'); + + // Should use bare cache + expect(mockEnsureBareCache).toHaveBeenCalled(); + + // Should only call git clone (no set-url, no checkout -b) + expect(mockExecFileSync).toHaveBeenCalledTimes(1); + const cloneCall = mockExecFileSync.mock.calls[0]; + expect(cloneCall[1]).toContain('clone'); }); - const result = setupWorkspace({ localPath: '/home/user/projects/my-app' }); + it('should checkout source_branch if specified', () => { + setupWorkspace({ + repoUrl: 'https://github.com/user/repo.git', + mode: 'readonly', + sourceBranch: 'develop', + }); - expect(result.repoName).toBe('my-app'); + const cloneArgs = mockExecFileSync.mock.calls[0][1]; + expect(cloneArgs).toContain('--branch'); + expect(cloneArgs).toContain('develop'); + }); - const cloneCall = mockExecFileSync.mock.calls[0]; - expect(cloneCall[1]).toContain('/home/user/projects/my-app'); + it('should include "readonly" in workspace dir name', () => { + const result = setupWorkspace({ + repoUrl: 'https://github.com/user/repo.git', + mode: 'readonly', + }); + + expect(result.workspacePath).toContain('readonly'); + }); + }); + + describe('localPath (no cache)', () => { + it('should clone directly from localPath without using cache', () => { + mockExistsSync.mockImplementation((p) => { + if (p === '/tmp/workspaces') return true; + if (p === '/home/user/projects/my-app') return true; + if (p === '/home/user/projects') return true; + return false; + }); + + const result = setupWorkspace({ localPath: '/home/user/projects/my-app' }); + + expect(result.repoName).toBe('my-app'); + // Should NOT call ensureBareCache + expect(mockEnsureBareCache).not.toHaveBeenCalled(); + + // Clone source should be the local path + const cloneArgs = mockExecFileSync.mock.calls[0][1]; + expect(cloneArgs).toContain('/home/user/projects/my-app'); + }); + }); + + it('should include git security parameters in clone args (local clone from cache)', () => { + setupWorkspace({ repoUrl: 'https://github.com/user/repo.git' }); + + const cloneArgs = mockExecFileSync.mock.calls[0][1]; + // 从 bare cache 本地 clone 时使用 LOCAL 安全参数(不含 protocol.file.allow=never) + expect(cloneArgs).toContain('--config'); + expect(cloneArgs[cloneArgs.indexOf('--config') + 1]).toBe('core.hooksPath=/dev/null'); + expect(cloneArgs).toContain('--no-recurse-submodules'); + // 本地 clone 不应禁用 file 协议 + expect(cloneArgs).not.toContain('protocol.file.allow=never'); }); it('should pass --branch when sourceBranch is specified', () => { setupWorkspace({ repoUrl: 'https://github.com/user/repo', sourceBranch: 'develop' }); const cloneCall = mockExecFileSync.mock.calls[0]; - const args = cloneCall[1] as string[]; + const args = cloneCall[1]; const branchIdx = args.indexOf('--branch'); expect(branchIdx).toBeGreaterThan(-1); expect(args[branchIdx + 1]).toBe('develop'); }); - it('should use custom featureBranch when specified', () => { - const result = setupWorkspace({ - repoUrl: 'https://github.com/user/repo', - featureBranch: 'fix/my-bug', - }); - - expect(result.branch).toBe('fix/my-bug'); - - const checkoutCall = mockExecFileSync.mock.calls[1]; - expect(checkoutCall[1]![2]).toBe('fix/my-bug'); - }); - it('should create baseDir if it does not exist', () => { mockExistsSync.mockReturnValue(false); @@ -161,12 +257,6 @@ describe('setupWorkspace', () => { expect(mockMkdirSync).toHaveBeenCalledWith('/tmp/workspaces', { recursive: true }); }); - it('should not create baseDir if it already exists', () => { - setupWorkspace({ repoUrl: 'https://github.com/user/repo' }); - - expect(mockMkdirSync).not.toHaveBeenCalled(); - }); - it('should wrap git clone errors', () => { mockExecFileSync.mockImplementationOnce(() => { throw new Error('fatal: repository not found'); @@ -176,10 +266,11 @@ describe('setupWorkspace', () => { .toThrow('git clone 失败: fatal: repository not found'); }); - it('should wrap git checkout errors', () => { + it('should wrap git checkout errors (writable mode)', () => { mockExecFileSync - .mockImplementationOnce(() => '') // clone 成功 - .mockImplementationOnce(() => { + .mockImplementationOnce(() => '') // clone + .mockImplementationOnce(() => '') // set-url + .mockImplementationOnce(() => { // checkout throw new Error('fatal: branch already exists'); }); @@ -187,24 +278,6 @@ describe('setupWorkspace', () => { .toThrow('创建分支失败: fatal: branch already exists'); }); - it('should prefer repoUrl over localPath when both provided', () => { - mockExistsSync.mockImplementation((p) => { - if (p === '/tmp/workspaces') return true; - if (p === '/local/path') return true; - return false; - }); - - const result = setupWorkspace({ - repoUrl: 'https://github.com/user/repo', - localPath: '/local/path', - }); - - const cloneCall = mockExecFileSync.mock.calls[0]; - expect(cloneCall[1]).toContain('https://github.com/user/repo'); - expect(cloneCall[1]).not.toContain('/local/path'); - expect(result.repoName).toBe('repo'); - }); - // ============================================================ // 输入校验 // ============================================================ @@ -238,7 +311,7 @@ describe('setupWorkspace', () => { it('should reject localPath that does not exist', () => { mockExistsSync.mockImplementation((p) => { if (p === '/tmp/workspaces') return true; - return false; // localPath 不存在 + return false; }); expect(() => setupWorkspace({ localPath: '/nonexistent/path' })) diff --git a/src/workspace/cache.ts b/src/workspace/cache.ts new file mode 100644 index 00000000..48ab4f5a --- /dev/null +++ b/src/workspace/cache.ts @@ -0,0 +1,310 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdirSync, renameSync, rmSync, readdirSync, statSync } from 'node:fs'; +import { resolve, join } from 'node:path'; +import { randomBytes } from 'node:crypto'; +import { config } from '../config.js'; +import { logger } from '../utils/logger.js'; +import { GIT_REMOTE_CLONE_ARGS, GIT_REMOTE_FETCH_ARGS } from './git-security.js'; + +// ============================================================ +// 仓库缓存管理 +// +// 维护本地 bare clone 镜像,作为 local clone 的快速源。 +// - URL 解析与路径穿越校验 +// - bare clone 创建与 fetch 更新 +// - 原子目录创建 (tmp + rename) +// - 过期 / LRU 清理 +// ============================================================ + +/** 最近 fetch 时间记录 (cachePath → timestamp) */ +const lastFetchTime = new Map(); + +// ============================================================ +// URL 解析 +// ============================================================ + +/** + * 将仓库 URL 解析为缓存路径(相对于 REPO_CACHE_DIR) + * + * 支持格式: + * https://github.com/foo/bar.git + * git@github.com:foo/bar.git + * ssh://git@github.com/foo/bar.git + * + * 返回: github.com/foo/bar.git (小写, 规范化) + */ +export function repoUrlToCachePath(repoUrl: string): string { + let host: string; + let pathname: string; + + // git@host:path 格式 (SSH shorthand) + const sshMatch = repoUrl.match(/^git@([^:]+):(.+)$/); + if (sshMatch) { + host = sshMatch[1]; + pathname = sshMatch[2]; + } else { + // HTTP(S), SSH, Git 协议 — 使用 URL 类解析 + try { + const url = new URL(repoUrl); + // 剥离认证信息,保留 host[:port] + host = url.port ? `${url.hostname}:${url.port}` : url.hostname; + pathname = url.pathname; + } catch { + throw new Error(`无法解析仓库 URL: ${repoUrl}`); + } + } + + // 规范化路径: 去除前导 /, 去除 .git 后缀, 再统一追加 .git + pathname = pathname.replace(/^\/+/, '').replace(/\.git\/?$/, ''); + + if (!host || !pathname) { + throw new Error(`无法解析仓库 URL: ${repoUrl}`); + } + + // 路径段校验: 禁止 .., 空段, 以 . 开头 + const segments = `${host}/${pathname}`.split('/'); + for (const seg of segments) { + if (!seg || seg === '..' || seg.startsWith('.')) { + throw new Error(`仓库 URL 包含非法路径段: "${seg}"`); + } + } + + // 统一小写 + 追加 .git + const relativePath = `${host}/${pathname}.git`.toLowerCase(); + + // 路径穿越防护: resolve 后校验是否仍在 cacheDir 下 + const cacheDir = config.repoCache.dir; + const fullPath = resolve(cacheDir, relativePath); + if (!fullPath.startsWith(resolve(cacheDir) + '/')) { + throw new Error(`缓存路径穿越防护: ${relativePath}`); + } + + return relativePath; +} + +/** + * 从仓库 URL 剥离认证信息,返回安全的 URL + * 用于 git remote set-url origin + */ +export function sanitizeRepoUrl(repoUrl: string): string { + // SSH shorthand 不含认证信息 + if (/^git@/.test(repoUrl)) return repoUrl; + + try { + const url = new URL(repoUrl); + url.username = ''; + url.password = ''; + return url.toString(); + } catch { + return repoUrl; + } +} + +// ============================================================ +// 缓存操作 +// ============================================================ + + +/** + * 确保仓库的 bare clone 缓存存在且是最新的 + * 返回缓存的绝对路径 + */ +export function ensureBareCache(repoUrl: string): string { + const relativePath = repoUrlToCachePath(repoUrl); + const cachePath = resolve(config.repoCache.dir, relativePath); + + if (existsSync(cachePath)) { + // 缓存已存在,检查是否需要 fetch + fetchIfStale(cachePath); + } else { + // 首次访问,创建 bare clone (原子操作) + cloneBareAtomic(repoUrl, cachePath); + } + + return cachePath; +} + +/** + * bare clone 到临时目录,成功后 rename (原子创建) + */ +function cloneBareAtomic(repoUrl: string, cachePath: string): void { + const tmpPath = `${cachePath}.tmp-${randomBytes(4).toString('hex')}`; + const parentDir = resolve(cachePath, '..'); + + if (!existsSync(parentDir)) { + mkdirSync(parentDir, { recursive: true }); + } + + logger.info({ repoUrl: sanitizeRepoUrl(repoUrl), cachePath }, 'Creating bare clone cache'); + + try { + execFileSync('git', [ + 'clone', '--bare', + ...GIT_REMOTE_CLONE_ARGS, + repoUrl, tmpPath, + ], { + timeout: 300_000, // 5 min for large repos + stdio: ['ignore', 'pipe', 'pipe'], + }); + + renameSync(tmpPath, cachePath); + logger.info({ cachePath }, 'Bare clone cache created'); + } catch (err) { + // 清理残留的临时目录 + cleanupTmpDir(tmpPath); + const msg = err instanceof Error ? err.message : String(err); + throw new Error(`bare clone 失败: ${msg}`); + } +} + +/** + * 如果上次 fetch 超过 fetchIntervalMin 分钟,执行 git fetch --all + */ +function fetchIfStale(cachePath: string): void { + const now = Date.now(); + const lastFetch = lastFetchTime.get(cachePath) ?? 0; + const intervalMs = config.repoCache.fetchIntervalMin * 60 * 1000; + + if (now - lastFetch < intervalMs) { + logger.debug({ cachePath }, 'Skipping fetch, recently updated'); + return; + } + + logger.info({ cachePath }, 'Fetching updates for bare cache'); + + try { + execFileSync('git', [ + '-C', cachePath, + ...GIT_REMOTE_FETCH_ARGS, + 'fetch', '--all', + ], { + timeout: 120_000, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + lastFetchTime.set(cachePath, now); + } catch (err) { + // fetch 失败不阻断流程,使用过期缓存 + const msg = err instanceof Error ? err.message : String(err); + logger.warn({ cachePath, err: msg }, 'Failed to fetch cache, using stale version'); + } +} + +// ============================================================ +// 缓存清理 +// ============================================================ + +/** + * 清理残留的 .tmp-* 临时目录 + * 在服务启动时调用 + */ +export function cleanupTmpDirs(): number { + let cleaned = 0; + const dirs = [config.repoCache.dir, config.workspace.baseDir]; + + for (const dir of dirs) { + if (!existsSync(dir)) continue; + cleaned += cleanupTmpDirsRecursive(dir); + } + + if (cleaned > 0) { + logger.info({ cleaned }, 'Cleaned up temporary directories'); + } + return cleaned; +} + +function cleanupTmpDirsRecursive(dir: string): number { + let cleaned = 0; + try { + const entries = readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory()) { + const fullPath = join(dir, entry.name); + if (entry.name.includes('.tmp-')) { + cleanupTmpDir(fullPath); + cleaned++; + } else { + cleaned += cleanupTmpDirsRecursive(fullPath); + } + } + } + } catch { + // 忽略读取失败 + } + return cleaned; +} + +function cleanupTmpDir(tmpPath: string): void { + try { + if (existsSync(tmpPath)) { + rmSync(tmpPath, { recursive: true, force: true }); + logger.debug({ tmpPath }, 'Cleaned up temp directory'); + } + } catch { + // best effort + } +} + +/** + * 清理过期缓存 (超过 maxAgeDays 未访问) + * 在定时 cleanup interval 中调用 + * + * 缓存目录结构为 host/owner/repo.git(3 级), + * 递归查找 .git 结尾的目录作为缓存单元进行过期检查。 + */ +export function cleanupExpiredCaches(): number { + const cacheDir = config.repoCache.dir; + if (!existsSync(cacheDir)) return 0; + + const maxAgeMs = config.repoCache.maxAgeDays * 24 * 60 * 60 * 1000; + const now = Date.now(); + const cleaned = cleanupExpiredRecursive(cacheDir, now, maxAgeMs); + + if (cleaned > 0) { + logger.info({ cleaned }, 'Cleaned up expired cache directories'); + } + return cleaned; +} + +function cleanupExpiredRecursive(dir: string, now: number, maxAgeMs: number): number { + let cleaned = 0; + try { + const entries = readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const fullPath = join(dir, entry.name); + + if (entry.name.endsWith('.git')) { + // 这是一个 bare clone 缓存目录,检查是否过期 + try { + const stat = statSync(fullPath); + const age = now - stat.mtimeMs; + if (age > maxAgeMs) { + rmSync(fullPath, { recursive: true, force: true }); + lastFetchTime.delete(fullPath); + cleaned++; + logger.debug({ path: fullPath, ageDays: Math.floor(age / 86400000) }, 'Removed expired cache'); + } + } catch { + // ignore stat errors + } + } else { + // 中间目录 (host, owner),递归查找 + cleaned += cleanupExpiredRecursive(fullPath, now, maxAgeMs); + + // 如果中间目录变空则删除 + try { + const remaining = readdirSync(fullPath); + if (remaining.length === 0) { + rmSync(fullPath, { recursive: true, force: true }); + } + } catch { + // ignore + } + } + } + } catch { + // ignore + } + return cleaned; +} diff --git a/src/workspace/git-security.ts b/src/workspace/git-security.ts new file mode 100644 index 00000000..7d59ebc5 --- /dev/null +++ b/src/workspace/git-security.ts @@ -0,0 +1,38 @@ +// ============================================================ +// Git 安全参数(共享常量) +// +// 所有 git 操作统一使用,避免分散定义导致不一致。 +// +// 注意 --config 与 -c 的区别: +// --config key=value → git clone 专用,将配置持久化到新仓库 +// -c key=value → git 顶层选项,临时生效,适用于所有子命令 +// ============================================================ + +/** clone 安全参数:用 --config 持久化到新仓库,禁用 hooks 和 submodules */ +const GIT_CLONE_BASE_ARGS = [ + '--config', 'core.hooksPath=/dev/null', + '--no-recurse-submodules', +]; + +/** 通用安全参数:用 -c 临时生效,适用于 fetch 等非 clone 子命令 */ +const GIT_CMD_BASE_ARGS = [ + '-c', 'core.hooksPath=/dev/null', + '--no-recurse-submodules', +]; + +/** 远程 bare clone 安全参数:禁用 hooks/submodules + 禁用 file 协议(防止 SSRF) */ +export const GIT_REMOTE_CLONE_ARGS = [ + ...GIT_CLONE_BASE_ARGS, + '-c', 'protocol.file.allow=never', +]; + +/** 远程 fetch 安全参数:禁用 hooks/submodules + 禁用 file 协议 */ +export const GIT_REMOTE_FETCH_ARGS = [ + ...GIT_CMD_BASE_ARGS, + '-c', 'protocol.file.allow=never', +]; + +/** 本地 clone 安全参数:禁用 hooks/submodules,不禁 file 协议(从 bare cache clone 需要) */ +export const GIT_LOCAL_CLONE_ARGS = [ + ...GIT_CLONE_BASE_ARGS, +]; diff --git a/src/workspace/manager.ts b/src/workspace/manager.ts index 68a115ea..7bf9b3ab 100644 --- a/src/workspace/manager.ts +++ b/src/workspace/manager.ts @@ -1,15 +1,19 @@ import { execFileSync } from 'node:child_process'; -import { existsSync, mkdirSync } from 'node:fs'; +import { existsSync, mkdirSync, realpathSync } from 'node:fs'; import { randomBytes } from 'node:crypto'; import { basename, resolve } from 'node:path'; import { config } from '../config.js'; import { logger } from '../utils/logger.js'; +import { ensureBareCache, sanitizeRepoUrl } from './cache.js'; +import { GIT_LOCAL_CLONE_ARGS } from './git-security.js'; // ============================================================ // 工作区管理器 // -// 负责 git clone 仓库到隔离工作目录,并创建 feature 分支。 -// 每次操作创建独立副本,多用户/多任务之间互不干扰。 +// 负责 git clone 仓库到隔离工作目录。 +// - writable 模式:从缓存 local clone + 创建 feature 分支 +// - readonly 模式:从缓存 local clone,不创建 feature 分支 +// - 无 repoUrl 时 (localPath):直接 clone 本地路径 // ============================================================ export interface SetupWorkspaceOptions { @@ -17,16 +21,18 @@ export interface SetupWorkspaceOptions { repoUrl?: string; /** 本地仓库路径 (与 repoUrl 二选一) */ localPath?: string; + /** 访问模式: readonly 只读分析, writable 需要修改代码 */ + mode?: 'readonly' | 'writable'; /** 源分支 (clone 时 checkout 的分支) */ sourceBranch?: string; - /** 自定义 feature 分支名 (默认自动生成) */ + /** 自定义 feature 分支名 (默认自动生成, 仅 writable 模式) */ featureBranch?: string; } export interface SetupWorkspaceResult { /** 工作区绝对路径 */ workspacePath: string; - /** 创建的 feature 分支名 */ + /** 创建的分支名 (readonly 模式下为源分支名) */ branch: string; /** 仓库名 */ repoName: string; @@ -37,22 +43,29 @@ const SAFE_BRANCH_RE = /^[a-zA-Z0-9._\/-]+$/; /** git 远程 URL 协议前缀 */ const GIT_URL_RE = /^(https?:\/\/|git@|ssh:\/\/|git:\/\/)/; + /** * 从 URL 或路径提取仓库名 */ export function deriveRepoName(source: string): string { - // URL: https://github.com/user/repo.git → repo - // URL: git@github.com:user/repo.git → repo - // Path: /home/user/projects/my-app → my-app const cleaned = source.replace(/\.git\/?$/, '').replace(/\/+$/, ''); return basename(cleaned) || 'repo'; } /** - * 创建隔离工作区:clone 仓库 + 创建 feature 分支 + * 创建隔离工作区 + * + * 流程 (repoUrl 有值时): + * 1. 通过 ensureBareCache() 获取/更新 bare clone 缓存 + * 2. 从 bare cache local clone 到工作区 (快速) + * 3. writable: 设置 origin 为原始远程地址 + 创建 feature 分支 + * readonly: 仅切换到 sourceBranch (如指定) + * + * 流程 (localPath 有值时): + * 直接从本地路径 clone (不经过缓存层) */ export function setupWorkspace(options: SetupWorkspaceOptions): SetupWorkspaceResult { - const { repoUrl, localPath, sourceBranch, featureBranch } = options; + const { repoUrl, localPath, mode = 'writable', sourceBranch, featureBranch } = options; const source = repoUrl || localPath; if (!source) { @@ -68,6 +81,13 @@ export function setupWorkspace(options: SetupWorkspaceOptions): SetupWorkspaceRe if (!existsSync(resolved)) { throw new Error(`本地路径不存在: ${localPath}`); } + // 安全校验:localPath 必须在允许的基目录下(用 realpathSync 跟踪 symlink) + const realResolved = realpathSync(resolved); + const resolvedBase = resolve(config.claude.defaultWorkDir); + const allowedBase = existsSync(resolvedBase) ? realpathSync(resolvedBase) : resolvedBase; + if (!realResolved.startsWith(allowedBase + '/') && realResolved !== allowedBase) { + throw new Error(`本地路径不在允许的目录范围内: ${localPath} (允许: ${allowedBase})`); + } } if (sourceBranch && !SAFE_BRANCH_RE.test(sourceBranch)) { throw new Error(`无效的分支名: ${sourceBranch}`); @@ -76,11 +96,19 @@ export function setupWorkspace(options: SetupWorkspaceOptions): SetupWorkspaceRe throw new Error(`无效的分支名: ${featureBranch}`); } + // 确定 clone 源: 有 repoUrl 时走缓存层,否则直接用 localPath + let cloneSource: string; + if (repoUrl) { + cloneSource = ensureBareCache(repoUrl); + logger.info({ repoUrl: sanitizeRepoUrl(repoUrl), cachePath: cloneSource }, 'Using bare cache as clone source'); + } else { + cloneSource = source; + } + const repoName = deriveRepoName(source); const shortId = randomBytes(3).toString('hex'); const branchPrefix = config.workspace.branchPrefix; - const branch = featureBranch || `${branchPrefix}-${shortId}`; - const dirName = `${repoName}-${branchPrefix.replace(/\//g, '-')}-${shortId}`; + const dirName = `${repoName}-${mode === 'writable' ? branchPrefix.replace(/\//g, '-') : 'readonly'}-${shortId}`; const workspacePath = resolve(config.workspace.baseDir, dirName); // 确保 baseDir 存在 @@ -89,14 +117,18 @@ export function setupWorkspace(options: SetupWorkspaceOptions): SetupWorkspaceRe logger.info({ baseDir: config.workspace.baseDir }, 'Created workspace base directory'); } - // git clone (使用 execFileSync 避免 shell 注入) - const cloneArgs: string[] = ['clone']; + // git clone: manager.ts 的 clone 源总是本地路径(bare cache 或 localPath), + // 远程 clone 由 cache.ts 的 cloneBareAtomic 负责(使用 GIT_REMOTE_SECURITY_ARGS) + const cloneArgs: string[] = [ + 'clone', + ...GIT_LOCAL_CLONE_ARGS, + ]; if (sourceBranch) { cloneArgs.push('--branch', sourceBranch); } - cloneArgs.push(source, workspacePath); + cloneArgs.push(cloneSource, workspacePath); - logger.info({ args: ['git', ...cloneArgs] }, 'Cloning repository'); + logger.info({ mode, source: cloneSource, workspacePath }, 'Cloning to workspace'); try { execFileSync('git', cloneArgs, { @@ -108,20 +140,41 @@ export function setupWorkspace(options: SetupWorkspaceOptions): SetupWorkspaceRe throw new Error(`git clone 失败: ${msg}`); } - // 创建 feature 分支 (使用 execFileSync 避免 shell 注入) - logger.info({ branch, cwd: workspacePath }, 'Creating feature branch'); + if (mode === 'writable') { + // writable: 设置 remote origin 为原始远程地址 (剥离认证信息) + if (repoUrl) { + try { + execFileSync('git', ['remote', 'set-url', 'origin', sanitizeRepoUrl(repoUrl)], { + cwd: workspacePath, + timeout: 10_000, + stdio: ['ignore', 'pipe', 'pipe'], + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.warn({ err: msg }, 'Failed to set remote URL, continuing'); + } + } - try { - execFileSync('git', ['checkout', '-b', branch], { - cwd: workspacePath, - timeout: 10_000, - stdio: ['ignore', 'pipe', 'pipe'], - }); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - throw new Error(`创建分支失败: ${msg}`); + // 创建 feature 分支 + const branchName = featureBranch || `${branchPrefix}-${shortId}`; + logger.info({ branch: branchName, cwd: workspacePath }, 'Creating feature branch'); + + try { + execFileSync('git', ['checkout', '-b', branchName], { + cwd: workspacePath, + timeout: 10_000, + stdio: ['ignore', 'pipe', 'pipe'], + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + throw new Error(`创建分支失败: ${msg}`); + } + + logger.info({ workspacePath, branch: branchName, repoName, mode }, 'Workspace setup complete'); + return { workspacePath, branch: branchName, repoName }; } - logger.info({ workspacePath, branch, repoName }, 'Workspace setup complete'); - return { workspacePath, branch, repoName }; + // readonly: 不创建 feature 分支 + logger.info({ workspacePath, repoName, mode }, 'Readonly workspace setup complete'); + return { workspacePath, branch: sourceBranch || 'default', repoName }; } diff --git a/src/workspace/tool.ts b/src/workspace/tool.ts index 0e258a6b..f483e5fd 100644 --- a/src/workspace/tool.ts +++ b/src/workspace/tool.ts @@ -33,26 +33,28 @@ export function createWorkspaceMcpServer(onWorkspaceChanged?: SessionUpdater) { tool( 'setup_workspace', [ - '为代码修改任务创建隔离工作区。', - '将远程仓库 URL 或本地仓库路径 clone 到独立目录,并创建 feature 分支。', + '为代码任务创建隔离工作区。', + '将远程仓库 URL 或本地仓库路径 clone 到独立目录。', + '远程仓库会使用本地缓存加速 clone。', 'clone 完成后会自动切换工作目录到新的工作区。', '', - '使用场景:', - '- 用户提供 GitHub/GitLab 等远程仓库 URL 需要修改代码时', - '- 用户指定本地仓库路径需要在隔离环境中修改时', - '- 需要确保修改不影响原始仓库时', + '模式选择:', + '- mode="readonly": 只读分析代码,不创建 feature 分支', + '- mode="writable": 修改代码,创建隔离工作区和 feature 分支', ].join('\n'), { repo_url: z.string().optional().describe('远程仓库 URL (如 https://github.com/user/repo)'), local_path: z.string().optional().describe('本地仓库绝对路径'), + mode: z.enum(['readonly', 'writable']).describe('访问模式: readonly 只读分析, writable 修改代码'), source_branch: z.string().optional().describe('源分支名 (默认使用仓库默认分支)'), - feature_branch: z.string().optional().describe('自定义 feature 分支名 (默认自动生成)'), + feature_branch: z.string().optional().describe('自定义 feature 分支名 (默认自动生成, 仅 writable 模式)'), }, async (args) => { try { const result = setupWorkspace({ repoUrl: args.repo_url, localPath: args.local_path, + mode: args.mode, sourceBranch: args.source_branch, featureBranch: args.feature_branch, });