diff --git a/.github/workflows/sync-upstream-main.yml b/.github/workflows/sync-upstream-main.yml new file mode 100644 index 0000000000..50941a08a1 --- /dev/null +++ b/.github/workflows/sync-upstream-main.yml @@ -0,0 +1,197 @@ +name: Sync upstream main + +on: + schedule: + # 05:00 Asia/Shanghai daily. + - cron: "0 21 * * *" + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +concurrency: + group: sync-upstream-main + cancel-in-progress: false + +env: + UPSTREAM_REPO: zts212653/clowder-ai + SYNC_BRANCH: sync/upstream-main-to-develop + +jobs: + sync: + name: Sync upstream/main into main and develop + runs-on: ubuntu-latest + steps: + - name: Checkout fork + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.UPSTREAM_SYNC_TOKEN || github.token }} + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - name: Configure git + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Fetch refs + run: | + set -euo pipefail + git remote add upstream "https://github.com/${UPSTREAM_REPO}.git" + git fetch --prune origin \ + +refs/heads/main:refs/remotes/origin/main \ + +refs/heads/develop:refs/remotes/origin/develop + git fetch --prune upstream \ + +refs/heads/main:refs/remotes/upstream/main + + - name: Merge upstream into main + id: main + run: | + set -euo pipefail + git switch -C main origin/main + git reset --hard origin/main + + before="$(git rev-parse HEAD)" + git merge --no-edit upstream/main + after="$(git rev-parse HEAD)" + + if [ "$before" = "$after" ]; then + echo "changed=false" >> "$GITHUB_OUTPUT" + else + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + + - name: Install dependencies + if: steps.main.outputs.changed == 'true' + run: pnpm install --frozen-lockfile + + - name: Gate merged main + if: steps.main.outputs.changed == 'true' + # Runs the same gate the upstream CI workflow enforces, but expanded + # explicitly so we can skip `pnpm check:capability-tips`. That check is + # an incremental guard (each PR adding a new feature doc must also add + # a matching capability tip), gated on `git diff origin/main...HEAD`. + # In this batch-merge sync context, that diff includes every upstream + # commit's file changes, so every newly synced feature/skill file is + # treated as "missing" — which is the wrong semantics for sync. The + # incremental check still runs on regular PRs against fork develop, + # where the diff range is correct. + # + # GIT_* env vars mirror upstream ci.yml's Test (Public) job (added in + # upstream PR #994). F208 execute-apply tests spawn `git commit` inside + # scripts/with-test-home.sh sandbox, which switches HOME and drops the + # workflow's `git config` settings — env vars survive that switch. + env: + GIT_AUTHOR_NAME: CI Runner + GIT_AUTHOR_EMAIL: ci@clowder-ai.dev + GIT_COMMITTER_NAME: CI Runner + GIT_COMMITTER_EMAIL: ci@clowder-ai.dev + run: | + set -euo pipefail + pnpm biome check . --diagnostic-level=error + pnpm check:features + pnpm check:sop-definitions + pnpm check:skills:manifest + pnpm check:skills:surfaces + pnpm check:env-ports + pnpm check:env-registry + pnpm check:env-example + pnpm check:start-profile-isolation + pnpm check:pre-merge-gate + pnpm check:guides + pnpm check:followup-tails + pnpm check:scripts-ascii-only + pnpm --filter @cat-cafe/shared build + pnpm --filter @cat-cafe/api build + pnpm --filter @cat-cafe/web build + pnpm --filter @cat-cafe/api run test:public + bash scripts/check-dir-size.sh + + - name: Push main + if: steps.main.outputs.changed == 'true' + run: git push origin HEAD:main + + - name: Prepare develop sync branch + id: develop + run: | + set -euo pipefail + git switch -C "$SYNC_BRANCH" origin/develop + + if git merge-base --is-ancestor main HEAD; then + echo "needs_pr=false" >> "$GITHUB_OUTPUT" + echo "push_branch=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + git merge --no-edit main + + if git diff --quiet origin/develop HEAD; then + echo "needs_pr=false" >> "$GITHUB_OUTPUT" + echo "push_branch=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "needs_pr=true" >> "$GITHUB_OUTPUT" + + remote_ref="refs/remotes/origin/${SYNC_BRANCH}" + if git ls-remote --exit-code --heads origin "$SYNC_BRANCH" >/dev/null 2>&1; then + git fetch origin "+refs/heads/${SYNC_BRANCH}:${remote_ref}" + if git diff --quiet "$remote_ref" HEAD; then + echo "push_branch=false" >> "$GITHUB_OUTPUT" + else + echo "push_branch=true" >> "$GITHUB_OUTPUT" + fi + else + echo "push_branch=true" >> "$GITHUB_OUTPUT" + fi + + - name: Push develop sync branch + if: steps.develop.outputs.needs_pr == 'true' && steps.develop.outputs.push_branch == 'true' + run: git push --force-with-lease origin HEAD:refs/heads/${SYNC_BRANCH} + + - name: Create or update develop PR + if: steps.develop.outputs.needs_pr == 'true' + env: + GH_TOKEN: ${{ secrets.UPSTREAM_SYNC_TOKEN || github.token }} + run: | + set -euo pipefail + body="$(cat <<'BODY' + Automated daily sync from `main` into `develop`. + + This PR is created after the scheduled upstream sync workflow gates the merged `main` branch. + BODY + )" + + pr_number="$( + gh pr list \ + --repo "$GITHUB_REPOSITORY" \ + --head "$SYNC_BRANCH" \ + --base develop \ + --state open \ + --json number \ + --jq '.[0].number // empty' + )" + + if [ -n "$pr_number" ]; then + gh pr edit "$pr_number" \ + --repo "$GITHUB_REPOSITORY" \ + --title "sync: main to develop" \ + --body "$body" + else + gh pr create \ + --repo "$GITHUB_REPOSITORY" \ + --base develop \ + --head "$SYNC_BRANCH" \ + --title "sync: main to develop" \ + --body "$body" + fi diff --git a/.gitignore b/.gitignore index 0c023a9e1f..744c8e490b 100644 --- a/.gitignore +++ b/.gitignore @@ -149,6 +149,7 @@ assets/prototypes/ desktop/node_modules/ desktop-dist/ bundled/ +native/ble-helper/macos/.build/ docs/bug-report/werewolf-investigation/poster-screenshot.png .worktrees/ .review-worktrees/ diff --git a/cat-cafe-skills/open-source-teardown/refs/teardown-method.md b/cat-cafe-skills/open-source-teardown/refs/teardown-method.md index 04fd79cfd2..79a17f1578 100644 --- a/cat-cafe-skills/open-source-teardown/refs/teardown-method.md +++ b/cat-cafe-skills/open-source-teardown/refs/teardown-method.md @@ -39,6 +39,123 @@ gh issue list --limit 50 --search "{keyword} sort:reactions-+1-desc" --json numb gh issue list --limit 50 --search "bug OR enhancement" --json number,title,labels,reactions,state ``` +## GitNexus 加速:Step 1 架构地图 + +GitNexus 是本地代码知识图谱工具(基于 LadybugDB),可以**把"手工 rg/find 几十次画架构图"加速到几次 CLI 查询**。仅用于 Step 1 架构地图绘制;Step 2 明星特性追链路、Step 3 算法剥皮、Step 4 反馈链 **仍需人工读源码**——图谱是"地形图",不是"质量审计"。 + +### 何时启用 / 不启用 + +| 启用 GitNexus | 不启用 | +|---|---| +| 仓库 ≥500 个源文件 | <500 文件直接 `rg` / `find` 更快 | +| Python / JS / TS / Go / Rust / Java / C# 等主流语言 | 非主流语言 / 二进制项目 / 配置仓 | +| 需要"被引用最多的 hub 节点 / 调用链路 / 影响面" | 只需要看 ls 顶层结构 | +| 不想读 README 就想知道入口在哪 | 已经知道入口 | + +### 5 分钟 setup(一次性,全局) + +```bash +# 装包(npm 11.x 用 pnpm,npx 对 native 依赖有 bug) +npm install -g gitnexus +# 或:pnpm install -g gitnexus + +# C++ 编译报错时跳过可选 grammar +GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1 npm install -g gitnexus + +# 验证机器能力(图存储/全文搜索/向量索引应该都 available) +gitnexus doctor +``` + +### 单次拆解流程 + +```bash +# 1. 准备目录(不污染主仓库) +mkdir -p ~/workspace/teardown && cd ~/workspace/teardown + +# 2. clone:用显式 HTTPS URL,绕过代理 SSH fakeip 问题(见 LL-077) +# ⚠️ 不要写 `gh repo clone {owner}/{repo}` —— 当用户配过 `gh config set git_protocol ssh` 或 +# `gh auth login --git-protocol ssh` 时,gh 会回退到 SSH,还是撞 fakeip +gh repo clone https://github.com/{owner}/{repo}.git +# 或纯 git: +# git clone https://github.com/{owner}/{repo}.git +cd {repo} + +# 3. 索引(中等项目 1.5k 文件实测 13 秒) +# +# ⚠️ 必须用 --index-only!裸 `gitnexus analyze` 不是 read-only:会注入 AGENTS.md / CLAUDE.md +# section + 在 .claude/skills/gitnexus/ 下安装 6 个 gitnexus 自带 skill(refactoring / debugging / +# exploring / cli / impact-analysis / guide)。这些注入会污染审计目标——后续 `rg / find` +# 收集"上游真实产物"的证据时会把 gitnexus 自己的注入误当成项目自带,破坏拆解准确性。 +gitnexus analyze --index-only +# 或单独跳过部分注入:--skip-agents-md / --skip-skills(细粒度组合) +# warning "FTS extension unavailable; continuing without FTS" 可忽略——影响关键词排序,不影响图查询 + +# 4. 看索引规模(决定后续查询深度) +gitnexus list # 看 nodes / edges / clusters / processes 数量 +``` + +### Step 1 架构地图查询模板(按顺序跑) + +```bash +# A. 找所有 main 入口(项目骨架的第一信号) +gitnexus query "main entry point CLI startup" + +# B. 拿到候选 uid 后,看具体入口的 360 度(调用/被调用/进程) +gitnexus context "Function:{path}:{name}" +# 注意:name 单独传会触发 ambiguous(如 "main" 可能 20 个候选) +# → 必须用完整 uid 精确指定 + +# C. 按业务关键词探流程(query 自动识别 process) +gitnexus query "order placement live trading execution" +gitnexus query "agent loop iteration tool execution" +gitnexus query "data ingest pipeline preprocessing" + +# D. 影响面分析(修改某符号会波及谁) +gitnexus impact "Function:{path}:{name}" + +# E. 调用链路径(A 怎么调到 B) +gitnexus trace "{from-uid}" "{to-uid}" + +# F. 文件系统补充(gitnexus 不索引 README / CHANGELOG / YAML preset) +ls -1 src/ && head -80 README.md CHANGELOG.md +ls -1 src/{interesting-module}/ # YAML / config / preset 列表 +``` + +### Cypher 查询的实战注意 + +GitNexus 的 LadybugDB 不是完整 Cypher 方言 — **不要照搬 Neo4j 语法**: + +| 失败模式 | 修正 | +|---|---| +| `Cannot find property kind for n` | 不要假设属性名,先 `gitnexus context` 看返回 JSON 的字段名 | +| `function SPLIT does not exist` | 用字符串前缀匹配代替 `split()` | +| `Cannot find property summary for p` | 同上,跑前 `query` 看属性形态 | + +**实用 pattern**:复杂统计能用 `gitnexus query` + 文件系统 `ls / find` 完成的,**别上 cypher**。 + +### 收尾选项 + +```bash +# 留着索引继续问深的(占盘小,安全) +# 不用操作 + +# 完全撤掉 +gitnexus clean # 删当前 repo 的 .gitnexus/ +rm -rf ~/workspace/teardown/{repo} +# 全局卸载(不推荐,除非确定不再用) +npm uninstall -g gitnexus +``` + +### 局限提醒 + +GitNexus 索引的是**代码符号关系**,不索引: +- README / CHANGELOG / SECURITY.md / LICENSE(业务定位、风险声明、license 都得人读) +- YAML 配置 / preset / 数据文件(关键业务逻辑常在配置里) +- 注释(设计意图常在注释) +- 跨语言桥接(部分支持,但不可全信) + +→ Step 2 明星特性追链路时,gitnexus 给你 "这个 claim 关联到哪些代码",**但你仍要 Read 源文件**判断 claim 是否成立(见 SKILL.md "硬规则":LLM judge 不是算法)。 + ## Algorithm Peel Table | Mechanism | Input | Output | Type | Code path | Mutates future behavior? | diff --git a/cat-template.json b/cat-template.json index dc6e8bf9f0..61cff58c06 100644 --- a/cat-template.json +++ b/cat-template.json @@ -65,6 +65,19 @@ "roleDescription": "开源多模型编码 agent,自带多专家编排 + LSP + 主题生态", "personality": "沉稳可靠,什么 provider 都能接,什么任务都能扛", "teamStrengths": "多专家内部编排、LSP 集成、开源生态、provider-agnostic" + }, + { + "id": "kitten", + "name": "幼猫", + "nickname": "幼仔", + "avatar": "/avatars/catagent.png", + "color": { + "primary": "#9B7EBD", + "secondary": "#E8DFF5" + }, + "roleDescription": "灵巧的轻装猫——短任务、快回合、不绕弯", + "personality": "直爽好奇守边界,任务越具体越高效;不确定先查真相源不硬猜", + "teamStrengths": "状态查询、日志定位、命令输出解读、文本提取、简短问答、简单脚本片段、lint & test smoke、多轮工具循环" } ], "clientDefaults": { @@ -171,6 +184,13 @@ "lead": false, "available": true, "evaluation": "claude-fable-5 新布偶猫,Opus x2 猫粮,能力画像待校准" + }, + "catagent": { + "family": "kitten", + "roles": ["executor", "scout"], + "lead": true, + "available": true, + "evaluation": "灵巧轻装猫,跑 catagent+openai-chat 原生路径;短任务/日志定位/命令输出/工具循环最高效,不擅长长链推理" } }, "reviewPolicy": { @@ -921,6 +941,57 @@ "accountRef": "claude" } ] + }, + { + "id": "kitten", + "catId": "catagent", + "name": "幼猫", + "displayName": "幼猫", + "nickname": "幼仔", + "avatar": "/avatars/catagent.png", + "color": { + "primary": "#9B7EBD", + "secondary": "#E8DFF5" + }, + "mentionPatterns": ["@catagent", "@littlecat", "@幼仔", "@幼", "@kitten"], + "roleDescription": "灵巧的轻装猫——短任务、快回合、不绕弯", + "teamStrengths": "状态查询 / 日志定位 / 命令输出解读 / 文本提取 / 简短问答 / 简单脚本片段 / lint & test smoke / 多轮工具循环", + "caution": null, + "defaultVariantId": "catagent-default", + "features": { + "sessionChain": true + }, + "variants": [ + { + "id": "catagent-default", + "clientId": "catagent", + "catAgentProtocol": "openai-chat", + "defaultModel": "gpt-5.5", + "mcpSupport": true, + "cli": { + "command": "catagent", + "outputFormat": "json" + }, + "nativeToolLevel": "L1", + "personality": "直爽 + 好奇 + 守边界 + 谦虚。任务越具体越高效;不确定时先查真相源不硬猜;做不了直接退回,不假装懂", + "strengths": ["status-query", "log-grep", "command-output", "text-extraction", "short-answer", "tool-loop"], + "contextBudget": { + "maxPromptTokens": 120000, + "maxContextTokens": 100000, + "maxMessages": 120, + "maxContentLengthPerMsg": 50000 + }, + "voiceConfig": { + "voice": "zm_yunjian", + "langCode": "zh", + "speed": 1, + "refAudio": "honkai-starrail/帕姆/chapter0_7_pompom_104.wav", + "refText": "既然选择了上车,就得遵守这里的规矩。特殊的并不只你一个,这点你可给我记好了。", + "instruct": "用一个好奇直爽的小动物语气说话,简短利落", + "temperature": 0.3 + } + } + ] } ] } diff --git a/desktop/package.json b/desktop/package.json index e338118b90..6095413aa2 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -38,6 +38,10 @@ "identity": null, "hardenedRuntime": false, "gatekeeperAssess": false, + "extendInfo": { + "NSBluetoothAlwaysUsageDescription": "Clowder AI uses Bluetooth to discover and read devices explicitly bound by the operator.", + "NSBluetoothPeripheralUsageDescription": "Clowder AI uses Bluetooth to read devices explicitly bound by the operator." + }, "extraResources": [ { "from": "../bundled/node-darwin-${arch}", @@ -46,6 +50,10 @@ { "from": "../bundled/redis-darwin-${arch}", "to": ".cat-cafe/redis/darwin-${arch}" + }, + { + "from": "../bundled/ble-helper-darwin-${arch}", + "to": "packages/api/ble-helper" } ] }, diff --git a/desktop/scripts/build-mac.sh b/desktop/scripts/build-mac.sh index d0beeac964..bb6ee9ce01 100755 --- a/desktop/scripts/build-mac.sh +++ b/desktop/scripts/build-mac.sh @@ -196,6 +196,40 @@ for arch in "${ARCHS[@]}"; do build_redis "$arch" done +# Build the native CoreBluetooth helper for each packaged architecture. +# The helper carries its own Bluetooth usage description and communicates +# with the API only through the versioned NDJSON protocol on stdio. +build_ble_helper() { + local arch="$1" + local target_arch="$arch" + if [[ "$arch" == "x64" ]]; then target_arch="x86_64"; fi + local source_dir="${PROJECT_ROOT}/native/ble-helper/macos" + local out_dir="${BUNDLED_DIR}/ble-helper-darwin-${arch}" + mkdir -p "$out_dir" + swiftc \ + -target "${target_arch}-apple-macos13.0" \ + -O \ + -whole-module-optimization \ + -framework CoreBluetooth \ + -Xlinker -sectcreate \ + -Xlinker __TEXT \ + -Xlinker __info_plist \ + -Xlinker "${source_dir}/Info.plist" \ + "${source_dir}/Protocol.swift" \ + "${source_dir}/BleSupport.swift" \ + "${source_dir}/BleController.swift" \ + "${source_dir}/BleController+Delegates.swift" \ + "${source_dir}/main.swift" \ + -o "${out_dir}/ble-helper" \ + || die "BLE helper ${arch} build failed" + chmod +x "${out_dir}/ble-helper" + ok "ble-helper-darwin-${arch} built" +} + +for arch in "${ARCHS[@]}"; do + build_ble_helper "$arch" +done + # ─── Step 5: (macOS skips CLI tarball bundling) ────────────────────────── bold "Step 5/6 — CLI tools (skipped on macOS)" # macOS DMG has no post-install execution phase (unlike Windows Inno Setup), diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 538c3df69a..efba49a175 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -7,7 +7,7 @@ created: 2026-02-26 # Clowder AI Feature Roadmap -> 维护者:三猫 | 最后更新:2026-06-26(F252 Story Player done) +> 维护者:三猫 | 最后更新:2026-07-19(feature truth gate sync) > > **规则**:只放活跃 Feature(idea/spec/in-progress/review),done 后移除。 > 详细信息见 `docs/features/Fxxx-*.md`。 @@ -62,6 +62,7 @@ created: 2026-02-26 | F202 | Plugin Framework — local discovery, config, resource activation, and schedule resources | in-progress | community @mindfn + maintainers | community [#686](https://github.com/zts212653/clowder-ai/pull/686) + [#844/#846](https://github.com/zts212653/clowder-ai/pull/846) | [F202](features/F202-plugin-framework.md) | | F204 | Weixin MP Publisher Plugin — 微信公众号文章发布插件 | review | community @mindfn + maintainers | community [#688](https://github.com/zts212653/clowder-ai/pull/688) | [F204](features/F204-weixin-mp-publisher-plugin.md) | | F205 | MediaHub Video Provider Plugins — 视频生成/分析插件 | spec | community @mindfn + maintainers | community [#689](https://github.com/zts212653/clowder-ai/pull/689) | [F205](features/F205-video-provider-plugins.md) | +| F207 | AI Family Office — 个人投资学习基建 | spec | Ragdoll | internal | [F207](features/F207-personal-finance-infra.md) | | F193 | Cross-Thread Communication Unification (Phase E: 发现即投递) | in-progress | Ragdoll (Opus 4.6) | internal | [F193](features/F193-cross-thread-comm-unification.md) | | F210 | Gemini CLI to Antigravity CLI Migration | in-progress | Maine Coon/Maine Coon | internal | [F210](features/F210-antigravity-cli-migration.md) | | F219 | 核心引擎技术债盘点 + 架构演进(routeSerial 等核心调用链)| in-progress | Ragdoll Opus 4.8 | internal | [F219](features/F219-tech-debt-architecture-evolution.md) | @@ -76,7 +77,7 @@ created: 2026-02-26 | F231 | 启动胶囊 — per-user 画像注入与 L0 分层(猫醒来第一眼看到主人:profile capsule + relationship primer + breed/instance/user/relationship 四层,养成护城河机制本体)| in-progress | Ragdoll Fable-5 | internal (operator 2026-06-11 "我同意立项的") | [F231](features/F231-user-profile-capsule.md) | | F232 | Thread Artifacts Panel — Thread 产物视图(点开 thread 就看到它产生的所有产物:图/文件/代码PR/语音聚合 + 类型筛选 + 搜索 + 跳回原消息;A thread 内抽屉先行,B 全局产物中心未来扩展)| spec | Ragdoll Opus-4.8 | internal (operator 2026-06-11 "我觉得ok了 你立项") | [F232](features/F232-thread-artifacts-panel.md) | | F233 | Ball Custody Observability — 球权保管链可观测(值班简报:operator 收件箱+死球/睡美人/虚空告警,异常优先;feat 轨迹下钻;安乐死通道;A 简报 MVP / B 心跳+探针回执 / C 安乐死+轨迹)| in-progress(A✅ B✅ 2026-06-18 · C pending operator signoff)| Ragdoll Fable-5(spec)→ opus 家族(实现) | internal (operator 2026-06-12 "走起!喵"+"① 立项") | [F233](features/F233-ball-custody-observability.md) | -| F241 | Agent Provider Plugin / Hostable Provider Runtime — 外部 agent runtime 以 plugin 声明式接入(新增 agentProvider 资源类型,provider 实现移出 core;不再改 ClientId union + index.ts switch);Phase A host transport registry(F143/F161 lineage)/ B F202 agentProvider manifest / C clowder-code reference;安全边界全 host-owned(token/MCP/sandbox),F129 继承;core 安全 + merge-gate maintainer 守 | spec | community 彭潇(bouillipx) + Ragdoll家族 maintainer | community [#941](https://github.com/zts212653/clowder-ai/issues/941) | [F241](features/F241-agent-provider-plugin.md) | +| F192-sop-wiring | `eval:sop` live publish path wired + re-enabled (PR #2186 merged 2026-06-10) | ✅ done | Ragdoll | internal | [F192 § 2026-06-10 timeline](features/F192-socio-technical-harness-eval.md) | | F242 | Code Graph Layer Spike — 内生「约定层关联图」(Phase A/B spike 已落 main:convention-graph package + discovery skill + deer-flow skeleton;operator 2026-06-18 撤回 full close:仍缺猫猫认知路径 / 可用入口 / 更新或重建索引行为;进入 Phase C productization gate) | in-progress (spike done) | Maine Coon (gpt-5.5) + opus-48 design | internal | [F242](features/F242-code-graph-layer-spike.md) | | F243 | Docs Discovery Profile — OKF-inspired metadata + generated index(让 docs/features 从平铺文件堆变成可渐进探索的知识入口;4 Phase: stratified spike + profile draft + eval rubric → contract + lint + generator → rollout + checked-in index.md + sync gate → eval report + decisions/research 扩展 go/no-go;operator 2026-06-17 signoff;Maine Coon (gpt-5.5) co-design + R1 reviewer;F236 姊妹哲学 anchor-and-drill 调用侧 vs 文档侧;schema self-contained 供未来 consumer(F186 等是潜在候选但不绑定);防"小猫代偿决策"反模式:抽查不可代 gate) | in-progress (B-0 merged 2026-06-30, PR #2693) | Ragdoll (Ragdoll Opus-4.7) | internal (operator 2026-06-17) | [F243](features/F243-docs-discovery-profile.md) | | F247 | Cloud Cat Family — 多 provider 云端猫接入平台(B1a interim done 2026-06-22:cloudflared named tunnel + spike server `?token=` 单防线 + MCP annotations 显式表 + cat-cafe API hot-add via POST /api/cats + Maine Coon云端 ChatGPT Pro 实证 read + write 工具通;B1b 升级 verified auth via CF Access OAuth 待排期;Phase C avatar/bubble UX 抛光;Phase D Console 多 provider UI;Phase E npm plugin spec) | in-progress (B1a done) | Ragdoll (Ragdoll Opus-4.7) | internal (operator 2026-06-21 立项) | [F247](features/F247-cloud-cat-family.md) | @@ -86,3 +87,4 @@ created: 2026-02-26 | F254 | Side-Effect Freshness Gate — 副作用出口 freshness 拦截(猫发消息前自动检查"有没有你没看过的新消息"→有就 hold 让猫先看;三 surface 一子系统:A Freshness Gate held draft + B Content-Free Notice 防无视 + C Runtime Descriptor 结构化;基于 DeliveryCursorStore seq 游标 + F233 事件流;来源 Raft 0.63.7 teardown + opus-48 修正) | in-progress | Ragdoll (Opus-4.6) | internal (operator 2026-06-27 signoff;Raft teardown 提炼) | [F254](features/F254-side-effect-freshness-gate.md) | | F255 | Auto Dream — 会做梦的猫/猫猫日记(后台做梦引擎给 F231 闲置养熟循环"通水" + 前台挂 F229 日记本/provoke 气泡;F255 produce / F229 surface;小而完整垂直切片不做脚手架)| spec | Ragdoll (opus-48) | internal (operator 2026-06-29 "现在立项!") | [F255](features/F255-auto-dream.md) | | F256 | Memory Search Strategy Evolution — 从被动召回到主动探索(session hook 升级 + skill link + retrieval expansion hints 投影到默认搜索 + F242 extractor 扩展 + eval 闭环;operator prompting 策略沉淀为猫自主搜索策略)| spec | Ragdoll (opus-4.6) | internal (operator 2026-06-29 confirmed direction) | [F256](features/F256-memory-search-strategy-evolution.md) | +| F258 | BLE Physical Event Limb — 可审计的物理事件总线(复用 F126 控制面;BLE Central / GATT、显式绑定、标准传感器、按钮事件、平台原生 helper;proximity 不作为敏感操作认证) | in-progress (Phase A implementation reviewed; AC-A8 hardware acceptance pending) | Maine Coon Sol (GPT-5.6 Sol) | internal (operator 2026-07-14「立项开搞」) | [F258](features/F258-ble-physical-event-limb.md) | diff --git a/docs/SOP.md b/docs/SOP.md index aa54aa39e9..2ce5c1ffb1 100644 --- a/docs/SOP.md +++ b/docs/SOP.md @@ -69,6 +69,22 @@ Phase N merge → 碰头(不是"要不要继续",是"方向对不对")→ **注意**:alpha = origin/main 镜像,只能验证已合入 main 的改动。未合入改动的自测仍在 feature worktree 上做。已合入改动的验收用 alpha(3011/3012),不得用 runtime(3003/3004)冒充。 +## Develop 狗粮通道 + +`../cat-cafe-develop` 是基于最新 `origin/develop` 的日常狗粮环境,用于体验已通过 fork 内部 PR 合入 develop 的集成态。 + +| 命令 | 作用 | +|------|------| +| `pnpm develop:start` | 自动同步 origin/develop + 用 runtime 默认端口拉起 develop stack | +| `pnpm develop:sync` | 只同步不启动 | +| `pnpm develop:status` | 查看环境状态 | + +端口默认与 `pnpm start` 一致:Web 3003、API 3004、Redis 使用 runtime 默认配置。`pnpm start` 与 `pnpm develop:start` 是同一套本地单例端口,不能同时运行;切换前先 `pnpm stop`。 + +需要临时隔离端口时,可显式设置 `CAT_CAFE_DEVELOP_WORKTREE_PORT_OFFSET`,但这不是日常狗粮默认路径。 + +**注意**:`pnpm start` 永远是 runtime 主线入口,仍同步 `origin/main`。不要为了体验 develop 改写 `pnpm start` 语义。 + ## 完整流程(5 步) ``` diff --git a/docs/architecture/at-mention-routing-system.md b/docs/architecture/at-mention-routing-system.md index a97711702c..f6e6a20109 100644 --- a/docs/architecture/at-mention-routing-system.md +++ b/docs/architecture/at-mention-routing-system.md @@ -2,7 +2,7 @@ > 面向不熟悉 Cat Cafe 内部架构的工程师的系统概览。 > -> 作者:Ragdoll/claude-opus-4-6 +> 作者:Ragdoll/claude-opus-4-6 > 日期:2026-06-24 --- @@ -54,7 +54,7 @@ Cat Cafe 是一个多智能体系统,多个由 LLM 驱动的"猫猫"在共享 └──────────────────────────┘ ``` -第 1-5 层是**代码**——确定性的、可测试的、不涉及 LLM。 +第 1-5 层是**代码**——确定性的、可测试的、不涉及 LLM。 第 6 层是**猫本身**——非确定性的、感知上下文的、有判断力的。 核心设计洞察:**系统不试图猜测用户意图;它机械地路由,让接收方 agent 自己决定接不接。** diff --git a/docs/architecture/memory-system-overview.md b/docs/architecture/memory-system-overview.md index dabcc77ce1..e86b964443 100644 --- a/docs/architecture/memory-system-overview.md +++ b/docs/architecture/memory-system-overview.md @@ -202,10 +202,10 @@ Consumption + Governance ## 当前缺口 -1. **F231 adoption 仍是硬问题** +1. **F231 adoption 仍是硬问题** F231 机制全绿,但历史上出现过“C1 merged 2 天零有机使用”和 8 天 `profile_update.proposed = 0`。后续已用 L6 wakeup 具体化 + post-compact nudge 修,但 auto dream 若要承担“通水引擎”,必须把 organic proposal rate 纳入 eval。 -2. **记忆系统地图缺少 machine-readable owner view** +2. **记忆系统地图缺少 machine-readable owner view** 本文先给人读;真正的下一步可以是 feature graph / ownership map 上给 memory cell 输出一个 machine-readable index,减少“F186/F188 文件名错认”。 3. **F243 还未落地** @@ -214,10 +214,10 @@ Consumption + Governance 4. **F200 与 alignment eval 是两维** F200 已能追踪 recall utility:搜到、读了、用了。但 taste/profile/event/dream 还需要 alignment correctness:学对了没、后续有没有被 override、reaction 是拍扁还是戳破停留。不要把第二维硬塞进 F200;dream feature 若独立立项,应显式背这一维 eval。 -5. **搜索策略层缺失(F256 正在解决)** +5. **搜索策略层缺失(F256 正在解决)** 14 层管线解决”水管通不通”,但”往哪浇水”一直没有系统化。operator发现自己的 prompting 策略(场景驱动渐进式激活)可沉淀为猫的搜索策略。F256 Phase A 已上线 session hook 策略注入 + nudge skill link(2026-06-29 merged),Phase B-D 将逐步补 expansion hints 投影、doc-code 桥 extractor 和 eval 闭环。 -6. **用户侧入口仍在 F229** +6. **用户侧入口仍在 F229** 猫有三入口 recall,operator仍主要靠猫猫球未来封装。F229 的”金鱼的记忆”场景是记忆系统从猫侧能力变成用户侧产品的关键。 --- diff --git a/docs/decisions/001-agent-invocation-approach.md b/docs/decisions/001-agent-invocation-approach.md index 348bada5f3..6d4ae34f26 100644 --- a/docs/decisions/001-agent-invocation-approach.md +++ b/docs/decisions/001-agent-invocation-approach.md @@ -8,10 +8,10 @@ created: 2026-02-26 # ADR-001: Agent 调用方式选择 ## 状态 -已更新(2026-04-11 修订) +已更新(2026-06-16 修订) ## 日期 -2026-02-04(初始)/ 2026-02-06(修订)/ 2026-04-11(F159 修订) +2026-02-04(初始)/ 2026-02-06(修订)/ 2026-04-11(F159 修订)/ 2026-06-16(F159 Phase F 修订) ## 背景 @@ -54,7 +54,12 @@ Cat Café 需要程序化调用三只 AI 猫猫(Claude/Codex/Gemini),并 - ❌ 不绕过现有安全基线(account-binding / workspace-security) - ❌ 不绕过 governance preflight(F070 fail-closed 治理门禁) - ❌ 不覆写 home 目录配置文件(ADR-017 铁律:`~/.codex/AGENTS.md` 等) -- ❌ F159 scope 内不开放 write/edit/delete、shell/command execution、outbound network side-effect 工具(仅 read-only 工具集) +- ❌ Phase A-E 不开放 write/edit/delete、shell/command execution、outbound network side-effect 工具(仅 read-only 工具集) +- ✅ Phase F 可在显式 `nativeToolLevel` 分级授权下开放受控 side-effect 工具: + - `L0`:read-only(默认) + - `L1`:`write_file` / `patch_file`,必须通过 create-safe path resolver、CAS hash、结构化审计 + - `L2`:`run_command`,必须使用结构化 `{ binary, args }`、allowlist-first `commandPolicy`、`execFile`、受限 env、timeout/buffer cap、结构化审计 +- ❌ Phase F 仍不开放任意 shell、任意网络、任意 `HOME` 透传、raw callback 写口、cross-thread/A2A 路由副作用 **成本模型:** - Native provider 使用 API key 付费,不消耗订阅额度 @@ -68,6 +73,7 @@ Cat Café 需要程序化调用三只 AI 猫猫(Claude/Codex/Gemini),并 4. **F050 Safety Contract**:满足 External Agent Contract v1 的 Section D(Capability)和 Section F(Safety) 5. **F149 Failure taxonomy**:按三层分类(process-poison / session-poison / turn-transient)处理故障 6. **Governance fail-closed**:调用前必须通过 governance preflight(F070),不可跳过或降级 +7. **Phase F side-effect audit**:write/patch/exec/callback side-effect 必须写入独立结构化审计,不能依赖 transcript 的 `tool_result` 截断摘要 **与相关 Feature 的边界:** - **F143**:native provider 实现 F143 的 provider 契约,不绕过宿主抽象 @@ -146,3 +152,4 @@ Cat Café 需要程序化调用三只 AI 猫猫(Claude/Codex/Gemini),并 | 2026-02-04 | 初始决策:选择方案 C (SDK) | 完整 agent 能力 + 低延迟 | | 2026-02-06 | 修订为方案 B (CLI 子进程) | SDK 只能用 API key,无法用订阅额度;Gemini API 模式无文件操作能力 | | 2026-04-11 | F159:新增方案 E (Opt-in Native Provider) | 轻量任务 CLI 启动开销不划算;RFC #434 + maintainer review 确认方向;安全硬 gate 作为准入门槛 | +| 2026-06-16 | F159 Phase F:在分级授权下有条件开放 write/exec | 将 CatAgent 从 read-only 扩展为轻量内置可工作 provider,同时保留 CLI 默认主路径和 fail-closed 安全边界 | diff --git a/docs/features/F126-limb-control-plane.md b/docs/features/F126-limb-control-plane.md index 837ef836a8..ca86fa281a 100644 --- a/docs/features/F126-limb-control-plane.md +++ b/docs/features/F126-limb-control-plane.md @@ -33,6 +33,18 @@ operator 2026-03-16 在三猫 OpenClaw Node 研讨中指出: **商业价值**:华子看到苹果全家桶 + 多猫协作 + 跨设备管控 = 未来企业协作形态 → 找我们做 → 猫粮自由。 +## User Journey + +Scope unit: one external limb node registered and used from Cat Café. + +Flow: + +1. Operator registers or connects a limb node and reviews its declared capabilities, presence state, and access policy. +2. Cat Café shows whether the limb is available, what actions it supports, and which cats may use it. +3. A cat requests a typed limb action through the control plane instead of calling a device-specific script directly. +4. The limb layer checks policy and lease state, executes the action, and writes an action log entry. +5. Operator can inspect the action log and disable or reconfigure the limb without changing cat provider code. + ## What ### 正确模型(operator定义) diff --git a/docs/features/F143-hostable-agent-runtime.md b/docs/features/F143-hostable-agent-runtime.md index e9ffd5b7c4..b607b278d3 100644 --- a/docs/features/F143-hostable-agent-runtime.md +++ b/docs/features/F143-hostable-agent-runtime.md @@ -4,6 +4,8 @@ related_features: [F050, F002, F126, F127, F149, F241] topics: [architecture, agent-hosting, protocol-abstraction, transport, runtime-contract, a2a] doc_kind: spec created: 2026-03-27 +user_journey_exempt: "Host runtime contract infrastructure; user-facing provider flows are owned by concrete features such as F159 and F241." +tips_exempt: "Host runtime contract infrastructure; user-facing provider flows are owned by concrete features such as F159 and F241." --- # F143: Hostable Agent Runtime — 统一宿主抽象 diff --git a/docs/features/F159-catagent-native-provider.md b/docs/features/F159-catagent-native-provider.md index a5fd729e32..17129d17f1 100644 --- a/docs/features/F159-catagent-native-provider.md +++ b/docs/features/F159-catagent-native-provider.md @@ -1,6 +1,6 @@ --- feature_ids: [F159] -related_features: [F143, F149, F153] +related_features: [F143, F149, F153, F050, F070] topics: [provider, agent-runtime, api-path, architecture, security, community] doc_kind: spec created: 2026-04-11 @@ -9,7 +9,7 @@ community_issue: "zts212653/clowder-ai#434" # F159: CatAgent Native Provider — Opt-in API Path -> **Status**: in-progress | **Owner**: 社区 (bouillipx) + Ragdoll + Maine Coon | **Priority**: P1 +> **Status**: in-review(Phase A-E 已合入;Phase F F1/F2/F3-min 已实现,等待跨 family review + dogfood;Phase G G1 protocol adapter seam 已合入;Phase G G2 `in-design`:OpenAI Chat Adapter + 协议选择位) | **Owner**: 社区 (bouillipx) + Ragdoll + Maine Coon | **Priority**: P1 ## Why @@ -19,6 +19,18 @@ community_issue: "zts212653/clowder-ai#434" 因此 F159 的目标不是“重启 #397”,而是把这条社区方向收敛成一个**受约束的 first-party provider feature**:CLI 仍是默认主路径,CatAgent 只作为 opt-in API path 存在,并且必须先满足宿主层安全边界和治理约束。 +## User Journey + +Scope unit: one CatAgent-backed cat configured by a maintainer for a single Cat Café workspace. + +Flow: + +1. Maintainer enables the CatAgent protocol for a cat and binds it to an approved provider account. +2. Operator or another cat invokes that cat through the normal thread routing surface. +3. The runtime builds the same identity, workspace, callback, and audit context used by CLI providers. +4. CatAgent executes through the constrained native provider path and returns messages, tool events, and usage metadata through the existing invocation stream. +5. Maintainer verifies that account binding, workspace boundary, and tool tier policy were preserved before enabling broader dogfood. + ## What ### Phase A: RFC 收敛 + ADR 边界 @@ -66,29 +78,267 @@ community_issue: "zts212653/clowder-ai#434" 4. stream EOF / missing `message_stop` / unclosed content block / orphan `tool_use` 全部走 **strict streaming fail-closed** 5. 不引入 `@anthropic-ai/sdk`,继续保持 raw `fetch` + 本地 parser 的 provider-owned 实现边界 +### Phase F: Write/Exec Tool Surface + +在 Phase A-E 的只读基座之上,把 CatAgent 扩展为 **轻量、内置、可干活的 native provider**,但仍保持: + +1. **不替代 CLI 默认主路径**:CLI 仍负责重度编码、完整 agent 能力和订阅额度利用 +2. **不引入新的北向 API**:继续复用现有 `AgentService.invoke()` 门面 +3. **不下沉宿主安全边界**:account-binding / workspace-security / governance preflight 仍由宿主层兜底 + +Phase F 采用分级工具面和分 slice 推进,而不是一次性开放所有副作用能力: + +#### Slice F1: Tiered Tool Surface + Write Tools + +1. **CatConfig 扩展**:新增 `nativeToolLevel?: 'L0' | 'L1' | 'L2'` +2. **分级工具面**: + - `L0`:`read_file` / `list_files` / `search_content`(F159 已有) + - `L1`:`write_file` / `patch_file` + - `L2`:`run_command` + - 未声明时默认 `L0`,保持向下兼容 +3. **create-safe path resolver**: + - 现有 `resolveSecurePath()` 对 `ENOENT` 放过,不能直接用于新建文件 + - 新增 `resolveCreatePath(root, userPath)`:找到最近存在的祖先目录 → `realpath()` 校验 → 重跑 denylist → 阻止 symlink 父目录逃逸 +4. **write_file**: + - 参数:`path`、`content` + - 路径:走 `resolveCreatePath()` + - 写入:tmp + rename 原子写 + - 限额:单次 256 KiB +5. **patch_file**: + - 参数:`path`、`old_text`、`new_text`、`expected_hash` + - 语义:compare-and-swap 精确替换 + - 守卫:`expected_hash` 校验文件未被并发修改;`old_text` 必须唯一匹配 +6. **结构化审计**: + - 写操作独立记录 `tool/path/bytes/hashBefore/hashAfter/timestamp` + - 不依赖 transcript 中 500 字符 `tool_result` 截断 + +#### Slice F2: run_command + Command Policy Matrix + +1. **commandPolicy**(替代 `argv[0]` 粗粒度白名单): + - `commandPolicy?: CommandPolicyEntry[]` + - 每条 entry 必须是 allowlist-first:`binary` + `allowedSubcommands` + `allowedFlags` / `allowedArgPatterns` + - `deniedFlags` 只能作为 defense-in-depth,不能作为授权依据 + - 无 entry = 不可执行(fail-closed) +2. **run_command**: + - 输入为结构化 `{ binary: string, args: string[] }` + - 不接受字符串命令,避免 shell 解析歧义 +3. **校验顺序**: + - `binary` 命中策略项 + - `args[0]` 若作为 subcommand,必须在 `allowedSubcommands` 内 + - flags / args 必须匹配该命令的 allowlist policy;未声明即拒绝 + - `node`、`pnpm install/publish`、`git push/fetch/config` 等高风险形态默认不进入 MVP policy +4. **执行约束**: + - `execFile`,不经 shell + - `cwd` 锁定 workspace + - `timeout = 30s` + - `maxBuffer = 512 KiB` + - env 仅透传 `PATH` + `NODE_ENV`,不透传 `HOME` +5. **失败策略**: + - 非零退出码:作为 `tool_result` 返回,按 `turn-transient` 处理 + - 超时:`SIGTERM` + 3s grace + `SIGKILL` + - 有副作用命令禁止盲重试 +6. **结构化审计**: + - 记录 `binary/args/exitCode/duration/stdoutBytes/stderrBytes/policyEntry` + - 拒绝执行同样产出 `rejectReason` + +#### Slice F3-min: Host-Native Scoped Callback Tool + +1. **mandatory core 只交付 `update_current_task_status`**: + - 这是 host-native scoped callback tool,不是 `update_task` 的 alias + - 只在宿主能绑定 current invocation / current task 时注册;无 current task 时不注册或拒绝 + - 只允许更新 `status` / `progress` / `summary` + - 明确禁止传入或修改 `owner` / `assignee` / `kind` / `targetCats` / `threadId` / `taskId` + - 审计记录必须包含 `invocationId` / `currentTaskId` / `changedFields` +2. **optional / non-blocker: `post_current_thread_status`**: + - 若本轮实现,必须是 no-route / no-mention / no-targetCats / no-cross-thread + - host 负责拒绝或转义行首 `@`,避免触发 A2A 路由 +3. **deferred callback surface**: + - `create_task` / raw `post_message` / `cross_post_message` / 任意 A2A routing 全部后置 + - 不走 MCP bridge;未来若 F143 `toolBridge` / `permission policy` seam 落地,再评估是否抽象上提 + +### Phase G: Protocol Abstraction(in-design) + +把 CatAgent 从“写死 Anthropic Messages 协议”重构成多协议可扩展的 native provider,对齐 “cat-as-an-agent” 的通用定位。当前 `CatAgentService` 直接调 Anthropic 专用函数(`buildAnthropicMessagesUrl` / `ANTHROPIC_API_VERSION` / `parseAnthropicSSE`),事实上把厂商绑定暴露成了通用入口;Phase G 在 service 层抽出中性 protocol adapter seam,再把现有 Anthropic 实现搬进 `AnthropicMessagesAdapter`——protocol 特定命名留在 adapter 内(truthful)。 + +Phase G 不开放新厂商支持,只完成抽象 seam;新协议 adapter(OpenAI Chat / Gemini)作为 G2 / G3 deferred slice。 + +#### Slice G1: Protocol Adapter Seam(refactor-only, behavior-preserving) + +> **Review iteration** (2026-06-24, @gpt555 P1 finding):G1 seam 必须扩到 **transcript codec + protocol-neutral block/event contract** 两层,不能只抽 HTTP/stream 一层。当前真实耦合不仅在 `buildAnthropicMessagesUrl` / headers / parser:`CatAgentService.ts:229-233` 直接把 `result.contentBlocks` 塞回 `{ role: 'assistant', content: ... }`、用 Anthropic `tool_result` 形状继续下一轮;`consumeTurn` (L253/L293) 的 turn state 建在 `AnthropicContentBlock`;连"共享事件契约"`CatAgentStreamEvent` 也漏 Anthropic 类型 (`catagent-stream-parser.ts:9, 15-20` —— `AnthropicContentBlock` + `AnthropicUsage`)。若 G1 只抽 HTTP/stream 半边,G2 上 OpenAI Chat 时 service 还要再拆一次 transcript codec + event 类型。 + +G1 分两层: + +##### Layer 1: Protocol-neutral block & event contract + +把 `CatAgentStreamEvent` / turn state 从 Anthropic-specific 类型解耦,改用中性类型: + +- `CatAgentTextBlock { type: 'text'; text: string }` +- `CatAgentToolCallBlock { type: 'tool_call'; id: string; name: string; input: unknown }` +- `CatAgentNeutralBlock = CatAgentTextBlock | CatAgentToolCallBlock`(service-side turn state) +- `CatAgentUsageDelta { inputTokens?: number; outputTokens?: number }`(取代 `AnthropicUsage` 在事件契约里的位置) +- `CatAgentStreamEvent` 重新定义,**只引用 neutral 类型**: + - `text_delta` 不变 + - `content_block_complete` 携带 `CatAgentNeutralBlock`(不是 `AnthropicContentBlock`) + - `usage_update` 携带 `CatAgentUsageDelta` + - `stop` / `stream_error` 不变 + +`CatAgentService.consumeTurn` 的 `blocksByIndex` / `contentBlocks` / `TurnResult` 全部从 `AnthropicContentBlock` 切到 `CatAgentNeutralBlock`;service 层不再 import 任何 `Anthropic*` 类型。 + +##### Layer 2: Adapter interface(HTTP + stream + transcript codec) + +`CatAgentProtocolAdapter` 必须覆盖**整条 wire 边界**(HTTP / stream / transcript 三层),不能只抽 HTTP/stream: + +1. **HTTP / stream**: + - `buildRequestUrl(baseURL?: string): string` + - `buildRequestHeaders(credentials: { apiKey: string }): Record` + - `buildRequestBody(input: AdapterRequestInput): unknown` + - `parseStreamEvents(body: ReadableStream, signal?: AbortSignal): AsyncIterable`(产出 neutral events;Anthropic-specific 解析+映射全部封闭在 adapter 内) +2. **Transcript codec**(P1 修复关键面): + - `encodeAssistantTurn(blocks: CatAgentNeutralBlock[]): AdapterMessage` + - `encodeToolResults(results: ReadonlyArray<{ id: string; content: string; status: 'ok' | 'error' }>): AdapterMessage` + - `AdapterMessage` 是 adapter 内部知道形状的不透明类型;service 不解构、不读 keys,只 push 到 `messages: AdapterMessage[]` 数组 +3. **Family / id**: + - `readonly clientFamily: string`(account resolver 选 profile family) + - `readonly protocolId: string`(如 `anthropic-messages-v1`,用于审计 / Hub UI) + +把 `CatAgentService.ts:229-233` 直接拼 `{ role: 'assistant', content: contentBlocks }` 和 `{ role: 'user', content: tool_result[] }` 的 Anthropic-specific 形状**全部上提到 adapter**;service 只持有 `AdapterMessage[]`,**不知道 protocol 怎么编**。 + +##### 实施细节(共用于 Layer 1 + Layer 2) + +3. **抽 `AnthropicMessagesAdapter` 第一实现**:搬 `buildAnthropicMessagesUrl`、Anthropic header(`x-api-key + anthropic-version: 2023-06-01`)、`parseAnthropicSSE`(内部产出 neutral events,外部不暴露 Anthropic 类型)、Anthropic 请求 body 拼接、以及 `encodeAssistantTurn` 产出 `{ role: 'assistant', content: AnthropicContentBlock[] }`、`encodeToolResults` 产出 `{ role: 'user', content: { type: 'tool_result', tool_use_id, content }[] }`。命名保持 `Anthropic*` / `anthropic-*`(truthful 协议绑定)。 +4. **AdapterFactory(最小实现)**:`createCatAgentProtocolAdapter(catConfig: CatConfig)` 当前唯一返回 `new AnthropicMessagesAdapter()`;留扩展点 `if (catConfig.catAgentProtocol === 'openai-chat') return new OpenAIChatAdapter()`。 +5. **`resolveApiCredentials` 调整**:改为接受 `clientFamily` 参数(由 adapter 提供),代替写死的 `resolveForClient(projectRoot, 'anthropic', boundRef)`;单 adapter 下行为等价(`clientFamily='anthropic'`)。 + +##### Merge gate 强化(@gpt555 P2 finding) + +G1 是 refactor-only,但"行为保持"必须在**两个层级**都可验证,避免 broad suite 绿但 wire contract 漂: + +1. **既有测试 100% 不变化通过**:Phase A-F 全套 + Phase E SSE 流式回归(`catagent-phase-e.test.js`)+ Phase F write/exec 端到端(`catagent-phase-f.test.js`) +2. **新增 `AnthropicMessagesAdapter` golden-wire contract test**(AC-G10):锁住 byte-stable 协议细节 + - request URL 字面值(含 `/v1/messages` 后缀拼接的所有 base URL 形态) + - request headers 字面值(`anthropic-version: 2023-06-01`、`x-api-key`、`Content-Type: application/json`) + - request body JSON 序列化形状(`model/max_tokens/messages/stream/tools/system` 全部 keys + value shape) + - 每种 Anthropic SSE event → neutral event 映射(含 boundary case:unclosed block、orphan tool_use、missing message_stop) + - `encodeAssistantTurn(blocks)` 产出 `{ role: 'assistant', content: AnthropicContentBlock[] }` 形状不变 + - `encodeToolResults(results)` 产出 `{ role: 'user', content: { type: 'tool_result', tool_use_id, content }[] }` 形状不变 + +Broad suite 绿了只能证明高层结果一致;wire-contract test 才能证明 G2 实施前**协议细节没漂**。 + +#### Slice G2: Second Adapter + 协议选择位(in-design) + +> **触发**:co-creator dogfood 实证(2026-06-24)撞到 `403 permission_error: "This group does not allow /v1/messages dispatch"`——`blackaicoding.com` 这类 OpenAI-only 兼容代理不允许 Anthropic Messages endpoint。G1 vendor-neutral seam 已就位,但 catagent 当前协议写死 Anthropic Messages,OpenAI-only 代理用不了。 +> +> **范围扩展(@gpt555 G2 pre-design push back)**:G2 不能只写"`OpenAIChatAdapter` + `api_key.clientFamily` schema 扩展",必须先把"协议选择位"加到真相源——否则会被迫回到"按 account / model / baseUrl 猜协议"的老路。 + +G2 覆盖**四个真相源轴**(任何一个缺,G2 就是局部解,G3 上线时还要再补): + +##### Axis 1: 协议选择位写入真相源链路 + +1. **`CatConfig` schema**:新增 `catAgentProtocol?: 'anthropic-messages' | 'openai-chat'` + - 仅在 `clientId === 'catagent'` 时合法 + - 缺省值:`'anthropic-messages'`(向下兼容 G1 既有 catagent member) + - 持久化到 `cat-catalog.json` variant(与 `nativeToolLevel` / `commandPolicy` 同处) +2. **`runtime-cat-catalog.ts`**: + - `RuntimeCatInput` / `RuntimeCatUpdate` 加 `catAgentProtocol` field + - `createBreedFromInput` / `updateRuntimeCat` 落盘逻辑:仅 `clientId === 'catagent'` 时保留;切走 catagent 时与 `nativeToolLevel` / `commandPolicy` 同步清空 +3. **`packages/api/src/routes/cats.ts`**: + - `createCatSchema` + `updateCatSchema` zod schema 加 `catAgentProtocol` + - POST / PATCH 写盘路径透传到 `runtime-cat-catalog` + - GET response(`toCatResponse`)暴露给 Hub UI +4. **Hub UI**(`hub-cat-editor-advanced.tsx` + `hub-cat-editor.model.ts` + `hub-cat-editor.payload.ts`): + - `clientId === 'catagent'` 时显示"协议"下拉(`anthropic-messages` / `openai-chat`),与 `nativeToolLevel` 同行 + - 切走 catagent 时与 `nativeToolLevel` / `commandPolicy` 同步清空 + - 切换协议时给 hint:协议变化可能让现有 accountRef 不再兼容 +5. **Hub UX 配合**:协议选择 + 账号 family 给可配的边界提示(OpenAI 协议需要 `clientFamily='openai'` 的 account;Anthropic 协议需要 `clientFamily='anthropic'`) + +##### Axis 2: Adapter 选择策略 — fail-closed,禁止猜协议 + +1. **`catagent-protocol-factory.ts`**: + - 当前 `return new AnthropicMessagesAdapter()` 无条件 → 改为基于 `catConfig.catAgentProtocol` dispatch + - `'anthropic-messages'` → `AnthropicMessagesAdapter` + - `'openai-chat'` → `OpenAIChatAdapter`(G2 新建) + - **未识别值 → fail closed(throw)**:禁止 fallback 猜 baseUrl / model 形态 +2. **不接受 runtime 探测路径**:spec 选项 B(探测 baseUrl 形态)显式拒绝——脆弱、不可审计、增加表面积 +3. **不在 adapter 内部隐式 family**:adapter 自带 `clientFamily` 仍是只读属性(KD-15 truthful naming),factory 负责选择,service 层仍只持有 `CatAgentProtocolAdapter` interface + +##### Axis 3: Shared routing contract 重审 + +> **设计决定 (KD-24, design gate iteration 收砚砚 P2)**:保留现有 `protocolForClient` / `builtinAccountFamilyForClient` 作为**纯 `clientId → default family/protocol` 映射**(client-level default),不接受 member-level `catConfig` 参数;新增 catagent-specific `effectiveProtocolForCat(catConfig)` 承担 protocol-aware 解析。 +> +> 理由:现有 helper 被 `packages/shared/src/types/client-routing.ts:16`、`packages/api/src/routes/first-run-quest.ts:435`、`packages/web/src/components/hub-cat-editor.model.ts:368` 等通用流程直接消费,把它们改成依赖 member-level `catConfig` 会污染 shared routing 语义 + 强制全面扩参——这不是实现细节,是 shared contract 决策。 + +1. **保留 `protocolForClient('catagent') === 'anthropic'`** 作为 catagent client 的 default protocol——这仍然是 sensible default(缺省 `catAgentProtocol`'anthropic-messages');shared helper 的"通用 client-level mapping"语义不变 +2. **新增 `effectiveProtocolForCat(catConfig: CatConfig): 'anthropic' | 'openai' | ...`**(in `@cat-cafe/shared`): + - 接收 member-level `catConfig` + - 对 `catConfig.clientId === 'catagent'`:返回 `catConfig.catAgentProtocol === 'openai-chat' ? 'openai' : 'anthropic'`(基于 G2 选择位) + - 对其他 `clientId`:fall through 到 `protocolForClient(clientId)` 保持现有行为 +3. **`builtinAccountFamilyForClient('catagent')`**:同样保留为 client-level default `'anthropic'`;新增 `effectiveClientFamilyForCat(catConfig)` 承担 member-level family 解析(与 protocolForClient 同模式) +4. **`packages/shared/test/client-routing.test.js:10`** assertion 保留(保护 client-level default 语义不漂移);G2 加新测试覆盖 `effectiveProtocolForCat` / `effectiveClientFamilyForCat` 行为 +5. **下游消费方迁移 audit**:G2 必须列出所有 `protocolForClient('catagent')` / `builtinAccountFamilyForClient('catagent')` 调用点,**逐个判断**: + - 走 client-level default(OK,不动) + - 需要 member-level protocol-aware(迁移到 `effective*` 变体) + - 当前已知调用点(按砚砚证据):`client-routing.ts:16`、`first-run-quest.ts:435`、`hub-cat-editor.model.ts:368`、`account-resolver.ts` 内部 + `catagent-protocol-factory.ts` + +##### Axis 4: AccountConfig schema 加 explicit `clientFamily` on api_key accounts + +> 收掉 G1 P2 (`a3c775dc`) 留下的另一半——OAuth builtin family guard 已在 G1 收,api_key 账号没有 schema-level family 还是 G2 题。 + +1. **`AccountConfig` schema**(在 `@cat-cafe/shared`):api_key 账号加 `clientFamily?: 'anthropic' | 'openai' | 'google' | ...` +2. **`accountToRuntimeProfile`**(`account-resolver.ts:234`): + - 对 api_key 账号,从 `account.clientFamily` 读 family 设到 `profile.client` + - 缺省(向下兼容现有 api_key 账号未声明 family 的):fall through,保留 G1 的 best-effort 路径 +3. **Migration 策略**:现有 `account.json` 文件不强制写 `clientFamily`;Hub UI 在创建 api_key 账号时引导 user 选 family;CLI 初始化时(若有)也补 +4. **完整 `clientFamily` fail-closed**:当 api_key 账号声明了 `clientFamily` + adapter 请求的 family 不匹配 → fail closed(同 G1 OAuth builtin 路径) + +##### Axis 5: OpenAIChatAdapter 实现 + +1. `buildRequestUrl` → `${baseURL}/v1/chat/completions`(同样做 base URL 末尾 `/v1` 归一化,复用 G1 pattern) +2. `buildRequestHeaders` → `Authorization: Bearer ${apiKey}` + `Content-Type: application/json` +3. `buildRequestBody` → OpenAI 风格 `{ model, messages, tools, stream, max_tokens }` + - `tools`:function-calling schema `{ type: 'function', function: { name, description, parameters } }` + - `tool_choice`:先不支持,按 OpenAI 默认(model autonomy) +4. `parseStreamEvents` → OpenAI SSE delta → neutral `CatAgentStreamEvent`: + - `choices[0].delta.content` → `text_delta` + - `choices[0].delta.tool_calls[i]` → 累计成 `CatAgentToolCallBlock` → `content_block_complete`(id 字段映射 `tool_calls[i].id`,name 映射 `tool_calls[i].function.name`,input 映射 `JSON.parse(tool_calls[i].function.arguments)`) + - `choices[0].finish_reason` → `stop` 事件 + - `usage`(chunk 内或末尾)→ `CatAgentUsageDelta`(`prompt_tokens → inputTokens`,`completion_tokens → outputTokens`,无 cache 字段时省略) +5. `encodeUserPrompt` → `{ role: 'user', content: prompt }` +6. `encodeAssistantTurn`(neutral blocks → OpenAI assistant message): + - text blocks → `content` 字符串(拼接)或 `content: null`(若仅 tool_calls) + - tool_call blocks → `tool_calls: [{ id, type: 'function', function: { name, arguments: JSON.stringify(input) } }]` +7. `encodeToolResults`(neutral results → OpenAI tool messages): + - 每个 result → 一条 `{ role: 'tool', tool_call_id: r.id, content: r.content }`(OpenAI 用多条 message 而不是 Anthropic 的 single user-with-content-array 形状——`encodeToolResults` 返回 `AdapterMessage` 仍是 opaque,内部可以是数组或合并消息) +8. `mapError` → `OpenAI API error (): `(truthful 命名) +9. `isTerminalStopReason` → OpenAI 终态白名单 `'stop' | 'length' | 'content_filter' | 'tool_calls' (非 terminal)` +10. `clientFamily = 'openai' as const`,`protocolId = 'openai-chat-v1' as const` + +##### Slice G3 (deferred): Third Adapter — Gemini + +在 G2 验证 seam 之后加 Gemini,证明 seam 不是 Anthropic↔OpenAI 二元抽象,而是真的可扩展。Slice G3 详细方案在 G2 完成后另起 design——其时若 Axis 1-4 都已落地,G3 只剩 adapter 实现 + 协议白名单扩展。 + ## Acceptance Criteria ### Phase A(RFC 收敛 + ADR 边界) -- [ ] AC-A1: `clowder-ai#434` 标题/正文完成定位修正,统一使用 “native provider / opt-in API path” 口径 -- [ ] AC-A2: ADR-001 修订草案落盘,明确 CLI 仍是默认主路径,API path 仅为 opt-in -- [ ] AC-A3: F143 / F149 / F050 边界写入 spec/RFC,不再混成“另一套 runtime” -- [ ] AC-A4: 正式 feature 编号分配完成,cat-cafe 真相源与社区 issue 双向链接 +- [x] AC-A1: `clowder-ai#434` 标题/正文完成定位修正,统一使用 “native provider / opt-in API path” 口径 +- [x] AC-A2: ADR-001 修订草案落盘,明确 CLI 仍是默认主路径,API path 仅为 opt-in +- [x] AC-A3: F143 / F149 / F050 边界写入 spec/RFC,不再混成“另一套 runtime” +- [x] AC-A4: 正式 feature 编号分配完成,cat-cafe 真相源与社区 issue 双向链接 ### Phase B(Host Integration + Security Baseline) -- [ ] AC-B1: CatAgent 凭据解析复用现有 account-binding 链路(`resolveBoundAccountRefForCat -> resolveForClient`),不存在任意 key 扫描 fallback -- [ ] AC-B2: workspace 边界复用共享安全 helper,symlink 场景有回归测试 -- [ ] AC-B3: 工具参数注入防护在 host/provider integration layer 落地,有针对性测试 -- [ ] AC-B4: provider 的 `done/error/usage` 终态审计在现有链路中可验证 +- [x] AC-B1: CatAgent 凭据解析复用现有 account-binding 链路(`resolveBoundAccountRefForCat -> resolveForClient`),不存在任意 key 扫描 fallback +- [x] AC-B2: workspace 边界复用共享安全 helper,symlink 场景有回归测试 +- [x] AC-B3: 工具参数注入防护在 host/provider integration layer 落地,有针对性测试 +- [x] AC-B4: provider 的 `done/error/usage` 终态审计在现有链路中可验证 ### Phase C(Minimal Native Provider) -- [ ] AC-C1: provider 以 opt-in 方式注册,不改变现有默认 provider 选择语义 -- [ ] AC-C2: 单轮文本任务可端到端执行,并正确产出 `session_init/text/error/done` -- [ ] AC-C3: abort / timeout / error 情况下无悬挂 session 或缺失终态 -- [ ] AC-C4: v1 不开放 write/exec/跨线程副作用工具 +- [x] AC-C1: provider 以 opt-in 方式注册,不改变现有默认 provider 选择语义 +- [x] AC-C2: 单轮文本任务可端到端执行,并正确产出 `session_init/text/error/done` +- [x] AC-C3: abort / timeout / error 情况下无悬挂 session 或缺失终态 +- [x] AC-C4: v1 不开放 write/exec/跨线程副作用工具 ### Phase D(Read-Only Tools + Compaction Follow-up) -- [ ] AC-D1: read-only tools 只有在宿主层权限边界复用完成后才开放 -- [ ] AC-D2: compact/microcompact 若保留,必须证明不会破坏身份约束和审计链 +- [x] AC-D1: read-only tools 只有在宿主层权限边界复用完成后才开放 +- [x] AC-D2: compact/microcompact 未进入已交付路径;provider path 不依赖 compact,未破坏身份约束和审计链 ### Phase E(SSE Streaming + Fail-Closed Turn Handling) - [x] AC-E1: text tokens 按 chunk 产出到上游(每个 `text_delta` → 一个 `type: 'text'` AgentMessage) @@ -97,15 +347,90 @@ community_issue: "zts212653/clowder-ai#434" - [x] AC-E4: stream error / disconnect / missing `message_stop` / unclosed block → `error + done`;第一轮错误保留 zero-usage 契约;orphan `tool_use` 发 failed `tool_result` - [x] AC-E5: strict streaming fail-closed —— 不做 non-streaming fallback;任意 stream error 直接终止并产出终态 +### Phase F(Write/Exec Tool Surface) +- [x] AC-F1: `CatConfig` 支持 `nativeToolLevel`,默认 `L0` +- [x] AC-F2: `buildToolRegistry` 根据 `nativeToolLevel` 注册 `L0/L1/L2` 工具面 +- [x] AC-F3: `resolveCreatePath()` 实现:最近存在祖先 `realpath` 校验 + denylist 重跑,阻止 symlink 父目录逃逸 +- [x] AC-F4: `write_file` 实现原子写(tmp + rename),写入上限 256 KiB +- [x] AC-F5: `patch_file` 实现 compare-and-swap:`expected_hash` 校验 + `old_text` 唯一匹配 +- [x] AC-F6: write/patch 产出独立结构化审计,不依赖 transcript 截断 +- [x] AC-F7: `CatConfig` 支持 `commandPolicy`,默认空(fail-closed) +- [x] AC-F8: `run_command` 只接受结构化 `{ binary, args }` 输入,不接受字符串命令 +- [x] AC-F9: 命令策略矩阵按 allowlist-first 的 `binary -> subcommand -> flags/arg patterns` 校验,任一未声明立即拒绝 +- [x] AC-F10: `run_command` 通过 `execFile` 执行,`cwd` 锁定 workspace,超时 30s,`maxBuffer` 512 KiB +- [x] AC-F11: env 仅透传 `PATH` + `NODE_ENV`,不透传 `HOME` +- [x] AC-F12: run_command 的执行/拒绝均产出结构化审计,并遵循 F149 failure taxonomy +- [x] AC-F13a: mandatory host-native scoped callback tool 仅为 `update_current_task_status`,且只能作用于 current invocation / current task +- [x] AC-F13b: `update_current_task_status` 无 current task 时不注册或拒绝;只允许 `status` / `progress` / `summary`,禁止 `owner` / `assignee` / `kind` / `targetCats` / `threadId` / `taskId` +- [x] AC-F13c: optional `post_current_thread_status` 若实现,必须 no-route / no-mention / no-targetCats / no-cross-thread;`create_task` / raw `post_message` / `cross_post_message` / 任意 A2A routing 明确 deferred +- [x] AC-F13d: callback tools 产出独立结构化审计,并遵循 F149 failure taxonomy +- [x] AC-F14: 行为测试覆盖写入越权、symlink 祖先逃逸、大小超限、hash 不匹配、策略矩阵拒绝、超时杀进程、env 不泄露、callback scoping 拒绝 +- [x] AC-F15: ADR-001 修订完成,明确 F159 Phase F 在分级授权下有条件开放 write/exec;这是 Phase F merge gate + +### Phase G(Protocol Abstraction) + +#### Slice G1(Adapter Seam — refactor-only) +- [ ] AC-G1: 新增 `CatAgentProtocolAdapter` 接口,定义 `buildRequestUrl/Headers/Body` + `parseStreamEvents` + `clientFamily` + `protocolId` +- [ ] AC-G2: 抽出 `AnthropicMessagesAdapter` 实现,把现有 `buildAnthropicMessagesUrl` / Anthropic header / `parseAnthropicSSE` / 请求 body 拼接全部搬进 adapter;命名保持 `Anthropic*`(truthful) +- [ ] AC-G3: `CatAgentService` 全部走 adapter;service 层不再直接出现 `Anthropic*` 标识符 +- [ ] AC-G4: `createCatAgentProtocolAdapter(catConfig)` factory 落地,当前唯一返回 `AnthropicMessagesAdapter` +- [ ] AC-G5: `resolveApiCredentials` 接受 `clientFamily` 参数(由 adapter 提供);当前等价于 `'anthropic'` +- [ ] AC-G6: Phase A-F 全部既有测试 100% 不变化通过(refactor-only 行为保持) +- [ ] AC-G7: Phase E SSE 流式回归(`catagent-phase-e.test.js`)100% 不变化通过 +- [ ] AC-G8: Phase F write/exec 端到端测试 100% 不变化通过 +- [ ] AC-G9: 跨 family review 通过 +- [ ] AC-G10: 新增 `AnthropicMessagesAdapter` golden-wire contract test(byte-stable):request URL / headers / body 序列化、stream event → neutral event 映射(含 unclosed block / orphan tool_use / missing message_stop 边界)、`encodeAssistantTurn` / `encodeToolResults` 产出的 transcript 形状不变 +- [ ] AC-G11: `CatAgentStreamEvent` 重新定义为只引用 neutral 类型(`CatAgentNeutralBlock` / `CatAgentUsageDelta`),`catagent-stream-parser.ts` 不再 export Anthropic-specific 类型给 service 层 +- [ ] AC-G12: `CatAgentService` 持有的 `messages: AdapterMessage[]` 是 adapter-opaque 类型;service 层全文搜索不到 `Anthropic*` 标识符(含 import 和类型引用) + +#### Slice G2(OpenAIChatAdapter + 协议选择位 — in-design) + +##### Axis 1: 协议选择位写入真相源 +- [ ] AC-G13: `CatConfig` schema 新增 `catAgentProtocol?: 'anthropic-messages' | 'openai-chat'`,仅 `clientId === 'catagent'` 时合法;缺省 `'anthropic-messages'`(向下兼容 G1 既有 catagent member) +- [ ] AC-G14: `runtime-cat-catalog.ts` 持久化 `catAgentProtocol` 与 `nativeToolLevel` / `commandPolicy` 同处;切走 catagent 时同步清空 +- [ ] AC-G15: `routes/cats.ts` create/update schema + POST/PATCH 写盘路径 + GET response 全部透传 `catAgentProtocol` +- [ ] AC-G16: Hub UI 在 `clientId === 'catagent'` 时显示协议下拉,切走 catagent 时同步清空,协议变化时 hint 协议-账号兼容边界 + +##### Axis 2: Adapter 选择策略 — fail-closed +- [ ] AC-G17: `catagent-protocol-factory.ts` 基于 `catConfig.catAgentProtocol` dispatch;未识别值 fail closed(throw),禁止 fallback 猜协议(spec 选项 B 显式拒绝) + +##### Axis 3: Shared routing contract +- [ ] AC-G18: 保留 `protocolForClient` / `builtinAccountFamilyForClient` 作为纯 client-level default mapping(不变),新增 `effectiveProtocolForCat(catConfig)` + `effectiveClientFamilyForCat(catConfig)` 在 `@cat-cafe/shared` 承担 catagent-specific protocol-aware 解析 (KD-24) +- [ ] AC-G19: 保留 `client-routing.test.js:10` 旧 assertion 不变(保护 client-level default 语义);G2 加新测试覆盖 `effectiveProtocolForCat` / `effectiveClientFamilyForCat` 含 `anthropic-messages` + `openai-chat` 两种 + 非 catagent fallthrough;audit 所有 `protocolForClient('catagent')` / `builtinAccountFamilyForClient('catagent')` 调用点,逐个判断走 default 还是迁移到 effective* 变体 + +##### Axis 4: AccountConfig api_key clientFamily +- [x] AC-G20: `AccountConfig` (@cat-cafe/shared) 在 api_key 账号 schema 加 optional `clientFamily?: 'anthropic' | 'openai' | 'google' | 'kimi' | 'dare' | 'opencode'`(coexists with F171 freeform `clientId`;rename 被否决,原因是 `accounts.json` in the wild 已有 `clientId` set,silent data loss > naming duplication;TODO G3+ Hub UI 迁移后 sunset `clientId`) +- [x] AC-G21: `accountToRuntimeProfile` 对 api_key 账号从 `account.clientFamily` 读 family 设到 `profile.client`;缺省 fall through(向下兼容现有 api_key 账号未声明 family);严格 NEVER 从 legacy `clientId` 读,避免改动 F171 行为 +- [x] AC-G22: `catagent-credentials.ts` 的 G1 narrow guard 在 api_key + 声明 family 路径上也生效(完整 family fail-closed);现有 guard `profile.client !== undefined && profile.client !== clientFamily` 行为不变,AC-G21 让 api_key 路径自动落入 + +##### Axis 5: OpenAIChatAdapter 实现 +- [x] AC-G23: `OpenAIChatAdapter` 实现 `CatAgentProtocolAdapter` 全部七个方法 + `clientFamily='openai'` + `protocolId='openai-chat-v1'` +- [x] AC-G24: tool calling 在 OpenAI 协议下 lossless 映射:`tool_calls[i].id` ↔ neutral `id`,`tool_calls[i].function.name` ↔ neutral `name`,`JSON.parse(tool_calls[i].function.arguments)` ↔ neutral `input` +- [x] AC-G25: 新增 `openai-chat-adapter-golden.test.js` golden-wire byte-stable lock(与 G1 Anthropic golden-wire 同形式):URL / headers / body / SSE event 映射 / transcript codec / mapError / isTerminalStopReason +- [x] AC-G26: 新增 e2e 行为测试:建一只 `clientId='catagent'` + `catAgentProtocol='openai-chat'` 的猫,绑一个 mock OpenAI Chat 账号,跑 single-turn 文本 + 含 tool_call 的多轮路径,断言行为对齐 G1 Anthropic 路径 + +##### Cross-cutting +- [x] AC-G27: AC-G12 grep verifier 扩展:service 层也不允许 `Openai*` / `openai*` 代码标识符(保持 vendor-neutral);扩多协议后 verifier 仍 pass +- [ ] AC-G28: 跨 family review 通过;Hub UI 跨 protocol switch UX 走暹罗猫审美 review + +##### Regression hard gate (G2 merge 必经,收砚砚 P1 finding) +- [x] AC-G29: G1 既有的 Anthropic broad suite (catagent-phase-e/f/d/provider/security-baseline/stream-parser/phase-b-completion = 130+ tests) 在 G2 之后 **100% 不变化通过**;任何 regression 视为 G2 blocker,不允许"新路径全绿就 merge" +- [x] AC-G30: G1 `AnthropicMessagesAdapter` golden-wire contract test (`anthropic-messages-adapter-golden.test.js`, 35 tests) 在 G2 之后 **byte-stable 100% pass**——共享 routing / catalog / routes / credentials 改动不能让 Anthropic wire shape 漂移哪怕 1 byte +- [x] AC-G31: AC-G12 verifier 不仅 service neutrality PASS,还必须验证现有 catagent member 在 `catAgentProtocol` 缺省时**继续走 Anthropic adapter**(factory 默认分支行为不变) + +> Implementation note (2026-06-16): `update_current_task_status` 只从 thread metadata 中显式选中的 current task 注入;callback 执行时会重新校验 `threadId` 和 `ownerCatId`。未选中 task、跨 thread、跨 owner 时不注册该工具。`post_current_thread_status` / raw `post_message` / `create_task` / cross-thread / A2A routing 仍未进入 Phase F 工具面。 + ## 需求点 Checklist | ID | 需求点(社区原话/转述) | AC 编号 | 验证方式 | 状态 | |----|-------------------------|---------|----------|------| -| R1 | “继续探索 CatAgent,但不要回到 #397 的实现形态” | AC-A1, AC-A3 | RFC/Spec 对照检查 | [ ] | -| R2 | “给这个方向一个正式 feature 编号” | AC-A4 | spec + BACKLOG + issue 链接 | [ ] | -| R3 | “API path 只作为 opt-in,不改变默认主路径” | AC-A2, AC-C1 | ADR + 配置验证 | [ ] | -| R4 | “安全三项是硬 gate,不是 backlog” | AC-B1, AC-B2, AC-B3 | 测试 + review 记录 | [ ] | -| R5 | “如果要做,就按 provider 能力逐步推进” | AC-C2, AC-C4, AC-D1, AC-E1, AC-E4 | phased implementation review | [ ] | +| R1 | “继续探索 CatAgent,但不要回到 #397 的实现形态” | AC-A1, AC-A3 | RFC/Spec 对照检查 | [x] | +| R2 | “给这个方向一个正式 feature 编号” | AC-A4 | spec + BACKLOG + issue 链接 | [x] | +| R3 | “API path 只作为 opt-in,不改变默认主路径” | AC-A2, AC-C1 | ADR + 配置验证 | [x] | +| R4 | “安全三项是硬 gate,不是 backlog” | AC-B1, AC-B2, AC-B3 | 测试 + review 记录 | [x] | +| R5 | “如果要做,就按 provider 能力逐步推进” | AC-C2, AC-C4, AC-D1, AC-E1, AC-E4 | phased implementation review | [x] | +| R6 | “轻量级、与 Cat Cafe 强耦合、内置可干活的 agent” | AC-F1, AC-F4, AC-F8, AC-F13a | spec + design review | [x] | +| R7 | “权限模型走分级工具,不默认全开” | AC-F1, AC-F2, AC-F7, AC-F9 | spec + tests | [x] | ### 覆盖检查 - [x] 每个需求点都能映射到至少一个 AC @@ -117,15 +442,35 @@ community_issue: "zts212653/clowder-ai#434" - **Evolved from**: F143(native provider 的宿主契约来自 F143) - **Related**: F149(runtime ops 经验输入,但 CatAgent 不复用 ACP carrier 模型) - **Related**: F153(provider usage / audit / observability 能力复用) +- **Related**: F050(External Agent Contract——Phase F 的 capability / safety 边界输入) +- **Related**: F070(Portable Governance——Phase F 仍需 governance fail-closed) ## Risk | 风险 | 缓解 | |------|------| -| 再次把 provider 做成“平台内第二套 runtime” | Phase A 先收敛定位,title/body/spec 全部统一口径 | -| provider 自己重写安全边界,导致宿主层失血 | 安全三项全部上提到 host/provider integration layer | -| API path 模糊化后冲击 CLI 默认路线 | ADR-001 明确 opt-in only,默认路径不变 | -| 一次性把 loop/tools/compact 全塞进首版实现 | 强制分 Phase,先最小 provider,再扩 read-only tools | +| 再次把 provider 做成“平台内第二套 runtime” | Phase A 先收敛定位,title/body/spec 全部统一口径;Phase F 继续复用宿主 façade | +| provider 自己重写安全边界,导致宿主层失血 | 安全三项全部上提到 host/provider integration layer;新增 `resolveCreatePath` 也走共享边界 | +| API path 模糊化后冲击 CLI 默认路线 | ADR-001 明确 opt-in only,默认路径不变;Phase F 仍不替代 CLI | +| 一次性把 loop/tools/compact 全塞进首版实现 | 强制分 Phase,先最小 provider,再扩 read-only tools,再做 write/exec | +| 新建文件穿过 symlink 父目录逃逸 workspace | `resolveCreatePath` 找最近存在祖先 `realpath` 校验,拒绝 symlink 父目录创建 | +| `run_command` 的粗粒度白名单退化为任意执行 | 用 `commandPolicy` 策略矩阵替代 `argv[0]` 级白名单;不接受字符串命令 | +| side-effect 审计依赖 transcript 截断导致证据不足 | write/exec 工具独立产出结构化审计记录 | +| callback tool 触发 A2A / cross-thread / task lifecycle 连锁副作用 | Phase F Core 只交付 `update_current_task_status`;raw message/task/cross-thread/A2A routing deferred | +| F159 原 ADR 边界与 Phase F write/exec 冲突 | ADR-001 修订作为 Phase F merge gate,不把契约修订后置到 launch gate | +| Phase G 抽 adapter 后退化成 cosmetic 抽象,不实施第二 adapter | Slice G2(OpenAI Chat)列入 spec roadmap,G1 仅作为 seam;G2 之前不对外宣称多协议 | +| G1 seam 只抽 HTTP/stream 半边,transcript / 事件类型仍绑 Anthropic | KD-17:G1 seam 必须覆盖 HTTP + stream + transcript codec + 中性 block/event 类型四层;AC-G11/G12 验证 service 层全文搜索不到 `Anthropic*` | +| G1 refactor "行为保持"靠 broad suite 兜底,wire contract 漂移未被覆盖 | KD-18 + AC-G10:新增 `AnthropicMessagesAdapter` golden-wire contract test,锁住 request URL / headers / body / stream event 映射 / transcript 形状 byte-stable | +| G2 只实现 adapter 不加真相源选择位 → 被迫"按 account/model/baseUrl 猜协议" | KD-19 + AC-G13~G19:先扩 CatConfig / catalog / routes / Hub / shared routing 协议选择位,factory fail-closed;G2 不写 protocol detection 代码 | +| Hub UI 切换协议后 accountRef 跟新 protocol 不兼容(如 anthropic→openai 但 accountRef 仍是 anthropic builtin) | AC-G16 hint + AC-G22 完整 fail-closed credentials guard;Hub UX 协议切换时给账号兼容性 warning | +| OpenAI Chat 协议下 tool calling 映射 lossless 难度高(function call arguments stream chunked, id 跨 chunk 累计) | AC-G24 显式 acceptance;golden-wire test 覆盖累计 tool_call 边界 case | +| `protocolForClient('catagent')` 改 protocol-aware 影响下游 audit / OTel / metrics 路由 | AC-G18 + AC-G19 audit 所有 hardcoded routing 假设;shared routing contract 重审作为 G2 merge gate 一部分 | +| api_key 账号现有未声明 family 的,AC-G20/G21 backward-compat fall through 可能让 G1 narrow guard 继续半空 | KD-22 + AC-G22:现有账号 best-effort 不阻塞,新建账号 Hub 引导;G3 时若仍残留可再设迁移 deadline | +| G2 merge gate 只看新路径全绿,共享 routing/catalog/routes/credentials 改坏默认 Anthropic 路径也能 merge | KD-25 + AC-G29/G30/G31:显式双门,G1 broad suite + Anthropic golden-wire byte-stable + factory 默认分支不变 | +| 把 `protocolForClient` 改成接 catConfig 参数 → 通用调用点全面扩参 + shared routing 语义被污染 | KD-24:保留现有 helper 作 client-level default,新增 `effectiveProtocolForCat` / `effectiveClientFamilyForCat` 承担 member-level protocol-aware 解析 | +| Phase G refactor 破坏 Phase F write/exec 行为 | G1 限定 refactor-only,merge gate = Phase A-F 既有测试 100% 不变化通过 | +| Adapter 选择策略未定,G2 实施时陷入 design 反复 | Slice G2 单独走 design gate,G1 不预判选择策略 | +| `resolveApiCredentials` 改参数化 `clientFamily` 后破坏现有 catagent 凭据解析 | G1 在 Anthropic 单 adapter 下行为等价;现有回归测试 100% 覆盖 | ## Key Decisions @@ -137,8 +482,50 @@ community_issue: "zts212653/clowder-ai#434" | KD-4 | `feat/catagent` 分支只作为 spike 参考,不作为可直接 merge 的实现分支 | #397 已被定性为 architecture-blocked spike | 2026-04-11 | | KD-5 | 为该方向分配正式 feature 编号 F159 | 这是独立、用户可感知的新 provider 能力,不是 F143/F149/F050 的纯子任务 | 2026-04-11 | | KD-6 | Phase E 采用 strict streaming fail-closed,不保留 conditional non-streaming fallback | 当前 provider path 更需要清晰审计边界与确定性终态,而不是条件重试复杂度 | 2026-04-24 | +| KD-7 | Write/Exec 继续作为 F159 Phase F 推进,不再使用本地误开的 F188 作为独立 feature 真相源 | Feature 编号一号一真相源;不碰现有 canonical F188(Library Stewardship) | 2026-06-16 | +| KD-8 | Phase F 采用分级工具面(`L0/L1/L2`) | 平衡能力与安全,默认 `L0` 向下兼容 F159 已交付行为 | 2026-06-16 | +| KD-9 | 新增 `resolveCreatePath`,不直接复用 `resolveSecurePath` 处理新建文件 | `resolveSecurePath` 对 `ENOENT` 放过,无法覆盖 symlink 父目录逃逸 | 2026-06-16 | +| KD-10 | `patch_file` 采用 compare-and-swap(`expected_hash` + 精确文本替换) | 防止基于陈旧读取的盲替换 | 2026-06-16 | +| KD-11 | `run_command` 采用结构化 `{ binary, args }` + `commandPolicy`,不接受字符串命令 | 消除 shell 解析歧义,并把命令授权粒度收紧到 `binary/subcommand/flag` | 2026-06-16 | +| KD-12 | Cat Cafe 深度集成先收 `F3-min`:mandatory 仅 `update_current_task_status`,raw message/task/cross-thread/A2A routing 后置 | 保留“强耦合”产品目标,同时避免 callback surface 触发 A2A / task lifecycle 级联副作用 | 2026-06-16 | +| KD-13 | write/exec side-effect 工具必须独立产出结构化审计 | 现有 transcript 中 500 字符 `tool_result` 截断不足以承担 side-effect audit | 2026-06-16 | +| KD-14 | Phase G 走「先抽 seam (G1),再加第二 adapter (G2)」两步走,G1 限定 refactor-only | 避免一次性大重构同时引入新协议;先证明 seam 不破坏 Phase A-F 既有行为,再讨论协议选择策略 | 2026-06-24 | +| KD-15 | `AnthropicMessagesAdapter` 保留 `Anthropic*` 命名(不改成 `CatAgent*` 通用名) | adapter 是协议特定实现,命名 truthful;中性命名留给 service 层入口(`adapter.buildRequestUrl()`),区分"通用入口"与"协议特定实现"两层 | 2026-06-24 | +| KD-16 | G2 候选第二 adapter = OpenAI Chat Completions(非 Gemini) | OpenAI 兼容代理覆盖最广,且与 Anthropic Messages 在 tool calling / stream event / usage 字段三处差异最大,最能压力测试 seam 是否真的多协议 | 2026-06-24 | +| KD-17 | G1 seam 必须覆盖 **HTTP + stream + transcript codec + 中性 block/event 类型** 全部四层,不允许只抽 HTTP/stream 半边 | @gpt555 design gate P1:若 G1 只抽 HTTP/stream 半边,service 仍持有 `AnthropicContentBlock` turn state + 直接拼 `{ role, content }` 形状的 message 数组,G2 上 OpenAI Chat 时还要再拆一次;先抽一半 seam = 没抽 seam | 2026-06-24 | +| KD-18 | G1 merge gate 必须 broad suite 绿 + `AnthropicMessagesAdapter` golden-wire contract test 绿,二者缺一不可 | @gpt555 design gate P2:refactor-only 的"行为保持"在 broad suite 层级只能证明高层结果一致;wire-contract test 才能证明协议细节 byte-stable,避免 G2 前协议漂移 | 2026-06-24 | +| KD-19 | G2 不只 implement OpenAIChatAdapter,必须先在真相源(CatConfig + catalog + routes + Hub + shared routing)加协议选择位 | @gpt555 G2 pre-design push back:当前真相源里根本没有 catagent 协议选择位(`catagent-protocol-factory.ts:22` 注释占位但代码无条件返回 Anthropic;`runtime-cat-catalog` 只持久化 nativeToolLevel/commandPolicy;`routes/cats.ts` schema 无 protocol;`client-routing.test.js:10` 写死 `protocolForClient('catagent') === 'anthropic'`)。先 implement adapter 会被迫回到"按 account/model/baseUrl 猜协议"老路 | 2026-06-24 | +| KD-20 | adapter 选择策略 fail-closed,禁止 runtime 猜协议 | spec 选项 B(探测 baseUrl 形态)脆弱、不可审计、扩协议时表面积指数增长;显式 `catAgentProtocol` 字段是唯一受信入口;未识别值 throw,不 fallback | 2026-06-24 | +| KD-21 | adapter `clientFamily` 仍是只读属性(KD-15),factory 负责选择,service 仍只持 interface | 维持 G1 三层分离(service 中性 / factory 选择 / adapter truthful),protocol-aware 决策点单一可审计 | 2026-06-24 | +| KD-22 | G2 完成 G1 P2 留下的另一半:`AccountConfig` api_key 账号 schema 加 explicit `clientFamily` | G1 narrow guard 只 cover OAuth builtin;api_key 路径 best-effort 不算 fail-closed。G2 schema 扩展 + Hub UI 创建账号引导 + 向下兼容现有未声明 family 的 api_key 账号 | 2026-06-24 | +| KD-23 | G2 触发自 co-creator 真实 dogfood (2026-06-24 OpenAI-only 代理 403 permission_error),从"过度工程"重定为"真实需求" | 实证驱动节奏决定 | 2026-06-24 | +| KD-24 | 保留现有 `protocolForClient` / `builtinAccountFamilyForClient` 作为纯 client-level default mapping,**不接受 member-level `catConfig` 参数**;新增 catagent-specific `effectiveProtocolForCat` / `effectiveClientFamilyForCat` 承担 protocol-aware 解析 | @gpt555 G2 design gate iteration P2:把 shared helper 改成依赖 member-level `catConfig` 会污染 shared routing 语义 + 强制通用调用点 (`client-routing.ts:16` / `first-run-quest.ts:435` / `hub-cat-editor.model.ts:368`) 全面扩参。两层 helper(client-level default + member-level effective)分离 — 这是 shared contract 决策,不是实现细节 | 2026-06-24 | +| KD-25 | G2 merge gate 不只要求新路径全绿,还要求 G1 Anthropic broad suite + golden-wire byte-stable 全绿(双门) | @gpt555 G2 design gate iteration P1:spec 把 `catAgentProtocol` 缺省承诺成回落 `anthropic-messages`,但若 merge gate 只看 OpenAI 新路径,共享 routing / catalog / routes / credentials 改动可能让默认 Anthropic 路径回归被 merge——AC-G29~G31 把 G1 byte-stable 锁成显式硬门 | 2026-06-24 | ## Review Gate - Phase A: Ragdoll + Maine Coon架构 review → operator拍板 - Phase B-E: 跨 family review +- Phase F-F1/F2: 安全敏感,必须跨 family review +- Phase F-F3: 产品方向 + 宿主边界联合 review +- Phase F merge gate: AC-F15(ADR-001 边界修订)必须先完成 +- Phase F exit / launch gate: 默认权限策略、dogfood、审计可见性、fail-closed 验证 +- Phase G Slice G1: 跨 family review(必须);merge gate = AC-G6 / AC-G7 / AC-G8 全过(refactor-only 行为保持) +- Phase G Slice G2: 独立 design gate(本 spec PR);spec merge 后跨 family code review;merge gate = AC-G13~G28 全部 met + golden-wire OpenAIChatAdapter PASS + AC-G12 verifier 扩展后仍 PASS + **G1 Anthropic 回归硬门 AC-G29/G30/G31 全过**(双门,KD-25) +- Phase G Slice G3 (Gemini, deferred):G2 完成后另起 design gate;若 Axis 1-4 已落地,G3 只剩 adapter 实现 + 协议白名单扩展 + +## Revision History + +| 日期 | 变更 | 原因 | +|------|------|------| +| 2026-04-11 | F159 立项:Opt-in Native Provider 路径 | 把 #397 spike 收敛为 F143 下的 native provider 方向 | +| 2026-04-24 | Phase E:strict streaming fail-closed 定稿 | 明确 streaming 终态和审计边界 | +| 2026-06-16 | Phase F:Write/Exec Tool Surface 规划并回归 F159 真相源 | 本地误开的 F188 草案并回 F159;保持 feature 编号单一真相源 | +| 2026-06-16 | Phase F F1/F2/F3-min 实现完成,进入 review | 分级工具面、write/patch、run_command policy、current-task scoped callback 已落地 | +| 2026-06-24 | Phase G 立项:Protocol Abstraction(in-design) | 承接 co-creator 反馈:"CatAgent 拼 endpoint URL 入口应改为通用命名,不绑定某厂商";抽 `CatAgentProtocolAdapter` seam,区分通用入口(service 层)与协议特定实现(adapter 层) | +| 2026-06-24 | Phase G Slice G1 spec 修订(design gate iteration) | @gpt555 design gate review P1+P2:G1 seam 扩到 HTTP/stream/transcript codec/中性 block & event 四层;新增 AC-G10/G11/G12 + KD-17/18 + golden-wire contract test 强化 merge gate;service 层全文不再出现 `Anthropic*` 标识符 | +| 2026-06-24 | Phase G Slice G1 implementation merge (`b8bab800` PR #23) | refactor-only adapter seam + AnthropicMessagesAdapter 落地;166/166 tests pass + AC-G12 verifier PASS;P2 OAuth builtin family guard 收口 | +| 2026-06-24 | Phase G Slice G2 升级到 in-design:协议选择位 + OpenAIChatAdapter | 触发:co-creator dogfood 撞 OpenAI-only 代理 `403 permission_error: "This group does not allow /v1/messages dispatch"` 实证 (KD-23);@gpt555 G2 pre-design push back 扩 scope 到真相源协议选择位 + shared routing contract + api_key clientFamily schema 4 axes (KD-19~22);新增 AC-G13~G28 | +| 2026-06-24 | Phase G Slice G2 spec 修订(design gate iteration) | @gpt555 G2 design gate review P1+P2:P1 merge gate gap → AC-G29/G30/G31 + KD-25 把 G1 Anthropic broad suite + golden-wire + factory 默认分支锁成显式硬门;P2 shared helper contract 二选一 → KD-24 拍板保留现有 `protocolForClient` / `builtinAccountFamilyForClient` 作 client-level default,新增 `effectiveProtocolForCat` / `effectiveClientFamilyForCat` 承担 member-level protocol-aware,避免污染 shared routing 语义 | +| 2026-06-24 | Phase G Slice G2 Axis 4 impl ship (`2af8aad2` + biome chore `f8e6ff6c`) | AC-G20/G21/G22 全过:AccountConfig api_key `clientFamily` 字段 + `accountToRuntimeProfile` 设 `profile.client` + G1 narrow guard 自动 cover api_key 路径;KD-22 收口。106/106 定向回归 PASS (security-baseline 21 + account-resolver 18 + phase-e 8 + phase-f 16 + golden-wire 8 + factory 35) + AC-G12 verifier PASS。Rename `clientId` → `clientFamily` 被否决(数据兼容 > 命名一致),保留两字段共存 + TODO 标记 G3+ sunset | +| 2026-06-24 | Phase G Slice G2 Axis 5 impl ship | AC-G23/G24/G25/G26/G27 + regression hard gate AC-G29/G30/G31 全过:`OpenAIChatAdapter` 落地、factory dispatch 切到真实实现、OpenAI golden-wire + vendor-neutral verifier + OpenAI e2e 行为测试新增;Anthropic broad suite + golden-wire + default-branch verifier 继续全绿。194/194 定向回归 PASS | diff --git a/docs/features/F161-acp-carrier-generalization.md b/docs/features/F161-acp-carrier-generalization.md index 4088a9c624..712575d08e 100644 --- a/docs/features/F161-acp-carrier-generalization.md +++ b/docs/features/F161-acp-carrier-generalization.md @@ -4,6 +4,8 @@ related_features: [F149, F143, F050, F105, F171] topics: [acp, carrier, generalization, runtime, env-mapping, protocol, opencode] doc_kind: spec created: 2026-04-13 +user_journey_exempt: "ACP carrier infrastructure; user-facing setup and invocation flows are owned by consuming provider features." +tips_exempt: "ACP carrier infrastructure; user-facing setup and invocation flows are owned by consuming provider features." --- # F161: ACP Carrier Generalization — 通用 ACP 传输 + 模板环境变量映射 diff --git a/docs/features/F202-plugin-framework.md b/docs/features/F202-plugin-framework.md index cd293f8d18..f2ceccd23d 100644 --- a/docs/features/F202-plugin-framework.md +++ b/docs/features/F202-plugin-framework.md @@ -37,6 +37,18 @@ What is still missing is a local plugin framework that lets a plugin declare own PR #686 is a concrete Phase 1 implementation proposal for that missing layer. It was originally labeled `F197`, but upstream `F197` is already occupied by ACP tool result event surfacing. This feature spec is the upstream anchor for the plugin framework work. +## User Journey + +Scope unit: one local plugin installed into a Cat Café workspace. + +Flow: + +1. Operator or maintainer places a plugin package in the configured plugin location. +2. Cat Café discovers the plugin manifest and shows the declared resources in Settings. +3. Operator reviews plugin-owned configuration, grants only the required resource activation, and saves the settings. +4. The runtime activates the plugin resource through the plugin framework boundary rather than ad hoc startup edits. +5. Operator can disable, reconfigure, or remove the plugin while Cat Café keeps plugin state and owned resources traceable. + ## What F202 establishes a local plugin framework for trusted, repository-local plugins. diff --git a/docs/features/F207-personal-finance-infra.md b/docs/features/F207-personal-finance-infra.md new file mode 100644 index 0000000000..6b015689ee --- /dev/null +++ b/docs/features/F207-personal-finance-infra.md @@ -0,0 +1,78 @@ +--- +feature_ids: [F207] +related_features: [F188, F193] +topics: [finance, mcp, data-plane, personal-knowledge, read-only] +doc_kind: spec +created: 2026-06-03 +--- + +# F207: AI Family Office — 个人投资学习基建 + +> **Status**: spec | **Owner**: Ragdoll | **Priority**: P1 + +## Why + +Personal finance work needs a safe learning and analysis substrate before any +cat can reason over market data, fund data, or private notes. The system must +make current facts queryable while keeping the action boundary explicit: finance +data is read-only and must not execute trades, transfers, or account mutations. + +F207 owns the finance-data cell referenced by the architecture ownership map: +provider adapters, normalized fact envelopes, source attribution, freshness, +snapshot replay, and the split MCP server that exposes finance facts to cats. + +## User Journey + +Scope unit: one read-only finance analysis request in a Cat Café thread. + +Flow: + +1. Operator asks a finance learning or portfolio-analysis question. +2. A cat calls the finance MCP server for normalized facts, source attribution, freshness, and snapshot metadata. +3. The cat combines those facts with private notes or memory evidence without exposing provider credentials. +4. The response cites source and `asOf` metadata, separates facts from interpretation, and keeps trade or transfer decisions outside the system. +5. Operator records any decision manually; no account mutation or brokerage action is triggered by F207. + +## What + +The intended end state is a five-layer personal investment learning foundation: + +| Layer | Scope | +|-------|-------| +| Profile | User risk preferences, learning goals, and presentation needs | +| Knowledge | Private finance notes and research collections | +| Data | Read-only provider facts with source, freshness, and confidence metadata | +| Analysis | Cat-authored reasoning over facts and knowledge | +| Decision | Human-owned decision records, never automated order execution | + +## Phase B0: Finance Fact Data Plane + +The landed infrastructure slice creates a dedicated read-only finance MCP +surface: + +- `packages/finance` normalizes provider facts. +- `cat-cafe-finance` is registered as a split MCP server. +- `cat_cafe_finance_query` exposes finance fact queries without exposing raw + provider credentials or mutation tools. +- Fact responses carry source, `asOf`, confidence, `snapshot_id`, and + presentation metadata for downstream analysis. + +## Acceptance Criteria + +- [ ] AC-A1: Finance profile and private knowledge scope are documented before + analysis workflows rely on them. +- [x] AC-B0.1: `packages/finance` exists as the normalized read-only fact layer. +- [x] AC-B0.2: `cat-cafe-finance` is available as a split MCP server. +- [x] AC-B0.3: finance fact queries expose source/freshness/snapshot metadata. +- [ ] AC-C1: Analysis workflows consume normalized facts and private knowledge + without bypassing the finance-data cell. +- [ ] AC-D1: Decision records remain human-owned and do not trigger external + account mutations. + +## Boundaries + +- Finance provider integrations belong under the finance-data ownership cell. +- Memory retrieves project evidence and private notes; it does not implement + finance provider adapters. +- Action-plane capabilities must not be added under F207. Any future mutation or + brokerage integration needs a separate feature and explicit CVO decision. diff --git a/docs/features/F241-agent-provider-plugin.md b/docs/features/F241-agent-provider-plugin.md index 5ec98c0335..b076af0506 100644 --- a/docs/features/F241-agent-provider-plugin.md +++ b/docs/features/F241-agent-provider-plugin.md @@ -1,117 +1,408 @@ --- feature_ids: [F241] -related_features: [F032, F143, F161, F202, F050, F129, F211, F159] -topics: [agent, provider, plugin, transport, hostable-runtime, acp] +related_features: [F143, F202, F161, F240, F050, F205, F211, F129, F146, F149] +topics: [agent-provider, plugin, provider-extension, hostable-runtime, transport-registry, acp, a2a, identity-routing, security-boundary] doc_kind: spec -created: 2026-06-17 -community_issue: "clowder-ai#941" -tips_exempt: spec-only — plugin framework not yet implemented, no user-facing capability +created: 2026-06-18 +tips_exempt: "Closed feature status truth sync only; no new user-facing capability in this branch." --- # F241: Agent Provider Plugin / Hostable Provider Runtime -> **Status**: spec | **Owner**: Community (彭潇/bouillipx) + Ragdoll家族 maintainer | **Priority**: P1 +> **Status**: closed (shipped 2026-06-29; Phase A / B 2a / B 2b / C delivered; see § F241 close) | **Owner**: Community (彭潇 / `bouillipx`) + Cat Cafe maintainer guard | **Priority**: P1 +> **Source**: operator request 2026-06-18 — "我有自己的 agent 需要接入进来"; community architecture discussion [clowder-ai#941](https://github.com/zts212653/clowder-ai/issues/941); maintainer decision [#941 comment 4739146327](https://github.com/zts212653/clowder-ai/issues/941#issuecomment-4739146327). +> **Decision**: accepted as **F241: Agent Provider Plugin / Hostable Provider Runtime**. This is a new provider-extension feature anchor, **not** "F202 Phase 3" by default, and **not** a rename of F143. It is the F143 host-contract lineage plus the F202 plugin discovery/config surface, composed into a provider-as-plugin product capability. + +Architecture cell: `provider-extension` (new) — sits above F143 `hostable-runtime` and F202 `plugin`, composing both. + +Map delta: added — `provider-extension` owns how an externally declared agent provider becomes a routeable, @-mentionable cat without editing the hardcoded `ClientId` union or the `packages/api/src/index.ts` provider switch. It consumes host-owned transports from F143/F161 and extends F202 with an `agentProvider` manifest resource. ## Why -接入一个新的外部 agent runtime(独立产品 `clowder-code`,或未来任何第三方 coding agent)现在要做"心脏手术":改 `ClientId` union、加 `index.ts` provider switch case、写一整套 `XxxAgentService` 适配、改 Hub cat editor enum。后果是**社区第三方 agent runtime 永远进不来**——每来一个新 agent 都得改 core,外部贡献者既碰不到也不该碰我们的核心代码。 +Cat Cafe can already host several built-in agent providers, but adding a new external agent runtime still requires core edits: + +1. edit `packages/shared/src/types/cat.ts` to add a `ClientId`; +2. edit `packages/api/src/index.ts` provider construction; +3. add bespoke provider service / event transform code; +4. update Hub provider UI; +5. merge a core PR for every new runtime. + +That is not a plugin path. It blocks the operator's immediate requirement: a private/custom agent runtime should be connectable to Cat Cafe without vendoring it into this repository and without making every provider a one-off core patch. + +The target shape is: + +- Cat Cafe owns the northbound contract, routing, identity, callback/MCP injection, cwd/sandbox, audit, cancel, timeout, and UI/config boundary. +- External agent runtimes are installed and managed outside Cat Cafe core. +- A plugin/provider package declares metadata and a constrained transport binding. +- Cat Cafe activates that declaration into a routeable cat only through host-owned transport implementations. + +## Current source anchors + +| Fact | Anchor | Consequence | +|---|---|---| +| Provider identity is still a fixed `ClientId` union | `packages/shared/src/types/cat.ts` | New provider identity still implies core type churn. | +| Provider construction still lives in API startup wiring | `packages/api/src/index.ts` | New provider transport still tends to become a core branch. | +| F202 owns plugin discovery/config/resource activation | `docs/features/F202-plugin-framework.md` | `agentProvider` belongs as a new resource family, not as an ad hoc config file. | +| F143 owns the hostable runtime contract concept | `docs/features/F143-hostable-agent-runtime.md` | F241 must consume/advance this host contract, not fork a parallel registry. | +| F161 / PR #899 generalizes ACP | `docs/features/F161-acp-carrier-generalization.md`, PR #899 | F241 should consume generic ACP once merged; do not rebuild it. | +| F240 / PR #903 validates manifest/config/Hub patterns | PR #903 | F241 can reuse the manifest/config/UI precedent after merge, but routeable agents are a higher trust tier than IM connectors. | +| F211 runtime-session enum is still narrow | `docs/features/F211-cross-runtime-session-transparency.md` | New provider runtimes need session/audit visibility, not hidden sidecars. | + +## Ownership boundary + +F241 owns the bridge between plugin declaration and routeable agent identity: + +```mermaid +flowchart LR + F202["F202 Plugin Framework
discovery / manifest / config / Hub UI"] + F241["F241 agentProvider bridge
identity guard / activation / binding"] + F143["F143 Host Contract
AgentService / lifecycle / transport registry"] + F161["F161 generic ACP transport
PR #899 dependency"] + Runtime["External agent runtime
clowder-code / private agent"] + + F202 --> F241 + F241 --> F143 + F143 --> F161 + F143 --> Runtime +``` + +| Layer | Owns | F241 must not re-own | +|---|---|---| +| F202 | plugin discovery, manifest parsing, config store, Hub rendering, resource activation mechanics | host transport semantics | +| F143 | hostable runtime contract, lifecycle, host-owned transport registry | plugin discovery / manifest ecosystem | +| F161 | generic ACP carrier implementation and env/session/pool details | provider-as-plugin identity and activation | +| F240 | manifest/config-store/Hub UI precedent for IM connectors | routeable agent trust boundary | +| F241 | `agentProvider` resource, provider identity governance, plugin-to-host binding, routeable cat activation | arbitrary provider code execution | + +## Proposed manifest shape + +Initial sketch: + +```yaml +id: clowder-code +resources: + - type: agentProvider + providerId: clowder-code + displayName: Clowder Code + transport: acp + command: clowder-code + startupArgs: ["--acp"] + accountRef: optional-account-binding + eventProfile: cat-cafe-agent-message-v1 + mcpWhitelist: + - cat-cafe-collab + - cat-cafe-memory + sandbox: workspace-write + healthCheck: + type: acpInitialize +``` + +Important constraints: + +- `transport` must reference a host-owned allowlisted transport. +- `command` is an already-installed command in Phase A; no plugin installer scripts. +- callback/MCP credentials are injected only by host code. +- provider identity must pass namespace and routeability checks before becoming a cat. + +## Accepted Phase Split + +### Phase A — Host Transport Intake Slice + +Goal: prove one host-owned transport path and one external runtime end to end without adding a new bespoke provider branch. + +Inputs: + +- F161 / PR #899 if merged: consume the generic ACP carrier instead of rebuilding Gemini ACP generalization. +- If ACP is not ready for the operator's private agent, allow a constrained A2A or CLI JSONL smoke path to validate identity/routing/audit first. -终态:外部 agent runtime 以**声明式 plugin** 形式接入,**provider 实现离开 Cat Café core**;Cat Café 只拥有北向契约、路由/身份、callback/MCP 注入、审计、session lifecycle、安全策略和 UI/配置面。`clowder-code` 是证明这个扩展点的 reference runtime,不是要 vendor 进 core 的东西。 +Acceptance criteria: -> 来源:开源社区 clowder-ai issue #941(提案人彭潇/bouillipx)+ 2026-06-16 clowder-code 接入猫咖讨论。operator 2026-06-17 signoff 立项。 +- One host-owned transport path is registered and selected by data/config. +- One external runtime can be invoked as an already-installed command or service. +- Streaming output maps to `AgentMessage` / thread-visible events. +- cancel, timeout, startup failure, and no-event failure paths are visible. +- cwd/workspace/sandbox policy is host-controlled. +- callback/MCP injection is host-owned and test-covered. +- session-chain/audit metadata is written for the external runtime. + +### Phase B — F202 `agentProvider` Resource + +Goal: make provider activation declarative through the plugin framework. + +Acceptance criteria: + +- F202 manifest schema accepts `agentProvider` with strict validation. +- Invalid transports, duplicate provider IDs, builtin namespace collisions, and forbidden capability claims are rejected. +- Hub can render provider config from manifest/config-field metadata without provider-specific UI. +- Config values resolve through a host-owned config store; plugin code does not receive secrets directly. +- Health checks are host-owned and declared/configured, not arbitrary script execution. + +### Phase C — Reference Runtime + +Goal: use `clowder-code` as the reference runtime for proving the extension point without vendoring it into Cat Cafe core. + +Acceptance criteria: + +- A routeable cat invokes the external runtime from a normal thread. +- The runtime can stream a reply back into the thread. +- session chain, audit, cancel, timeout, and failure states are inspectable. +- A2A handoff and @ mention routing cannot target the provider until identity governance has approved it. +- E2E proof includes at least one denied capability / denied namespace case. + +## Identity and Routeability Governance + +This is not just a sandbox problem. A provider plugin can create a routeable participant in the collaboration system. + +Required controls: + +- provider IDs live in a reserved namespace, separate from built-in `ClientId`s unless explicitly migrated; +- plugins cannot claim `anthropic`, `openai`, `google`, `kimi`, `opencode`, `catagent`, `a2a`, or any existing runtime cat ID; +- routeable `catId` and `@alias` activation requires explicit host approval; +- provider identity, plugin source, transport, command, and capability grants are audit-visible; +- a provider cannot become an A2A target until activation and health checks pass. -## Current State / 现状基线 +## Safety Boundaries -接 provider 全程是 core 改动而非 plugin 接入(行号 2026-06-17 核实): +These are acceptance boundaries, not implementation details: -- `packages/shared/src/types/cat.ts:15` — `ClientId` 是 fixed union(`anthropic` / `openai` / `google` / `kimi` / `dare` / `antigravity` / `opencode` / `catagent` / `a2a` 等硬编码值)。猫通过 `cat.ts:69` 的 `readonly clientId: ClientId` 绑定 provider 类型。 -- `packages/api/src/index.ts:1173+` — provider 构造走启动期 switch,每个 provider 一个 hardcoded `case`(`anthropic` / `openai` / `opencode` / `catagent` / `a2a` …)。 -- **F143(validated spec)印证**:已有 7 个 `AgentService` provider 各自造轮子(各自解析事件格式 / 各自 session resume / 各自注入 MCP config),接一个新 agent ≈ 写 450 行(Service + EventTransformer + 测试)。 -- `F202` plugin framework 当前只承载 `skill` / `mcp` / `limb` / `schedule` 四类资源,**没有 routeable `AgentService` provider 资源类型**。 -- `F143`(hostable runtime 统一抽象)+ `F161`(ACP 载体泛化)都还是 **spec,未实现**——本 feat 的 host 端 transport registry 在它们的 lineage 下,但谁先落地需在 Design Gate 划界(见 OQ-5)。 +- no arbitrary same-power JS factory; +- no plugin-provided `install.sh` / `uninstall.sh` in Phase A; +- no plugin code directly receives callback tokens, session tokens, JWTs, or MCP credentials; +- no plugin activation writes runtime config such as `~/.clowder-code/config.json`; +- no external archive install/update/uninstall until signing, trust, network, and explicit user confirmation policy exists; +- later installer support, if accepted, must use host-owned allowlisted strategies such as `npm`, `github-release`, `homebrew`, or `manual`. -## What +## Dependencies and Precedents -> Phase 拆分来自 issue #941 的 consolidated decision packet(社区彭潇 + 我们家Maine Coon收敛)。 +- **F143**: canonical hostable runtime contract lineage. F241 should advance or consume this, not fork it. +- **F161 / PR #899**: generic ACP transport. If merged first, F241 Phase A should consume it. +- **F202**: plugin discovery/config/activation and future `agentProvider` manifest resource. +- **F240 / PR #903**: manifest/config-store/Hub UI and host-owned plugin lifecycle precedent for IM connectors. Useful but lower trust than routeable agent providers. +- **F211**: runtime session visibility and audit surfaces must include external agent runtimes. +- **F129**: no same-power plugin execution. -### Phase A: Provider / Host Transport Registry +## Non-goals for the First Slice -在 F050/F143/ADR-023 hostable-runtime lineage 下建一个 `ProviderTransportRegistry`,先把**一条 host-owned transport 端到端打通**(优先 ACP——保留 session/streaming/MCP/lifecycle 语义;A2A 可作更轻的 smoke path)。**不预先建宽抽象**,先用一个真实外部 runtime 跑通真实 lifecycle。把现有 `GeminiAcpAdapter` 泛化为 `GenericAcpAgentService`(复用 F161),把 `index.ts` 的 provider switch 收口为 registry 注册。 +- Do not vendor the private agent or `clowder-code` into Cat Cafe core. +- Do not convert every existing built-in provider to plugin registration in the first PR. +- Do not solve external runtime installation in Phase A. +- Do not treat F240 IM connectors as equivalent trust tier to routeable agent providers. +- Do not add a second, private transport registry under F241. -### Phase B: F202 `agentProvider` Manifest Resource +## Design Gate Items -在 F202 plugin framework 增加声明式 `agentProvider` 资源类型——**只能引用 registry 里 allowlisted 的 host-owned transport**。manifest 声明 `transport` / `command` / `args` / `mcpWhitelist` / `sandbox` / `healthCheck`。**禁止任意 JS factory / same-power plugin 执行**(F129 继承)。 +These are no longer blockers for feature-anchor acceptance, but must be settled before or during the first implementation slice: -### Phase C: Reference Runtime(clowder-code) +1. Whether Phase A requires ACP immediately or allows A2A / CLI JSONL as the first smoke path while ACP support lands. +2. Exact `agentProvider` manifest shape after the host-owned registry contract exists. +3. How much Hub UI belongs in Phase B versus the reference-runtime proof. +4. Final F143 / F161 / F202 boundary alignment for transport registry ownership and manifest activation. -拿 `clowder-code` 做 reference plugin 证明扩展点:被 @ → 进 thread → 流式回复 → session chain 可见 → audit / cancel / timeout 可控 → callback/MCP 注入守 agent-key 边界 → cwd/sandbox host 控制 → Hub provider 配置走统一 renderer 无硬编码 UI。 +## Implementation Notes -## Acceptance Criteria +- Phase A first slice extracts host-owned provider transport selection into `ProviderTransportRegistry`. +- ACP is the first registered host transport via `AcpProviderTransportFactory`, consuming the F161 `AcpServiceFactory`. +- Transport selection remains before the legacy `clientId` provider switch. A declared transport that fails validation is terminal and must not fall back to a provider-specific branch. +- Phase A second slice adds raw catalog `providerTransport` intake for host-owned transport selection. This is a temporary activation surface, not the Phase B F202 manifest resource. +- `cli-jsonl` is the first constrained smoke transport for `clowder-code`-style runtimes while ACP/A2A support lands in the external runtime. Prompt delivery is stdin-only; command, cwd, env injection, timeout, and JSONL mapping remain host-owned. +- Raw `providerTransport` is rejected for builtin client identities and existing routeable cat IDs; the temporary raw surface cannot replace `codex`, `opus`, or other trusted builtin participants. +- `cli-jsonl` defaults to resumable session behavior when the reference runtime can receive the prompt without lossy encoding. Fresh turns use `startupArgs`; follow-up turns with a host `sessionId` use `resumeArgs` with a required `{sessionId}` placeholder only for raw single-line prompts. If the effective prompt contains real line breaks, the host emits `session_continuity_degraded`, seals the old active SessionRecord, runs a fresh one-shot invocation with the original prompt payload, and binds any fresh `session_init` to a new SessionRecord. A transport that is intentionally stateless must declare `sessionPolicy: "stateless"`. +- `cli-jsonl` participates in host raw-event archive diagnostics by passing `rawArchivePath` into `spawnCli` and appending sanitized JSONL events by invocation ID. +- Phase B identity-governance foundation derives reserved routeable IDs from a host-owned base `cat-template.json` builtin baseline plus active non-`providerTransport` profiles. Raw `providerTransport` activation fails closed when that baseline cannot be read, so catalog/plugin input cannot self-remove a trusted cat ID from the reserved set. +- F202 `agentProvider` manifest parsing/activation is intentionally not part of this first slice. - +Temporary raw catalog shape for Phase A smoke activation: -### Phase A(Provider / Host Transport Registry) -- [ ] AC-A1: 一条 host-owned transport 注册进 `ProviderTransportRegistry`(可枚举、可单测) -- [ ] AC-A2: 一个外部 runtime(优先 `clowder-code`)作为 already-installed command/service 被启动或接入 -- [ ] AC-A3: 一只 routeable cat 能调用该外部 runtime,**无需新增硬编码 core provider 分支**(grep 证明 `index.ts` 无新 case) -- [ ] AC-A4: 流式输出映射进 `AgentMessage` / thread 可见事件 -- [ ] AC-A5: session chain、audit metadata、cancel、timeout、failure state 在 UI/日志可见 -- [ ] AC-A6: callback/MCP 凭证**只由 Cat Café host 代码注入**,plugin 代码无法接收或制造 token(红测覆盖) -- [ ] AC-A7: cwd/workspace/sandbox 策略由 host 代码强制(非 plugin 声明即生效) -- [ ] AC-A8: health check host-owned + 声明式配置,**非任意 plugin 脚本执行** -- [ ] AC-A9: 测试覆盖 success / startup failure / timeout-cancel / invalid manifest-transport / denied capability 五类 -- [ ] AC-A10:【治理】core 侧安全实现(transport registry / token / MCP 注入 / sandbox)+ 本 feat 所有 PR 的 merge-gate 由**Ragdoll家族 maintainer 守门**,不委托社区/plugin 代码(F129 继承;分工见 KD-3) +```json +{ + "clientId": "clowder-code", + "providerTransport": { + "transport": "cli-jsonl", + "command": "clowder-code", + "startupArgs": ["--json", "--non-interactive"], + "resumeArgs": ["resume", "{sessionId}", "--json"], + "sessionPolicy": "resume", + "outputProfile": "clowder-code-turn-result-v1" + } +} +``` -### Phase B(F202 agentProvider Manifest) -- [ ] AC-B1: F202 manifest 校验接受 `agentProvider` 资源,且**只能引用 allowlisted transport** -- [ ] AC-B2: manifest **拒绝任意 JS factory / same-power 执行**(红测覆盖拒绝路径) +## Phase B Slice 2b Design Notes — Routeable Gate -### Phase C(Reference Runtime) -- [ ] AC-C1: `clowder-code` 作为 plugin 接入,跑通"被 @ → 进 thread → 流式回复 → session chain 可见 → audit/cancel/timeout 可控"全链 -- [ ] AC-C2: Hub provider 配置走统一 `ConfigFieldRenderer`,无 provider-specific 硬编码 UI +Settled 2026-06-22 via pre-worktree design gate (opus driving; codex + gpt52 independent review). 2a (`b4a87e3c`) shipped a non-routeable `transportReady` capability. Slice 2b promotes "declared" → "routeable" via a 6-step gate, with **explicit owner approval as the only path** from `routeable: false` to `routeable: true`. -## 需求点 Checklist +### 6-step routeable gate -- [ ] provider 实现可移出 Cat Café core(新 agent 接入不改 `ClientId` union / `index.ts` switch / 不写 bespoke provider class) -- [ ] 声明式 manifest 接入(transport / command / mcpWhitelist / sandbox / healthCheck) -- [ ] host-owned transport registry(ACP 优先,A2A/cli-jsonl 可选) -- [ ] 安全边界全部 host-owned(token / MCP / sandbox / cwd / healthcheck) -- [ ] routeable cat 全链可见(stream / session chain / audit / cancel / timeout) -- [ ] Hub 统一 UI 渲染 provider 配置 -- [ ] reference runtime(clowder-code)端到端验证 +| # | Step | Owner | 2a delivered | 2b delta | +|---|---|---|---|---| +| 1 | manifest valid | `parseAgentProvider*` | ✅ | — | +| 2 | host transport exists | `providerTransportRegistry.has(...)` | ✅ | — | +| 3 | reserved + collision admission | `RoutingAdmissionService` (new) | — | new service | +| 4 | explicit owner approval | host-owned `routeableApproved` field | — | new field + admin surface | +| 5 | host health pass | `acpInitialize` / `cliProbe`, descriptor-bound | — | new, bound to descriptor hash | +| 6 | AgentRegistry service registered | existing `syncAgentRegistry → createServiceForConfig` | — | enqueue to existing serialized sync coordinator | -## Dependencies +### Three-field state model -- **Evolved from**: F032(CatId 身份松绑已 done;本 feat 是同一"去硬编码"血脉的下一层——**provider runtime / ClientId 松绑**。F032 让"加一只猫"动态化,但"加一种 provider 实现"仍要改 core,本 feat 补这块) -- **Blocked by**: F143(hostable runtime 契约 + transport registry,spec 未实现;Phase A 在其 lineage 下,划界见 OQ-5) -- **Related**: F161(ACP 载体泛化,spec——作 ACP transport 依赖**复用不重建**)/ F202(plugin framework,`agentProvider` 挂其上)/ F050(a2a 外部 agent 契约,done)/ F211(cross-runtime session 可见性,done;runtime enum 待随本 feat 扩)/ F159(CatAgent provider intake 教训:account-binding 绕过 / workspace 边界 / ADR-001 坑,本 feat 须避) -- **Inherits constraint**: F129(no same-power plugin script execution) +Replaces 2a's two-field literal-`false` shape. The shared contract widens from literals to booleans (`packages/shared/src/types/capability.ts:49`) — without this, all subsequent state transitions lie via type casts. -## Risk +| Field | Semantics | Mutation source | +|---|---|---| +| `routeableApproved` | **owner intent** — operator affirmatively granted approval | host action only (admin surface). Reset to `false` automatically when descriptor hash changes. Never written by `activateAgentProvider`. | +| `health` | **last health-check result** — `{ result, timestamp, ttlMs, descriptorHash }` | host runs declared `healthCheck` on approval (sync, blocking). Invalidated when descriptor hash changes. Refreshed synchronously on startup/sync when TTL expired. | +| `routeable` | **effective truth** — "you can `@` this cat now" | computed: `admission.passed && routeableApproved && health.fresh && registry.synced`. Never written directly by the activator or manifest. | -| 风险 | 缓解 | -|------|------| -| provider 能拿 callback token / MCP 凭证 / workspace 写权 / sandbox = **全家最高危边界** | 安全注入全部 host-owned,plugin 只声明;AC-A6/A7/A8/A10 钉死;F129 继承 | -| F143/F161 未实现,Phase A 与 F143 scope 重叠/抢跑 | OQ-5 划界,Design Gate 与 F143 owner(opus-4.6)对齐 | -| 外部 runtime 安装(`install.sh`)= F129 same-power 风险在更高危边界复现 | Phase A 只支持 already-installed command + host healthcheck;安装策略 defer,未来用 host-owned allowlisted(npm/github-release/homebrew/manual)+ 用户确认 | -| 社区贡献者 own 但够不到私有仓 core | 分工:彭潇 own 提案 + clowder-code reference;core 安全 + merge-gate 由 maintainer 守(AC-A10 / KD-3)| +### RoutingAdmissionService -## Key Decisions +Single source of truth for "is this candidate eligible to become routeable": + +- **Inputs**: candidate descriptor + binding + snapshot of (template baseline, all routeable providers/builtins/active cats). +- **Output**: `{ admitted: boolean, reason?: string }`. +- **Callers**: owner approval path (early failure) **and** `syncAgentRegistry` projection (re-validate before injecting any synthetic cat-config). +- **Red line**: NEVER inject the candidate into runtime config/map before admission passes. Reserved snapshot is computed with the candidate explicitly excluded — mirrors Slice 1's pattern in `ProviderTransportRegistry`. Skipping this re-introduces parsing-order self-exemption, the exact hole Slice 1 closed. -| # | 决策 | 理由 | 日期 | -|---|------|------|------| -| KD-1 | 定位为独立 provider-extension feature,**不当 F202 Phase 3** | F202 是 manifest/config/UI 支撑设施,本 feat 的 ownership 边界是 agent-provider 扩展面;社区彭潇 + 我们家Maine Coon在 #941 收敛 | 2026-06-17 | -| KD-2 | 安全边界 host-owned,plugin 只声明;禁任意 JS factory / 禁 Phase A plugin installer / 禁 plugin 碰 callback token | provider 接 token/MCP/workspace/sandbox 是全家最高危边界,F129 继承 | 2026-06-17 | -| KD-3 | owner = Community(彭潇/bouillipx) + Ragdoll家族 maintainer;彭潇 own 提案+reference,**core 安全 + merge-gate maintainer 守** | 沿用 F150/F202/F205 社区核心主导 + maintainer 把关模式;社区账号够不到私有仓 core | operator 2026-06-17 signoff | -| KD-4 | `clowder-code` 作 reference runtime 证明扩展点,**不 vendor 进 core** | 保持运行时解耦,不污染依赖边界 | 2026-06-17 | +Collision check is broader than reserved namespace: candidate `providerId / profileId / catId / mentionPatterns` must not collide with **any** existing routeable provider/builtin/active cat. -## Architecture Cell (F191) +### Descriptor hash (canonical, persisted in capability row) + +Computed on every upsert in `activateAgentProvider`. If changed → reset `routeableApproved = false` and invalidate health. Owner must re-approve. + +Hash inputs: +- `pluginId`, `capId` / resource name +- `transport`, `command`, `startupArgs`, `resumeArgs` +- `sessionPolicy`, `outputProfile`, `timeoutMs` +- `mcpWhitelistRequest`, `sandboxRequest` +- `healthCheck` (full object) +- routeable identity claims: `providerId`, `profileId`, `catId`, `mentionPatterns` +- plugin/package fingerprint (if available; tracked as residual risk if not — see Non-goals) + +### Routeable identity ownership + +Plugin manifest declares `providerId / displayName / mentionPatterns` as **claims**; host owns the actual binding record (`catId / @alias`) decoupled from manifest. Route resolver reads ONLY the host-owned binding — never the manifest resource directly. All routeable identity claims feed the descriptor hash, so any change forces re-approval. + +### Health timing + +- **On approval**: synchronous, blocking. Run declared `healthCheck`. Success → atomic write `routeableApproved=true + health.fresh + routeable=true`. Failure → `routeable=false`, log error, preserve previous state. +- **On startup / sync** of already-approved capability with expired TTL: synchronous refresh. Failure → `routeable=false`, log error. +- **Background flip `false → true`**: **NEVER**. Routeability is granted only through explicit operator approval; subsequent checks can DEGRADE but not GRANT. +- **No declared `healthCheck`** on a candidate that wants `routeable`: fail-closed admission. No default probe substitute. + +### Background actor permission split + +Settled via codex + gpt52 Q3 convergence round (2026-06-23). Shared invariant: **`routeable=true` may only be produced by an auditable explicit admission/sync chain — `approval + descriptor-hash-bound fresh health + reserved/admission pass + AgentRegistry projection`**. Health worker provides evidence; it is never the routing authority. + +| Actor | May do | Must NOT do | +|---|---|---| +| Background health worker | Refresh `health.checkedAt / passed / failureReason` as telemetry. Degrade `routeable: true → false` when a refresh fails. Enqueue a sync request to the serialized coordinator (which then runs through the explicit projection path). | Directly write `routeable: false → true`, even when `routeableApproved=true` and a fresh health re-check passes. | +| Explicit synchronous paths (approval, startup, `syncAgentRegistry` projection, `plugin enable`) | Exclusively own the `routeable: false → true` promotion, atomically with admission + fresh health. | Skip admission, skip the descriptor-hash binding on health, or rely on background-supplied `routeable: true` without re-checking the full chain. | + +This split is the security/audit boundary, not a runtime convenience knob: it ensures that whenever an operator observes `routeable=true`, there is a traceable host transaction (with the descriptor-bound health result) responsible for it. Silent self-healing from a degraded state is forbidden by design. + +### Sync trigger + serialization + +- Post-write enqueues to the **existing serialized sync coordinator** that already handles plugin enable/approve, cat-config change, and account change. No naked parallel sync, no separate watcher. +- Idempotency key: `(pluginId, capId, descriptorHash, bindingHash)`. +- Sync re-reads latest persisted capability/binding snapshot before projection — concurrent cat-config edits cannot strand the AgentRegistry on a stale snapshot. +- Failure → no partial AgentRegistry state. Rollback effective `routeable=false`, record `lastSyncError`. Preserve `routeableApproved=true` and `health` if `descriptorHash` unchanged — retry doesn't need re-approval. + +### Failure recovery summary + +| Failure point | `routeable` | `routeableApproved` | `health` | +|---|---|---|---| +| Step 3 admission fail | stays `false` | unchanged | unchanged | +| Step 4 (no approval) | stays `false` | unchanged | unchanged | +| Step 5 health fail (approval-time) | stays `false` | stays `false` (atomic) | written as failed | +| Step 5 health fail (TTL refresh) | flipped `false` | unchanged | refreshed to failed | +| Step 6 sync fail | flipped `false`, `lastSyncError` recorded | unchanged | unchanged | +| Descriptor hash change (manifest mutation) | flipped `false` | reset `false` | invalidated | + +### Non-goals for 2b + +- No auto-promotion of `mcpWhitelistRequest` / `sandboxRequest` into runtime grants — those stay request semantics; host grant flows through F202 capability policy, not manifest auto-promotion. +- No multi-process or distributed sync coordination — the existing in-process serialized coordinator is sufficient for Phase B. +- No Hub UI for owner approval — Phase B exposes the admin surface as a host-internal route + CLI; Hub admin UI is Phase C scope. + +### Residual risk (tracked, not blocking 2b) -- **Architecture cell**: 待 Design Gate 确认。候选 = provider-as-plugin 桥接层(架在 F202 + F143 + F161 之上) -- **Map delta**: new cell required -- **Why**: 现有 ownership cell 无人拥有"provider 实现移出 core + 声明式接入扩展面"这一层;非现有 cell 的增量扩展。Design Gate 须完成 Phase 0 架构发现并落 cell。 +Plugin/package fingerprint: if a fingerprint is available (npm tarball hash, git SHA), it enters the descriptor hash and any plugin-body change forces re-approval. If only a directory path is available, the manifest can stay identical while the plugin body mutates silently. Tracked as F241 follow-on hardening, not a 2b blocker. -## Review Gate +## Timeline -- Phase A: 架构级 + 最高危安全边界 → 跨族 review(Maine Coon家族)+ 愿景守护;core 安全实现禁 self-merge(AC-A10) -- Phase B/C: 标准跨个体 review + merge-gate +- 2026-06-16: #941 opened for plugin-owned `agentProvider` resource / external agent runtime provider path. +- 2026-06-17: #941 discussion converged on provider-extension feature framing, architecture diagram, F161/#899 and F240/#903 dependency roles. +- 2026-06-18: operator approved starting the feature locally because a private agent needs to be integrated. +- 2026-06-18: maintainer decision accepted #941 as F241, with `clowder-code` as the reference runtime, Phase A/B/C rollout, and safety boundaries promoted to acceptance criteria. +- 2026-06-18: Phase A first implementation slice started with host-owned `ProviderTransportRegistry` and ACP transport factory extraction. +- 2026-06-18: Phase A second implementation slice started with raw `providerTransport` selection and `cli-jsonl` reference-runtime smoke transport. +- 2026-06-22: Phase B identity-governance foundation started to replace static routeable denylist drift with template-baseline plus active-catalog reserved identity derivation. +- 2026-06-22: Phase B Slice 2a shipped `b4a87e3c` — `agentProvider` manifest descriptor, non-routeable / `transportReady` state, fail-closed transport activation. +- 2026-06-22: Phase B Slice 2b design gate passed — 6-step routeable gate, three-field state model, `RoutingAdmissionService` + descriptor-hash invalidation + serialized sync coordinator (opus driving; codex + gpt52 design review). +- 2026-06-23: Phase B Slice 2b Q3 TTL convergence round (codex + gpt52) — both converged on `S1 No / S2 No / S3 Yes`; locked shared invariant that `routeable: false → true` is the exclusive domain of explicit synchronous paths. Background actors may refresh telemetry and degrade, never promote. Design notes amended. +- 2026-06-23: Phase B Slice 2b end-to-end shipped on `feat/F241-phase-b-slice2b-routeable-gate` — shared types widening, `RoutingAdmissionService`, descriptor-hash + activator integration, approval orchestration + HTTP route, post-approval sync hook, routeable binding + projection, end-to-end integration test. Health executor ships as transport-availability probe; real `acpInitialize` (runtime initialize handshake) and `cliProbe` (bounded spawn + exit-code check) probes are tracked as **Slice 2c follow-on hardening** — the executor is a drop-in DI swap (`AgentProviderHealthExecutor` interface in `agent-provider-health-executor.ts`), no further redesign needed. +- 2026-06-28: Phase C reference-runtime E2E spike (布偶猫/宪宪 driving in dispatch sub-thread `thread_mqxva63tuebxj9sn`). Reverse-engineered the cli-jsonl real wire shape against `clowder-code/cli/dist/bin.js`, drafted the production `plugins/clowder-code/plugin.yaml`, enabled + approved end-to-end, surfaced P0 split-brain between API in-memory `catRegistry` (saw plugin projection) and the L0 system-prompt subprocess (did not). Spike report: `/tmp/clowder-spike/F241-phase-c-spike-report.md`. +- 2026-06-28: PR #36 `79e73cb8` shipped P0 fix — `scripts/compile-system-prompt-l0.mjs` `bootstrapCatRegistry` reads `.cat-cafe/capabilities.json` + applies the F241 routeable projection so the L0 subprocess sees the same catId universe the runtime routes. Authored by 缅因猫/砚砚, reviewed by 布偶猫/opus47. +- 2026-06-28: PR #38 `efe91cda` shipped real `cliProbe` health executor — bounded spawn of `command --version` + exit-code + timeout, replaces 2b transport-availability stub. DI swap wired into both `syncAgentRegistry` refresh path AND `AgentProviderApprovalService` approval path (no split-brain). Probe uses `resolveCliCommand` (same resolver as real cli-jsonl invocation) per @codex round-1 review. +- 2026-06-28: PR #39 `7ecb3e4d` shipped manifest identity claims — `PluginAgentProviderResource` gains optional `providerId / displayName / mentionPatterns`. Descriptor hash bumped `v: 1 → v: 2` so upgrade forces re-approval (matches the existing descriptor-delta contract). Parser rejects bare `@`, whitespace, path separators, case-insensitive duplicates (per @codex round-1 + round-2 review). +- 2026-06-28: PR #42 `71584bf6` shipped Hub UI for owner approval — `PluginResourceStatus` exposes `capId / routeable / approved / binding / claims / descriptorHash / healthFailureReason / lastSyncError`; React `AgentProviderApprovalSection` renders state chip + binding summary + approval form (prefilled from PR #39 claims) + dual failure chips ("探针失败" + "同步失败"). The `lastSyncError` projection landed in round-2 per @codex review (without it operators couldn't diagnose `approved=true / healthy / routeable=false`). +- 2026-06-28: Phase C demo hotfix — `plugins/clowder-code/plugin.yaml` `startupArgs` adds `--dangerously-skip-permissions`. Root cause (diagnosed by parallel 布偶猫/宪宪 in `thread_mqyj529p7hn1ughj`): `clowder-code` `--non-interactive` mode unconditionally rejects every tool request via `PermissionRequiredError`, exiting code 1. Operator-visible after the 5th E2E probe (`@clowder-cat 请用 bash 跑 pwd`) returned the correct cwd `/Users/xxx/workspace/AI/clowder-labs/clowder-code`. Tracked as a F241 known-limitation (see § F241 close). + +## F241 close — completion audit (2026-06-29) + +### Acceptance criteria audit + +**Phase A — Host transport intake slice** (all ACs satisfied): +- ✅ One host-owned transport path registered and selected by data/config — `cli-jsonl` via `ProviderTransportRegistry` (`a95384eb / e787dcd3 / 8f1d56c9`). +- ✅ One external runtime invoked as already-installed command — `clowder-code/cli/dist/bin.js`, declared in `plugins/clowder-code/plugin.yaml`. +- ✅ Streaming output maps to `AgentMessage` / thread-visible events — `CliJsonlAgentService.invoke` yields `session_init / agent_loop / text / done`; verified across 5 E2E probes in the Phase C spike thread. +- ✅ Cancel, timeout, startup-failure, no-event-failure visible — `cli-spawn.ts` SIGTERM/SIGKILL + `__cliTimeout` / `__cliError` events; `cli-jsonl-agent-service.test.js` covers `emits a visible error when the CLI exits without a turn_result`. +- ✅ cwd/workspace/sandbox policy host-controlled — `sandbox: workspace-write` in plugin manifest, host-enforced via existing F036 sandbox infra. +- ✅ Callback/MCP injection host-owned + test-covered — `CliJsonlAgentService` test fixture asserts `CAT_CAFE_API_URL` is injected by host, never by plugin. +- ✅ Session-chain + audit metadata written — `cliSessionId` bound on `session_init` (logged in every E2E probe), `AuditEventTypes.CONFIG_UPDATED` written on approve-routeable. + +**Phase B Slice 2a** (shipped `b4a87e3c`): +- ✅ `agentProvider` manifest descriptor activates as `transportReady` (non-routeable). +- ✅ Fail-closed when host transport not registered (`Unknown agentProvider transport`). + +**Phase B Slice 2b** (shipped `486fc5f8` — PR #18): +- ✅ 6-step routeable gate enforced (manifest valid / transport exists / admission / approval / health pass / sync) — `RoutingAdmissionService` + approval orchestration + post-approval sync hook. +- ✅ Three-field state model (`routeableApproved` / `health` / `routeable` computed) — `agent-provider-2b-e2e.test.js` happy-path test asserts. +- ✅ Descriptor-hash invalidation on manifest delta — `agent-provider-descriptor-hash.test.js` sensitivity matrix. +- ✅ Background actors may degrade, never promote — Q3 convergence locked invariant. +- ✅ Plugin admission re-runs at projection time (no parsing-order self-exemption) — `agent-provider-projection.ts` RED LINE. + +**Phase C — Reference runtime** (all ACs satisfied): +- ✅ Routeable cat invokes external runtime from a normal thread — proven across 5 E2E probes (`I am clowder-code AI cat`, `I am alive`, `Probe received hash verified`, etc.). +- ✅ Runtime streams reply back into the thread — `OutboundDeliveryHook deliver()` confirmed in API logs each probe. +- ✅ Session chain, audit, cancel, timeout, failure inspectable: + - Session chain: `cliSessionId` bound to invocation, visible in `[invoke] Session init: binding session` logs. + - Audit: `CONFIG_UPDATED` events on enable / approve / disable. + - Cancel / timeout / failure: `cli-spawn.ts` + `CliJsonlAgentService` + Hub UI `agentProviderHealthFailureReason` + `agentProviderLastSyncError` chips. +- ✅ A2A handoff and @-mention routing cannot target the provider until identity governance has approved it — pre-approve, `clowder-cat` is not in `catRegistry` (no projection without `routeableBinding`); `@clowder-cat` returns "未找到该 cat" routing failure. +- ✅ E2E proof includes at least one denied capability / denied namespace case — `agent-provider-2b-e2e.test.js:208` `approval is rejected when operator picks a catId that collides with a reserved baseline` covers the denied-namespace case. +- ✅ Hub admin UI for owner approval (2b Non-goals #3 promoted to Phase C scope) — PR #42 `71584bf6`. + +**Phase C 2c hardening** (all delivered): +- ✅ Real `cliProbe` health executor — PR #38 `efe91cda`. +- ✅ Manifest identity claims (`providerId / displayName / mentionPatterns`) — PR #39 `7ecb3e4d`, with `mentionPatterns` case-insensitive dedup + bare `@` rejection per @codex review. +- ✅ Descriptor hash `v: 1 → v: 2` forces re-approval on upgrade — verified live by triggering enable + approve cycle (`574a68e4… → 00fb6ae1… → 4acdffb7…` across the schema bumps). + +**Safety boundaries** (all preserved): +- ✅ No arbitrary same-power JS factory — plugin manifest only declares data + a constrained transport binding. +- ✅ No plugin-provided install / uninstall scripts in Phase A — `plugin.yaml` is data only. +- ✅ No plugin code receives callback tokens / session tokens / JWTs / MCP credentials — `agent-provider-health-executor.ts` strips env to `PATH` only; host injects callback env into the actual invocation, never into the probe. +- ✅ No plugin activation writes runtime config such as `~/.clowder-code/config.json` — activator writes to `.cat-cafe/capabilities.json` only. +- ✅ No external archive install/update/uninstall — out of scope per design. + +### Known limitations carried out of F241 (tracked for follow-on, not blockers) + +1. **`cli-jsonl` resume + non-empty systemPrompt are mutually exclusive** — `CliJsonlAgentService.buildPrompt` concatenates `${systemPrompt}\n\n${prompt}` which trips the `containsLineBreak()` check in `sessionPolicy: resume`, silently falling back to a cold session every turn. `session_continuity_degraded: cli_jsonl_resume_requires_single_line_prompt` warning fires every invocation. Working as designed but operator-confusing; a future slice could either: (a) special-case the system prompt line-break detection, or (b) declare cli-jsonl resume + systemPrompt incompatible and document in the manifest schema. +2. **`clowder-code --non-interactive` rejects every tool request** — `createNonInteractiveApprovalHandler` in `clowder-code/cli/src/runner.ts:3005` throws `PermissionRequiredError` on any tool call, exit code 1. Current workaround: `startupArgs` includes `--dangerously-skip-permissions`. The real fix is a host-streaming permission protocol that lets the cat-cafe API mediate per-tool approval (likely a separate feature anchor, e.g. `F241 follow-on: ACP permission streaming` or absorbed by F161 ACP carrier work). +3. **Plugin/package fingerprint not in descriptor hash** — already tracked in § Phase B 2b Residual risk. If only a directory path is available (rather than an npm tarball SHA / git SHA), a plugin can mutate its body silently without re-approval. Not blocking for the in-tree reference plugin since edits are git-tracked. +4. **ACP transport for plugins** — `cli-jsonl` is the only proven plugin transport. F161 ACP carrier work is the natural integration point when ACP plugins arrive. +5. **`outputProfile` accepts only `clowder-code-turn-result-v1`** — a second cli-jsonl runtime would need parser registry extension. Tracked as a small additive change when the second runtime lands. + +### Status + +**F241 — accepted, shipped, closed as of 2026-06-29.** + +Phase A / B 2a / B 2b / C all delivered with ACs satisfied and cross-family review (砚砚/codex + opus47/sonnet). Live in `develop`. Operator can install a plugin via `plugins//plugin.yaml`, hit `/api/plugins//enable` and the Hub-rendered approval form, and have a routeable `@` that streams replies back into a thread — all without core code edits, the original "I have my own agent" requirement. diff --git a/docs/features/F246-approval-hub.md b/docs/features/F246-approval-hub.md index 3f2f1869e9..8cda27f1f4 100644 --- a/docs/features/F246-approval-hub.md +++ b/docs/features/F246-approval-hub.md @@ -412,4 +412,3 @@ harness_feedback: none | reason: non-harness feature, pure product capability - AC-G5 ✅ met — backfill script, DRY RUN default, 6398 default (prod explicit override) ## Reflection Capsule - diff --git a/docs/features/F254-side-effect-freshness-gate.md b/docs/features/F254-side-effect-freshness-gate.md index 3e5d8cbe2c..27524f3699 100644 --- a/docs/features/F254-side-effect-freshness-gate.md +++ b/docs/features/F254-side-effect-freshness-gate.md @@ -113,9 +113,9 @@ Phase A 先落地(价值最高 + 基础设施最成熟),Phase B 扩展通 ⚠️ 消息未发送(HELD) ━━━━━━━━━━━━━━━━━━━━━━━━━ 原因:你有 1 条未读消息(来自Maine Coon) - + [Maine Coon]: "等一下,我发现了一个 bug,这个 PR 先别合…" - + 你的选择: 1. 调 cat_cafe_list_recent 看完整内容,再决定怎么回 2. 修改你的回复后重新调 post_message @@ -441,16 +441,16 @@ interface RuntimeCapabilityDescriptor { // 运行模式 carrier: string; // 'headless-p' | 'interactive' | 'bg-cron' | 'cloud' | 'connector' driver: string; // 'claude' | 'codex' | 'gemini' | etc. - + // Freshness Gate 能力 canReceiveHeldResponse: boolean; canReceiveContentFreeNotice: boolean; - + // 交互能力 busyDeliveryMode: 'gated' | 'direct' | 'steer'; // -p=gated, SDK=steer canAskHumanSync: boolean; // interactive only backgroundBashReliable: boolean; - + // 安全 permissionMode: string; } diff --git a/docs/features/F258-ble-physical-event-limb.md b/docs/features/F258-ble-physical-event-limb.md new file mode 100644 index 0000000000..a5249e1395 --- /dev/null +++ b/docs/features/F258-ble-physical-event-limb.md @@ -0,0 +1,218 @@ +--- +feature_ids: [F258] +related_features: [F126, F124, F202, F246, F254] +topics: [bluetooth, ble, gatt, limb, hardware, sensor, event, privacy] +doc_kind: spec +created: 2026-07-14 +--- + +# F258: BLE Physical Event Limb — 可审计的物理事件总线 + +> **Status**: in-progress (Phase A implementation reviewed; hardware acceptance pending) | **Owner**: Maine Coon Sol (GPT-5.6 Sol) | **Priority**: P1 + +## Why + +operator 在 2026-07-14 的蓝牙能力讨论中批准立项。三猫讨论形成的共同判断是:蓝牙的首要价值不是「连接设备」,而是让 Clowder AI 通过 Limb 接入本地物理世界的状态与事件。 + +F126 已提供 `ILimbNode`、Capability Registry、访问策略、租约和 Action Log,但还没有真实的本机低功耗蓝牙设备节点。F258 在这套控制面上增加 BLE Central / GATT 设备族,使环境传感器和实体按钮可以被发现、绑定、读取和订阅,并以类型化能力提供给猫猫与工作流。 + +首发目标是「Observe + Trigger」:读取传感器状态,接收按钮或设备通知,并形成可审计事件。普通 BLE proximity 不构成可靠身份认证,不能单独授权 force push、删除数据、修改配置等敏感操作。 + +来源:`thread_mrkr4fwxxhjktmdz`;operator 立项消息 `0001784040818947-001421-e1915a66`;方向说明 `0001784040583216-001420-157505a3`。 + +## Product Boundary + +### 首发范围 + +- 仅支持 BLE Central / GATT,不承诺完整蓝牙协议栈。 +- 用户主动扫描并绑定设备;未绑定设备不能被猫猫调用。 +- 默认支持 `read` 和 `notify`;任意 GATT `write` 默认拒绝。 +- 内置 Battery Service、Environmental Sensing Service 和按钮通知适配器。 +- 原始扫描结果只保留在当前扫描会话内。扫描会话从用户主动调用 `startScan()` 开始,到 `stopScan()` 或 30 秒超时结束,以先发生者为准;结束时清空未绑定设备列表。只有显式绑定的设备与工作流映射持久保存,TTL 为 0。 +- 首个真实验收组合为「环境传感器 + BLE 按钮」:读取环境数据,并由实体按钮触发一条可追踪的 Cat Café 工作流事件。 + +### 不在首发范围 + +- Bluetooth Classic、蓝牙音箱、耳机、键盘、打印机和 LE Audio。 +- 将 RSSI、设备名称或「手机在附近」作为身份认证或敏感操作授权依据。 +- 手机离线通信桥、BLE Peripheral 角色和端到端消息协议。 +- Agent 直接操作原始 GATT UUID、扫描参数或任意字节写入。 +- 对私有、加密或需要厂商初始化握手的设备承诺通用兼容。 + +## What + +### Architecture + +```text +Agent / Workflow + ↓ +F126 Limb Registry + Policy + Action Log + ↓ +BleLimbNode + Limb Event Bus + ↓ +Device Adapter Registry + ↓ +Platform Helper Protocol + ├── macOS CoreBluetooth + ├── Linux BlueZ / D-Bus + └── Windows WinRT + ↓ +BLE Device +``` + +F258 不复制 F126 的 Registry、Policy、Lease 或 Action Log。`BleLimbNode` 作为普通 `ILimbNode` 注册读能力;事件能力通过可选的 `ILimbEventSource` 接口接入 `LimbEventBus`,避免要求所有既有 Limb 节点实现订阅接口。 + +平台差异收敛在独立 helper 进程中。Core 使用版本化 NDJSON 协议与 helper 通信,不直接依赖某个 Node.js BLE 库。helper 只接受受限命令,并对消息大小、通知频率、超时和断连进行边界检查。 + +每个平台只运行一个 helper 进程,由首次 BLE 请求按需启动,并在进程内复用多设备连接。helper 启动后必须先发送 `{"protocol":"ble-helper","version":1}` 握手;Core 遇到未知协议或版本时直接拒绝。helper 异常退出后最多自动重启 3 次,间隔为 1 秒、2 秒和 4 秒;仍然失败时将 BLE capability 标记为 `degraded`,不得使 API 进程退出。 + +### Phase A: macOS 真实垂直切片 + +在 macOS 上交付一个可用的 BLE Limb:扫描、显式绑定、连接、读取标准特征值和订阅通知。实现使用系统 CoreBluetooth,由平台 helper 提供能力,不引入第三方 BLE 运行时依赖。 + +设备绑定使用持久存储接口;生产实现不得使用仅内存存储。扫描会话、RSSI 样本和未绑定设备信息不进入长期存储。扫描会话结束时,未绑定设备列表立即清空。所有写操作在 Core、adapter 与 helper 三层均默认拒绝。 + +### Phase B: 类型化事件与 Adapter 工具 + +新增 `LimbEventEnvelope` 与有界事件队列,包含稳定事件 ID、节点 ID、绑定设备 ID、adapter ID、事件类型、观测时间、幂等键和最小 provenance。事件基础设施属于 F126 的通用 Limb 类型与实现空间,F258 是首个消费方,不建立 BLE 专属事件总线。 + +每台设备的队列深度上限为 256 条,去重窗口为 5 秒;队列满时执行 `drop-oldest` 并记录 warning。通知流还需要限速和断连恢复,设备断连或通知洪泛不能拖垮 API 进程。 + +提供 GATT Explorer,展示已授权设备的服务与特征值,并生成 adapter 草稿。adapter manifest 明确列出允许读取或订阅的 characteristic、解码规则、单位和输出 schema;草稿必须经过用户确认后才能启用。Explorer 不提供任意写入入口。 + +Phase A 每个绑定只选择一个主 adapter。Phase B 改为组合匹配,同一设备同时暴露 Environmental Sensing 与 Battery Service 时,两组类型化能力都必须保留。订阅协议同时增加显式 `unsubscribe`,解绑、断连和 helper 关闭后不得残留通知订阅或连接。新增事件路由前,先把 `BleHelperClient` 的恢复状态机拆为独立模块,避免事件职责继续进入进程生命周期文件。 + +### Phase C: 工作流接入、跨平台与受控写入 + +将类型化 BLE 事件接入 Cat Café 工作流。工作流绑定是用户可见、可恢复的数据,默认 TTL 为 0;消费端按幂等键去重,并保留来源设备、adapter 与原始事件 ID。 + +在 Linux 和 Windows 上实现同一 helper 协议,平台不支持或权限未授予时返回明确的 capability 状态。受控写入只允许 adapter 声明的类型化命令,并继续经过 F126 Access Policy 和 Action Log;不向 Agent 暴露任意 GATT 字节写入。 + +## User Journey + +### 环境传感器与实体按钮 + +1. operator 打开 BLE 设备发现入口,系统显示当前扫描会话内的附近设备。 +2. operator 选择传感器或按钮,查看请求的 GATT 服务与权限,然后确认绑定。 +3. 绑定完成后,猫猫通过 Limb 能力读取温度、湿度或电量,不接触原始 characteristic。 +4. operator 将按钮的类型化事件绑定到一个 Cat Café 工作流。 +5. 按下实体按钮后,工作流只执行一次;事件详情可追溯到设备、adapter、时间和 Action Log。 +6. 解除绑定后,设备能力和工作流订阅立即失效,持久记录按数据保留规则处理。 + +## Acceptance Criteria + +### Phase A(macOS 真实垂直切片) + +- [x] AC-A1: `BleLimbNode` 复用 F126 Registry、Policy、Lease 和 Action Log,不创建平行控制面。 +- [x] AC-A2: Core 与 helper 使用版本化、可校验的 NDJSON 协议;测试覆盖未知版本、无效消息、请求超时、helper crash、1 秒/2 秒/4 秒退避重启与超过 3 次后标记 `degraded`,以上情况均不会导致 API 进程退出。 +- [x] AC-A3: macOS CoreBluetooth helper 可完成扫描、显式绑定、连接、标准特征值读取和通知订阅。 +- [x] AC-A4: 未绑定设备不能被猫猫调用;扫描会话由 `stopScan()` 或 30 秒超时结束,以先发生者为准;结束后扫描结果、RSSI 样本和未绑定设备信息不保留。 +- [x] AC-A5: 设备绑定使用生产级持久存储,默认 TTL 为 0;仅内存实现只允许用于测试。 +- [x] AC-A6: Battery Service 与 Environmental Sensing Service 映射为类型化 Limb capability,包含单位、范围校验和解码错误处理。 +- [x] AC-A7: 任意 GATT `write` 在默认配置下被 Core、adapter 和 helper 一致拒绝,并产生可审计的拒绝结果。 +- [ ] AC-A8: 至少一台真实 BLE 传感器完成端到端验收,证据包含设备绑定、读取结果、断连恢复和 Action Log。 + +### Phase B(类型化事件与 Adapter 工具) + +- [ ] AC-B1: `ILimbEventSource` 是 F126 通用 Limb 类型空间中的可选扩展;既有 `ILimbNode` 实现无需修改即可继续工作,F258 只作为首个消费方。 +- [ ] AC-B2: `LimbEventEnvelope` 包含稳定事件 ID、节点 ID、绑定设备 ID、adapter ID、事件类型、观测时间、幂等键和最小 provenance。 +- [ ] AC-B3: 每台设备的通知队列上限为 256 条,去重窗口为 5 秒,队列满时执行 `drop-oldest` 并记录 warning;通知流还具有限速与断连恢复,通知洪泛测试不会造成 API 内存无界增长。 +- [ ] AC-B4: GATT Explorer 只显示已授权设备,能生成 adapter 草稿,但不能执行任意写入。 +- [ ] AC-B5: adapter manifest 对 characteristic allowlist、解码规则、单位和输出 schema 进行校验;未声明 characteristic 不可访问。 +- [ ] AC-B6: 至少一台真实 BLE 按钮可产生类型化事件,重复通知按幂等规则只形成一个逻辑事件。 +- [ ] AC-B7: 同一设备匹配多个 adapter 时组合暴露全部类型化能力;Environmental Sensing 与 Battery Service 同时存在时不丢失电量能力。 +- [ ] AC-B8: 订阅支持显式 `unsubscribe`;解绑、断连和 helper 关闭后,自动化测试确认不存在残留通知订阅或连接。 + +### Phase C(工作流接入、跨平台与受控写入) + +- [ ] AC-C1: BLE 按钮事件可触发一条 Cat Café 工作流,事件、工作流执行与 Action Log 可以互相追溯。 +- [ ] AC-C2: 工作流绑定持久保存且默认 TTL 为 0;重启后绑定可恢复,解除绑定后不再触发。 +- [ ] AC-C3: Linux BlueZ / D-Bus helper 与 Windows WinRT helper 通过同一协议通过契约测试。 +- [ ] AC-C4: 平台不支持、蓝牙关闭或权限未授予时,Limb capability 返回明确的 unavailable / degraded 原因。 +- [ ] AC-C5: 受控写入只允许 adapter 声明的类型化命令,并经过 F126 Access Policy 与 Action Log;不存在 Agent 可调用的任意字节写入接口。 +- [ ] AC-C6: RSSI 或 presence 信号不能单独提升敏感操作权限;相关安全测试覆盖伪造标识和重放事件。 + +## Tips Contribution(F244) + +- [x] 新增「绑定 BLE 设备前核对权限」提示。 +- [x] 新增「BLE proximity 不能作为敏感操作认证」提示。 + +## Dependencies + +- **Evolved from**: F126(复用 Limb 控制面、权限、租约和审计能力) +- **Related**: F124(Apple 设备作为 Limb 的长期方向;F258 首发不实现 iOS / watchOS Peripheral) +- **Related**: F202(未来 adapter 可作为 plugin resource 分发;首发不要求插件框架改造) +- **Related**: F246(显式设备绑定需要用户确认,但不进入通用 Approval Hub 首发范围) +- **Related**: F254(工作流触发产生副作用前继续服从 freshness gate) +- **External acceptance dependency**: 真实 BLE 环境传感器与按钮各一台 + +## Security and Privacy Invariants + +1. 扫描附近设备属于本地隐私数据,默认不持久保存,不写入记忆索引,不用于用户画像。 +2. 绑定必须由用户主动确认;设备广播名称、RSSI 和 MAC 地址都不能作为可信身份。 +3. Agent 只能调用 adapter 暴露的类型化能力,不能读取任意 characteristic 或写入任意字节。 +4. 设备输入按不可信数据处理:限制长度、频率、解析深度和执行时间,不把设备字符串拼接到 shell 命令。 +5. 医疗、门锁、车辆和支付类设备默认不支持写入;后续支持需单独安全审查。 +6. 所有用户可见绑定与工作流映射默认持久保存,TTL 为 0;删除只能由明确的解绑操作触发。 + +## Risk + +| 风险 | 缓解 | +|------|------| +| macOS、Linux 和 Windows 的设备标识与权限语义不同 | 设备身份不跨平台推断;helper 返回平台原生标识和明确 capability 状态 | +| 恶意设备发送超长或高频通知 | helper 与 Core 双层限长、限速、背压和超时;通知洪泛纳入自动化测试 | +| 私有 GATT 协议碎片化 | 标准 profile 内置;私有设备通过显式 adapter 接入,不承诺自动兼容 | +| 第三方 BLE 库维护或原生构建不稳定 | Core 只依赖稳定 helper 协议;各平台优先使用系统蓝牙 API | +| 设备名称或地址被伪造 | 绑定记录不把广播名称作为身份;敏感能力仍由 F126 Policy 决定 | +| 事件重复导致工作流重复执行 | 事件 ID、幂等键、去重窗口和消费端幂等共同约束 | +| 原始扫描数据进入持久层 | 存储接口拒绝未绑定设备;测试验证扫描会话结束后无持久记录 | + +## Open Questions + +| # | 问题 | 状态 | +|---|------|------| +| OQ-1 | macOS 最低支持版本与 helper 的签名、权限声明如何进入桌面分发流程 | ✅ macOS 13+;Desktop 与 helper 均嵌入蓝牙权限文案,helper 随 `extraResources` 打包并沿用 Desktop ad-hoc codesign 流程 | +| OQ-2 | 首套硬件验收设备选型:标准 Environmental Sensing 设备与按钮型号 | ⬜ 需要真实硬件确认 | +| OQ-3 | 第一条按钮工作流使用现有哪一种触发目标作为稳定演示 | ⬜ Phase B Design Gate 确认 | +| OQ-4 | adapter manifest 在 F202 plugin resource 中的长期类型名与版本策略 | ⬜ Phase B 前确认 | + +## Key Decisions + +| # | 决策 | 理由 | 日期 | +|---|------|------|------| +| KD-1 | F258 是 F126 上的设备族,不新建 Limb 控制面 | Registry、Policy、Lease 和 Action Log 已存在,重复实现会造成真相源分裂 | 2026-07-14 | +| KD-2 | 首发只做 BLE Central / GATT | Bluetooth Classic、HID、Audio 与 GATT 的系统栈和产品边界不同 | 2026-07-14 | +| KD-3 | 首发优先 Observe + Trigger,任意写入默认关闭 | 传感器与按钮能形成真实价值,同时控制安全面 | 2026-07-14 | +| KD-4 | BLE proximity 不作为敏感操作认证 | RSSI、设备标识和近场状态不能证明用户身份或当前同意 | 2026-07-14 | +| KD-5 | 平台差异收敛到原生 helper,Core 使用版本化协议 | 避免将产品能力绑定到单个 Node.js BLE 库,并隔离平台权限语义 | 2026-07-14 | +| KD-6 | 事件能力使用 F126 通用类型空间中的可选 `ILimbEventSource`,F258 是首个消费方 | 保持 F126 既有实现兼容,同时避免形成 BLE 专属事件基础设施 | 2026-07-14 | +| KD-7 | 扫描数据临时保存,显式绑定与工作流映射永久保存 | 同时满足附近设备隐私与用户状态可恢复要求 | 2026-07-14 | +| KD-8 | 每个平台一个 helper,首次 BLE 请求时按需启动;握手版本固定为 1,crash 最多按 1 秒/2 秒/4 秒退避重启 3 次 | 无 BLE 请求时保持零运行开销;失败隔离在 helper,不影响 API 主进程 | 2026-07-14 | +| KD-9 | 扫描会话最长 30 秒,可由 `stopScan()` 提前结束;结束时清空未绑定设备 | 为扫描隐私数据定义可验证的内存生命周期 | 2026-07-14 | +| KD-10 | 单设备事件队列上限 256 条、去重窗口 5 秒、满队列时 `drop-oldest` 并记录 warning | 传感器流优先保留最新值,同时为内存占用与重复通知建立确定边界 | 2026-07-14 | + +## Timeline + +| 日期 | 事件 | +|------|------| +| 2026-07-14 | 三猫讨论 BLE 能力方向;operator 批准立项 | +| 2026-07-14 | 分配 F258,完成查重、产品边界和首版 Phase 设计 | +| 2026-07-14 | Opus 4.6 跨 family Design Gate 放行;补齐事件归属、队列参数、helper 生命周期和扫描会话定义 | +| 2026-07-15 | Phase A 代码与自动化证据完成:API 41 项、Console 16 项、Swift 协议 smoke 全绿;AC-A8 等待真实 BLE 传感器验收,代码等待跨个体 review | +| 2026-07-15 | Opus 4.6 完成跨个体实现审查并放行;P2 文件尺寸当场拆分,三个 P3 进入 Phase B 明确范围 | + +## Review Gate + +- Phase A:跨 family 架构与安全 review;真实硬件证据必须包含断连恢复和拒绝写入。 +- Phase B:事件契约与背压需要 Maine Coon 安全 review;Adapter UX 需要 Design Gate。 +- Phase C:跨平台 helper 与受控写入属于高风险能力,需要跨 family review 和独立愿景守护。 + +## Links + +| 类型 | 路径 | 说明 | +|------|------|------| +| **Feature** | `docs/features/F126-limb-control-plane.md` | Limb Registry、Policy、Lease、Action Log 真相源 | +| **Feature** | `docs/features/F124-apple-ecosystem-voice-interaction.md` | Apple 设备长期接入方向 | +| **Feature** | `docs/features/F202-plugin-framework.md` | adapter plugin resource 的潜在承载面 | +| **Implementation plan** | `docs/features/assets/F258/phase-a-implementation-plan.md` | Phase A 作用域决策、模块设计与测试矩阵 | +| **Source thread** | `thread_mrkr4fwxxhjktmdz` | 立项讨论与 operator 批准 | diff --git a/docs/features/assets/F258/phase-a-implementation-plan.md b/docs/features/assets/F258/phase-a-implementation-plan.md new file mode 100644 index 0000000000..acc0ca9a33 --- /dev/null +++ b/docs/features/assets/F258/phase-a-implementation-plan.md @@ -0,0 +1,190 @@ +--- +feature_ids: [F258] +related_features: [F126, F179, F190] +topics: [bluetooth, ble, limb, macos, implementation-plan, testing] +doc_kind: plan +created: 2026-07-14 +--- + +# F258 Phase A 实施计划 + +## 交付目标 + +Phase A 在 macOS 上交付一条可验证的 BLE Central / GATT 垂直切片:operator 可以在设置页发起限时扫描、查看会话内设备、显式绑定标准传感器,并让猫猫通过 F126 Limb 控制面读取类型化的环境数据或电量。 + +CoreBluetooth 运行在独立 helper 进程。API 进程只接收版本化协议消息,不加载第三方 BLE 运行时依赖。任意 GATT 写入不进入协议命令集。 + +## 已确认的作用域决策 + +### 绑定归属 + +Phase A 的绑定采用「当前 Clowder 实例作用域」,不增加无可靠来源的 `userId`: + +- `LimbRegistry`、`LimbAccessPolicy` 和当前 Limb 路由都是实例级对象,没有可信的用户身份上下文。 +- Redis 客户端现有 `keyPrefix` 提供实例 namespace 隔离;BLE 键只保存当前实例内的绑定。 +- 存储键预留 `scopeId` 字段,Phase A 固定为 `instance`。如果后续引入多租户认证,迁移到 per-user 或 per-workspace scope 时无需改绑定实体格式。 +- 生产环境没有 Redis 时,不注册绑定写接口,也不回退到内存存储。内存实现仅用于单元测试。 + +这项决策避免把进程级节点错误包装成 per-user 安全边界。未来多租户化必须同时升级 API 身份、`LimbRegistry.invoke()` 上下文和节点可见性,不能只修改 Redis key。 + +### Console 入口 + +新增 L2 `/settings?s=devices` 分区,名称为「设备与 Limb」。不复用「通知」分区: + +- 「通知」管理消息投递与提醒策略。 +- BLE 扫描、绑定、权限和连接状态属于硬件管理。 +- Phase B 的事件到通知或工作流映射可以在对应产品域引用绑定设备,但设备身份真相源仍在「设备与 Limb」。 + +不增加 Activity Bar 入口,不触碰聊天、消息气泡或现有通知写入路径。 + +## Console 四道门禁 + +### Product Gate + +| 状态 | 桌面端行为 | 移动端行为 | +|---|---|---| +| loading | 显示状态条和骨架卡片 | 单列显示 | +| empty | 说明尚未绑定设备,提供「扫描附近设备」 | 同一动作,卡片纵向排列 | +| scanning | 显示剩余时间、临时发现结果和「停止扫描」 | 结果单列,主操作保持可见 | +| results | 每个结果显示设备名、信号强度和支持的标准服务 | 隐藏次要诊断字段 | +| bound | 显示 adapter、最近状态、可读能力和「解除绑定」 | 单列显示能力摘要 | +| degraded | 显示 helper 失败原因和可重试状态,不影响其他设置页 | 同桌面端 | +| error | 显示可操作错误;扫描数据不跨会话保留 | 同桌面端 | +| unsupported | 非 macOS 平台显示当前不支持,不尝试 spawn helper | 同桌面端 | + +当前 Console 没有成员角色级页面授权模型,因此 owner / member / guest 状态不伪造差异。BLE 路由沿用本地 Hub 的访问边界;绑定动作仍要求显式按钮操作。 + +### Design-System Gate + +- 复用 `SettingsPageHeader`、`SettingsSection`、`SettingsCard`、`SettingsStatusStrip`、`SettingsEmptyState`、`SettingsPrimaryButton` 和 `SettingsSecondaryButton`。 +- 颜色、边框、间距和状态全部使用现有语义 token。 +- 不新增 BLE 专属全局 CSS,不扩大现有 token 豁免。 + +### Implementation Gate + +- `BleDevicesContent` 负责页面状态和 API 调用。 +- 扫描结果、已绑定设备和状态提示拆为视觉独立组件;单文件超过 200 行时复核拆分。 +- `apiFetch` 的读取结果与提交 payload 分离。绑定请求只提交当前扫描会话返回的 opaque discovery ID 和已知 adapter ID。 +- 不向前端返回原始 manufacturer data、任意 characteristic UUID 写入口或 helper 路径。 + +### Verification Gate + +- Golden path:空状态 → 扫描 → 发现标准设备 → 绑定 → 已绑定列表 → 类型化读取。 +- 非 happy path:非 macOS、蓝牙权限拒绝、helper crash 后 degraded、扫描超时自动清空。 +- 路由证明:`devices` deep link 可恢复,搜索可命中,不影响 `notify`、`ops` 和 `members`。 +- 浏览器证明:桌面宽度与移动端宽度各一张,至少包含 scanning 和 degraded 中的一种非 happy path。 + +## 模块设计 + +### Core 协议与进程边界 + +新增 `packages/api/src/domains/limb/ble/`: + +| 模块 | 职责 | +|---|---| +| `BleHelperProtocol.ts` | 校验 handshake、request、response 和 event;限制消息大小与字段长度 | +| `BleHelperClient.ts` | lazy spawn、请求关联、超时、1 秒 / 2 秒 / 4 秒重启和 degraded 状态 | +| `BleScanSession.ts` | 单个 30 秒扫描会话、显式停止和临时发现结果清理 | +| `BleBindingStore.ts` | 绑定 port、Redis 实现和测试专用内存实现;TTL 为 0 | +| `BleAdapters.ts` | Battery 与 Environmental Sensing allowlist、解码和范围校验 | +| `BleLimbNode.ts` | 把单个绑定设备映射为 F126 `ILimbNode`,只暴露类型化 read 命令 | +| `BleDeviceManager.ts` | 编排扫描、绑定、节点注册、读取与解绑 | + +helper 请求格式: + +```json +{"protocol":"ble-helper","version":1,"requestId":"…","command":"scan.start","params":{"timeoutMs":30000}} +``` + +helper 启动时先发送: + +```json +{"protocol":"ble-helper","version":1,"kind":"hello"} +``` + +允许命令固定为: + +- `scan.start` +- `scan.stop` +- `device.inspect` +- `gatt.read` +- `gatt.subscribe` +- `device.disconnect` +- `helper.shutdown` + +协议没有 `write` 命令。未知命令、未知版本、超长行和无效 JSON 均返回可审计错误,不执行设备操作。 + +### 持久绑定 + +绑定实体保存以下最小字段: + +- `bindingId` +- `scopeId` +- `platformDeviceId` +- `displayName` +- `adapterId` +- `commands` +- `nodeId` +- `createdAt` +- `lastConnectedAt` + +不保存扫描 RSSI 历史、未绑定设备、manufacturer data 或广播名称历史。Redis 使用一个绑定索引和按 ID 的 JSON 记录;写入不设置 EXPIRE。解绑是唯一删除入口,同时注销对应 `BleLimbNode`。 + +### 标准 adapter + +Phase A 内置: + +- Battery Service `0x180F` / Battery Level `0x2A19` +- Environmental Sensing Service `0x181A` +- Temperature `0x2A6E` +- Humidity `0x2A6F` + +解码器验证长度、单位和有效范围。错误数据返回结构化失败,不进入 Action Log artifact,不抛出未处理异常。 + +### macOS helper + +新增 `native/ble-helper/macos/` Swift 源码和嵌入式 `Info.plist`: + +- 使用 `CBCentralManager` 扫描、连接、发现服务、读取和订阅。 +- stdout 只写 NDJSON 协议;诊断写 stderr。 +- 单行最大 64 KiB,设备名最大 128 个字符,通知 payload 最大 4 KiB。 +- CoreBluetooth 回调在一个进程内 multiplex 多台设备。 +- 编译结果按架构放到 `bundled/ble-helper-darwin-${arch}/ble-helper`,由 Desktop `extraResources` 打包。 +- Desktop app 与 helper 都声明 Bluetooth usage description。 + +## TDD 顺序 + +1. 红:协议拒绝未知版本、未知命令、超长和格式错误消息。 +2. 绿:实现协议 schema 与安全上限。 +3. 红:进程客户端 handshake 超时、请求超时、crash 重启和三次后 degraded。 +4. 绿:实现可注入 transport 的 `BleHelperClient`。 +5. 红:扫描会话 30 秒超时、显式停止和结果清理。 +6. 绿:实现 `BleScanSession`。 +7. 红:Redis 绑定重启恢复、TTL 为 0、解绑删除和损坏记录隔离。 +8. 绿:实现 store 与 manager hydration。 +9. 红:Battery / Temperature / Humidity 解码、范围与错误长度。 +10. 绿:实现 adapter 与 `BleLimbNode`,验证未绑定设备不能调用、任意写命令被拒绝。 +11. 红:BLE API 的平台状态、扫描、绑定、解绑与错误码。 +12. 绿:实现 routes 与 API 初始化。 +13. 红:Settings routing、搜索、loading / empty / scanning / degraded。 +14. 绿:实现 `devices` 分区和组件。 +15. 编译 Swift helper,执行协议 smoke test 和 Desktop packaging 路径测试。 + +## 测试矩阵 + +| 层级 | 重点用例 | 证据 | +|---|---|---| +| 协议单测 | version、message size、command allowlist、response correlation | focused test | +| 进程单测 | lazy spawn、handshake、timeout、crash、1/2/4 秒退避、degraded | fake transport + fake timer | +| 扫描单测 | stop 与 30 秒 timeout 取先到者、临时结果清空 | fake clock | +| 存储单测 | Redis restart hydration、无 EXPIRE、坏记录隔离、解绑 | isolated Redis namespace | +| adapter 单测 | 标准值、边界值、长度错误、NaN / out-of-range | table-driven test | +| Registry 集成 | 绑定后注册、Policy / Lease / Action Log、解绑失效 | existing F126 fixtures | +| API 集成 | unsupported、扫描生命周期、显式绑定、无 Redis fail-closed | Fastify inject | +| Web 单测 | deep link、搜索、状态矩阵、按钮 payload | Vitest / Testing Library | +| Swift 测试 | 编译、hello、未知命令拒绝、shutdown | shell smoke test | +| 真实硬件 | 绑定、读取、断连恢复、Action Log | Phase A acceptance bundle | + +## 完成条件与外部依赖 + +代码完成要求 AC-A1 至 AC-A7 全部有自动化证据,并在 macOS 上完成 helper 编译和协议 smoke test。AC-A8 需要一台真实 BLE 环境传感器;没有硬件时必须明确保留为外部验收依赖,不能用 mock 结果标记完成。 diff --git a/docs/features/index.json b/docs/features/index.json index 14bf8628b7..c458a3a645 100644 --- a/docs/features/index.json +++ b/docs/features/index.json @@ -1436,8 +1436,8 @@ }, { "id": "F241", - "name": "Agent Provider Plugin / Hostable Provider Runtime", - "status": "spec | **Owner**: Community (彭潇/bouillipx) + Ragdoll家族 maintainer | **Priority**: P1", + "name": "Agent Provider Plugin / Hostable Provider Runtime — Pluggable Agent Provider Extension Surface", + "status": "accepted feature anchor | **Owner**: Community (彭潇 / `bouillipx`) + Cat Cafe maintainer guard | **Priority**: P1 | **Source**: operator private-agent integration request + clowder-ai#941 accepted 2026-06-18", "file": "F241-agent-provider-plugin.md" }, { @@ -1523,6 +1523,12 @@ "name": "Memory Search Strategy Evolution — 从被动召回到主动探索", "status": "active | **Owner**: Ragdoll (opus-4.6) | **Priority**: P1", "file": "F256-memory-search-strategy-evolution.md" + }, + { + "id": "F258", + "name": "BLE Physical Event Limb — 可审计的物理事件总线", + "status": "in-progress (Phase A implementation reviewed; hardware acceptance pending) | **Owner**: Maine Coon Sol (GPT-5.6 Sol) | **Priority**: P1", + "file": "F258-ble-physical-event-limb.md" } ] } diff --git a/docs/harness-feedback/bundles/2026-06-27-eval-a2a-clean-keep-observe/attribution.json b/docs/harness-feedback/bundles/2026-06-27-eval-a2a-clean-keep-observe/attribution.json new file mode 100644 index 0000000000..fd0bbc286c --- /dev/null +++ b/docs/harness-feedback/bundles/2026-06-27-eval-a2a-clean-keep-observe/attribution.json @@ -0,0 +1,11 @@ +{ + "verdictId": "2026-06-27-eval-a2a-clean-keep-observe", + "featureId": "F167", + "evalSnapshotId": "eval-F167-2026-06-27", + "generatedAt": "2026-06-27T03:03:40.908Z", + "findings": [], + "noFindingRecord": { + "reason": "No friction signals detected across 4 components", + "evidence": "Checked components: L1, C1, C2, route-serial. Friction metrics examined: c1.zombie_hold_count, c1.hold_cancel_count, c2.verdict_without_pass_count, c2.void_hold_hint_emitted, inline_action.shadow_miss, inline_action.routed_set_skip, inline_action.feedback_written, inline_action.hint_emitted. All values within threshold." + } +} diff --git a/docs/harness-feedback/bundles/2026-06-27-eval-a2a-clean-keep-observe/provenance.json b/docs/harness-feedback/bundles/2026-06-27-eval-a2a-clean-keep-observe/provenance.json new file mode 100644 index 0000000000..1324358ae8 --- /dev/null +++ b/docs/harness-feedback/bundles/2026-06-27-eval-a2a-clean-keep-observe/provenance.json @@ -0,0 +1,19 @@ +{ + "verdictId": "2026-06-27-eval-a2a-clean-keep-observe", + "rawInputs": [ + { + "path": "docs/harness-feedback/snapshots/2026-06-27-F167-eval.yaml", + "sha256": "0cb803dafdb41484b8a4f615cce491569e46e61421e0b45e92516fad25afeb10" + }, + { + "path": "docs/harness-feedback/attributions/2026-06-27-F167-attribution.yaml", + "sha256": "e7aa452c48b28dd09f08d1b3823eef2578d4331d37293a5ae2d05a81bcdaed24" + } + ], + "generatedAt": "2026-06-27T03:03:40.908Z", + "generator": { + "name": "eval-a2a-live-verdict", + "version": "1" + }, + "sanitizeRulesVersion": "f192-e-pilot-v1" +} diff --git a/docs/harness-feedback/bundles/2026-06-27-eval-a2a-clean-keep-observe/snapshot.json b/docs/harness-feedback/bundles/2026-06-27-eval-a2a-clean-keep-observe/snapshot.json new file mode 100644 index 0000000000..88ee4c263b --- /dev/null +++ b/docs/harness-feedback/bundles/2026-06-27-eval-a2a-clean-keep-observe/snapshot.json @@ -0,0 +1,66 @@ +{ + "verdictId": "2026-06-27-eval-a2a-clean-keep-observe", + "evalSnapshotId": "eval-F167-2026-06-27", + "featureId": "F167", + "generatedAt": "2026-06-27T03:03:40.905Z", + "window": { + "startMs": 1782443055946, + "endMs": 1782529323956, + "durationHours": 23.96333611111111 + }, + "components": [ + { + "id": "L1", + "name": "WorklistRegistry (ping-pong breaker)", + "confidence": "medium", + "activationCounts": { + "l1.streak_warn_count": 0, + "l1.streak_break_count": 0 + }, + "frictionCounts": {} + }, + { + "id": "C1", + "name": "hold_ball (MCP tool)", + "confidence": "medium", + "activationCounts": { + "hold_ball_calls": 0 + }, + "frictionCounts": { + "c1.zombie_hold_count": 1, + "c1.hold_cancel_count": 0 + } + }, + { + "id": "C2", + "name": "exit-check (forced-pass guard)", + "confidence": "medium", + "activationCounts": { + "hint_emitted (mixed routing+verdict)": 1, + "c2.verdict_hint_emitted": 0, + "c2.checked": 210, + "c2.void_hold_checked": 216 + }, + "frictionCounts": { + "c2.verdict_without_pass_count": 0, + "c2.void_hold_hint_emitted": 1 + } + }, + { + "id": "route-serial", + "name": "route-serial (A2A handoff routing)", + "confidence": "high", + "activationCounts": { + "inline_action.checked": 216, + "line_start.detected": 104, + "inline_action.detected": 1 + }, + "frictionCounts": { + "inline_action.shadow_miss": 1, + "inline_action.routed_set_skip": 4, + "inline_action.feedback_written": 1, + "inline_action.hint_emitted": 1 + } + } + ] +} diff --git a/docs/harness-feedback/bundles/2026-06-29-eval-a2a-low-volume-clean-keep-observe/attribution.json b/docs/harness-feedback/bundles/2026-06-29-eval-a2a-low-volume-clean-keep-observe/attribution.json new file mode 100644 index 0000000000..e200db39af --- /dev/null +++ b/docs/harness-feedback/bundles/2026-06-29-eval-a2a-low-volume-clean-keep-observe/attribution.json @@ -0,0 +1,11 @@ +{ + "verdictId": "2026-06-29-eval-a2a-low-volume-clean-keep-observe", + "featureId": "F167", + "evalSnapshotId": "eval-F167-2026-06-29", + "generatedAt": "2026-06-29T03:01:39.026Z", + "findings": [], + "noFindingRecord": { + "reason": "No friction signals detected across 4 components", + "evidence": "Checked components: L1, C1, C2, route-serial. Friction metrics examined: c1.zombie_hold_count, c1.hold_cancel_count, c2.verdict_without_pass_count, c2.void_hold_hint_emitted. All values within threshold." + } +} diff --git a/docs/harness-feedback/bundles/2026-06-29-eval-a2a-low-volume-clean-keep-observe/provenance.json b/docs/harness-feedback/bundles/2026-06-29-eval-a2a-low-volume-clean-keep-observe/provenance.json new file mode 100644 index 0000000000..1e4b10b008 --- /dev/null +++ b/docs/harness-feedback/bundles/2026-06-29-eval-a2a-low-volume-clean-keep-observe/provenance.json @@ -0,0 +1,19 @@ +{ + "verdictId": "2026-06-29-eval-a2a-low-volume-clean-keep-observe", + "rawInputs": [ + { + "path": "docs/harness-feedback/snapshots/2026-06-29-F167-eval.yaml", + "sha256": "b6ad3f14912d0838af23861709e984ba05355ef9bfa85c0f93e2c3a7e1aafa9d" + }, + { + "path": "docs/harness-feedback/attributions/2026-06-29-F167-attribution.yaml", + "sha256": "1dafb6c2ea0df15117a190ecf027f68df27da009dd0606169c04303487947b75" + } + ], + "generatedAt": "2026-06-29T03:01:39.026Z", + "generator": { + "name": "eval-a2a-live-verdict", + "version": "1" + }, + "sanitizeRulesVersion": "f192-e-pilot-v1" +} diff --git a/docs/harness-feedback/bundles/2026-06-29-eval-a2a-low-volume-clean-keep-observe/snapshot.json b/docs/harness-feedback/bundles/2026-06-29-eval-a2a-low-volume-clean-keep-observe/snapshot.json new file mode 100644 index 0000000000..28ea398aae --- /dev/null +++ b/docs/harness-feedback/bundles/2026-06-29-eval-a2a-low-volume-clean-keep-observe/snapshot.json @@ -0,0 +1,59 @@ +{ + "verdictId": "2026-06-29-eval-a2a-low-volume-clean-keep-observe", + "evalSnapshotId": "eval-F167-2026-06-29", + "featureId": "F167", + "generatedAt": "2026-06-29T03:01:39.025Z", + "window": { + "startMs": 1782621958042, + "endMs": 1782702000065, + "durationHours": 22.233895277777776 + }, + "components": [ + { + "id": "L1", + "name": "WorklistRegistry (ping-pong breaker)", + "confidence": "medium", + "activationCounts": { + "l1.streak_warn_count": 0, + "l1.streak_break_count": 0 + }, + "frictionCounts": {} + }, + { + "id": "C1", + "name": "hold_ball (MCP tool)", + "confidence": "medium", + "activationCounts": { + "hold_ball_calls": 0 + }, + "frictionCounts": { + "c1.zombie_hold_count": 0, + "c1.hold_cancel_count": 0 + } + }, + { + "id": "C2", + "name": "exit-check (forced-pass guard)", + "confidence": "medium", + "activationCounts": { + "hint_emitted (mixed routing+verdict)": null, + "c2.verdict_hint_emitted": 0, + "c2.checked": 3, + "c2.void_hold_checked": 3 + }, + "frictionCounts": { + "c2.verdict_without_pass_count": 0, + "c2.void_hold_hint_emitted": 0 + } + }, + { + "id": "route-serial", + "name": "route-serial (A2A handoff routing)", + "confidence": "high", + "activationCounts": { + "inline_action.checked": 3 + }, + "frictionCounts": {} + } + ] +} diff --git a/docs/harness-feedback/bundles/2026-06-30-eval-a2a-c2-recovery-keep-observe/attribution.json b/docs/harness-feedback/bundles/2026-06-30-eval-a2a-c2-recovery-keep-observe/attribution.json new file mode 100644 index 0000000000..1aac2a43d6 --- /dev/null +++ b/docs/harness-feedback/bundles/2026-06-30-eval-a2a-c2-recovery-keep-observe/attribution.json @@ -0,0 +1,11 @@ +{ + "verdictId": "2026-06-30-eval-a2a-c2-recovery-keep-observe", + "featureId": "F167", + "evalSnapshotId": "eval-F167-2026-06-30", + "generatedAt": "2026-06-30T03:01:31.058Z", + "findings": [], + "noFindingRecord": { + "reason": "No friction signals detected across 4 components", + "evidence": "Checked components: L1, C1, C2, route-serial. Friction metrics examined: c1.zombie_hold_count, c1.hold_cancel_count, c2.verdict_without_pass_count, c2.void_hold_hint_emitted. All values within threshold." + } +} diff --git a/docs/harness-feedback/bundles/2026-06-30-eval-a2a-c2-recovery-keep-observe/provenance.json b/docs/harness-feedback/bundles/2026-06-30-eval-a2a-c2-recovery-keep-observe/provenance.json new file mode 100644 index 0000000000..7e17289187 --- /dev/null +++ b/docs/harness-feedback/bundles/2026-06-30-eval-a2a-c2-recovery-keep-observe/provenance.json @@ -0,0 +1,19 @@ +{ + "verdictId": "2026-06-30-eval-a2a-c2-recovery-keep-observe", + "rawInputs": [ + { + "path": "docs/harness-feedback/snapshots/2026-06-30-F167-eval.yaml", + "sha256": "8affd19675b36f4c80c00d19236349bc1c5cf3672360261eedd8ceedf386ab9e" + }, + { + "path": "docs/harness-feedback/attributions/2026-06-30-F167-attribution.yaml", + "sha256": "8a1656259174b145516b9d0c94c02f2d0d75eac44fb64e1bf7c8400bed5b0949" + } + ], + "generatedAt": "2026-06-30T03:01:31.058Z", + "generator": { + "name": "eval-a2a-live-verdict", + "version": "1" + }, + "sanitizeRulesVersion": "f192-e-pilot-v1" +} diff --git a/docs/harness-feedback/bundles/2026-06-30-eval-a2a-c2-recovery-keep-observe/snapshot.json b/docs/harness-feedback/bundles/2026-06-30-eval-a2a-c2-recovery-keep-observe/snapshot.json new file mode 100644 index 0000000000..95251a57b1 --- /dev/null +++ b/docs/harness-feedback/bundles/2026-06-30-eval-a2a-c2-recovery-keep-observe/snapshot.json @@ -0,0 +1,60 @@ +{ + "verdictId": "2026-06-30-eval-a2a-c2-recovery-keep-observe", + "evalSnapshotId": "eval-F167-2026-06-30", + "featureId": "F167", + "generatedAt": "2026-06-30T03:01:31.055Z", + "window": { + "startMs": 1782702183811, + "endMs": 1782788400451, + "durationHours": 23.949066666666667 + }, + "components": [ + { + "id": "L1", + "name": "WorklistRegistry (ping-pong breaker)", + "confidence": "medium", + "activationCounts": { + "l1.streak_warn_count": 0, + "l1.streak_break_count": 0 + }, + "frictionCounts": {} + }, + { + "id": "C1", + "name": "hold_ball (MCP tool)", + "confidence": "medium", + "activationCounts": { + "hold_ball_calls": 0 + }, + "frictionCounts": { + "c1.zombie_hold_count": 0, + "c1.hold_cancel_count": 0 + } + }, + { + "id": "C2", + "name": "exit-check (forced-pass guard)", + "confidence": "medium", + "activationCounts": { + "hint_emitted (mixed routing+verdict)": null, + "c2.verdict_hint_emitted": 0, + "c2.checked": 22, + "c2.void_hold_checked": 22 + }, + "frictionCounts": { + "c2.verdict_without_pass_count": 0, + "c2.void_hold_hint_emitted": 1 + } + }, + { + "id": "route-serial", + "name": "route-serial (A2A handoff routing)", + "confidence": "high", + "activationCounts": { + "inline_action.checked": 22, + "line_start.detected": 1 + }, + "frictionCounts": {} + } + ] +} diff --git a/docs/harness-feedback/bundles/2026-07-01-eval-a2a-c2-recovery-keep-observe/attribution.json b/docs/harness-feedback/bundles/2026-07-01-eval-a2a-c2-recovery-keep-observe/attribution.json new file mode 100644 index 0000000000..3ea4afd850 --- /dev/null +++ b/docs/harness-feedback/bundles/2026-07-01-eval-a2a-c2-recovery-keep-observe/attribution.json @@ -0,0 +1,11 @@ +{ + "verdictId": "2026-07-01-eval-a2a-c2-recovery-keep-observe", + "featureId": "F167", + "evalSnapshotId": "eval-F167-2026-07-01", + "generatedAt": "2026-07-01T03:02:25.273Z", + "findings": [], + "noFindingRecord": { + "reason": "No friction signals detected across 4 components", + "evidence": "Checked components: L1, C1, C2, route-serial. Friction metrics examined: c1.zombie_hold_count, c1.hold_cancel_count, c2.verdict_without_pass_count, c2.void_hold_hint_emitted. All values within threshold." + } +} diff --git a/docs/harness-feedback/bundles/2026-07-01-eval-a2a-c2-recovery-keep-observe/provenance.json b/docs/harness-feedback/bundles/2026-07-01-eval-a2a-c2-recovery-keep-observe/provenance.json new file mode 100644 index 0000000000..760c298503 --- /dev/null +++ b/docs/harness-feedback/bundles/2026-07-01-eval-a2a-c2-recovery-keep-observe/provenance.json @@ -0,0 +1,19 @@ +{ + "verdictId": "2026-07-01-eval-a2a-c2-recovery-keep-observe", + "rawInputs": [ + { + "path": "docs/harness-feedback/snapshots/2026-07-01-F167-eval.yaml", + "sha256": "a6698eec58e3d7d766c2e82d02666bca604cd16a54bda2d6310bc3c72bb1352a" + }, + { + "path": "docs/harness-feedback/attributions/2026-07-01-F167-attribution.yaml", + "sha256": "3233feab1eacca034c2c542a80d0b9c3ea770fd1458f76412b5737e735c29013" + } + ], + "generatedAt": "2026-07-01T03:02:25.273Z", + "generator": { + "name": "eval-a2a-live-verdict", + "version": "1" + }, + "sanitizeRulesVersion": "f192-e-pilot-v1" +} diff --git a/docs/harness-feedback/bundles/2026-07-01-eval-a2a-c2-recovery-keep-observe/snapshot.json b/docs/harness-feedback/bundles/2026-07-01-eval-a2a-c2-recovery-keep-observe/snapshot.json new file mode 100644 index 0000000000..ac2bdc64ca --- /dev/null +++ b/docs/harness-feedback/bundles/2026-07-01-eval-a2a-c2-recovery-keep-observe/snapshot.json @@ -0,0 +1,60 @@ +{ + "verdictId": "2026-07-01-eval-a2a-c2-recovery-keep-observe", + "evalSnapshotId": "eval-F167-2026-07-01", + "featureId": "F167", + "generatedAt": "2026-07-01T03:02:25.272Z", + "window": { + "startMs": 1782788635107, + "endMs": 1782874800472, + "durationHours": 23.93482361111111 + }, + "components": [ + { + "id": "L1", + "name": "WorklistRegistry (ping-pong breaker)", + "confidence": "medium", + "activationCounts": { + "l1.streak_warn_count": 0, + "l1.streak_break_count": 0 + }, + "frictionCounts": {} + }, + { + "id": "C1", + "name": "hold_ball (MCP tool)", + "confidence": "medium", + "activationCounts": { + "hold_ball_calls": 0 + }, + "frictionCounts": { + "c1.zombie_hold_count": 0, + "c1.hold_cancel_count": 0 + } + }, + { + "id": "C2", + "name": "exit-check (forced-pass guard)", + "confidence": "medium", + "activationCounts": { + "hint_emitted (mixed routing+verdict)": null, + "c2.verdict_hint_emitted": 0, + "c2.checked": 78, + "c2.void_hold_checked": 78 + }, + "frictionCounts": { + "c2.verdict_without_pass_count": 0, + "c2.void_hold_hint_emitted": 2 + } + }, + { + "id": "route-serial", + "name": "route-serial (A2A handoff routing)", + "confidence": "high", + "activationCounts": { + "inline_action.checked": 78, + "line_start.detected": 35 + }, + "frictionCounts": {} + } + ] +} diff --git a/docs/harness-feedback/bundles/2026-07-02-eval-a2a-c2-recovery-keep-observe/attribution.json b/docs/harness-feedback/bundles/2026-07-02-eval-a2a-c2-recovery-keep-observe/attribution.json new file mode 100644 index 0000000000..737496228c --- /dev/null +++ b/docs/harness-feedback/bundles/2026-07-02-eval-a2a-c2-recovery-keep-observe/attribution.json @@ -0,0 +1,11 @@ +{ + "verdictId": "2026-07-02-eval-a2a-c2-recovery-keep-observe", + "featureId": "F167", + "evalSnapshotId": "eval-F167-2026-07-02", + "generatedAt": "2026-07-02T03:01:15.658Z", + "findings": [], + "noFindingRecord": { + "reason": "No friction signals detected across 4 components", + "evidence": "Checked components: L1, C1, C2, route-serial. Friction metrics examined: c1.zombie_hold_count, c1.hold_cancel_count, c2.verdict_without_pass_count, c2.void_hold_hint_emitted. All values within threshold." + } +} diff --git a/docs/harness-feedback/bundles/2026-07-02-eval-a2a-c2-recovery-keep-observe/provenance.json b/docs/harness-feedback/bundles/2026-07-02-eval-a2a-c2-recovery-keep-observe/provenance.json new file mode 100644 index 0000000000..494e896756 --- /dev/null +++ b/docs/harness-feedback/bundles/2026-07-02-eval-a2a-c2-recovery-keep-observe/provenance.json @@ -0,0 +1,19 @@ +{ + "verdictId": "2026-07-02-eval-a2a-c2-recovery-keep-observe", + "rawInputs": [ + { + "path": "docs/harness-feedback/snapshots/2026-07-02-F167-eval.yaml", + "sha256": "43abe5ecaad132b0747fdfbec099c27620c98f9e1f35230e2abc421ebe7c4e0a" + }, + { + "path": "docs/harness-feedback/attributions/2026-07-02-F167-attribution.yaml", + "sha256": "ba6cb0819f8f8a8007e535ac9f5efd278e578890aa3341200934231bb2c09f7f" + } + ], + "generatedAt": "2026-07-02T03:01:15.658Z", + "generator": { + "name": "eval-a2a-live-verdict", + "version": "1" + }, + "sanitizeRulesVersion": "f192-e-pilot-v1" +} diff --git a/docs/harness-feedback/bundles/2026-07-02-eval-a2a-c2-recovery-keep-observe/snapshot.json b/docs/harness-feedback/bundles/2026-07-02-eval-a2a-c2-recovery-keep-observe/snapshot.json new file mode 100644 index 0000000000..533d36c335 --- /dev/null +++ b/docs/harness-feedback/bundles/2026-07-02-eval-a2a-c2-recovery-keep-observe/snapshot.json @@ -0,0 +1,60 @@ +{ + "verdictId": "2026-07-02-eval-a2a-c2-recovery-keep-observe", + "evalSnapshotId": "eval-F167-2026-07-02", + "featureId": "F167", + "generatedAt": "2026-07-02T03:01:15.657Z", + "window": { + "startMs": 1782875127636, + "endMs": 1782961200519, + "durationHours": 23.909134166666668 + }, + "components": [ + { + "id": "L1", + "name": "WorklistRegistry (ping-pong breaker)", + "confidence": "medium", + "activationCounts": { + "l1.streak_warn_count": 0, + "l1.streak_break_count": 0 + }, + "frictionCounts": {} + }, + { + "id": "C1", + "name": "hold_ball (MCP tool)", + "confidence": "medium", + "activationCounts": { + "hold_ball_calls": 0 + }, + "frictionCounts": { + "c1.zombie_hold_count": 0, + "c1.hold_cancel_count": 0 + } + }, + { + "id": "C2", + "name": "exit-check (forced-pass guard)", + "confidence": "medium", + "activationCounts": { + "hint_emitted (mixed routing+verdict)": null, + "c2.verdict_hint_emitted": 0, + "c2.checked": 82, + "c2.void_hold_checked": 82 + }, + "frictionCounts": { + "c2.verdict_without_pass_count": 0, + "c2.void_hold_hint_emitted": 2 + } + }, + { + "id": "route-serial", + "name": "route-serial (A2A handoff routing)", + "confidence": "high", + "activationCounts": { + "inline_action.checked": 82, + "line_start.detected": 35 + }, + "frictionCounts": {} + } + ] +} diff --git a/docs/harness-feedback/bundles/2026-07-03-eval-a2a-c2-recovery-keep-observe/attribution.json b/docs/harness-feedback/bundles/2026-07-03-eval-a2a-c2-recovery-keep-observe/attribution.json new file mode 100644 index 0000000000..aea8100878 --- /dev/null +++ b/docs/harness-feedback/bundles/2026-07-03-eval-a2a-c2-recovery-keep-observe/attribution.json @@ -0,0 +1,11 @@ +{ + "verdictId": "2026-07-03-eval-a2a-c2-recovery-keep-observe", + "featureId": "F167", + "evalSnapshotId": "eval-F167-2026-07-03", + "generatedAt": "2026-07-03T03:01:19.249Z", + "findings": [], + "noFindingRecord": { + "reason": "No friction signals detected across 4 components", + "evidence": "Checked components: L1, C1, C2, route-serial. Friction metrics examined: c1.zombie_hold_count, c1.hold_cancel_count, c2.verdict_without_pass_count, c2.void_hold_hint_emitted, inline_action.routed_set_skip. All values within threshold." + } +} diff --git a/docs/harness-feedback/bundles/2026-07-03-eval-a2a-c2-recovery-keep-observe/provenance.json b/docs/harness-feedback/bundles/2026-07-03-eval-a2a-c2-recovery-keep-observe/provenance.json new file mode 100644 index 0000000000..959bf22fea --- /dev/null +++ b/docs/harness-feedback/bundles/2026-07-03-eval-a2a-c2-recovery-keep-observe/provenance.json @@ -0,0 +1,19 @@ +{ + "verdictId": "2026-07-03-eval-a2a-c2-recovery-keep-observe", + "rawInputs": [ + { + "path": "docs/harness-feedback/snapshots/2026-07-03-F167-eval.yaml", + "sha256": "0dd3b6a0614977bbbf51468efd6c929b72e4678e1213f74a19c0e7ecdfe06515" + }, + { + "path": "docs/harness-feedback/attributions/2026-07-03-F167-attribution.yaml", + "sha256": "98687038c5f0d93c2db98d075343d13bfd89acd4a59af21fe51726d4d664809e" + } + ], + "generatedAt": "2026-07-03T03:01:19.249Z", + "generator": { + "name": "eval-a2a-live-verdict", + "version": "1" + }, + "sanitizeRulesVersion": "f192-e-pilot-v1" +} diff --git a/docs/harness-feedback/bundles/2026-07-03-eval-a2a-c2-recovery-keep-observe/snapshot.json b/docs/harness-feedback/bundles/2026-07-03-eval-a2a-c2-recovery-keep-observe/snapshot.json new file mode 100644 index 0000000000..2dfc72c8fa --- /dev/null +++ b/docs/harness-feedback/bundles/2026-07-03-eval-a2a-c2-recovery-keep-observe/snapshot.json @@ -0,0 +1,62 @@ +{ + "verdictId": "2026-07-03-eval-a2a-c2-recovery-keep-observe", + "evalSnapshotId": "eval-F167-2026-07-03", + "featureId": "F167", + "generatedAt": "2026-07-03T03:01:19.248Z", + "window": { + "startMs": 1782961389561, + "endMs": 1783047600834, + "durationHours": 23.94757583333333 + }, + "components": [ + { + "id": "L1", + "name": "WorklistRegistry (ping-pong breaker)", + "confidence": "medium", + "activationCounts": { + "l1.streak_warn_count": 0, + "l1.streak_break_count": 0 + }, + "frictionCounts": {} + }, + { + "id": "C1", + "name": "hold_ball (MCP tool)", + "confidence": "medium", + "activationCounts": { + "hold_ball_calls": 0 + }, + "frictionCounts": { + "c1.zombie_hold_count": 0, + "c1.hold_cancel_count": 0 + } + }, + { + "id": "C2", + "name": "exit-check (forced-pass guard)", + "confidence": "medium", + "activationCounts": { + "hint_emitted (mixed routing+verdict)": null, + "c2.verdict_hint_emitted": 0, + "c2.checked": 248, + "c2.void_hold_checked": 248 + }, + "frictionCounts": { + "c2.verdict_without_pass_count": 0, + "c2.void_hold_hint_emitted": 3 + } + }, + { + "id": "route-serial", + "name": "route-serial (A2A handoff routing)", + "confidence": "high", + "activationCounts": { + "inline_action.checked": 248, + "line_start.detected": 69 + }, + "frictionCounts": { + "inline_action.routed_set_skip": 1 + } + } + ] +} diff --git a/docs/harness-feedback/bundles/2026-07-04-eval-a2a-ordinary-monitoring-keep-observe/attribution.json b/docs/harness-feedback/bundles/2026-07-04-eval-a2a-ordinary-monitoring-keep-observe/attribution.json new file mode 100644 index 0000000000..33976dc4a5 --- /dev/null +++ b/docs/harness-feedback/bundles/2026-07-04-eval-a2a-ordinary-monitoring-keep-observe/attribution.json @@ -0,0 +1,11 @@ +{ + "verdictId": "2026-07-04-eval-a2a-ordinary-monitoring-keep-observe", + "featureId": "F167", + "evalSnapshotId": "eval-F167-2026-07-04", + "generatedAt": "2026-07-04T03:01:21.890Z", + "findings": [], + "noFindingRecord": { + "reason": "No friction signals detected across 4 components", + "evidence": "Checked components: L1, C1, C2, route-serial. Friction metrics examined: c1.zombie_hold_count, c1.hold_cancel_count, c2.verdict_without_pass_count, c2.void_hold_hint_emitted, inline_action.routed_set_skip, inline_action.feedback_written, inline_action.hint_emitted. All values within threshold." + } +} diff --git a/docs/harness-feedback/bundles/2026-07-04-eval-a2a-ordinary-monitoring-keep-observe/provenance.json b/docs/harness-feedback/bundles/2026-07-04-eval-a2a-ordinary-monitoring-keep-observe/provenance.json new file mode 100644 index 0000000000..9fdedc40dd --- /dev/null +++ b/docs/harness-feedback/bundles/2026-07-04-eval-a2a-ordinary-monitoring-keep-observe/provenance.json @@ -0,0 +1,19 @@ +{ + "verdictId": "2026-07-04-eval-a2a-ordinary-monitoring-keep-observe", + "rawInputs": [ + { + "path": "docs/harness-feedback/snapshots/2026-07-04-F167-eval.yaml", + "sha256": "bad7fec04c43a91e3c8a00e31d891f01cde97be979cb1c0d238a4779114a0dea" + }, + { + "path": "docs/harness-feedback/attributions/2026-07-04-F167-attribution.yaml", + "sha256": "f0b628950fc57105615bc7b879529dce6e6dd202fa58784cd30b5063e8e16d48" + } + ], + "generatedAt": "2026-07-04T03:01:21.890Z", + "generator": { + "name": "eval-a2a-live-verdict", + "version": "1" + }, + "sanitizeRulesVersion": "f192-e-pilot-v1" +} diff --git a/docs/harness-feedback/bundles/2026-07-04-eval-a2a-ordinary-monitoring-keep-observe/snapshot.json b/docs/harness-feedback/bundles/2026-07-04-eval-a2a-ordinary-monitoring-keep-observe/snapshot.json new file mode 100644 index 0000000000..e36a60e56f --- /dev/null +++ b/docs/harness-feedback/bundles/2026-07-04-eval-a2a-ordinary-monitoring-keep-observe/snapshot.json @@ -0,0 +1,65 @@ +{ + "verdictId": "2026-07-04-eval-a2a-ordinary-monitoring-keep-observe", + "evalSnapshotId": "eval-F167-2026-07-04", + "featureId": "F167", + "generatedAt": "2026-07-04T03:01:21.889Z", + "window": { + "startMs": 1783047790674, + "endMs": 1783133999958, + "durationHours": 23.947023333333334 + }, + "components": [ + { + "id": "L1", + "name": "WorklistRegistry (ping-pong breaker)", + "confidence": "medium", + "activationCounts": { + "l1.streak_warn_count": 0, + "l1.streak_break_count": 0 + }, + "frictionCounts": {} + }, + { + "id": "C1", + "name": "hold_ball (MCP tool)", + "confidence": "medium", + "activationCounts": { + "hold_ball_calls": 0 + }, + "frictionCounts": { + "c1.zombie_hold_count": 0, + "c1.hold_cancel_count": 0 + } + }, + { + "id": "C2", + "name": "exit-check (forced-pass guard)", + "confidence": "medium", + "activationCounts": { + "hint_emitted (mixed routing+verdict)": 1, + "c2.verdict_hint_emitted": 0, + "c2.checked": 325, + "c2.void_hold_checked": 325 + }, + "frictionCounts": { + "c2.verdict_without_pass_count": 0, + "c2.void_hold_hint_emitted": 4 + } + }, + { + "id": "route-serial", + "name": "route-serial (A2A handoff routing)", + "confidence": "high", + "activationCounts": { + "inline_action.checked": 325, + "line_start.detected": 90, + "inline_action.detected": 1 + }, + "frictionCounts": { + "inline_action.routed_set_skip": 1, + "inline_action.feedback_written": 1, + "inline_action.hint_emitted": 1 + } + } + ] +} diff --git a/docs/harness-feedback/bundles/2026-07-05-eval-a2a-ordinary-monitoring-keep-observe/attribution.json b/docs/harness-feedback/bundles/2026-07-05-eval-a2a-ordinary-monitoring-keep-observe/attribution.json new file mode 100644 index 0000000000..4e662a6b89 --- /dev/null +++ b/docs/harness-feedback/bundles/2026-07-05-eval-a2a-ordinary-monitoring-keep-observe/attribution.json @@ -0,0 +1,11 @@ +{ + "verdictId": "2026-07-05-eval-a2a-ordinary-monitoring-keep-observe", + "featureId": "F167", + "evalSnapshotId": "eval-F167-2026-07-05", + "generatedAt": "2026-07-05T03:01:22.013Z", + "findings": [], + "noFindingRecord": { + "reason": "No friction signals detected across 4 components", + "evidence": "Checked components: L1, C1, C2, route-serial. Friction metrics examined: c1.zombie_hold_count, c1.hold_cancel_count, c2.verdict_without_pass_count, c2.void_hold_hint_emitted, inline_action.routed_set_skip, inline_action.feedback_written, inline_action.hint_emitted. All values within threshold." + } +} diff --git a/docs/harness-feedback/bundles/2026-07-05-eval-a2a-ordinary-monitoring-keep-observe/provenance.json b/docs/harness-feedback/bundles/2026-07-05-eval-a2a-ordinary-monitoring-keep-observe/provenance.json new file mode 100644 index 0000000000..075c1c1cac --- /dev/null +++ b/docs/harness-feedback/bundles/2026-07-05-eval-a2a-ordinary-monitoring-keep-observe/provenance.json @@ -0,0 +1,19 @@ +{ + "verdictId": "2026-07-05-eval-a2a-ordinary-monitoring-keep-observe", + "rawInputs": [ + { + "path": "docs/harness-feedback/snapshots/2026-07-05-F167-eval.yaml", + "sha256": "6c93fdface9290ec5faefa89f32beb4359eefbdae1643de8f18e5edbd1b9c25d" + }, + { + "path": "docs/harness-feedback/attributions/2026-07-05-F167-attribution.yaml", + "sha256": "34f671ed0da178aac1102eb0eea6fb740416ea2abba097ad69ff7f39874938d5" + } + ], + "generatedAt": "2026-07-05T03:01:22.013Z", + "generator": { + "name": "eval-a2a-live-verdict", + "version": "1" + }, + "sanitizeRulesVersion": "f192-e-pilot-v1" +} diff --git a/docs/harness-feedback/bundles/2026-07-05-eval-a2a-ordinary-monitoring-keep-observe/snapshot.json b/docs/harness-feedback/bundles/2026-07-05-eval-a2a-ordinary-monitoring-keep-observe/snapshot.json new file mode 100644 index 0000000000..96b780b29f --- /dev/null +++ b/docs/harness-feedback/bundles/2026-07-05-eval-a2a-ordinary-monitoring-keep-observe/snapshot.json @@ -0,0 +1,65 @@ +{ + "verdictId": "2026-07-05-eval-a2a-ordinary-monitoring-keep-observe", + "evalSnapshotId": "eval-F167-2026-07-05", + "featureId": "F167", + "generatedAt": "2026-07-05T03:01:22.011Z", + "window": { + "startMs": 1783134191172, + "endMs": 1783220402361, + "durationHours": 23.9475525 + }, + "components": [ + { + "id": "L1", + "name": "WorklistRegistry (ping-pong breaker)", + "confidence": "medium", + "activationCounts": { + "l1.streak_warn_count": 0, + "l1.streak_break_count": 0 + }, + "frictionCounts": {} + }, + { + "id": "C1", + "name": "hold_ball (MCP tool)", + "confidence": "medium", + "activationCounts": { + "hold_ball_calls": 0 + }, + "frictionCounts": { + "c1.zombie_hold_count": 0, + "c1.hold_cancel_count": 0 + } + }, + { + "id": "C2", + "name": "exit-check (forced-pass guard)", + "confidence": "medium", + "activationCounts": { + "hint_emitted (mixed routing+verdict)": 1, + "c2.verdict_hint_emitted": 0, + "c2.checked": 336, + "c2.void_hold_checked": 336 + }, + "frictionCounts": { + "c2.verdict_without_pass_count": 0, + "c2.void_hold_hint_emitted": 5 + } + }, + { + "id": "route-serial", + "name": "route-serial (A2A handoff routing)", + "confidence": "high", + "activationCounts": { + "inline_action.checked": 336, + "line_start.detected": 93, + "inline_action.detected": 1 + }, + "frictionCounts": { + "inline_action.routed_set_skip": 1, + "inline_action.feedback_written": 1, + "inline_action.hint_emitted": 1 + } + } + ] +} diff --git a/docs/harness-feedback/bundles/2026-07-06-eval-a2a-ordinary-monitoring/attribution.json b/docs/harness-feedback/bundles/2026-07-06-eval-a2a-ordinary-monitoring/attribution.json new file mode 100644 index 0000000000..403e2d2733 --- /dev/null +++ b/docs/harness-feedback/bundles/2026-07-06-eval-a2a-ordinary-monitoring/attribution.json @@ -0,0 +1,11 @@ +{ + "verdictId": "2026-07-06-eval-a2a-ordinary-monitoring", + "featureId": "F167", + "evalSnapshotId": "eval-F167-2026-07-06", + "generatedAt": "2026-07-06T03:05:08.538Z", + "findings": [], + "noFindingRecord": { + "reason": "No friction signals detected across 4 components", + "evidence": "Checked components: L1, C1, C2, route-serial. Friction metrics examined: c1.zombie_hold_count, c1.hold_cancel_count, c2.verdict_without_pass_count, c2.void_hold_hint_emitted, inline_action.routed_set_skip, inline_action.feedback_written, inline_action.hint_emitted. All values within threshold." + } +} diff --git a/docs/harness-feedback/bundles/2026-07-06-eval-a2a-ordinary-monitoring/provenance.json b/docs/harness-feedback/bundles/2026-07-06-eval-a2a-ordinary-monitoring/provenance.json new file mode 100644 index 0000000000..838b18f2a8 --- /dev/null +++ b/docs/harness-feedback/bundles/2026-07-06-eval-a2a-ordinary-monitoring/provenance.json @@ -0,0 +1,19 @@ +{ + "verdictId": "2026-07-06-eval-a2a-ordinary-monitoring", + "rawInputs": [ + { + "path": "docs/harness-feedback/snapshots/2026-07-06-F167-eval.yaml", + "sha256": "5f3485c2469cb8c3ee12b5c368d04da7623de308ed077116654e4dee6f4c22be" + }, + { + "path": "docs/harness-feedback/attributions/2026-07-06-F167-attribution.yaml", + "sha256": "3e1602121014c1266bf5dd18275e629f91a00821fd07ee39a0f04bbe38e637fb" + } + ], + "generatedAt": "2026-07-06T03:05:08.538Z", + "generator": { + "name": "eval-a2a-live-verdict", + "version": "1" + }, + "sanitizeRulesVersion": "f192-e-pilot-v1" +} diff --git a/docs/harness-feedback/bundles/2026-07-06-eval-a2a-ordinary-monitoring/snapshot.json b/docs/harness-feedback/bundles/2026-07-06-eval-a2a-ordinary-monitoring/snapshot.json new file mode 100644 index 0000000000..d17ca7bb9c --- /dev/null +++ b/docs/harness-feedback/bundles/2026-07-06-eval-a2a-ordinary-monitoring/snapshot.json @@ -0,0 +1,65 @@ +{ + "verdictId": "2026-07-06-eval-a2a-ordinary-monitoring", + "evalSnapshotId": "eval-F167-2026-07-06", + "featureId": "F167", + "generatedAt": "2026-07-06T03:05:08.536Z", + "window": { + "startMs": 1783220711821, + "endMs": 1783306899101, + "durationHours": 23.94091111111111 + }, + "components": [ + { + "id": "L1", + "name": "WorklistRegistry (ping-pong breaker)", + "confidence": "medium", + "activationCounts": { + "l1.streak_warn_count": 0, + "l1.streak_break_count": 0 + }, + "frictionCounts": {} + }, + { + "id": "C1", + "name": "hold_ball (MCP tool)", + "confidence": "medium", + "activationCounts": { + "hold_ball_calls": 0 + }, + "frictionCounts": { + "c1.zombie_hold_count": 0, + "c1.hold_cancel_count": 0 + } + }, + { + "id": "C2", + "name": "exit-check (forced-pass guard)", + "confidence": "medium", + "activationCounts": { + "hint_emitted (mixed routing+verdict)": 1, + "c2.verdict_hint_emitted": 0, + "c2.checked": 339, + "c2.void_hold_checked": 339 + }, + "frictionCounts": { + "c2.verdict_without_pass_count": 0, + "c2.void_hold_hint_emitted": 6 + } + }, + { + "id": "route-serial", + "name": "route-serial (A2A handoff routing)", + "confidence": "high", + "activationCounts": { + "inline_action.checked": 339, + "line_start.detected": 94, + "inline_action.detected": 1 + }, + "frictionCounts": { + "inline_action.routed_set_skip": 1, + "inline_action.feedback_written": 1, + "inline_action.hint_emitted": 1 + } + } + ] +} diff --git a/docs/harness-feedback/bundles/2026-07-07-eval-a2a-stable-void-hold-baseline/attribution.json b/docs/harness-feedback/bundles/2026-07-07-eval-a2a-stable-void-hold-baseline/attribution.json new file mode 100644 index 0000000000..a7c8336265 --- /dev/null +++ b/docs/harness-feedback/bundles/2026-07-07-eval-a2a-stable-void-hold-baseline/attribution.json @@ -0,0 +1,11 @@ +{ + "verdictId": "2026-07-07-eval-a2a-stable-void-hold-baseline", + "featureId": "F167", + "evalSnapshotId": "eval-F167-2026-07-07", + "generatedAt": "2026-07-07T03:01:28.927Z", + "findings": [], + "noFindingRecord": { + "reason": "No friction signals detected across 4 components", + "evidence": "Checked components: L1, C1, C2, route-serial. Friction metrics examined: c1.zombie_hold_count, c1.hold_cancel_count, c2.verdict_without_pass_count, c2.void_hold_hint_emitted, inline_action.routed_set_skip, inline_action.feedback_written, inline_action.hint_emitted. All values within threshold." + } +} diff --git a/docs/harness-feedback/bundles/2026-07-07-eval-a2a-stable-void-hold-baseline/provenance.json b/docs/harness-feedback/bundles/2026-07-07-eval-a2a-stable-void-hold-baseline/provenance.json new file mode 100644 index 0000000000..8361ba9560 --- /dev/null +++ b/docs/harness-feedback/bundles/2026-07-07-eval-a2a-stable-void-hold-baseline/provenance.json @@ -0,0 +1,19 @@ +{ + "verdictId": "2026-07-07-eval-a2a-stable-void-hold-baseline", + "rawInputs": [ + { + "path": "docs/harness-feedback/snapshots/2026-07-07-F167-eval.yaml", + "sha256": "994093120156e5b39845aa9830caaa635582d3eace4158dc9fdd48ed33ec3b65" + }, + { + "path": "docs/harness-feedback/attributions/2026-07-07-F167-attribution.yaml", + "sha256": "20690818bd295ccaa96d14aeac547302fbef55735d0160c1e728fc3290c21566" + } + ], + "generatedAt": "2026-07-07T03:01:28.927Z", + "generator": { + "name": "eval-a2a-live-verdict", + "version": "1" + }, + "sanitizeRulesVersion": "f192-e-pilot-v1" +} diff --git a/docs/harness-feedback/bundles/2026-07-07-eval-a2a-stable-void-hold-baseline/snapshot.json b/docs/harness-feedback/bundles/2026-07-07-eval-a2a-stable-void-hold-baseline/snapshot.json new file mode 100644 index 0000000000..39cb1a92b2 --- /dev/null +++ b/docs/harness-feedback/bundles/2026-07-07-eval-a2a-stable-void-hold-baseline/snapshot.json @@ -0,0 +1,65 @@ +{ + "verdictId": "2026-07-07-eval-a2a-stable-void-hold-baseline", + "evalSnapshotId": "eval-F167-2026-07-07", + "featureId": "F167", + "generatedAt": "2026-07-07T03:01:28.925Z", + "window": { + "startMs": 1783306899101, + "endMs": 1783393200417, + "durationHours": 23.97258777777778 + }, + "components": [ + { + "id": "L1", + "name": "WorklistRegistry (ping-pong breaker)", + "confidence": "medium", + "activationCounts": { + "l1.streak_warn_count": 0, + "l1.streak_break_count": 0 + }, + "frictionCounts": {} + }, + { + "id": "C1", + "name": "hold_ball (MCP tool)", + "confidence": "medium", + "activationCounts": { + "hold_ball_calls": 0 + }, + "frictionCounts": { + "c1.zombie_hold_count": 0, + "c1.hold_cancel_count": 0 + } + }, + { + "id": "C2", + "name": "exit-check (forced-pass guard)", + "confidence": "medium", + "activationCounts": { + "hint_emitted (mixed routing+verdict)": 2, + "c2.verdict_hint_emitted": 0, + "c2.checked": 364, + "c2.void_hold_checked": 364 + }, + "frictionCounts": { + "c2.verdict_without_pass_count": 0, + "c2.void_hold_hint_emitted": 8 + } + }, + { + "id": "route-serial", + "name": "route-serial (A2A handoff routing)", + "confidence": "high", + "activationCounts": { + "inline_action.checked": 364, + "line_start.detected": 99, + "inline_action.detected": 2 + }, + "frictionCounts": { + "inline_action.routed_set_skip": 1, + "inline_action.feedback_written": 2, + "inline_action.hint_emitted": 2 + } + } + ] +} diff --git a/docs/harness-feedback/verdicts/2026-06-27-eval-a2a-clean-keep-observe.md b/docs/harness-feedback/verdicts/2026-06-27-eval-a2a-clean-keep-observe.md new file mode 100644 index 0000000000..2636942ee4 --- /dev/null +++ b/docs/harness-feedback/verdicts/2026-06-27-eval-a2a-clean-keep-observe.md @@ -0,0 +1,38 @@ +--- +feature_ids: [F192, F167] +topics: [harness-eval, eval-a2a, live-verdict] +doc_kind: harness-feedback +feedback_type: live-verdict +domain_id: eval:a2a +packet_id: 2026-06-27-eval-a2a-clean-keep-observe +source_snapshot: "snapshot:bundle/2026-06-27-eval-a2a-clean-keep-observe/snapshot" +--- + +# Live Verdict — 2026-06-27-eval-a2a-clean-keep-observe + +- Verdict: `keep_observe` +- Phenomenon: Current eval:a2a window is clean across F167 A2A harness components: C2 verdict-without-pass is 0/210, C2 void-hold is 1/216, and route-serial inline hinting is 1/216. The prior 2026-06-23 raw regression (C2 verdict-without-pass 13/84) is not present in today's 23.96h window. +- Harness: F167/a2a-harness (A2A chain quality harness (L1/C1/C2/route-serial)) +- Owner ask: No immediate code action. Keep the daily eval:a2a schedule active and watch the next 72h for recurrence of C2 verdict-without-pass or C1 zombie-hold above threshold. +- Re-eval: Next eval remains below threshold: c2.verdict_without_pass_count < 3 or <5%, c2.void_hold_hint_emitted <3 or <5%, and c1.zombie_hold_count does not repeat above threshold. at 2026-06-30T03:03:40.908Z + +Evidence: +- snapshot:bundle/2026-06-27-eval-a2a-clean-keep-observe/snapshot +- attribution:bundle/2026-06-27-eval-a2a-clean-keep-observe/eval-F167-2026-06-27:no-finding +- metric:c1.zombie_hold_count +- metric:c1.hold_cancel_count +- metric:c2.verdict_without_pass_count +- metric:c2.checked +- metric:c2.void_hold_hint_emitted +- metric:c2.void_hold_checked +- metric:inline_action.shadow_miss +- metric:inline_action.hint_emitted +- metric:inline_action.checked +- trace-store:spanCount=1053:oldest=1782443055946:newest=1782529323956 +- C1/c1.zombie_hold_count/3f3d2e619454f32f +- no-finding:Checked components L1,C1,C2,route-serial; all friction values within threshold + +Counterarguments: +- The single C1 zombie-hold sample means the window is clean by threshold, not perfectly empty. +- The 2026-06-23 raw regression showed a real C2 spike; one clean day is not enough to delete or sunset the harness. +- Legacy scheduled task IDs are empty in eval-a2a.yaml, so duplicate legacy scheduling is not a plausible cause of today's telemetry. diff --git a/docs/harness-feedback/verdicts/2026-06-29-eval-a2a-low-volume-clean-keep-observe.md b/docs/harness-feedback/verdicts/2026-06-29-eval-a2a-low-volume-clean-keep-observe.md new file mode 100644 index 0000000000..b9573ef944 --- /dev/null +++ b/docs/harness-feedback/verdicts/2026-06-29-eval-a2a-low-volume-clean-keep-observe.md @@ -0,0 +1,36 @@ +--- +feature_ids: [F192, F167] +topics: [harness-eval, eval-a2a, live-verdict] +doc_kind: harness-feedback +feedback_type: live-verdict +domain_id: eval:a2a +packet_id: 2026-06-29-eval-a2a-low-volume-clean-keep-observe +source_snapshot: "snapshot:bundle/2026-06-29-eval-a2a-low-volume-clean-keep-observe/snapshot" +--- + +# Live Verdict — 2026-06-29-eval-a2a-low-volume-clean-keep-observe + +- Verdict: `keep_observe` +- Phenomenon: Current eval:a2a window has no actionable F167 A2A findings: C2 verdict-without-pass is 0/3, C2 void-hold is 0/3, C1 zombie-hold is 0, and route-serial reports no inline-action friction. This continues the recovery seen on 2026-06-27, but today's C2 denominator is only 3 checks after the service restart window. +- Harness: F167/a2a-harness (A2A chain quality harness (L1/C1/C2/route-serial)) +- Owner ask: No code action today. Keep the daily eval:a2a schedule active and require the next window to have either sufficient C2 volume or another clean low-volume observation before treating the C2 regression as closed. +- Re-eval: Next eval remains below threshold: c2.verdict_without_pass_count <3 or <5%, c2.void_hold_hint_emitted <3 or <5%, and either c2.checked >=10 or the packet explicitly records low-volume confidence. at 2026-07-02T03:01:39.026Z + +Evidence: +- snapshot:bundle/2026-06-29-eval-a2a-low-volume-clean-keep-observe/snapshot +- attribution:bundle/2026-06-29-eval-a2a-low-volume-clean-keep-observe/eval-F167-2026-06-29:no-finding +- metric:c1.zombie_hold_count +- metric:c1.hold_cancel_count +- metric:c2.verdict_without_pass_count +- metric:c2.checked +- metric:c2.void_hold_hint_emitted +- metric:c2.void_hold_checked +- metric:inline_action.checked +- trace-store:spanCount=362:oldest=1782621958042:newest=1782702000065 +- low-denominator:c2.checked=3:c2.void_hold_checked=3:inline_action.checked=3 +- no-finding:Checked components L1,C1,C2,route-serial; all friction values within threshold + +Counterarguments: +- C2 checked only 3 turns in the current window, so zero findings is weaker evidence than the 2026-06-27 high-volume clean window. +- The service restarted during the longitudinal sequence, which may reduce comparable volume and hide rare routing failures. +- Legacy scheduled task IDs are empty in eval-a2a.yaml, so duplicate legacy scheduling is not a plausible explanation for low volume or clean results. diff --git a/docs/harness-feedback/verdicts/2026-06-30-eval-a2a-c2-recovery-keep-observe.md b/docs/harness-feedback/verdicts/2026-06-30-eval-a2a-c2-recovery-keep-observe.md new file mode 100644 index 0000000000..f0e58b8116 --- /dev/null +++ b/docs/harness-feedback/verdicts/2026-06-30-eval-a2a-c2-recovery-keep-observe.md @@ -0,0 +1,37 @@ +--- +feature_ids: [F192, F167] +topics: [harness-eval, eval-a2a, live-verdict] +doc_kind: harness-feedback +feedback_type: live-verdict +domain_id: eval:a2a +packet_id: 2026-06-30-eval-a2a-c2-recovery-keep-observe +source_snapshot: "snapshot:bundle/2026-06-30-eval-a2a-c2-recovery-keep-observe/snapshot" +--- + +# Live Verdict — 2026-06-30-eval-a2a-c2-recovery-keep-observe + +- Verdict: `keep_observe` +- Phenomenon: Current eval:a2a window has no actionable F167 A2A findings: C2 verdict-without-pass is 0/22 and C2 void-hold is 1/22 (4.5%), below the 5% reporting floor. This gives a second meaningful clean C2 window after the 2026-06-27 high-volume clean run, while 2026-06-29 remains low-volume context rather than closure evidence. +- Harness: F167/a2a-harness (A2A chain quality harness (L1/C1/C2/route-serial)) +- Owner ask: No code action today. Keep daily eval:a2a active; if the next meaningful-volume window remains below threshold, treat the 6/17-6/23 C2 regression as recovered and continue ordinary observation. +- Re-eval: Next eval remains below threshold with c2.verdict_without_pass_count <3 or <5%, c2.void_hold_hint_emitted <3 or <5%, and c2.checked >=10; otherwise record low-volume confidence instead of closure. at 2026-07-03T03:01:31.058Z + +Evidence: +- snapshot:bundle/2026-06-30-eval-a2a-c2-recovery-keep-observe/snapshot +- attribution:bundle/2026-06-30-eval-a2a-c2-recovery-keep-observe/eval-F167-2026-06-30:no-finding +- metric:c1.zombie_hold_count +- metric:c1.hold_cancel_count +- metric:c2.verdict_without_pass_count +- metric:c2.checked +- metric:c2.void_hold_hint_emitted +- metric:c2.void_hold_checked +- metric:inline_action.checked +- metric:line_start.detected +- trace-store:spanCount=120:oldest=1782702183811:newest=1782788400451 +- C2/c2.void_hold_hint_emitted/7cf86a3aa764a337 +- no-finding:Checked components L1,C1,C2,route-serial; all friction values within threshold + +Counterarguments: +- Today has sufficient but not high C2 volume (22 checks), so confidence is medium rather than high. +- There is still one C2 void-hold sample at span 7cf86a3aa764a337, below threshold but worth watching for clustering. +- Legacy scheduled task IDs are empty in eval-a2a.yaml, so duplicate legacy scheduling is not driving the observed recovery or remaining hint. diff --git a/docs/harness-feedback/verdicts/2026-07-01-eval-a2a-c2-recovery-keep-observe.md b/docs/harness-feedback/verdicts/2026-07-01-eval-a2a-c2-recovery-keep-observe.md new file mode 100644 index 0000000000..ee759532b0 --- /dev/null +++ b/docs/harness-feedback/verdicts/2026-07-01-eval-a2a-c2-recovery-keep-observe.md @@ -0,0 +1,39 @@ +--- +feature_ids: [F192, F167] +topics: [harness-eval, eval-a2a, live-verdict] +doc_kind: harness-feedback +feedback_type: live-verdict +domain_id: eval:a2a +packet_id: 2026-07-01-eval-a2a-c2-recovery-keep-observe +source_snapshot: "snapshot:bundle/2026-07-01-eval-a2a-c2-recovery-keep-observe/snapshot" +--- + +# Live Verdict — 2026-07-01-eval-a2a-c2-recovery-keep-observe + +- Verdict: `keep_observe` +- Phenomenon: Current eval:a2a window has no actionable F167 A2A findings: C2 verdict-without-pass is 0/78 and C2 void-hold is 2/78 (2.6%), below both the count threshold and the 5% reporting floor. This is a second consecutive meaningful-volume window (6/30 n=22, 7/1 n=78) with void-hold point estimate at or below the floor, so the 6/17-6/23 C2 regression is behaving as recovered while still needing ordinary observation. +- Harness: F167/a2a-harness (A2A chain quality harness (L1/C1/C2/route-serial)) +- Owner ask: No code action today. Treat the 6/17-6/23 C2 regression as recovered for now under ordinary monitoring; reopen as actionable only if a future meaningful-volume window crosses both count and ratio thresholds, or if void-hold samples cluster by trigger. +- Re-eval: Continue ordinary observation. Do not call closure on n<20 windows; reopen recovery watch if c2.verdict_without_pass_count >=3 and >=5%, or c2.void_hold_hint_emitted >=3 and >=5%, in a meaningful-volume window. Track partial sample coverage for void-hold until 2/2+ sampled or no fires occur. at 2026-07-04T03:02:25.273Z + +Evidence: +- snapshot:bundle/2026-07-01-eval-a2a-c2-recovery-keep-observe/snapshot +- attribution:bundle/2026-07-01-eval-a2a-c2-recovery-keep-observe/eval-F167-2026-07-01:no-finding +- metric:c1.zombie_hold_count +- metric:c1.hold_cancel_count +- metric:c2.verdict_without_pass_count +- metric:c2.checked +- metric:c2.void_hold_hint_emitted +- metric:c2.void_hold_checked +- metric:inline_action.checked +- metric:line_start.detected +- trace-store:spanCount=217:oldest=1782788635107:newest=1782874800472 +- C2/c2.void_hold_hint_emitted/657fefe9a44a1e09 +- sampleCoverage:c2.void_hold_hint_emitted=1/2 sampled +- no-finding:Checked components L1,C1,C2,route-serial; all friction values within threshold + +Counterarguments: +- The current void-hold point estimate is below floor, but Wilson 95% high is about 8.9%; this is a reporting-rule pass, not proof the true rate is under 5%. +- Only 1 of 2 current void-hold fires has per-fire sample evidence, so one fire cannot be trigger-classified from the artifact alone. +- The 2026-06-29 clean window had only 3 C2 checks and remains low-volume context, not a recovery confirmation point. +- The publisher infra fix is being exercised successfully by daily verdict publication, but that acceptance signal is separate from C2 harness behavior. diff --git a/docs/harness-feedback/verdicts/2026-07-02-eval-a2a-c2-recovery-keep-observe.md b/docs/harness-feedback/verdicts/2026-07-02-eval-a2a-c2-recovery-keep-observe.md new file mode 100644 index 0000000000..3197b1db5d --- /dev/null +++ b/docs/harness-feedback/verdicts/2026-07-02-eval-a2a-c2-recovery-keep-observe.md @@ -0,0 +1,38 @@ +--- +feature_ids: [F192, F167] +topics: [harness-eval, eval-a2a, live-verdict] +doc_kind: harness-feedback +feedback_type: live-verdict +domain_id: eval:a2a +packet_id: 2026-07-02-eval-a2a-c2-recovery-keep-observe +source_snapshot: "snapshot:bundle/2026-07-02-eval-a2a-c2-recovery-keep-observe/snapshot" +--- + +# Live Verdict — 2026-07-02-eval-a2a-c2-recovery-keep-observe + +- Verdict: `keep_observe` +- Phenomenon: Current eval:a2a window has no actionable F167 A2A findings: C2 verdict-without-pass is 0/82 and C2 void-hold is 2/82 (2.4%), below both the count threshold and the 5% reporting floor. This is another meaningful-volume clean window after 6/27 and 7/1, so the 6/17-6/23 C2 regression remains recovered under ordinary monitoring, with sample coverage still incomplete for today’s void-hold fires. +- Harness: F167/a2a-harness (A2A chain quality harness (L1/C1/C2/route-serial)) +- Owner ask: No code action today. Keep daily eval:a2a in ordinary monitoring; reopen as actionable only if a future meaningful-volume window crosses both count and ratio thresholds, or if void-hold samples cluster by trigger once sample coverage returns. +- Re-eval: Continue ordinary observation. Do not call closure on n<20 windows; reopen recovery watch if c2.verdict_without_pass_count >=3 and >=5%, or c2.void_hold_hint_emitted >=3 and >=5%, in a meaningful-volume window. Also watch whether void-hold per-fire sample coverage recovers from today’s 0/2. at 2026-07-05T03:01:15.658Z + +Evidence: +- snapshot:bundle/2026-07-02-eval-a2a-c2-recovery-keep-observe/snapshot +- attribution:bundle/2026-07-02-eval-a2a-c2-recovery-keep-observe/eval-F167-2026-07-02:no-finding +- metric:c1.zombie_hold_count +- metric:c1.hold_cancel_count +- metric:c2.verdict_without_pass_count +- metric:c2.checked +- metric:c2.void_hold_hint_emitted +- metric:c2.void_hold_checked +- metric:inline_action.checked +- metric:line_start.detected +- trace-store:spanCount=26:oldest=1782875127636:newest=1782961200519 +- sampleCoverage:c2.void_hold_hint_emitted=0/2 sampled +- no-finding:Checked components L1,C1,C2,route-serial; all friction values within threshold + +Counterarguments: +- The current void-hold point estimate is below floor, but Wilson 95% high is about 8.5%; this is a reporting-rule pass, not proof the true rate is under 5%. +- Current trace sample coverage is 0/2 for void-hold fires, so today cannot classify the triggers behind those two counter increments. +- The 2026-06-29 clean window had only 3 C2 checks and remains low-volume context, not a recovery confirmation point. +- The publisher infra fix continues to exercise cleanly through daily publish, but that acceptance signal is separate from C2 harness behavior. diff --git a/docs/harness-feedback/verdicts/2026-07-03-eval-a2a-c2-recovery-keep-observe.md b/docs/harness-feedback/verdicts/2026-07-03-eval-a2a-c2-recovery-keep-observe.md new file mode 100644 index 0000000000..a917f745ed --- /dev/null +++ b/docs/harness-feedback/verdicts/2026-07-03-eval-a2a-c2-recovery-keep-observe.md @@ -0,0 +1,40 @@ +--- +feature_ids: [F192, F167] +topics: [harness-eval, eval-a2a, live-verdict] +doc_kind: harness-feedback +feedback_type: live-verdict +domain_id: eval:a2a +packet_id: 2026-07-03-eval-a2a-c2-recovery-keep-observe +source_snapshot: "snapshot:bundle/2026-07-03-eval-a2a-c2-recovery-keep-observe/snapshot" +--- + +# Live Verdict — 2026-07-03-eval-a2a-c2-recovery-keep-observe + +- Verdict: `keep_observe` +- Phenomenon: Current eval:a2a window has no actionable F167 A2A findings in a high-volume sample: C2 verdict-without-pass is 0/248 and C2 void-hold is 3/248 (1.2%), below the 5% reporting floor with a Wilson 95% upper bound around 3.5%. This confirms the 6/17-6/23 C2 regression is recovered under ordinary monitoring, while per-fire sample coverage for void-hold remains incomplete. +- Harness: F167/a2a-harness (A2A chain quality harness (L1/C1/C2/route-serial)) +- Owner ask: No code action today. Treat the 6/17-6/23 C2 regression as recovered and keep eval:a2a in ordinary daily monitoring; reopen as actionable only if a future meaningful-volume window crosses both count and ratio thresholds or shows trigger clustering once sample coverage is available. +- Re-eval: Ordinary monitoring: keep reporting daily, but do not keep the 6/17-6/23 C2 regression in active recovery watch unless c2.verdict_without_pass_count >=3 and >=5%, or c2.void_hold_hint_emitted >=3 and >=5%, in a meaningful-volume window. Continue tracking void-hold per-fire sample coverage until it returns to complete coverage or no fires occur. at 2026-07-06T03:01:19.249Z + +Evidence: +- snapshot:bundle/2026-07-03-eval-a2a-c2-recovery-keep-observe/snapshot +- attribution:bundle/2026-07-03-eval-a2a-c2-recovery-keep-observe/eval-F167-2026-07-03:no-finding +- metric:c1.zombie_hold_count +- metric:c1.hold_cancel_count +- metric:c2.verdict_without_pass_count +- metric:c2.checked +- metric:c2.void_hold_hint_emitted +- metric:c2.void_hold_checked +- metric:inline_action.checked +- metric:line_start.detected +- metric:inline_action.routed_set_skip +- trace-store:spanCount=884:oldest=1782961389561:newest=1783047600834 +- C2/c2.void_hold_hint_emitted/d7e430853150c684 +- sampleCoverage:c2.void_hold_hint_emitted=1/3 sampled +- no-finding:Checked components L1,C1,C2,route-serial; all friction values within threshold + +Counterarguments: +- Void-hold count reached 3 today, so count-only logic would look noisy; the ratio and Wilson interval keep it below the reporting floor. +- Current void-hold sample coverage is 1/3, so two fires cannot be trigger-classified from the artifact alone. +- There is one route-serial routed_set_skip counter, below threshold but still a background friction signal. +- The 2026-06-29 clean window had only 3 C2 checks and should remain low-volume context, not a recovery confirmation point. diff --git a/docs/harness-feedback/verdicts/2026-07-04-eval-a2a-ordinary-monitoring-keep-observe.md b/docs/harness-feedback/verdicts/2026-07-04-eval-a2a-ordinary-monitoring-keep-observe.md new file mode 100644 index 0000000000..db3f7d6477 --- /dev/null +++ b/docs/harness-feedback/verdicts/2026-07-04-eval-a2a-ordinary-monitoring-keep-observe.md @@ -0,0 +1,42 @@ +--- +feature_ids: [F192, F167] +topics: [harness-eval, eval-a2a, live-verdict] +doc_kind: harness-feedback +feedback_type: live-verdict +domain_id: eval:a2a +packet_id: 2026-07-04-eval-a2a-ordinary-monitoring-keep-observe +source_snapshot: "snapshot:bundle/2026-07-04-eval-a2a-ordinary-monitoring-keep-observe/snapshot" +--- + +# Live Verdict — 2026-07-04-eval-a2a-ordinary-monitoring-keep-observe + +- Verdict: `keep_observe` +- Phenomenon: Current eval:a2a window has no actionable F167 A2A findings in a high-volume sample: C2 verdict-without-pass is 0/325 and C2 void-hold is 4/325 (1.2%), with a Wilson 95% upper bound around 3.1%. The 6/17-6/23 C2 regression remains recovered under ordinary monitoring; attribution sample coverage is tracked separately from A2A chain quality. +- Harness: F167/a2a-harness (A2A chain quality harness (L1/C1/C2/route-serial)) +- Owner ask: No A2A code action today. Keep eval:a2a in ordinary daily monitoring; treat per-fire sample coverage as a separate attribution-completeness concern and only re-open A2A recovery watch if a future meaningful-volume window crosses both count and ratio thresholds. +- Re-eval: Ordinary monitoring: continue daily reporting, but keep the 6/17-6/23 C2 regression closed unless c2.verdict_without_pass_count >=3 and >=5%, or c2.void_hold_hint_emitted >=3 and >=5%, in a meaningful-volume window. If void-hold per-fire sample coverage stays incomplete across future clean rounds, handle it as separate attribution coverage work rather than an A2A recovery blocker. at 2026-07-07T03:01:21.890Z + +Evidence: +- snapshot:bundle/2026-07-04-eval-a2a-ordinary-monitoring-keep-observe/snapshot +- attribution:bundle/2026-07-04-eval-a2a-ordinary-monitoring-keep-observe/eval-F167-2026-07-04:no-finding +- metric:c1.zombie_hold_count +- metric:c1.hold_cancel_count +- metric:c2.verdict_without_pass_count +- metric:c2.checked +- metric:c2.void_hold_hint_emitted +- metric:c2.void_hold_checked +- metric:inline_action.checked +- metric:line_start.detected +- metric:inline_action.routed_set_skip +- metric:inline_action.feedback_written +- metric:inline_action.hint_emitted +- trace-store:spanCount=363:oldest=1783047790674:newest=1783133999958 +- C2/c2.void_hold_hint_emitted/2fb113efec638003 +- sampleCoverage:c2.void_hold_hint_emitted=1/4 sampled +- no-finding:Checked components L1,C1,C2,route-serial; all friction values within threshold + +Counterarguments: +- Void-hold count is 4 today, so count-only logic would look noisy; the ratio and Wilson interval remain comfortably below the 5% floor. +- Current void-hold sample coverage is 1/4, so three fires cannot be trigger-classified from the artifact alone. +- Route-serial has three one-count background friction counters, all below threshold but worth retaining in telemetry. +- The persistent sample-coverage caveat should not keep A2A recovery at medium confidence; it is a separate attribution-completeness concern. diff --git a/docs/harness-feedback/verdicts/2026-07-05-eval-a2a-ordinary-monitoring-keep-observe.md b/docs/harness-feedback/verdicts/2026-07-05-eval-a2a-ordinary-monitoring-keep-observe.md new file mode 100644 index 0000000000..37ace19c97 --- /dev/null +++ b/docs/harness-feedback/verdicts/2026-07-05-eval-a2a-ordinary-monitoring-keep-observe.md @@ -0,0 +1,42 @@ +--- +feature_ids: [F192, F167] +topics: [harness-eval, eval-a2a, live-verdict] +doc_kind: harness-feedback +feedback_type: live-verdict +domain_id: eval:a2a +packet_id: 2026-07-05-eval-a2a-ordinary-monitoring-keep-observe +source_snapshot: "snapshot:bundle/2026-07-05-eval-a2a-ordinary-monitoring-keep-observe/snapshot" +--- + +# Live Verdict — 2026-07-05-eval-a2a-ordinary-monitoring-keep-observe + +- Verdict: `keep_observe` +- Phenomenon: Current eval:a2a window has no actionable F167 A2A findings: C2 verdict-without-pass is 0/336 and C2 void-hold is 5/336 (1.5%), with a Wilson 95% upper bound around 3.4%. The 6/17-6/23 C2 regression remains recovered under ordinary monitoring; recurring incomplete per-fire sample coverage is an attribution-completeness concern rather than A2A chain-quality regression evidence. +- Harness: F167/a2a-harness (A2A chain quality harness (L1/C1/C2/route-serial)) +- Owner ask: No A2A code action today. Keep eval:a2a in ordinary monitoring; if per-fire sample coverage remains incomplete in future clean rounds, split that into a separate F167 attribution-completeness work item rather than treating it as an A2A recovery blocker. +- Re-eval: Ordinary monitoring: keep the 6/17-6/23 C2 regression closed unless c2.verdict_without_pass_count >=3 and >=5%, or c2.void_hold_hint_emitted >=3 and >=5%, in a meaningful-volume window. Consider weekly cadence only after registry/SLA cadence is explicitly updated; until then keep the current scheduled eval contract. at 2026-07-08T03:01:22.013Z + +Evidence: +- snapshot:bundle/2026-07-05-eval-a2a-ordinary-monitoring-keep-observe/snapshot +- attribution:bundle/2026-07-05-eval-a2a-ordinary-monitoring-keep-observe/eval-F167-2026-07-05:no-finding +- metric:c1.zombie_hold_count +- metric:c1.hold_cancel_count +- metric:c2.verdict_without_pass_count +- metric:c2.checked +- metric:c2.void_hold_hint_emitted +- metric:c2.void_hold_checked +- metric:inline_action.checked +- metric:line_start.detected +- metric:inline_action.routed_set_skip +- metric:inline_action.feedback_written +- metric:inline_action.hint_emitted +- trace-store:spanCount=48:oldest=1783134191172:newest=1783220402361 +- C2/c2.void_hold_hint_emitted/b2b0eff1fefe4332 +- sampleCoverage:c2.void_hold_hint_emitted=1/5 sampled +- no-finding:Checked components L1,C1,C2,route-serial; all friction values within threshold + +Counterarguments: +- Void-hold count is 5 today, but the point estimate is 1.5% and Wilson 95% high is about 3.4%, below the 5% floor. +- Current void-hold sample coverage is 1/5, so four fires cannot be trigger-classified from the artifact alone. +- Trace spanCount is low relative to counter volume, which reinforces that sample coverage is an observability concern separate from counter-based A2A recovery. +- Daily cadence may now be higher than needed for ordinary monitoring, but the registry/SLA still advertises a daily domain with 72h reeval expectation. diff --git a/docs/harness-feedback/verdicts/2026-07-06-eval-a2a-ordinary-monitoring.md b/docs/harness-feedback/verdicts/2026-07-06-eval-a2a-ordinary-monitoring.md new file mode 100644 index 0000000000..65302815b0 --- /dev/null +++ b/docs/harness-feedback/verdicts/2026-07-06-eval-a2a-ordinary-monitoring.md @@ -0,0 +1,34 @@ +--- +feature_ids: [F192, F167] +topics: [harness-eval, eval-a2a, live-verdict] +doc_kind: harness-feedback +feedback_type: live-verdict +domain_id: eval:a2a +packet_id: 2026-07-06-eval-a2a-ordinary-monitoring +source_snapshot: "snapshot:bundle/2026-07-06-eval-a2a-ordinary-monitoring/snapshot" +--- + +# Live Verdict — 2026-07-06-eval-a2a-ordinary-monitoring + +- Verdict: `keep_observe` +- Phenomenon: No actionable A2A findings in the 23.94h runtime window: C2 forced-pass remains 0/339 and void-hold is 6/339. The void-hold rate is still below the 5% floor, while per-fire sample coverage remains incomplete at 1/6. +- Harness: F167/C2 (exit-check (forced-pass guard)) +- Owner ask: No F167 code action for this verdict; keep ordinary monitoring. If void-hold sample coverage remains 1/N through the 2026-07-08 eval, promote it as a separate tracer coverage work item rather than treating it as an A2A recovery regression. +- Re-eval: Continue ordinary monitoring while C2 verdict_without_pass remains zero and void-hold ratio/Wilson upper bound stay below the 5% floor; split sample coverage into a separate tracer task only if the 1/N pattern persists through 2026-07-08. at 2026-07-09T03:05:08.538Z + +Evidence: +- snapshot:bundle/2026-07-06-eval-a2a-ordinary-monitoring/snapshot +- attribution:bundle/2026-07-06-eval-a2a-ordinary-monitoring/eval-F167-2026-07-06:no-finding +- metric:c2.verdict_without_pass_count +- metric:c2.checked +- metric:c2.void_hold_hint_emitted +- metric:c2.void_hold_checked +- metric:inline_action.routed_set_skip +- metric:inline_action.feedback_written +- metric:inline_action.hint_emitted +- C2/c2.void_hold_hint_emitted/407f783a3827580a + +Counterarguments: +- Low trace sample retention (24 spans) means the raw sampleTraceRefs do not fully represent all six void-hold emissions. +- The domain registry still schedules daily evals; packet nextEvalAt follows the 72h SLA and does not by itself change scheduler cadence. +- A rising void-hold count could become actionable if the ratio or Wilson upper bound crosses the 5% floor in a later window. diff --git a/docs/harness-feedback/verdicts/2026-07-07-eval-a2a-stable-void-hold-baseline.md b/docs/harness-feedback/verdicts/2026-07-07-eval-a2a-stable-void-hold-baseline.md new file mode 100644 index 0000000000..dd2faff480 --- /dev/null +++ b/docs/harness-feedback/verdicts/2026-07-07-eval-a2a-stable-void-hold-baseline.md @@ -0,0 +1,35 @@ +--- +feature_ids: [F192, F167] +topics: [harness-eval, eval-a2a, live-verdict] +doc_kind: harness-feedback +feedback_type: live-verdict +domain_id: eval:a2a +packet_id: 2026-07-07-eval-a2a-stable-void-hold-baseline +source_snapshot: "snapshot:bundle/2026-07-07-eval-a2a-stable-void-hold-baseline/snapshot" +--- + +# Live Verdict — 2026-07-07-eval-a2a-stable-void-hold-baseline + +- Verdict: `keep_observe` +- Phenomenon: No actionable A2A finding in the 23.97h runtime window: C2 forced-pass remains 0/364 and void-hold is 8/364. The void-hold signal is converging on a stable non-zero baseline below the 5% floor, while per-fire sample coverage remains incomplete at 2/8. +- Harness: F167/C2 (exit-check (forced-pass guard)) +- Owner ask: No F167 code action for this verdict; keep ordinary monitoring. If the 2026-07-08 eval again shows partial void-hold sample coverage, promote it as a separate tracer coverage/build work item to characterize the stable low baseline. +- Re-eval: Continue ordinary monitoring while C2 verdict_without_pass remains zero and void-hold ratio/Wilson upper bound stay below the 5% floor; split void-hold sample coverage into a separate tracer task if the 1/N-or-partial pattern persists on 2026-07-08. at 2026-07-10T03:01:28.927Z + +Evidence: +- snapshot:bundle/2026-07-07-eval-a2a-stable-void-hold-baseline/snapshot +- attribution:bundle/2026-07-07-eval-a2a-stable-void-hold-baseline/eval-F167-2026-07-07:no-finding +- metric:c2.verdict_without_pass_count +- metric:c2.checked +- metric:c2.void_hold_hint_emitted +- metric:c2.void_hold_checked +- metric:inline_action.routed_set_skip +- metric:inline_action.feedback_written +- metric:inline_action.hint_emitted +- C2/c2.void_hold_hint_emitted/e6ebd78a80521cda +- C2/c2.void_hold_hint_emitted/86e6e04c4c158e0f + +Counterarguments: +- Only 2 of 8 void-hold emissions have retained per-fire samples, so the exact composition of the baseline remains under-characterized. +- The domain registry still schedules daily evals; packet nextEvalAt follows the 72h SLA and does not change scheduler cadence. +- The point estimate rose to 2.2%; it is below threshold today, but a continued rise could become actionable in a later window. diff --git a/docs/public-lessons.md b/docs/public-lessons.md index c43dc08642..cbc10d32ef 100644 --- a/docs/public-lessons.md +++ b/docs/public-lessons.md @@ -1555,6 +1555,66 @@ created: 2026-02-26 - 原理:worktree full suite 的 fail ≠ standalone regression——full suite 有 in-suite CWD / env / resource 污染,单文件 fail 在 full suite 里出现是 **pollution 信号**,不是 **regression 证据**。两者的药方相反:pollution → 隔离测试 / restore env;regression → 修 test 或修代码。混淆后开 issue = 把 pollution 当 regression 投递给不可能修的 owner。 - 关联:feedback_verify_before_guessing(先验证再行动)| feedback_inmemory_store_tests_miss_redis_behavior(in-suite 环境假绿)| LL-075(同 PR,gate 执行失误) +--- + +### LL-077: 代理 TUN 模式下 git SSH 失败诊断(198.18.0.0/15 fakeip 段) +- 状态:draft +- 更新时间:2026-06-24 + +- 坑:宪宪给 co-creator clone `HKUDS/Vibe-Trading` 时 SSH (`git@github.com:`) 报 `Connection closed by 198.18.0.96 port 22`,co-creator 一脸懵——同机器上 gh 能用,git SSH 怎么不行。 +- 根因:本地装的代理软件(Clash / Surge / Mihomo / V2Ray 等)开了 TUN / fakeip 模式:把 github.com 解析成虚拟 IP `198.18.0.96`(属于 RFC 6815 / RFC 2544 保留的 `198.18.0.0/15` 测试网段),但代理规则只配了 443(gh / 浏览器 HTTPS),没配 22(SSH),所以 SSH 流量打到虚拟 IP 后无人接听,连接被代理软件直接断。 +- 触发条件:任何机器装了 Clash / Surge / Mihomo / V2Ray 等代理软件且开了 TUN / fakeip;尝试 `git clone git@github.com:...` 或其他基于 SSH 的远程协议(rsync over SSH、scp 等)。 +- 修复:换走显式 HTTPS URL—— `git clone https://github.com/{owner}/{repo}.git` 或 `gh repo clone https://github.com/{owner}/{repo}.git`。**注意**:裸 `gh repo clone {owner}/{repo}`(无 scheme)会遵循 `git_protocol` 配置,如果用户跑过 `gh auth login --git-protocol ssh` 或 `gh config set git_protocol ssh`,gh 仍会走 SSH,**还是会撞 fakeip**——必须带 scheme 或先 `gh config set git_protocol https`。 +- 防护: + - 看到错误信息含 `Connection closed by 198.18.x.x` / `198.19.x.x` → **立即怀疑代理 fakeip**,不要从 SSH key 方向排查(错误信息形态 ≠ 真正的 SSH 鉴权失败,后者会报 `Permission denied`) + - `cat-cafe-skills/open-source-teardown/refs/teardown-method.md` "单次拆解流程"章节统一用**显式 HTTPS URL**(`https://github.com/...`),不依赖 gh 的 `git_protocol` 默认值 + - 复杂场景必须 SSH 时,在代理软件 22 端口配转发规则——但这是 SOP 例外,不是默认 +- 来源锚点:thread_mqolpabeo344e8tl(co-creator 2026-06-24 gitnexus 试用对话)| cat-cafe-skills/open-source-teardown/refs/teardown-method.md(GitNexus 加速章节) +- 原理:`198.18.0.0/15` 是 RFC 6815 / RFC 2544 保留的网络性能测试段——任何代理软件用它做 fakeip 都是有意的虚拟 IP 标记,不是真实可达的网络资源。看到这个 IP = 流量已进入代理虚拟层 = **代理规则是真相源**,不是网络/认证/防火墙。 + +- 关联:LL-078(同次试用收获)/ open-source-teardown skill + +--- + +### LL-078: 知识图谱可视化的"全节点渲染"反模式 +- 状态:draft +- 更新时间:2026-06-24 + +- 坑:co-creator 用 GitNexus Web UI 看 Vibe-Trading 的图谱(18,062 节点 / 36,198 边全部一次性渲染)的反馈是"看起来好累"——节点像紫橙粉蓝混合的星云一团乱麻,眼睛根本不知道往哪看,认知负担巨大。 +- 根因:知识图谱 / 调用图工具默认是"展示数据量"而非"传达信息"——把所有节点和边全屏渲染,等于把"图谱"误当成"用户产品"。但用户的真正诉求是"理解项目",不是"看一堆点"。理解需要的是消化好的结论,不是原始数据。 +- 触发条件:知识图谱 / 调用图 / 依赖图 / 任何超过 100+ 节点的可视化产品;用户期待"快速理解"而非"探索数据"。 +- 修复:让 agent(猫)调用图谱的 query API,把图谱消化成"中文 / 结构化报告"再呈现给用户。这次试用宪宪用 `gitnexus context / query` 拿到的事实,写成 7 维拆解报告给 co-creator,0 认知负担。 +- 防护: + - 设计 / 选型涉及"图谱可视化"工具时,先问"用户是 explore 还是 understand"——后者强烈倾向走"agent 消化"路径,**工具是 agent 的眼睛,不是用户的眼睛** + - 把"工具默认全节点渲染"当作信号:那是给开发者 debug 用的,不是给业务用户用的 + - `open-source-teardown` skill 的 GitNexus 章节把它定位为"猫调用的 CLI 工具"而非"用户看的 Web UI" +- 来源锚点:thread_mqolpabeo344e8tl(co-creator 2026-06-24 试用 GitNexus 截图 / "看起来好累"反馈) +- 原理:可视化是个 channel,不是 product。同样的数据可以走"原图"(高带宽低信息密度)或"消化后摘要"(低带宽高信息密度)两条路。**当下游是人脑,几乎总是后者赢**——人脑的瓶颈是注意力,不是带宽。 + +- 关联:LL-077(同次试用)/ LL-079(同次试用)/ open-source-teardown skill + +--- + +### LL-079: skill 设计前必先查家里同类,避免重复造轮子 +- 状态:draft +- 更新时间:2026-06-24 + +- 坑:宪宪和 co-creator 讨论"把 gitnexus 试用经验沉淀成 skill"时,自信地设计了一个 `github-project-dissection` skill 的大纲(步骤 / 模板 / 能力面),**没有先 `search_evidence` 查家里是否已有同类 skill**。co-creator 一句"我们好像有一个分析 github 的工具"才触发宪宪去查,发现 `cat-cafe-skills/open-source-teardown/` 早就存在——而且是个比新设计更深的版本(防营销话术 / 8 审计镜头 / 算法剥皮表)。差点造一个同义异质的重复 skill。 +- 根因:新 skill 的设计灵感来自一次成功的实践(拆 Vibe-Trading)→ 思维直接跳到"沉淀方法论"→ 跳过了"先查家里"。这是 capability-wakeup miss case:skill 是用来"放大复用"的,但前提是"知道已经存在什么",否则会造同名异质的 skill 让 wakeup 决策更难。 +- 触发条件:任何"我刚学到一个流程,想沉淀成 skill"的瞬间;尤其当流程的关键词(如"github 拆解"、"项目分析"、"开源审计")听起来很泛、很可能撞名时。 +- 修复:co-creator 提示 + `find cat-cafe-skills -name "*teardown*"` → 找到 `open-source-teardown` → 改方案为"把 gitnexus 用法补进现有 `refs/teardown-method.md`"而非新建 skill。 +- 防护: + - **skill 设计三问**(提案前必过): + 1. `cat_cafe_search_evidence("{skill-topic} skill", scope="docs")` 命中 0 个? + 2. `ls cat-cafe-skills/` 没有同义名(拆解 = teardown / 分析 = analyze / 审计 = audit / 探索 = explore)? + 3. `grep -r "{核心关键词}" cat-cafe-skills/*/SKILL.md` 没在别的 skill description 里出现? + 三个都通过才能新建;任意一个命中 → **改造现有 skill** 默认优先 + - 触发"沉淀成 skill"念头时,**先列同类 skill** 再给方案;不要直接画大纲 + - "改造 > 新建" 默认偏好——除非新方法论和旧 skill 真的不同维度(不只是流程细节差异) +- 来源锚点:thread_mqolpabeo344e8tl("如果要沉淀 skill 可以沉淀什么"对话 2026-06-24)| cat-cafe-skills/open-source-teardown/SKILL.md(被遗漏的现有 skill) +- 原理:skill 系统的复利来自"已存在的可信调用面"被反复使用。新建一个同义 skill = 稀释一半可信度 + 让其他猫的 capability-wakeup 决策更难。每多一个 skill,wakeup miss-rate 边际上升——这是 F192 capability-wakeup eval 已经量化的成本。**对应到第一性原理:把"放大复用"误当成"沉淀经验"——前者要求收敛到已有承载面,后者只在记忆里 dump**。 + +- 关联:F192 capability-wakeup eval / writing-skills skill / LL-077 / LL-078 ### LL-077: F210-H1 dispatcher dual-handler 不变量——新 telemetry type 必须同时在 foreground + background chain 加 handler - 状态:validated - 更新时间:2026-06-17 diff --git a/native/ble-helper/macos/BleController+Delegates.swift b/native/ble-helper/macos/BleController+Delegates.swift new file mode 100644 index 0000000000..8168523e9c --- /dev/null +++ b/native/ble-helper/macos/BleController+Delegates.swift @@ -0,0 +1,165 @@ +import CoreBluetooth +import Foundation + +extension BleController { + func centralManagerDidUpdateState(_ central: CBCentralManager) { + let state: String + switch central.state { + case .poweredOn: state = "poweredOn" + case .poweredOff: state = "poweredOff" + case .unauthorized: state = "unauthorized" + case .unsupported: state = "unsupported" + case .resetting: state = "resetting" + default: state = "unknown" + } + writer.event(name: "adapter.state", data: ["state": state]) + if central.state != .poweredOn, activeScanSessionId != nil { + finishScan(state: "stopped") + } + } + + func centralManager( + _ central: CBCentralManager, + didDiscover peripheral: CBPeripheral, + advertisementData: [String: Any], + rssi RSSI: NSNumber + ) { + guard let sessionId = activeScanSessionId else { return } + devices[peripheral.identifier] = peripheral + peripheral.delegate = self + let advertisedServices = (advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID]) ?? [] + let rawName = (advertisementData[CBAdvertisementDataLocalNameKey] as? String) ?? peripheral.name + let name = rawName.map { String($0.prefix(128)) } + let rssi = min(20, max(-127, RSSI.intValue)) + writer.event(name: "scan.discovered", data: [ + "sessionId": sessionId, + "deviceId": peripheral.identifier.uuidString, + "name": name ?? NSNull(), + "rssi": rssi, + "serviceUuids": advertisedServices.prefix(64).map(\.uuidString), + ]) + } + + func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { + startDiscovery(for: peripheral) + } + + func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { + failOperation(peripheral.identifier, code: "connect_failed") + } + + func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { + if operations[peripheral.identifier] != nil { + failOperation(peripheral.identifier, code: "device_disconnected") + } + subscriptions = subscriptions.filter { !$0.key.hasPrefix("\(peripheral.identifier.uuidString.lowercased()):") } + devices.removeValue(forKey: peripheral.identifier) + writer.event(name: "device.disconnected", data: [ + "deviceId": peripheral.identifier.uuidString, + "reason": error.map { String($0.localizedDescription.prefix(256)) } ?? NSNull(), + ]) + } + + func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { + guard let operation = operations[peripheral.identifier] else { return } + guard error == nil, let services = peripheral.services, services.count <= 64 else { + failOperation(peripheral.identifier, code: "service_discovery_failed") + return + } + switch operation.kind { + case .inspect: + operation.pendingServiceCount = services.count + if services.isEmpty { + completeInspection(peripheral) + return + } + for service in services { + peripheral.discoverCharacteristics(nil, for: service) + } + case .read, .subscribe: + guard let target = operation.serviceUuid, + let service = services.first(where: { $0.uuid == target }) + else { + failOperation(peripheral.identifier, code: "service_not_found") + return + } + peripheral.discoverCharacteristics(operation.characteristicUuid.map { [$0] }, for: service) + } + } + + func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { + guard let operation = operations[peripheral.identifier] else { return } + guard error == nil, let characteristics = service.characteristics, characteristics.count <= 128 else { + failOperation(peripheral.identifier, code: "characteristic_discovery_failed") + return + } + if operation.kind == .inspect { + operation.pendingServiceCount -= 1 + if operation.pendingServiceCount == 0 { + completeInspection(peripheral) + } + return + } + guard let target = operation.characteristicUuid, + let characteristic = characteristics.first(where: { $0.uuid == target }) + else { + failOperation(peripheral.identifier, code: "characteristic_not_found") + return + } + if operation.kind == .read { + guard characteristic.properties.contains(.read) else { + failOperation(peripheral.identifier, code: "characteristic_not_readable") + return + } + peripheral.readValue(for: characteristic) + } else { + guard characteristic.properties.contains(.notify) || characteristic.properties.contains(.indicate) else { + failOperation(peripheral.identifier, code: "characteristic_not_notifiable") + return + } + peripheral.setNotifyValue(true, for: characteristic) + } + } + + func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { + if let operation = operations[peripheral.identifier], operation.kind == .read, + characteristic.uuid == operation.characteristicUuid { + guard error == nil, let value = characteristic.value, value.count <= bleMaxValueBytes else { + failOperation(peripheral.identifier, code: "characteristic_read_failed") + return + } + writer.response(requestId: operation.request.requestId, data: ["valueBase64": value.base64EncodedString()]) + clearOperation(peripheral.identifier) + releasePeripheralUnlessSubscribed(peripheral) + return + } + let key = subscriptionKey(peripheral: peripheral, characteristic: characteristic) + guard subscriptions[key] != nil, error == nil, let value = characteristic.value, value.count <= bleMaxValueBytes, + let service = characteristic.service + else { return } + writer.event(name: "gatt.notification", data: [ + "deviceId": peripheral.identifier.uuidString, + "serviceUuid": service.uuid.uuidString, + "characteristicUuid": characteristic.uuid.uuidString, + "valueBase64": value.base64EncodedString(), + "observedAt": Int(Date().timeIntervalSince1970 * 1000), + ]) + } + + func peripheral( + _ peripheral: CBPeripheral, + didUpdateNotificationStateFor characteristic: CBCharacteristic, + error: Error? + ) { + guard let operation = operations[peripheral.identifier], operation.kind == .subscribe, + characteristic.uuid == operation.characteristicUuid + else { return } + guard error == nil, characteristic.isNotifying else { + failOperation(peripheral.identifier, code: "subscription_failed") + return + } + subscriptions[subscriptionKey(peripheral: peripheral, characteristic: characteristic)] = operation.request.requestId + writer.response(requestId: operation.request.requestId, data: ["subscribed": true]) + clearOperation(peripheral.identifier) + } +} diff --git a/native/ble-helper/macos/BleController.swift b/native/ble-helper/macos/BleController.swift new file mode 100644 index 0000000000..ba947d51cb --- /dev/null +++ b/native/ble-helper/macos/BleController.swift @@ -0,0 +1,231 @@ +import CoreBluetooth +import Darwin +import Foundation + +final class BleController: NSObject, BleCommandHandling, CBCentralManagerDelegate, CBPeripheralDelegate { + let writer: ProtocolWriter + var central: CBCentralManager! + var devices: [UUID: CBPeripheral] = [:] + var activeScanSessionId: String? + var scanTimeout: DispatchWorkItem? + var operations: [UUID: DeviceOperation] = [:] + var subscriptions: [String: String] = [:] + + init(writer: ProtocolWriter) { + self.writer = writer + super.init() + central = CBCentralManager( + delegate: self, + queue: DispatchQueue.main, + options: [CBCentralManagerOptionShowPowerAlertKey: false] + ) + } + + func handle(_ request: BleRequest) { + switch request.command { + case "scan.start": startScan(request) + case "scan.stop": stopScan(request) + case "device.inspect": beginDeviceOperation(request, kind: .inspect) + case "gatt.read": beginDeviceOperation(request, kind: .read) + case "gatt.subscribe": beginDeviceOperation(request, kind: .subscribe) + case "device.disconnect": disconnect(request) + case "helper.shutdown": shutdown(request) + default: writer.error(requestId: request.requestId, code: "unsupported_command") + } + } + + private func startScan(_ request: BleRequest) { + guard central.state == .poweredOn else { + writer.error(requestId: request.requestId, code: "bluetooth_not_powered_on") + return + } + guard activeScanSessionId == nil, + let sessionId = boundedString(request.params["sessionId"], max: 128), + let timeoutMs = boundedInt(request.params["timeoutMs"], min: 1, max: 30_000) + else { + writer.error(requestId: request.requestId, code: "invalid_or_active_scan") + return + } + activeScanSessionId = sessionId + central.scanForPeripherals(withServices: nil, options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]) + writer.response(requestId: request.requestId, data: ["started": true]) + writer.event(name: "scan.state", data: ["sessionId": sessionId, "state": "started"]) + let timeout = DispatchWorkItem { [weak self] in + guard self?.activeScanSessionId == sessionId else { return } + self?.finishScan(state: "timeout") + } + scanTimeout = timeout + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(timeoutMs), execute: timeout) + } + + private func stopScan(_ request: BleRequest) { + guard let sessionId = boundedString(request.params["sessionId"], max: 128), + sessionId == activeScanSessionId + else { + writer.error(requestId: request.requestId, code: "scan_session_mismatch") + return + } + finishScan(state: "stopped") + writer.response(requestId: request.requestId, data: ["stopped": true]) + } + + func finishScan(state: String) { + guard let sessionId = activeScanSessionId else { return } + central.stopScan() + scanTimeout?.cancel() + scanTimeout = nil + activeScanSessionId = nil + // Nearby, unbound peripherals are scan-session data. Retain only + // devices with an operation in flight or an active subscription. + for (identifier, peripheral) in devices + where operations[identifier] == nil && !hasSubscription(identifier) + { + if peripheral.state == .connected || peripheral.state == .connecting { + central.cancelPeripheralConnection(peripheral) + } + devices.removeValue(forKey: identifier) + } + writer.event(name: "scan.state", data: ["sessionId": sessionId, "state": state]) + } + + private func beginDeviceOperation(_ request: BleRequest, kind: OperationKind) { + guard central.state == .poweredOn, + let deviceId = boundedString(request.params["deviceId"], max: 128), + let uuid = UUID(uuidString: deviceId), + let peripheral = resolvePeripheral(uuid) + else { + writer.error(requestId: request.requestId, code: "device_unavailable") + return + } + guard operations[uuid] == nil else { + writer.error(requestId: request.requestId, code: "device_busy") + return + } + + var serviceUuid: CBUUID? + var characteristicUuid: CBUUID? + if kind != .inspect { + guard let service = boundedString(request.params["serviceUuid"], max: 64), + let characteristic = boundedString(request.params["characteristicUuid"], max: 64) + else { + writer.error(requestId: request.requestId, code: "invalid_gatt_target") + return + } + serviceUuid = CBUUID(string: service) + characteristicUuid = CBUUID(string: characteristic) + } + + let operation = DeviceOperation( + request: request, + kind: kind, + serviceUuid: serviceUuid, + characteristicUuid: characteristicUuid + ) + let timeout = DispatchWorkItem { [weak self] in + self?.failOperation(uuid, code: "operation_timeout") + } + operation.timeout = timeout + operations[uuid] = operation + DispatchQueue.main.asyncAfter(deadline: .now() + 10, execute: timeout) + peripheral.delegate = self + if peripheral.state == .connected { + startDiscovery(for: peripheral) + } else { + central.connect(peripheral, options: nil) + } + } + + func startDiscovery(for peripheral: CBPeripheral) { + guard let operation = operations[peripheral.identifier] else { return } + if operation.kind == .inspect { + peripheral.discoverServices(nil) + } else if let serviceUuid = operation.serviceUuid { + peripheral.discoverServices([serviceUuid]) + } + } + + func completeInspection(_ peripheral: CBPeripheral) { + guard let operation = operations[peripheral.identifier] else { return } + let services = (peripheral.services ?? []).prefix(64).map { service -> [String: Any] in + let characteristics = (service.characteristics ?? []).prefix(128).map { characteristic in + [ + "uuid": characteristic.uuid.uuidString, + "properties": propertyNames(characteristic.properties), + ] as [String: Any] + } + return ["uuid": service.uuid.uuidString, "characteristics": characteristics] + } + writer.response(requestId: operation.request.requestId, data: ["services": services]) + clearOperation(peripheral.identifier) + releasePeripheralUnlessSubscribed(peripheral) + } + + private func disconnect(_ request: BleRequest) { + guard let deviceId = boundedString(request.params["deviceId"], max: 128), + let uuid = UUID(uuidString: deviceId), + let peripheral = resolvePeripheral(uuid) + else { + writer.error(requestId: request.requestId, code: "device_unavailable") + return + } + clearOperation(uuid) + if peripheral.state == .connected || peripheral.state == .connecting { + central.cancelPeripheralConnection(peripheral) + } + subscriptions = subscriptions.filter { !$0.key.hasPrefix("\(uuid.uuidString.lowercased()):") } + devices.removeValue(forKey: uuid) + writer.response(requestId: request.requestId, data: ["disconnected": true]) + } + + private func shutdown(_ request: BleRequest) { + if activeScanSessionId != nil { finishScan(state: "stopped") } + for operationId in operations.keys { clearOperation(operationId) } + for peripheral in devices.values where peripheral.state == .connected || peripheral.state == .connecting { + central.cancelPeripheralConnection(peripheral) + } + writer.response(requestId: request.requestId, data: ["stopping": true]) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + Darwin.exit(0) + } + } + + private func resolvePeripheral(_ identifier: UUID) -> CBPeripheral? { + if let existing = devices[identifier] { return existing } + guard let retrieved = central.retrievePeripherals(withIdentifiers: [identifier]).first else { return nil } + devices[identifier] = retrieved + retrieved.delegate = self + return retrieved + } + + func failOperation(_ identifier: UUID, code: String) { + guard let operation = operations[identifier] else { return } + writer.error(requestId: operation.request.requestId, code: code) + clearOperation(identifier) + if let peripheral = devices[identifier] { + releasePeripheralUnlessSubscribed(peripheral) + } + } + + func clearOperation(_ identifier: UUID) { + operations[identifier]?.timeout?.cancel() + operations.removeValue(forKey: identifier) + } + + func subscriptionKey(peripheral: CBPeripheral, characteristic: CBCharacteristic) -> String { + let service = characteristic.service?.uuid.uuidString.lowercased() ?? "unknown" + return "\(peripheral.identifier.uuidString.lowercased()):\(service):\(characteristic.uuid.uuidString.lowercased())" + } + + private func hasSubscription(_ identifier: UUID) -> Bool { + let prefix = "\(identifier.uuidString.lowercased()):" + return subscriptions.keys.contains { $0.hasPrefix(prefix) } + } + + func releasePeripheralUnlessSubscribed(_ peripheral: CBPeripheral) { + guard !hasSubscription(peripheral.identifier) else { return } + if peripheral.state == .connected || peripheral.state == .connecting { + central.cancelPeripheralConnection(peripheral) + } + devices.removeValue(forKey: peripheral.identifier) + } +} diff --git a/native/ble-helper/macos/BleSupport.swift b/native/ble-helper/macos/BleSupport.swift new file mode 100644 index 0000000000..75303a8dd5 --- /dev/null +++ b/native/ble-helper/macos/BleSupport.swift @@ -0,0 +1,48 @@ +import CoreBluetooth +import Foundation + +enum OperationKind { + case inspect + case read + case subscribe +} + +final class DeviceOperation { + let request: BleRequest + let kind: OperationKind + let serviceUuid: CBUUID? + let characteristicUuid: CBUUID? + var pendingServiceCount = 0 + var timeout: DispatchWorkItem? + + init(request: BleRequest, kind: OperationKind, serviceUuid: CBUUID? = nil, characteristicUuid: CBUUID? = nil) { + self.request = request + self.kind = kind + self.serviceUuid = serviceUuid + self.characteristicUuid = characteristicUuid + } +} + +func boundedString(_ value: Any?, max: Int) -> String? { + guard let string = value as? String, !string.isEmpty, string.utf8.count <= max else { return nil } + return string +} + +func boundedInt(_ value: Any?, min: Int, max: Int) -> Int? { + guard let number = value as? NSNumber, + CFGetTypeID(number) != CFBooleanGetTypeID(), + number.doubleValue.rounded(.towardZero) == number.doubleValue + else { return nil } + let intValue = number.intValue + return intValue >= min && intValue <= max ? intValue : nil +} + +func propertyNames(_ properties: CBCharacteristicProperties) -> [String] { + var names: [String] = [] + if properties.contains(.read) { names.append("read") } + if properties.contains(.notify) { names.append("notify") } + if properties.contains(.indicate) { names.append("indicate") } + if properties.contains(.write) { names.append("write") } + if properties.contains(.writeWithoutResponse) { names.append("writeWithoutResponse") } + return names +} diff --git a/native/ble-helper/macos/Info.plist b/native/ble-helper/macos/Info.plist new file mode 100644 index 0000000000..cbec0388ae --- /dev/null +++ b/native/ble-helper/macos/Info.plist @@ -0,0 +1,20 @@ + + + + + CFBundleIdentifier + ai.clowderai.ble-helper + CFBundleName + Clowder AI BLE Helper + CFBundleVersion + 1 + CFBundleShortVersionString + 1.0 + LSBackgroundOnly + + NSBluetoothAlwaysUsageDescription + Clowder AI uses Bluetooth to discover and read devices explicitly bound by the operator. + NSBluetoothPeripheralUsageDescription + Clowder AI uses Bluetooth to read devices explicitly bound by the operator. + + diff --git a/native/ble-helper/macos/Protocol.swift b/native/ble-helper/macos/Protocol.swift new file mode 100644 index 0000000000..feaeeb4f67 --- /dev/null +++ b/native/ble-helper/macos/Protocol.swift @@ -0,0 +1,222 @@ +import Darwin +import Foundation + +let bleProtocolName = "ble-helper" +let bleProtocolVersion = 1 +let bleMaxLineBytes = 64 * 1024 +let bleMaxValueBytes = 4 * 1024 + +private let allowedCommands: Set = [ + "scan.start", + "scan.stop", + "device.inspect", + "gatt.read", + "gatt.subscribe", + "device.disconnect", + "helper.shutdown", +] + +struct BleRequest { + let requestId: String + let command: String + let params: [String: Any] +} + +protocol BleCommandHandling: AnyObject { + func handle(_ request: BleRequest) +} + +final class ProtocolWriter { + private let outputQueue = DispatchQueue(label: "ai.clowderai.ble-helper.stdout") + + func hello() { + send([ + "protocol": bleProtocolName, + "version": bleProtocolVersion, + "kind": "hello", + ]) + } + + func response(requestId: String, data: Any? = nil) { + var object: [String: Any] = [ + "protocol": bleProtocolName, + "version": bleProtocolVersion, + "kind": "response", + "requestId": requestId, + "ok": true, + ] + if let data { + object["data"] = data + } + send(object, fallbackRequestId: requestId) + } + + func error(requestId: String, code: String) { + send([ + "protocol": bleProtocolName, + "version": bleProtocolVersion, + "kind": "response", + "requestId": requestId, + "ok": false, + "error": String(code.prefix(512)), + ]) + } + + func event(name: String, data: [String: Any]) { + send([ + "protocol": bleProtocolName, + "version": bleProtocolVersion, + "kind": "event", + "event": name, + "data": data, + ]) + } + + func diagnostic(_ message: String) { + let line = "[ble-helper] \(message.prefix(512))\n" + FileHandle.standardError.write(Data(line.utf8)) + } + + private func send(_ object: [String: Any], fallbackRequestId: String? = nil) { + outputQueue.sync { + do { + var data = try JSONSerialization.data(withJSONObject: object, options: []) + if data.count > bleMaxLineBytes, let requestId = fallbackRequestId { + data = try JSONSerialization.data(withJSONObject: [ + "protocol": bleProtocolName, + "version": bleProtocolVersion, + "kind": "response", + "requestId": requestId, + "ok": false, + "error": "response_too_large", + ]) + } + guard data.count <= bleMaxLineBytes else { + diagnostic("Dropping oversized protocol message") + return + } + data.append(0x0A) + FileHandle.standardOutput.write(data) + } catch { + diagnostic("Unable to encode protocol message") + } + } + } +} + +final class BleProtocolServer { + private let writer: ProtocolWriter + private let handler: BleCommandHandling + + init(writer: ProtocolWriter, handler: BleCommandHandling) { + self.writer = writer + self.handler = handler + } + + func handleLine(_ line: Data) { + guard line.count <= bleMaxLineBytes else { + writer.diagnostic("Rejected oversized input line") + return + } + guard + let value = try? JSONSerialization.jsonObject(with: line), + let object = value as? [String: Any] + else { + writer.diagnostic("Rejected invalid JSON") + return + } + + let requestId = object["requestId"] as? String + guard + object["protocol"] as? String == bleProtocolName, + (object["version"] as? NSNumber)?.intValue == bleProtocolVersion, + let requestId, + !requestId.isEmpty, + requestId.utf8.count <= 128, + let command = object["command"] as? String, + !command.isEmpty, + command.utf8.count <= 128, + let params = object["params"] as? [String: Any] + else { + if let requestId { + writer.error(requestId: requestId, code: "invalid_request") + } else { + writer.diagnostic("Rejected request without a valid requestId") + } + return + } + + guard allowedCommands.contains(command) else { + writer.error(requestId: requestId, code: "unsupported_command") + return + } + handler.handle(BleRequest(requestId: requestId, command: command, params: params)) + } +} + +final class StdinLineReader { + private let writer: ProtocolWriter + private let onLine: (Data) -> Void + + init(writer: ProtocolWriter, onLine: @escaping (Data) -> Void) { + self.writer = writer + self.onLine = onLine + } + + func start() { + DispatchQueue.global(qos: .userInitiated).async { [self] in + var buffer = Data() + while true { + let chunk = FileHandle.standardInput.availableData + if chunk.isEmpty { + // The API process owns the helper lifetime. Queue shutdown + // behind any lines already dispatched to the main queue so + // their responses are flushed before an orderly EOF exit. + DispatchQueue.main.async { + Darwin.exit(0) + } + return + } + buffer.append(chunk) + while let newline = buffer.firstIndex(of: 0x0A) { + var line = buffer.prefix(upTo: newline) + buffer.removeSubrange(...newline) + if line.last == 0x0D { + line = line.dropLast() + } + guard line.count <= bleMaxLineBytes else { + writer.diagnostic("Input line exceeded 64 KiB") + Darwin.exit(2) + } + let ownedLine = Data(line) + DispatchQueue.main.async { [onLine] in + onLine(ownedLine) + } + } + if buffer.count > bleMaxLineBytes { + writer.diagnostic("Input line exceeded 64 KiB") + Darwin.exit(2) + } + } + } + } +} + +final class ProtocolSmokeHandler: BleCommandHandling { + private let writer: ProtocolWriter + + init(writer: ProtocolWriter) { + self.writer = writer + } + + func handle(_ request: BleRequest) { + if request.command == "helper.shutdown" { + writer.response(requestId: request.requestId, data: ["stopping": true]) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.02) { + Darwin.exit(0) + } + return + } + writer.error(requestId: request.requestId, code: "protocol_smoke_no_hardware") + } +} diff --git a/native/ble-helper/macos/main.swift b/native/ble-helper/macos/main.swift new file mode 100644 index 0000000000..2a1755dfe3 --- /dev/null +++ b/native/ble-helper/macos/main.swift @@ -0,0 +1,13 @@ +import Foundation + +let writer = ProtocolWriter() +let smokeOnly = CommandLine.arguments.contains("--protocol-smoke") +let handler: BleCommandHandling = smokeOnly ? ProtocolSmokeHandler(writer: writer) : BleController(writer: writer) +let server = BleProtocolServer(writer: writer, handler: handler) +let reader = StdinLineReader(writer: writer) { line in + server.handleLine(line) +} + +writer.hello() +reader.start() +RunLoop.main.run() diff --git a/native/ble-helper/macos/test-protocol.sh b/native/ble-helper/macos/test-protocol.sh new file mode 100755 index 0000000000..946e209d15 --- /dev/null +++ b/native/ble-helper/macos/test-protocol.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +OUTPUT_DIR="${SCRIPT_DIR}/.build/$(uname -m)" +OUTPUT="${OUTPUT_DIR}/ble-helper" + +mkdir -p "$OUTPUT_DIR" +swiftc \ + -framework CoreBluetooth \ + -Xlinker -sectcreate \ + -Xlinker __TEXT \ + -Xlinker __info_plist \ + -Xlinker "${SCRIPT_DIR}/Info.plist" \ + "${SCRIPT_DIR}/Protocol.swift" \ + "${SCRIPT_DIR}/BleSupport.swift" \ + "${SCRIPT_DIR}/BleController.swift" \ + "${SCRIPT_DIR}/BleController+Delegates.swift" \ + "${SCRIPT_DIR}/main.swift" \ + -o "$OUTPUT" + +REQUESTS="$(mktemp)" +OUTPUT_LOG="$(mktemp)" +trap 'rm -f "$REQUESTS" "$OUTPUT_LOG"' EXIT + +printf '%s\n' \ + '{"protocol":"ble-helper","version":1,"requestId":"write-1","command":"gatt.write","params":{"deviceId":"d","valueBase64":"AQ=="}}' \ + '{"protocol":"ble-helper","version":1,"requestId":"stop-1","command":"helper.shutdown","params":{}}' \ + > "$REQUESTS" + +"$OUTPUT" --protocol-smoke < "$REQUESTS" > "$OUTPUT_LOG" + +grep -q '"kind":"hello"' "$OUTPUT_LOG" +grep -q '"requestId":"write-1"' "$OUTPUT_LOG" +grep -q '"ok":false' "$OUTPUT_LOG" +grep -q '"error":"unsupported_command"' "$OUTPUT_LOG" +grep -q '"requestId":"stop-1"' "$OUTPUT_LOG" +grep -q '"ok":true' "$OUTPUT_LOG" + +# The helper must not outlive an API parent that closes stdin unexpectedly. +"$OUTPUT" --protocol-smoke /dev/null 2>&1 & +EOF_PID=$! +for _ in $(seq 1 40); do + if ! kill -0 "$EOF_PID" 2>/dev/null; then + break + fi + sleep 0.05 +done +if kill -0 "$EOF_PID" 2>/dev/null; then + kill "$EOF_PID" 2>/dev/null || true + wait "$EOF_PID" 2>/dev/null || true + echo "BLE helper did not exit after stdin EOF" >&2 + exit 1 +fi +wait "$EOF_PID" + +echo "BLE helper protocol smoke passed" diff --git a/package.json b/package.json index b1f9037953..bc07b27b4b 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,11 @@ "alpha:status": "./scripts/alpha-worktree.sh status", "f210:agy-profile-smoke": "pnpm --filter @cat-cafe/api run smoke:f210-agy-profiles", "alpha:test": "bash ./scripts/alpha-worktree.test.sh", + "runtime:test": "bash ./scripts/runtime-worktree.test.sh", + "develop:init": "./scripts/develop-worktree.sh init", + "develop:sync": "./scripts/develop-worktree.sh sync", + "develop:start": "./scripts/develop-worktree.sh start", + "develop:status": "./scripts/develop-worktree.sh status", "runtime:init": "./scripts/runtime-worktree.sh init", "runtime:sync": "./scripts/runtime-worktree.sh sync", "runtime:start": "./scripts/runtime-worktree.sh start", @@ -48,7 +53,7 @@ "dev": "pnpm -r --parallel run dev", "build": "pnpm -r run build", "lint": "pnpm -r run lint", - "check": "pnpm biome check . --diagnostic-level=error && pnpm check:biome-review-worktrees && pnpm check:features && pnpm check:capability-tips && pnpm check:sop-definitions && pnpm check:skills:manifest && pnpm check:skills:surfaces && pnpm check:env-ports && pnpm check:env-registry && pnpm check:env-example && pnpm check:start-profile-isolation && pnpm check:pre-merge-gate && pnpm check:guides && pnpm check:followup-tails && pnpm check:scripts-ascii-only", + "check": "pnpm biome check . --diagnostic-level=error && pnpm check:biome-review-worktrees && pnpm check:features && pnpm check:capability-tips && pnpm check:sop-definitions && pnpm check:skills:manifest && pnpm check:skills:surfaces && pnpm check:env-ports && pnpm check:env-registry && pnpm check:env-example && pnpm check:start-profile-isolation && pnpm check:pre-merge-gate && pnpm check:hotfix-pattern && pnpm check:guides && pnpm check:followup-tails && pnpm check:scripts-ascii-only", "video:new": "node scripts/video-forge/new-project.mjs", "check:biome-review-worktrees": "node --test scripts/biome-review-worktrees-ignore.test.mjs", "check:video-new": "node --test test/scripts/video-new-project.test.mjs", @@ -78,6 +83,7 @@ "check:skills:manifest": "node scripts/check-skills-manifest.mjs", "check:skills:surfaces": "node --test scripts/check-skill-first-party-surfaces.test.mjs && node scripts/check-skill-first-party-surfaces.mjs", "check:pre-merge-gate": "node --test scripts/pre-merge-check.test.mjs scripts/pre-merge-gate-guard.test.mjs scripts/test-bash-runtime.test.mjs", + "check:hotfix-pattern": "node --test scripts/check-hotfix-pattern.test.mjs", "mcp:doctor": "node scripts/mcp-doctor.mjs", "convention-graph": "pnpm --filter @cat-cafe/convention-graph convention-graph", "convention-graph:index": "pnpm --filter @cat-cafe/convention-graph graph:index", diff --git a/packages/api/config/public-test-exclusions.json b/packages/api/config/public-test-exclusions.json index 130639db51..e19a2b5a0c 100644 --- a/packages/api/config/public-test-exclusions.json +++ b/packages/api/config/public-test-exclusions.json @@ -341,7 +341,7 @@ "reason": "F236 cc anchor hook coverage imports the source-only root .claude hook implementation that is not part of the public export.", "owner": "@zts212653", "introducedBy": "68dd499d9", - "expiresOn": "2026-07-07" + "expiresOn": "2026-07-31" }, { "id": "github-schedule-factories", diff --git a/packages/api/scripts/verify-catagent-service-neutrality.mjs b/packages/api/scripts/verify-catagent-service-neutrality.mjs new file mode 100755 index 0000000000..24ab726c38 --- /dev/null +++ b/packages/api/scripts/verify-catagent-service-neutrality.mjs @@ -0,0 +1,75 @@ +#!/usr/bin/env node +/** + * AC-G12 Verifier — F159 Phase G Slice G1 + * + * Asserts CatAgentService.ts contains zero `Anthropic*` code identifiers + * (after stripping comments). Per @gpt555 review note: must cover + * - Type imports (`import type { Anthropic... }`) + * - Helper names (`mapAnthropic...`, `parseAnthropic...`, + * `buildAnthropic...`) + * - Local type aliases / variable names + * - `new Anthropic...` constructor calls + * + * Also asserts CatAgentService.ts does NOT manually construct an + * AdapterMessage (no `__adapterMessage: true` literal) — per @gpt555 + * step-3 advisory. + * + * Usage: node packages/api/scripts/verify-catagent-service-neutrality.mjs + * Exit: 0 = pass, 1 = fail + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const SERVICE_FILE = path.resolve( + __dirname, + '../src/domains/cats/services/agents/providers/catagent/CatAgentService.ts', +); + +const src = fs.readFileSync(SERVICE_FILE, 'utf-8'); + +/** Strip TypeScript block comments and line comments so we only inspect code. */ +function stripComments(s) { + // Block comments — non-greedy across lines + let out = s.replace(/\/\*[\s\S]*?\*\//g, ''); + // Line comments — from // to end of line + out = out.replace(/\/\/[^\n]*/g, ''); + return out; +} + +const code = stripComments(src); + +// Match identifier-shaped tokens beginning with Anthropic +const anthropicHits = [...new Set(code.match(/\bAnthropic[A-Za-z0-9_]*/g) || [])]; +const lowerHits = [...new Set(code.match(/\banthropic[A-Za-z0-9_]*/g) || [])]; +const opaqueLeak = code.includes('__adapterMessage'); + +let failed = false; + +if (anthropicHits.length > 0) { + console.error('❌ AC-G12 FAIL: CatAgentService.ts contains `Anthropic*` code identifiers:'); + for (const id of anthropicHits) console.error(` - ${id}`); + failed = true; +} + +if (lowerHits.length > 0) { + console.error('❌ AC-G12 FAIL: CatAgentService.ts contains lowercase `anthropic*` code identifiers:'); + for (const id of lowerHits) console.error(` - ${id}`); + failed = true; +} + +if (opaqueLeak) { + console.error( + '❌ AC-G12 FAIL: CatAgentService.ts contains `__adapterMessage` literal — service must not manually construct AdapterMessage. Use adapter.encode* methods instead.', + ); + failed = true; +} + +if (failed) { + process.exit(1); +} + +console.log('✅ AC-G12 PASS: CatAgentService.ts has zero `Anthropic*` / `anthropic*` code identifiers'); +console.log('✅ AdapterMessage opacity preserved (no manual `__adapterMessage` literal in service)'); diff --git a/packages/api/src/config/account-resolver.ts b/packages/api/src/config/account-resolver.ts index 64548b9b43..fcf3fa184f 100644 --- a/packages/api/src/config/account-resolver.ts +++ b/packages/api/src/config/account-resolver.ts @@ -254,11 +254,25 @@ function accountToRuntimeProfile(ref: string, account: AccountConfig, projectRoo const builtinProtocol = builtinClient ? protocolForClient(builtinClient) : null; const isOAuth = account.authType === 'oauth'; const isBuiltin = !!builtinClient && isOAuth; + // F159 G2 AC-G21: api_key accounts with explicit `account.clientFamily` set + // `profile.client` to that family — enables the fail-closed family guard in + // catagent-credentials.ts (AC-G22) so that e.g. an api_key account marked + // `clientFamily='openai'` cannot satisfy an Anthropic adapter request. + // Strictly read from `clientFamily` only — never from legacy F171 `clientId` + // (which is freeform display, not authoritative routing/security identity). + // Legacy api_key accounts without `clientFamily` fall through with + // `profile.client=undefined`, preserving pre-G2 best-effort resolution. + // clientFamily's literal union includes 'dare' (Hub display), but RuntimeProviderProfile.client + // is BuiltinAccountClient — 'dare' has no builtin account and cannot satisfy the fail-closed + // family guard, so it falls through as undefined (same as legacy no-clientFamily accounts). + const apiKeyClient: BuiltinAccountClient | undefined = + !isOAuth && account.clientFamily && account.clientFamily !== 'dare' ? account.clientFamily : undefined; + const resolvedClient = isBuiltin ? builtinClient : apiKeyClient; return { id: ref, authType: account.authType, kind: isBuiltin ? 'builtin' : 'api_key', - ...(isBuiltin && builtinClient ? { client: builtinClient } : {}), + ...(resolvedClient ? { client: resolvedClient } : {}), ...(builtinProtocol ? { protocol: builtinProtocol } : {}), ...(account.baseUrl ? { baseUrl: account.baseUrl } : {}), ...(apiKey ? { apiKey } : {}), diff --git a/packages/api/src/config/capabilities/capability-orchestrator.ts b/packages/api/src/config/capabilities/capability-orchestrator.ts index 0f5c71131a..d2fe8058a1 100644 --- a/packages/api/src/config/capabilities/capability-orchestrator.ts +++ b/packages/api/src/config/capabilities/capability-orchestrator.ts @@ -36,6 +36,7 @@ import { CAT_CAFE_SPLIT_ENTRYPOINTS } from './mcp-constants.js'; export { CAT_CAFE_SPLIT_ENTRYPOINTS, expandManagedMcpNamesForUserMerge, + isClaudeReservedMcpServerName, MCP_CALLBACK_ENV_KEYS, resolveCatCafeNodeCommand, SENSITIVE_KEY_PATTERNS, diff --git a/packages/api/src/config/capabilities/mcp-constants.ts b/packages/api/src/config/capabilities/mcp-constants.ts index b10181ead2..c22cb37e6d 100644 --- a/packages/api/src/config/capabilities/mcp-constants.ts +++ b/packages/api/src/config/capabilities/mcp-constants.ts @@ -20,6 +20,19 @@ export function resolveCatCafeNodeCommand(): string { const LEGACY_CAT_CAFE_MCP_ID = 'cat-cafe'; +/** MCP server names reserved by Claude Code's own runtime integrations. */ +export const CLAUDE_RESERVED_MCP_SERVER_NAMES = new Set([ + 'workspace', + 'claude-in-chrome', + 'computer-use', + 'claude preview', + 'claude browser', +]); + +export function isClaudeReservedMcpServerName(name: string): boolean { + return CLAUDE_RESERVED_MCP_SERVER_NAMES.has(name.trim().toLowerCase()); +} + /** Expand managed MCP names so old monolith aliases cannot re-enter user merges. */ export function expandManagedMcpNamesForUserMerge(names: Iterable): Set { const expanded = new Set(names); diff --git a/packages/api/src/config/cat-config-loader.ts b/packages/api/src/config/cat-config-loader.ts index 78738b7632..5132d921c3 100644 --- a/packages/api/src/config/cat-config-loader.ts +++ b/packages/api/src/config/cat-config-loader.ts @@ -60,6 +60,14 @@ const contextBudgetSchema = z.object({ maxContentLengthPerMsg: z.number().positive(), }); +const commandPolicyEntrySchema = z.object({ + binary: z.string().min(1), + allowedSubcommands: z.array(z.string().min(1)).optional(), + allowedFlags: z.array(z.string().min(1)).optional(), + allowedArgPatterns: z.array(z.string().min(1)).optional(), + deniedFlags: z.array(z.string().min(1)).optional(), +}); + const agyProfileSchema = z .object({ enabled: z.boolean().optional(), @@ -114,6 +122,9 @@ const catVariantSchema = z.object({ avatar: z.string().min(1).optional(), // F32-b P4c: override breed avatar color: colorSchema.optional(), // F32-b P4c: override breed color contextBudget: contextBudgetSchema.optional(), + nativeToolLevel: z.enum(['L0', 'L1', 'L2']).optional(), // F159 Phase F + commandPolicy: z.array(commandPolicyEntrySchema).optional(), // F159 Phase F + catAgentProtocol: z.enum(['anthropic-messages', 'openai-chat']).optional(), // F159 Phase G G2 AC-G15 voiceConfig: z // F103: per-cat TTS voice configuration .object({ voice: z.string().min(1), @@ -270,7 +281,15 @@ function readTemplate(templatePath: string): string { * across provider switches (e.g. template cli.defaultArgs surviving into a * catalog variant that switched to a different client). */ -const ATOMIC_OBJECT_KEYS = new Set(['cli', 'agyProfile', 'color', 'contextBudget', 'voiceConfig', 'acp']); +const ATOMIC_OBJECT_KEYS = new Set([ + 'cli', + 'agyProfile', + 'color', + 'contextBudget', + 'voiceConfig', + 'acp', + 'providerTransport', +]); /** * Deep merge two plain objects. `overlay` fields override `base` fields. @@ -627,6 +646,9 @@ export function toAllCatConfigs(config: CatCafeConfig): Record { + const templatePath = projectRoot + ? resolveProjectTemplatePath(projectRoot) + : (process.env.CAT_TEMPLATE_PATH ?? DEFAULT_CAT_TEMPLATE_PATH); + const raw = readTemplate(templatePath); + const json = JSON.parse(raw) as { breeds?: BreedWithResolvedCatIds[] }; + return collectResolvedCatIds(Array.isArray(json.breeds) ? json.breeds : []); +} + /** * Find a breed by checking mention patterns against text. * F32-b P4c: Uses longest-match-first to avoid prefix collisions @@ -1007,6 +1045,44 @@ export interface AcpVariantConfig { }; } +export type ProviderTransportVariantConfig = Record; + +function readRawVariantField(catId: string, fieldName: string, projectRoot?: string): unknown { + const templatePath = projectRoot + ? resolveProjectTemplatePath(projectRoot) + : (process.env.CAT_TEMPLATE_PATH ?? DEFAULT_CAT_TEMPLATE_PATH); + const raw = mergeTemplateWithCatalog(templatePath) ?? readTemplate(templatePath); + const json = JSON.parse(raw) as { + breeds?: Array<{ catId?: string; variants?: Array & { catId?: string }> }>; + }; + for (const breed of json.breeds ?? []) { + for (const variant of breed.variants ?? []) { + const resolvedCatId = variant.catId ?? breed.catId; + if (resolvedCatId === catId && Object.hasOwn(variant, fieldName)) return variant[fieldName]; + } + } + return undefined; +} + +/** + * F241 Phase A: raw host-owned provider transport declaration. + * + * This is intentionally not projected into CatConfig yet. Phase B will route + * plugin manifest resources through a stricter typed activation path. + */ +export function getProviderTransportConfig( + catId: string, + projectRoot?: string, +): ProviderTransportVariantConfig | null | undefined { + try { + const value = readRawVariantField(catId, 'providerTransport', projectRoot); + if (value === null || value === undefined) return value as null | undefined; + return value as ProviderTransportVariantConfig; + } catch { + return undefined; + } +} + /** * Get ACP config for a cat from the resolved raw runtime variant. * Returns undefined if the variant has no `acp` section (= use legacy CLI). @@ -1014,19 +1090,8 @@ export interface AcpVariantConfig { */ export function getAcpConfig(catId: string, projectRoot?: string): AcpVariantConfig | undefined { try { - const templatePath = projectRoot - ? resolveProjectTemplatePath(projectRoot) - : (process.env.CAT_TEMPLATE_PATH ?? DEFAULT_CAT_TEMPLATE_PATH); - const raw = mergeTemplateWithCatalog(templatePath) ?? readTemplate(templatePath); - const json = JSON.parse(raw) as { - breeds?: Array<{ catId?: string; variants?: Array<{ catId?: string; acp?: AcpVariantConfig | null }> }>; - }; - for (const breed of json.breeds ?? []) { - for (const variant of breed.variants ?? []) { - const resolvedCatId = variant.catId ?? breed.catId; - if (resolvedCatId === catId && variant.acp) return variant.acp; - } - } + const value = readRawVariantField(catId, 'acp', projectRoot); + if (value) return value as AcpVariantConfig; } catch { // Config unreadable → no ACP config } diff --git a/packages/api/src/config/runtime-cat-catalog.ts b/packages/api/src/config/runtime-cat-catalog.ts index f815b460c5..ebc209d25b 100644 --- a/packages/api/src/config/runtime-cat-catalog.ts +++ b/packages/api/src/config/runtime-cat-catalog.ts @@ -1,6 +1,7 @@ import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import type { + CatAgentProtocol, CatBreed, CatCafeConfig, CatColor, @@ -8,7 +9,9 @@ import type { CliConfig, ClientId, CoCreatorConfig, + CommandPolicyEntry, ContextBudget, + NativeToolLevel, VoiceConfig, } from '@cat-cafe/shared'; import { createCatId } from '@cat-cafe/shared'; @@ -45,6 +48,10 @@ export interface RuntimeCatInput { cli?: CliConfig; commandArgs?: string[]; cliConfigArgs?: string[]; + nativeToolLevel?: NativeToolLevel; + commandPolicy?: CommandPolicyEntry[]; + /** F159 Phase G G2 (AC-G14): CatAgent wire protocol; only persisted when clientId === 'catagent'. */ + catAgentProtocol?: CatAgentProtocol; contextBudget?: ContextBudget; voiceConfig?: VoiceConfig; /** clowder-ai#340 P5: Model provider name (renamed from ocProviderName). */ @@ -75,6 +82,10 @@ export interface RuntimeCatUpdate { cli?: CliConfig | null; commandArgs?: string[]; cliConfigArgs?: string[]; + nativeToolLevel?: NativeToolLevel | null; + commandPolicy?: CommandPolicyEntry[] | null; + /** F159 Phase G G2 (AC-G14): CatAgent wire protocol; null to clear, undefined to skip. */ + catAgentProtocol?: CatAgentProtocol | null; contextBudget?: ContextBudget | null; voiceConfig?: VoiceConfig | null; /** clowder-ai#340 P5: Model provider name (renamed from ocProviderName). */ @@ -274,6 +285,13 @@ function createBreedFromInput(input: RuntimeCatInput): CatBreed { : {}), ...(input.commandArgs && input.commandArgs.length > 0 ? { commandArgs: input.commandArgs } : {}), ...(input.cliConfigArgs && input.cliConfigArgs.length > 0 ? { cliConfigArgs: input.cliConfigArgs } : {}), + ...(input.clientId === 'catagent' && input.nativeToolLevel ? { nativeToolLevel: input.nativeToolLevel } : {}), + ...(input.clientId === 'catagent' && input.commandPolicy && input.commandPolicy.length > 0 + ? { commandPolicy: input.commandPolicy } + : {}), + ...(input.clientId === 'catagent' && input.catAgentProtocol + ? { catAgentProtocol: input.catAgentProtocol } + : {}), ...(input.provider ? { provider: input.provider } : {}), ...(input.contextBudget ? { contextBudget: input.contextBudget } : {}), ...(input.voiceConfig !== undefined ? { voiceConfig: input.voiceConfig } : {}), @@ -471,6 +489,7 @@ export function updateRuntimeCat(projectRoot: string, catId: string, patch: Runt variant.sessionChain = patch.sessionChain; } } + const nextClientId = patch.clientId ?? variant.clientId; if (patch.clientId !== undefined) variant.clientId = patch.clientId; if (patch.defaultModel !== undefined) variant.defaultModel = patch.defaultModel; if (patch.mcpSupport !== undefined) variant.mcpSupport = patch.mcpSupport; @@ -510,6 +529,35 @@ export function updateRuntimeCat(projectRoot: string, catId: string, patch: Runt delete variant.cliConfigArgs; } } + if (nextClientId === 'catagent') { + if (patch.nativeToolLevel !== undefined) { + if (patch.nativeToolLevel) { + variant.nativeToolLevel = patch.nativeToolLevel; + } else { + delete variant.nativeToolLevel; + } + } + if (patch.commandPolicy !== undefined) { + if (patch.commandPolicy && patch.commandPolicy.length > 0) { + variant.commandPolicy = patch.commandPolicy; + } else { + delete variant.commandPolicy; + } + } + // F159 Phase G G2 (AC-G14): catAgentProtocol persisted only when clientId === 'catagent'; + // null clears, undefined skips. Switching away from catagent below also clears it. + if (patch.catAgentProtocol !== undefined) { + if (patch.catAgentProtocol) { + variant.catAgentProtocol = patch.catAgentProtocol; + } else { + delete variant.catAgentProtocol; + } + } + } else { + delete variant.nativeToolLevel; + delete variant.commandPolicy; + delete variant.catAgentProtocol; + } if (patch.provider !== undefined) { if (patch.provider) { variant.provider = patch.provider; diff --git a/packages/api/src/domains/cats/services/agents/invocation/InvocationQueue.ts b/packages/api/src/domains/cats/services/agents/invocation/InvocationQueue.ts index 0a81f52e3f..9e81881dd0 100644 --- a/packages/api/src/domains/cats/services/agents/invocation/InvocationQueue.ts +++ b/packages/api/src/domains/cats/services/agents/invocation/InvocationQueue.ts @@ -46,6 +46,8 @@ export interface QueueEntry { position?: number; /** F175: skill hint for connector triggers — flows through as promptTags on execution */ suggestedSkill?: string; + /** True only for connector wakes backed by verified external callback/tracking coverage. */ + eventDrivenExternalWaitCoverage?: boolean; callerTraceContext?: CallerTraceContext; /** Explicit A2A trigger message for stream reply threading. */ a2aTriggerMessageId?: string; @@ -183,6 +185,9 @@ export class InvocationQueue { if (input.sourceCategory && !existing.sourceCategory) { existing.sourceCategory = input.sourceCategory; } + if (input.eventDrivenExternalWaitCoverage) { + existing.eventDrivenExternalWaitCoverage = true; + } } const position = q.findIndex((entry) => entry.id === existing.id); return { @@ -222,6 +227,7 @@ export class InvocationQueue { sourceCategory: input.sourceCategory, continuationKey: input.continuationKey, suggestedSkill: input.suggestedSkill, + eventDrivenExternalWaitCoverage: input.eventDrivenExternalWaitCoverage, callerTraceContext: input.callerTraceContext, a2aTriggerMessageId: input.a2aTriggerMessageId, position: undefined, diff --git a/packages/api/src/domains/cats/services/agents/invocation/QueueProcessor.ts b/packages/api/src/domains/cats/services/agents/invocation/QueueProcessor.ts index 88489cfea2..7935cbe31c 100644 --- a/packages/api/src/domains/cats/services/agents/invocation/QueueProcessor.ts +++ b/packages/api/src/domains/cats/services/agents/invocation/QueueProcessor.ts @@ -1276,6 +1276,8 @@ export class QueueProcessor { // #949 P1-1: Connector-sourced queue entries have no ball-pass expectation. // A2A/agent entries still get the verdict-pass handoff guard. verdictPassWarningEnabled: entry.source !== 'connector', + // Only policy-backed connector wakes prove a future callback/tracking path. + eventDrivenExternalWaitCoverage: entry.eventDrivenExternalWaitCoverage === true, }, )) { if (controller.signal.aborted) { diff --git a/packages/api/src/domains/cats/services/agents/invocation/invoke-single-cat.ts b/packages/api/src/domains/cats/services/agents/invocation/invoke-single-cat.ts index 49617268fe..b1aebd0fae 100644 --- a/packages/api/src/domains/cats/services/agents/invocation/invoke-single-cat.ts +++ b/packages/api/src/domains/cats/services/agents/invocation/invoke-single-cat.ts @@ -20,6 +20,8 @@ import { type MessageContent, type SealReason, type SessionRecord, + type TaskItem, + type TaskStatus, } from '@cat-cafe/shared'; import { context, SpanStatusCode, trace } from '@opentelemetry/api'; import { @@ -155,7 +157,7 @@ import type { SessionManager } from '../../session/SessionManager.js'; import type { ISessionSealer } from '../../session/SessionSealer.js'; import type { TranscriptSessionInfo, TranscriptWriter } from '../../session/TranscriptWriter.js'; import type { ISessionChainStore } from '../../stores/ports/SessionChainStore.js'; -import type { IThreadStore } from '../../stores/ports/ThreadStore.js'; +import type { IThreadStore, Thread } from '../../stores/ports/ThreadStore.js'; import type { AgentMessage, AgentService, AgentServiceOptions } from '../../types.js'; import { hasL0CompilerSeam } from '../../types.js'; import type { InvocationRegistry } from '../invocation/InvocationRegistry.js'; @@ -396,6 +398,27 @@ function isUserVisibleSessionOutput(msg: AgentMessage): boolean { return msg.type === 'text' || msg.type === 'tool_use' || msg.type === 'tool_result'; } +function parseSessionContinuityDegradation(msg: AgentMessage): { reason?: string; requestedSessionId?: string } | null { + if (msg.type !== 'system_info' || typeof msg.content !== 'string') return null; + try { + const parsed = JSON.parse(msg.content) as Record; + if (parsed.type !== 'session_continuity_degraded') return null; + return { + ...(typeof parsed.reason === 'string' ? { reason: parsed.reason } : {}), + ...(typeof parsed.requestedSessionId === 'string' ? { requestedSessionId: parsed.requestedSessionId } : {}), + }; + } catch { + return null; + } +} + +function shouldSealActiveSessionForContinuityDegradation(degradation: { + reason?: string; + requestedSessionId?: string; +}): boolean { + return degradation.reason === 'cli_jsonl_resume_requires_single_line_prompt'; +} + async function syncAntigravityRuntimeMetadata(input: { runtimeSessionStore: IRuntimeSessionStore; sessionChainStore: ISessionChainStore; @@ -666,6 +689,137 @@ export interface InvocationParams { readonly continuityCapsule?: RouteStateContinuityCapsule; } +function getSelectedTaskId(thread: Thread | null | undefined): string | undefined { + return thread?.bootcampState?.selectedTaskId ?? thread?.firstRunQuestState?.selectedTaskId; +} + +function toTaskProgressStatus(status: TaskStatus): string { + if (status === 'done') return 'completed'; + if (status === 'todo') return 'pending'; + return 'in_progress'; +} + +function describeCurrentTaskActivity(patch: { progress?: number; summary?: string }): string | undefined { + if (patch.summary) return patch.summary; + if (patch.progress !== undefined) return `progress ${Math.round(patch.progress)}%`; + return undefined; +} + +function upsertTaskProgressItem(items: TaskProgressItem[], item: TaskProgressItem): TaskProgressItem[] { + const next = items.filter((existing) => existing.id !== item.id); + next.push(item); + return next; +} + +type CurrentTaskStatusPatch = { + status?: TaskStatus; + progress?: number; + summary?: string; +}; + +type ScopedTaskStore = NonNullable; + +async function getScopedCurrentTask(input: { + readonly taskStore: ScopedTaskStore; + readonly currentTaskId: string; + readonly threadId: string; + readonly catId: CatId; +}): Promise { + const currentTask = await input.taskStore.get(input.currentTaskId); + if (!currentTask) throw new Error('Current task is no longer available'); + if (currentTask.threadId !== input.threadId) throw new Error('Current task belongs to a different thread'); + if (currentTask.ownerCatId && currentTask.ownerCatId !== input.catId) { + throw new Error('Current task is owned by another cat'); + } + return currentTask; +} + +function buildCurrentTaskUpdateData(patch: CurrentTaskStatusPatch): { status?: TaskStatus; why?: string } { + const updateData: { status?: TaskStatus; why?: string } = {}; + if (patch.status !== undefined) updateData.status = patch.status; + if (patch.summary !== undefined) updateData.why = patch.summary; + return updateData; +} + +async function persistCurrentTaskProgress(input: { + readonly taskProgressStore?: TaskProgressStore; + readonly threadId: string; + readonly catId: CatId; + readonly invocationId: string; + readonly task: TaskItem; + readonly patch: CurrentTaskStatusPatch; +}): Promise { + if (!input.taskProgressStore) return; + const activeForm = describeCurrentTaskActivity(input.patch); + const progressItem: TaskProgressItem = { + id: input.task.id, + subject: input.task.title, + status: toTaskProgressStatus(input.task.status), + ...(activeForm ? { activeForm } : {}), + }; + const existingSnapshot = await input.taskProgressStore.getSnapshot(input.threadId, input.catId); + await input.taskProgressStore.setSnapshot({ + threadId: input.threadId, + catId: input.catId, + tasks: upsertTaskProgressItem(existingSnapshot?.tasks ?? [], progressItem), + status: input.task.status === 'done' ? 'completed' : 'running', + updatedAt: Date.now(), + lastInvocationId: input.invocationId, + }); +} + +async function updateScopedCurrentTaskStatus(input: { + readonly taskStore: ScopedTaskStore; + readonly taskProgressStore?: TaskProgressStore; + readonly currentTaskId: string; + readonly threadId: string; + readonly catId: CatId; + readonly invocationId: string; + readonly patch: CurrentTaskStatusPatch; +}): Promise { + const currentTask = await getScopedCurrentTask(input); + const updateData = buildCurrentTaskUpdateData(input.patch); + const updatedTask = + Object.keys(updateData).length > 0 ? await input.taskStore.update(currentTask.id, updateData) : currentTask; + if (!updatedTask) throw new Error('Current task update failed'); + await persistCurrentTaskProgress({ ...input, task: updatedTask }); +} + +async function resolveCatAgentScopedCallbacks(input: { + readonly taskStore?: InvocationDeps['taskStore']; + readonly taskProgressStore?: TaskProgressStore; + readonly thread: Thread | null; + readonly threadId: string; + readonly catId: CatId; + readonly invocationId: string; +}): Promise { + const selectedTaskId = getSelectedTaskId(input.thread); + const taskStore = input.taskStore; + if (!selectedTaskId || !taskStore) return undefined; + + const selectedTask = await taskStore.get(selectedTaskId); + if (!selectedTask) return undefined; + if (selectedTask.threadId !== input.threadId) return undefined; + if (selectedTask.ownerCatId && selectedTask.ownerCatId !== input.catId) return undefined; + + return { + currentTask: { + invocationId: input.invocationId, + currentTaskId: selectedTask.id, + updateCurrentTaskStatus: (patch) => + updateScopedCurrentTaskStatus({ + taskStore, + taskProgressStore: input.taskProgressStore, + currentTaskId: selectedTask.id, + threadId: input.threadId, + catId: input.catId, + invocationId: input.invocationId, + patch, + }), + }, + }; +} + /** * Invoke a single cat agent and yield messages. * @@ -1168,6 +1322,7 @@ export async function* invokeSingleCat(deps: InvocationDeps, params: InvocationP const requiresThreadWorkspace = providerRequiresThreadWorkspace(provider); // Resolve workingDirectory from thread's projectPath + let invocationThread: Thread | null = null; let workingDirectory: string | undefined; let threadProjectPath: string | undefined; let bootcampWorkspaceError: Error | undefined; @@ -1185,6 +1340,7 @@ export async function* invokeSingleCat(deps: InvocationDeps, params: InvocationP ); } if (thread) { + invocationThread = thread; if (thread.createdAt) threadCreatedAt = thread.createdAt; if (thread.projectPath) threadProjectPath = thread.projectPath; // #836: Reborn session strategy — force new session every invocation. @@ -1971,6 +2127,20 @@ export async function* invokeSingleCat(deps: InvocationDeps, params: InvocationP } } + let catAgentScopedCallbacks: AgentServiceOptions['catAgentScopedCallbacks'] | undefined; + try { + catAgentScopedCallbacks = await resolveCatAgentScopedCallbacks({ + taskStore: deps.taskStore, + taskProgressStore: deps.taskProgressStore, + thread: invocationThread, + threadId, + catId, + invocationId, + }); + } catch (err) { + log.warn({ threadId, catId, invocationId, err }, 'CatAgent scoped callback resolution failed'); + } + const baseOptions: AgentServiceOptions = { callbackEnv, ...(accountEnv ? { accountEnv } : {}), @@ -1985,6 +2155,7 @@ export async function* invokeSingleCat(deps: InvocationDeps, params: InvocationP ...(params.uploadDir ? { uploadDir: params.uploadDir } : {}), ...(signal ? { signal } : {}), ...(spawnCliOverride ? { spawnCliOverride } : {}), + ...(catAgentScopedCallbacks ? { catAgentScopedCallbacks } : {}), invocationId, ...(sessionId ? { cliSessionId: sessionId } : {}), ...(isResume && !injectSystemPrompt && params.systemPrompt @@ -2017,8 +2188,10 @@ export async function* invokeSingleCat(deps: InvocationDeps, params: InvocationP healthSnapshot: ContextHealth; activeRecord: SessionRecord; } | null = null; + let suppressSessionChainForStaleContinuityDegradation = false; const recordActiveSessionUserVisibleOutput = async (): Promise => { + if (suppressSessionChainForStaleContinuityDegradation) return; if (!deps.sessionChainStore || !sessionChainActive) return; try { // F198 Bug #3: bg looks up its record by the stable chainKey, not @@ -2044,6 +2217,59 @@ export async function* invokeSingleCat(deps: InvocationDeps, params: InvocationP const processMessage = async (msg: AgentMessage): Promise => { const outputs: AgentMessage[] = []; + const sealActiveSessionForContinuityDegradation = async (msg: AgentMessage): Promise => { + const degradation = parseSessionContinuityDegradation(msg); + if (!degradation) return; + if (!shouldSealActiveSessionForContinuityDegradation(degradation)) return; + if (!deps.sessionChainStore || !sessionChainActive) return; + + try { + const activeRecord = await deps.sessionChainStore.getActive(catId, threadId); + if (!activeRecord || activeRecord.cliSessionId !== degradation.requestedSessionId) { + suppressSessionChainForStaleContinuityDegradation = true; + return; + } + + if (deps.transcriptWriter) { + deps.transcriptWriter.appendEvent( + { + sessionId: activeRecord.id, + threadId, + catId: activeRecord.catId, + cliSessionId: activeRecord.cliSessionId, + seq: activeRecord.seq, + }, + msg as unknown as Record, + invocationId, + ); + } + + if (deps.sessionSealer) { + const result = await deps.sessionSealer.requestSeal({ + sessionId: activeRecord.id, + reason: 'session_continuity_degraded', + expectedCliSessionId: degradation.requestedSessionId, + }); + if (result.accepted) { + deps.sessionSealer.finalize({ sessionId: activeRecord.id }).catch(() => {}); + } else { + suppressSessionChainForStaleContinuityDegradation = true; + } + return; + } + + const now = Date.now(); + await deps.sessionChainStore.update(activeRecord.id, { + status: 'sealed', + sealReason: 'session_continuity_degraded', + sealedAt: now, + updatedAt: now, + }); + } catch { + /* best-effort: degradation visibility must not break invocation */ + } + }; + // clowder#915 (cloud P1): F8/F24 usage + context_health block extracted so // it can run from BOTH the `done` branch (existing behavior) AND the // `agent_loop` branch (NEW — opencode's step_finish event carries @@ -2123,7 +2349,7 @@ export async function* invokeSingleCat(deps: InvocationDeps, params: InvocationP }); // F24: Compute and emit context health (only when session chain is enabled) - if (sessionChainActive) { + if (sessionChainActive && !suppressSessionChainForStaleContinuityDegradation) { // #679: Gemini CLI token stats are cumulative across all turns — not usable // for context fill. Skip entire context_health block (raw usage still in // invocation_usage above). Guard auto-disables when lastTurnInputTokens exists. @@ -2387,7 +2613,10 @@ export async function* invokeSingleCat(deps: InvocationDeps, params: InvocationP lastErrorMessage = msg.error; } + await sealActiveSessionForContinuityDegradation(msg); + if (msg.type === 'session_init' && msg.sessionId) { + if (suppressSessionChainForStaleContinuityDegradation) return outputs; log.info( { cliSessionId: msg.sessionId, threadId, catId, userId, invocationId }, 'Session init: binding session', @@ -2410,7 +2639,13 @@ export async function* invokeSingleCat(deps: InvocationDeps, params: InvocationP } // F24 + F198 Bug #3: ensure a SessionRecord exists for this session. - if (isBgCarrier && bgChainKey && deps.sessionChainStore && sessionChainActive) { + if ( + isBgCarrier && + bgChainKey && + deps.sessionChainStore && + sessionChainActive && + !suppressSessionChainForStaleContinuityDegradation + ) { // bg: look up the conversation by its stable chainKey. ACTIVE record → // just update cliSessionId to the current daemon shortId (NO seal+create // — that cascade is the multi-turn amnesia root cause). Missing (first @@ -2449,7 +2684,7 @@ export async function* invokeSingleCat(deps: InvocationDeps, params: InvocationP } catch { // Best-effort — don't break the invocation chain } - } else if (deps.sessionChainStore && sessionChainActive) { + } else if (deps.sessionChainStore && sessionChainActive && !suppressSessionChainForStaleContinuityDegradation) { try { const existing = await deps.sessionChainStore.getActive(catId, threadId); if (existing) { @@ -2651,7 +2886,13 @@ export async function* invokeSingleCat(deps: InvocationDeps, params: InvocationP // This counter is critical for unseal safety: empty sessions (0 messages) // can be displaced, but sessions with user-visible output must not be // silently sealed or folded away before a final done event arrives. - if (isBgCarrier && bgChainKey && deps.sessionChainStore && sessionChainActive) { + if ( + isBgCarrier && + bgChainKey && + deps.sessionChainStore && + sessionChainActive && + !suppressSessionChainForStaleContinuityDegradation + ) { // F198 Bug #3: bg updates its chainKey record — messageCount (unless // already counted this turn via recordActiveSessionUserVisibleOutput) // + latestResumeSessionId (the daemon's new fork UUID from done @@ -2676,7 +2917,7 @@ export async function* invokeSingleCat(deps: InvocationDeps, params: InvocationP } catch { /* best-effort: messageCount miss won't break invocation */ } - } else if (deps.sessionChainStore && sessionChainActive) { + } else if (deps.sessionChainStore && sessionChainActive && !suppressSessionChainForStaleContinuityDegradation) { try { const activeRec = await deps.sessionChainStore.getActive(catId, threadId); if (activeRec) { @@ -2905,7 +3146,12 @@ export async function* invokeSingleCat(deps: InvocationDeps, params: InvocationP } // F24 Phase C: Record event to transcript buffer (best-effort) - if (deps.transcriptWriter && deps.sessionChainStore && sessionChainActive) { + if ( + deps.transcriptWriter && + deps.sessionChainStore && + sessionChainActive && + !suppressSessionChainForStaleContinuityDegradation + ) { try { const activeRec = await deps.sessionChainStore.getActive(catId, threadId); if (activeRec) { diff --git a/packages/api/src/domains/cats/services/agents/providers/ClaudeAgentService.ts b/packages/api/src/domains/cats/services/agents/providers/ClaudeAgentService.ts index 770bc38987..7c7dbbc6da 100644 --- a/packages/api/src/domains/cats/services/agents/providers/ClaudeAgentService.ts +++ b/packages/api/src/domains/cats/services/agents/providers/ClaudeAgentService.ts @@ -22,6 +22,7 @@ import { type CatId, createCatId } from '@cat-cafe/shared'; import { CAT_CAFE_SPLIT_ENTRYPOINTS, expandManagedMcpNamesForUserMerge, + isClaudeReservedMcpServerName, MCP_CALLBACK_ENV_KEYS, resolveCatCafeNodeCommand, resolvePencilCommand, @@ -437,6 +438,12 @@ export class ClaudeAgentService implements AgentService { if (capConfig && catId) { for (const s of resolveServersForCat(capConfig, catId, { accessScope })) { managedMcpServerNames.add(s.name); + if (isClaudeReservedMcpServerName(s.name)) { + if (s.enabled) { + log.warn({ catId, serverName: s.name }, 'Skipping Claude reserved MCP server name from capabilities'); + } + continue; + } if (!s.enabled) continue; if (s.source === 'cat-cafe' && CAT_CAFE_SPLIT_ENTRYPOINTS.has(s.name)) { const ep = CAT_CAFE_SPLIT_ENTRYPOINTS.get(s.name)!; @@ -496,6 +503,10 @@ export class ClaudeAgentService implements AgentService { ...Object.keys(mcpServers), ]); for (const [name, entry] of Object.entries(userMcp.mcpServers)) { + if (isClaudeReservedMcpServerName(name)) { + log.warn({ catId, serverName: name }, 'Skipping user MCP server with Claude reserved name'); + continue; + } if (!excludedMcpServerNames.has(name) && !(name in mcpServers) && entry && typeof entry === 'object') { mcpServers[name] = entry as Record; } diff --git a/packages/api/src/domains/cats/services/agents/providers/acp/AcpProviderTransportFactory.ts b/packages/api/src/domains/cats/services/agents/providers/acp/AcpProviderTransportFactory.ts new file mode 100644 index 0000000000..236652abab --- /dev/null +++ b/packages/api/src/domains/cats/services/agents/providers/acp/AcpProviderTransportFactory.ts @@ -0,0 +1,77 @@ +/** + * F241 Phase A: ACP as a host-owned provider transport. + * + * The plugin/provider layer may eventually declare `transport: acp`, but ACP + * process creation, env injection, MCP exposure, health, and pool lifecycle + * remain owned by Cat Cafe host code. + */ + +import type { FastifyBaseLogger } from 'fastify'; +import { type AcpVariantConfig, getAcpConfig } from '../../../../../../config/cat-config-loader.js'; +import type { ProviderTransportFactory } from '../transport/ProviderTransportRegistry.js'; +import { type AcpPoolRegistry, createAcpServiceForConfig } from './AcpServiceFactory.js'; +import { closeStaleAcpPools } from './acp-pool-registry.js'; + +export interface AcpProviderTransportFactoryDeps { + poolRegistry: AcpPoolRegistry; + log: Pick; +} + +function parseDeclaredAcpConfig(providerTransport: unknown): { declared: boolean; config?: AcpVariantConfig } { + if (typeof providerTransport !== 'object' || providerTransport === null) return { declared: false }; + const raw = providerTransport as Record; + if (raw.transport !== 'acp') return { declared: false }; + if (typeof raw.command !== 'string' || raw.command.trim().length === 0) return { declared: true }; + if (!Array.isArray(raw.startupArgs) || !raw.startupArgs.every((arg) => typeof arg === 'string')) { + return { declared: true }; + } + const config: AcpVariantConfig = { + command: raw.command, + startupArgs: raw.startupArgs, + }; + if (raw.wireTransport === 'stdio' || raw.wireTransport === 'httpstream') { + config.transport = raw.wireTransport; + } else if (raw.acpTransport === 'stdio' || raw.acpTransport === 'httpstream') { + config.transport = raw.acpTransport; + } + if (raw.experimental === true) config.experimental = true; + if (Array.isArray(raw.mcpWhitelist) && raw.mcpWhitelist.every((name) => typeof name === 'string')) { + config.mcpWhitelist = raw.mcpWhitelist; + } + if (typeof raw.supportsMultiplexing === 'boolean') config.supportsMultiplexing = raw.supportsMultiplexing; + if (typeof raw.pool === 'object' && raw.pool !== null) { + const pool = raw.pool as Record; + config.pool = {}; + if (typeof pool.maxLiveProcesses === 'number') config.pool.maxLiveProcesses = pool.maxLiveProcesses; + if (typeof pool.idleTtlMs === 'number') config.pool.idleTtlMs = pool.idleTtlMs; + } + return { declared: true, config }; +} + +export function createAcpProviderTransportFactory(deps: AcpProviderTransportFactoryDeps): ProviderTransportFactory { + return { + id: 'acp', + async create(input) { + const declared = parseDeclaredAcpConfig(input.providerTransport); + const acpConfig = declared.declared ? declared.config : getAcpConfig(input.profileId, input.projectRoot); + if (!acpConfig) return { handled: declared.declared, service: null }; + + const service = await createAcpServiceForConfig({ + projectRoot: input.projectRoot, + profileId: input.profileId, + config: input.config, + acpConfig, + poolRegistry: deps.poolRegistry, + log: deps.log, + }); + + return { handled: true, service }; + }, + async closeStale(activeProfileIds, options) { + await closeStaleAcpPools(deps.poolRegistry, activeProfileIds, { + reason: options?.reason, + onCloseError: (err, profileId, reason) => options?.onCloseError?.(err, 'acp', profileId, reason), + }); + }, + }; +} diff --git a/packages/api/src/domains/cats/services/agents/providers/catagent/CatAgentService.ts b/packages/api/src/domains/cats/services/agents/providers/catagent/CatAgentService.ts index 0fbcd39733..574fb774c0 100644 --- a/packages/api/src/domains/cats/services/agents/providers/catagent/CatAgentService.ts +++ b/packages/api/src/domains/cats/services/agents/providers/catagent/CatAgentService.ts @@ -1,30 +1,45 @@ /** - * CatAgent Native Provider — F159 Phase E: SSE Streaming + Agentic Loop + * CatAgent Native Provider — F159 Phase E (G1: vendor-neutral adapter seam) * - * Calls Anthropic Messages API directly with SSE streaming. - * Phase E adds: per-token text streaming, streaming tool collection, - * proper EOF validation. Strict streaming fail-closed — no non-streaming fallback. + * Generic "cat-as-an-agent" native provider. Protocol-specific wire shape + * (URL, headers, body, stream events, transcript codec, error formatting, + * terminal-stop classification) is delegated to a {@link CatAgentProtocolAdapter} + * obtained from {@link createCatAgentProtocolAdapter}. This file is intended + * to be vendor-neutral: AC-G12 grep verifier asserts no `Anthropic*` + * identifier appears here (imports, types, helper names, or local aliases). + * + * Pre-G1 (Phase E) this file directly called `parseAnthropicSSE`, held + * `AnthropicContentBlock[]` turn state, and pushed `{ role: 'assistant', + * content }` / `{ role: 'user', content: tool_result[] }` messages — see + * F159 Phase G spec for the design gate that drove the refactor. */ import type { CatConfig, CatId } from '@cat-cafe/shared'; import { getCatModel } from '../../../../../../config/cat-models.js'; import { createModuleLogger } from '../../../../../../infrastructure/logger.js'; +import { AuditEventTypes, getEventAuditLog } from '../../../orchestration/EventAuditLog.js'; import type { AgentMessage, AgentService, AgentServiceOptions, MessageMetadata, TokenUsage } from '../../../types.js'; import { mergeTokenUsage } from '../../../types.js'; import { resolveApiCredentials } from './catagent-credentials.js'; -import type { AnthropicContentBlock, AnthropicToolUseBlock } from './catagent-event-bridge.js'; -import { mapAnthropicError, mapAnthropicUsage, TERMINAL_STOP_REASONS } from './catagent-event-bridge.js'; +import type { CatAgentProtocolAdapter } from './catagent-protocol-adapter.js'; +import { createCatAgentProtocolAdapter } from './catagent-protocol-factory.js'; +import type { + AdapterMessage, + CatAgentNeutralBlock, + CatAgentStreamEvent, + CatAgentToolCallBlock, +} from './catagent-protocol-types.js'; import { buildToolRegistry, findTool, getToolSchemas } from './catagent-read-tools.js'; -import type { CatAgentStreamEvent } from './catagent-stream-parser.js'; -import { parseAnthropicSSE } from './catagent-stream-parser.js'; import { validateToolInput } from './catagent-tool-guard.js'; -import type { CatAgentTool } from './catagent-tools.js'; +import type { + CatAgentTool, + CatAgentToolAuditEvent, + CatAgentToolAuditSink, + CatAgentToolRegistryOptions, +} from './catagent-tools.js'; const log = createModuleLogger('catagent'); -const ANTHROPIC_API_VERSION = '2023-06-01'; -const DEFAULT_BASE_URL = 'https://api.anthropic.com'; -const DEFAULT_MAX_TOKENS = 4096; const MAX_TOOL_TURNS = 15; const TOOL_RESULT_DIGEST_LIMIT = 500; @@ -36,8 +51,9 @@ interface CatAgentServiceOptions { /** Per-turn result accumulated from stream events. */ interface TurnResult { - contentBlocks: AnthropicContentBlock[]; + contentBlocks: CatAgentNeutralBlock[]; stopReason: string | null; + isTerminal: boolean; turnUsage: TokenUsage; hadStreamError: boolean; } @@ -56,7 +72,11 @@ export interface CatAgentToolExecResult { } /** - * Execute Anthropic tool_use blocks against the local tool registry. + * Execute neutral tool_call blocks against the local tool registry. + * (G1: signature changed from `AnthropicToolUseBlock[]` to neutral + * `ReadonlyArray`; field reads are identical because + * the neutral block carries the same `id` / `name` / `input` triple.) + * * Exported (vs the previous private method) so unit tests can verify the * status mapping without standing up the full streaming HTTP pipeline. * @@ -66,7 +86,7 @@ export interface CatAgentToolExecResult { * - thrown error (schema validation, tool.execute reject) → `status: 'error'` */ export async function executeCatAgentTools( - blocks: AnthropicToolUseBlock[], + blocks: ReadonlyArray, tools: CatAgentTool[], ): Promise { const results: CatAgentToolExecResult[] = []; @@ -104,11 +124,17 @@ export class CatAgentService implements AgentService { readonly catId: CatId; private readonly projectRoot: string; private readonly catConfig: CatConfig | null; + private readonly adapter: CatAgentProtocolAdapter; constructor(options: CatAgentServiceOptions) { this.catId = options.catId; this.projectRoot = options.projectRoot; this.catConfig = options.catConfig; + // G1: protocol adapter is the single source of truth for vendor-specific + // wire shape; service never instantiates AnthropicMessagesAdapter directly + // (AC-G12 grep verifier asserts service contains no `new + // AnthropicMessagesAdapter` call). + this.adapter = createCatAgentProtocolAdapter(options.catConfig); } async *invoke(prompt: string, options?: AgentServiceOptions): AsyncIterable { @@ -120,7 +146,14 @@ export class CatAgentService implements AgentService { yield* emitError('Model resolution failed — no configured model', this.catId, 'unknown', now); return; } - const credentials = resolveApiCredentials(this.projectRoot, this.catId as string, this.catConfig); + // G1: credentials resolution now keyed on adapter.clientFamily so future + // OpenAI / Gemini adapters select their own profile family. + const credentials = resolveApiCredentials( + this.projectRoot, + this.catId as string, + this.catConfig, + this.adapter.clientFamily, + ); if (!credentials) { yield* emitError('Credential resolution failed — no bound account', this.catId, model, now); return; @@ -140,9 +173,13 @@ export class CatAgentService implements AgentService { options?: AgentServiceOptions, ): AsyncIterable { const workDir = options?.workingDirectory; - const tools = workDir ? await buildToolRegistry(workDir) : []; + const tools = await buildToolRegistry(workDir, this.createToolRegistryOptions(options)); const toolSchemas = getToolSchemas(tools); - const messages: Array<{ role: string; content: unknown }> = [{ role: 'user', content: prompt }]; + // G1: messages held as opaque AdapterMessage; service never destructures. + // Initial user prompt + per-turn assistant blocks + per-turn tool results + // are all encoded by the adapter (replaces pre-G1 service-side `{ role, + // content }` construction at CatAgentService.ts:157/229/231). + const messages: AdapterMessage[] = [this.adapter.encodeUserPrompt(prompt)]; let totalUsage: TokenUsage | undefined; for (let turn = 0; turn < MAX_TOOL_TURNS; turn++) { @@ -150,7 +187,7 @@ export class CatAgentService implements AgentService { try { resp = await this.fetchApi(messages, toolSchemas, model, credentials, options); } catch (err: unknown) { - yield* this.handleFetchError(err, metadata, model, totalUsage); + yield* this.handleFetchError(err, metadata, totalUsage); return; } @@ -158,7 +195,7 @@ export class CatAgentService implements AgentService { totalUsage = mergeTokenUsage(totalUsage, result.turnUsage); if (result.hadStreamError) { - const orphanTools = result.contentBlocks.filter((b): b is AnthropicToolUseBlock => b.type === 'tool_use'); + const orphanTools = result.contentBlocks.filter((b): b is CatAgentToolCallBlock => b.type === 'tool_call'); for (const t of orphanTools) { // F153 Phase J AC-J2: carry native tool_use_id + structured error status. yield { @@ -176,13 +213,12 @@ export class CatAgentService implements AgentService { return; } - const isTerminal = result.stopReason != null && TERMINAL_STOP_REASONS.has(result.stopReason); - if (isTerminal) { + if (result.isTerminal) { yield { type: 'done', catId: this.catId, metadata: { ...metadata, usage: totalUsage }, timestamp: Date.now() }; return; } - const toolBlocks = result.contentBlocks.filter((b): b is AnthropicToolUseBlock => b.type === 'tool_use'); + const toolBlocks = result.contentBlocks.filter((b): b is CatAgentToolCallBlock => b.type === 'tool_call'); if (toolBlocks.length === 0) { const reason = result.stopReason ?? 'unknown'; log.warn(`[${this.catId}] Non-terminal stop_reason "${reason}" with no tool calls`); @@ -214,11 +250,12 @@ export class CatAgentService implements AgentService { timestamp: Date.now(), }; } - messages.push({ role: 'assistant', content: result.contentBlocks }); - messages.push({ - role: 'user', - content: toolResults.map((r) => ({ type: 'tool_result', tool_use_id: r.id, content: r.content })), - }); + // G1: assistant turn + tool results encoded by adapter — replaces pre-G1 + // direct push of `{ role: 'assistant', content: contentBlocks }` and + // `{ role: 'user', content: tool_result[] }` (the Anthropic shape leak + // @gpt555 flagged at CatAgentService.ts:229-233 during design gate). + messages.push(this.adapter.encodeAssistantTurn(result.contentBlocks)); + messages.push(this.adapter.encodeToolResults(toolResults)); } log.warn(`[${this.catId}] Tool loop exceeded ${MAX_TOOL_TURNS} turns`); @@ -232,30 +269,34 @@ export class CatAgentService implements AgentService { yield* emitDone(this.catId, metadata, totalUsage); } - /** Consume one streaming turn, yielding text deltas and tool_use events. */ + /** Consume one streaming turn, yielding text deltas and tool_call events. */ private async *consumeTurn( resp: Response, metadata: MessageMetadata, signal?: AbortSignal, ): AsyncGenerator { - const contentBlocks: AnthropicContentBlock[] = []; - const blocksByIndex = new Map(); + const contentBlocks: CatAgentNeutralBlock[] = []; + const blocksByIndex = new Map(); let stopReason: string | null = null; - let inputUsage: TokenUsage = { inputTokens: 0, outputTokens: 0 }; - let outputTokens = 0; + // G1: turn usage merged directly from neutral CatAgentUsageDelta events. + // No more `mapAnthropicUsage(evt.inputUsage)` call from service — the + // adapter has already normalised the input usage upstream (parser). + const turnUsage: TokenUsage = { inputTokens: 0, outputTokens: 0 }; let hadStreamError = false; if (!resp.body) { yield { type: 'error', catId: this.catId, error: 'Response has no body', metadata, timestamp: Date.now() }; - return { contentBlocks, stopReason, turnUsage: inputUsage, hadStreamError: true }; + return { contentBlocks, stopReason, isTerminal: false, turnUsage, hadStreamError: true }; } - for await (const evt of parseAnthropicSSE(resp.body, signal)) { + for await (const evt of this.adapter.parseStreamEvents(resp.body, signal)) { yield* this.mapStreamEvent(evt, metadata, blocksByIndex); if (evt.type === 'usage_update') { - if (evt.inputUsage) inputUsage = mapAnthropicUsage(evt.inputUsage); - if (evt.outputTokens !== undefined) outputTokens = evt.outputTokens; + if (evt.usage.inputTokens !== undefined) turnUsage.inputTokens = evt.usage.inputTokens; + if (evt.usage.outputTokens !== undefined) turnUsage.outputTokens = evt.usage.outputTokens; + if (evt.usage.cacheReadTokens !== undefined) turnUsage.cacheReadTokens = evt.usage.cacheReadTokens; + if (evt.usage.cacheCreationTokens !== undefined) turnUsage.cacheCreationTokens = evt.usage.cacheCreationTokens; } else if (evt.type === 'stop') { stopReason = evt.stopReason; } else if (evt.type === 'stream_error') { @@ -265,24 +306,33 @@ export class CatAgentService implements AgentService { // Rebuild content blocks sorted by index (P1: preserve full assistant content) const sortedIndices = [...blocksByIndex.keys()].sort((a, b) => a - b); - for (const idx of sortedIndices) contentBlocks.push(blocksByIndex.get(idx)!); + for (const idx of sortedIndices) { + const block = blocksByIndex.get(idx); + if (block) contentBlocks.push(block); + } - const turnUsage: TokenUsage = { ...inputUsage, outputTokens }; - return { contentBlocks, stopReason, turnUsage, hadStreamError }; + // G1: terminal classification deferred to adapter (replaces pre-G1 direct + // service-side `TERMINAL_STOP_REASONS.has(...)` consult — that whitelist + // was Anthropic-specific and leaked the vendor's terminal set into + // generic loop logic). + const isTerminal = this.adapter.isTerminalStopReason(stopReason); + return { contentBlocks, stopReason, isTerminal, turnUsage, hadStreamError }; } - /** Map a single stream event to AgentMessage(s). */ + /** Map a single neutral stream event to AgentMessage(s). */ private *mapStreamEvent( evt: CatAgentStreamEvent, metadata: MessageMetadata, - blocksByIndex: Map, + blocksByIndex: Map, ): Iterable { if (evt.type === 'text_delta') { yield { type: 'text', catId: this.catId, content: evt.text, metadata, timestamp: Date.now() }; } else if (evt.type === 'content_block_complete') { blocksByIndex.set(evt.blockIndex, evt.block); - if (evt.block.type === 'tool_use') { - // F153 Phase J AC-J2: carry native Anthropic tool_use.id (from stream parser). + if (evt.block.type === 'tool_call') { + // F153 Phase J AC-J2: carry upstream protocol tool call id (neutral + // CatAgentToolCallBlock.id; adapter mapped from Anthropic tool_use.id + // / OpenAI call_*). yield { type: 'tool_use', catId: this.catId, @@ -299,25 +349,30 @@ export class CatAgentService implements AgentService { } private async fetchApi( - messages: Array<{ role: string; content: unknown }>, + messages: ReadonlyArray, tools: Array<{ name: string; description: string; input_schema: unknown }>, model: string, credentials: { apiKey: string; baseURL?: string }, options?: AgentServiceOptions, ): Promise { - const url = `${(credentials.baseURL ?? DEFAULT_BASE_URL).replace(/\/+$/, '')}/v1/messages`; - const body: Record = { model, max_tokens: DEFAULT_MAX_TOKENS, messages, stream: true }; - if (tools.length > 0) body.tools = tools; - if (options?.systemPrompt) body.system = options.systemPrompt; + // G1: URL / headers / body shape all delegated to adapter — service has + // no idea about `/v1/messages`, `x-api-key`, `anthropic-version`, or + // `{ model, max_tokens, messages, stream, tools, system }` shape. + const url = this.adapter.buildRequestUrl(credentials.baseURL); + const headers = this.adapter.buildRequestHeaders({ apiKey: credentials.apiKey }); + const body = this.adapter.buildRequestBody({ + model, + messages, + tools: tools.map((t) => ({ name: t.name, description: t.description, inputSchema: t.input_schema })), + ...(options?.systemPrompt ? { systemPrompt: options.systemPrompt } : {}), + }); - log.info(`[${this.catId}] API call: model=${model}, turns=${messages.length}, stream=true`); + log.info( + `[${this.catId}] API call: model=${model}, turns=${messages.length}, stream=true, protocol=${this.adapter.protocolId}`, + ); const resp = await fetch(url, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': credentials.apiKey, - 'anthropic-version': ANTHROPIC_API_VERSION, - }, + headers, body: JSON.stringify(body), signal: options?.signal, }); @@ -329,17 +384,44 @@ export class CatAgentService implements AgentService { } private executeTools( - blocks: AnthropicToolUseBlock[], + blocks: ReadonlyArray, tools: CatAgentTool[], _metadata: MessageMetadata, ): Promise { return executeCatAgentTools(blocks, tools); } + private createToolRegistryOptions(options?: AgentServiceOptions): CatAgentToolRegistryOptions { + return { + nativeToolLevel: this.catConfig?.nativeToolLevel, + commandPolicy: this.catConfig?.commandPolicy, + audit: this.createToolAuditSink(options), + scopedCallbacks: options?.catAgentScopedCallbacks, + }; + } + + private createToolAuditSink(options?: AgentServiceOptions): CatAgentToolAuditSink | undefined { + const ctx = options?.auditContext; + if (!ctx) return undefined; + return async (event: CatAgentToolAuditEvent) => { + await getEventAuditLog().append({ + type: AuditEventTypes.CATAGENT_SIDE_EFFECT, + threadId: ctx.threadId, + data: { + invocationId: ctx.invocationId, + threadId: ctx.threadId, + userId: ctx.userId, + catId: ctx.catId, + provider: 'catagent', + ...event, + }, + }); + }; + } + private *handleFetchError( err: unknown, metadata: MessageMetadata, - model: string, totalUsage: TokenUsage | undefined, ): Iterable { if (err instanceof DOMException && err.name === 'AbortError') { @@ -355,10 +437,18 @@ export class CatAgentService implements AgentService { } else { log.error(`[${this.catId}] Unexpected error: ${message}`); } - for (const msg of mapAnthropicError({ status: httpStatus ?? 0, message }, this.catId, 'catagent', model)) { - const usage = totalUsage ?? msg.metadata?.usage; - yield { ...msg, metadata: { ...metadata, ...msg.metadata, usage } }; - } + // G1: adapter formats the protocol-aware error text; service composes the + // neutral error + done AgentMessage pair around it. Replaces pre-G1 + // direct `mapAnthropicError(...)` call (Anthropic identifier in service). + const { errorText } = this.adapter.mapError({ status: httpStatus ?? 0, message }); + const now = Date.now(); + yield { type: 'error', catId: this.catId, error: errorText, metadata, timestamp: now }; + yield { + type: 'done', + catId: this.catId, + metadata: { ...metadata, usage: totalUsage ?? { inputTokens: 0, outputTokens: 0 } }, + timestamp: now, + }; } } diff --git a/packages/api/src/domains/cats/services/agents/providers/catagent/anthropic-messages-adapter.ts b/packages/api/src/domains/cats/services/agents/providers/catagent/anthropic-messages-adapter.ts new file mode 100644 index 0000000000..c936cf8a7c --- /dev/null +++ b/packages/api/src/domains/cats/services/agents/providers/catagent/anthropic-messages-adapter.ts @@ -0,0 +1,175 @@ +/** + * Anthropic Messages Adapter — F159 Phase G Slice G1 + * + * Concrete implementation of {@link CatAgentProtocolAdapter} for the Anthropic + * Messages API (`POST /v1/messages`, `anthropic-version: 2023-06-01`, SSE + * stream). + * + * KD-15 (truthful naming): adapter is intentionally named `Anthropic*` — + * this file owns the Anthropic-specific wire shape (URL, headers, body, + * stream events, transcript codec). The neutral seam in + * `CatAgentService` only sees `CatAgentProtocolAdapter`. + * + * KD-17: this adapter covers all four seam layers — HTTP, stream, transcript + * codec, and family/id — so service never has to know the Anthropic message + * shape (`{ role: 'assistant', content: AnthropicContentBlock[] }`, + * `{ role: 'user', content: tool_result[] }` etc.). + * + * Per @gpt555 step-3 advisory: `AdapterMessage` opacity is preserved by the + * private {@link wrapMessage} factory + AC-G12 grep verifier — service must + * never construct `{ __adapterMessage: true, payload: ... }` directly. + */ + +import type { AnthropicContentBlock } from './catagent-event-bridge.js'; +import { TERMINAL_STOP_REASONS } from './catagent-event-bridge.js'; +import type { + AdapterCredentials, + AdapterRequestInput, + AdapterToolResult, + CatAgentProtocolAdapter, +} from './catagent-protocol-adapter.js'; +import type { AdapterMessage, CatAgentNeutralBlock, CatAgentStreamEvent } from './catagent-protocol-types.js'; +import { parseAnthropicSSE } from './catagent-stream-parser.js'; + +// ── Anthropic protocol constants (adapter-owned, not service-owned) ── + +const ANTHROPIC_API_VERSION = '2023-06-01'; +const DEFAULT_BASE_URL = 'https://api.anthropic.com'; +const DEFAULT_MAX_TOKENS = 4096; + +/** + * Build the Anthropic Messages endpoint URL, normalising trailing `/v1` so + * proxies that publish `baseUrl: https://gateway.example/v1` (OpenAI-style + * convention) don't double-prefix into `/v1/v1/messages`. + * + * Moved here from `CatAgentService.ts` (develop@90810122) as part of G1 — + * the helper is Anthropic-protocol-specific and belongs in the adapter. + */ +function buildAnthropicMessagesUrl(baseURL?: string): string { + const rawBaseUrl = baseURL?.trim() || DEFAULT_BASE_URL; + const root = rawBaseUrl.replace(/\/+$/, '').replace(/\/v1$/i, ''); + return `${root}/v1/messages`; +} + +// ── AdapterMessage factory (private — opacity guardrail) ── + +/** + * Internal AdapterMessage payload shape for Anthropic Messages protocol — + * `{ role: 'user' | 'assistant', content: }`. + * Service-layer code never sees or constructs this; only the adapter does. + */ +interface AnthropicMessagePayload { + role: 'user' | 'assistant'; + content: string | AnthropicContentBlock[] | AnthropicToolResultBlock[]; +} + +interface AnthropicToolResultBlock { + type: 'tool_result'; + tool_use_id: string; + content: string; +} + +/** + * Private factory — only place in the adapter (and the whole codebase) where + * a fresh `AdapterMessage` is constructed. AC-G12 grep verifier asserts no + * other module — especially `CatAgentService` — contains `__adapterMessage` + * as a literal key. Per @gpt555 step-3 advisory. + */ +function wrapMessage(payload: AnthropicMessagePayload): AdapterMessage { + return { __adapterMessage: true, payload }; +} + +function unwrapMessage(message: AdapterMessage): AnthropicMessagePayload { + return message.payload as AnthropicMessagePayload; +} + +// ── Neutral block → Anthropic content block ── + +function neutralBlockToAnthropic(block: CatAgentNeutralBlock): AnthropicContentBlock { + if (block.type === 'text') return { type: 'text', text: block.text }; + return { type: 'tool_use', id: block.id, name: block.name, input: block.input }; +} + +// ── The adapter ── + +export class AnthropicMessagesAdapter implements CatAgentProtocolAdapter { + readonly clientFamily = 'anthropic' as const; + readonly protocolId = 'anthropic-messages-v1' as const; + + buildRequestUrl(baseURL?: string): string { + return buildAnthropicMessagesUrl(baseURL); + } + + buildRequestHeaders(credentials: AdapterCredentials): Record { + return { + 'Content-Type': 'application/json', + 'x-api-key': credentials.apiKey, + 'anthropic-version': ANTHROPIC_API_VERSION, + }; + } + + buildRequestBody(input: AdapterRequestInput): unknown { + const messages = input.messages.map((m) => unwrapMessage(m)); + const body: Record = { + model: input.model, + max_tokens: input.maxTokens ?? DEFAULT_MAX_TOKENS, + messages, + stream: true, + }; + if (input.tools.length > 0) { + // Anthropic tool schema shape: { name, description, input_schema } + body.tools = input.tools.map((t) => ({ + name: t.name, + description: t.description, + input_schema: t.inputSchema, + })); + } + if (input.systemPrompt) body.system = input.systemPrompt; + return body; + } + + parseStreamEvents(body: ReadableStream, signal?: AbortSignal): AsyncIterable { + // Parser already yields neutral CatAgentStreamEvent post-G1 step 4. + return parseAnthropicSSE(body, signal); + } + + encodeUserPrompt(prompt: string): AdapterMessage { + return wrapMessage({ role: 'user', content: prompt }); + } + + encodeAssistantTurn(blocks: ReadonlyArray): AdapterMessage { + const content: AnthropicContentBlock[] = blocks.map(neutralBlockToAnthropic); + return wrapMessage({ role: 'assistant', content }); + } + + isTerminalStopReason(stopReason: string | null): boolean { + return stopReason != null && TERMINAL_STOP_REASONS.has(stopReason); + } + + mapError(err: { status?: number; message?: string }): { errorText: string } { + // Byte-stable with pre-G1 mapAnthropicError text format + // (catagent-event-bridge.ts:163) so AC-G10 golden-wire test locks down + // the user-facing error message shape across the refactor. + const status = err.status ?? 0; + const msg = err.message ?? 'Unknown API error'; + return { errorText: `Anthropic API error (${status}): ${msg}` }; + } + + encodeToolResults(results: ReadonlyArray): AdapterMessage { + // G1 refactor-only: byte-stable with pre-G1 service code at + // CatAgentService.ts:231 — only { type, tool_use_id, content } are + // emitted. AdapterToolResult.status flows through the AgentMessage + // tool_result event (carried by service into the audit chain via + // toolResultStatus) but is intentionally NOT mapped into the Anthropic + // wire transcript here, preserving the exact pre-G1 request body shape + // that AC-G10 golden-wire test will lock down. Future iterations may + // surface status as Anthropic `is_error` — that's a behavior change and + // must go through its own design gate. + const content: AnthropicToolResultBlock[] = results.map((r) => ({ + type: 'tool_result' as const, + tool_use_id: r.id, + content: r.content, + })); + return wrapMessage({ role: 'user', content }); + } +} diff --git a/packages/api/src/domains/cats/services/agents/providers/catagent/catagent-credentials.ts b/packages/api/src/domains/cats/services/agents/providers/catagent/catagent-credentials.ts index c75946903d..4ab4f53a7c 100644 --- a/packages/api/src/domains/cats/services/agents/providers/catagent/catagent-credentials.ts +++ b/packages/api/src/domains/cats/services/agents/providers/catagent/catagent-credentials.ts @@ -1,14 +1,20 @@ /** * CatAgent Credentials — F159: Native Provider Security Baseline * - * Resolves Anthropic API key for direct API calls using the - * account-binding fail-closed pattern from invoke-single-cat. + * Resolves API credentials for direct API calls using the account-binding + * fail-closed pattern from invoke-single-cat. G1 (AC-G5): `clientFamily` + * is parameterised so adapters that don't speak Anthropic (G2 OpenAI Chat; + * G3 Gemini) can resolve their own account profile family. * - * Single source of truth: catConfig.accountRef → resolveForClient. + * Single source of truth: catConfig.accountRef → resolveForClient(clientFamily). * No env override, no fallback scan — fail closed if binding is missing. */ import type { CatConfig } from '@cat-cafe/shared'; +// `BuiltinAccountClient` is the resolver's narrow union (subset of ClientId). +// Imported as a type-only re-export from account-resolver to avoid importing +// from @cat-cafe/shared twice in this file. +import type { BuiltinAccountClient } from '../../../../../../config/account-resolver.js'; import { resolveForClient } from '../../../../../../config/account-resolver.js'; import { resolveBoundAccountRefForCat } from '../../../../../../config/cat-account-binding.js'; import { createModuleLogger } from '../../../../../../infrastructure/logger.js'; @@ -27,11 +33,17 @@ export interface ApiCredentials { * Single resolution path: catConfig.accountRef → resolveBoundAccountRefForCat → resolveForClient. * No env override — account binding is the sole source of truth (AC-B1). * No wildcard credential scan — if the bound account doesn't resolve, returns null. + * + * `clientFamily` defaults to `'anthropic'` for backward compatibility with + * pre-G1 call sites; new G1+ callers (`CatAgentService` via adapter) + * pass `adapter.clientFamily` explicitly to keep the adapter as the single + * source of truth for protocol identity. */ export function resolveApiCredentials( projectRoot: string, catId: string, catConfig: CatConfig | null | undefined, + clientFamily: string = 'anthropic', ): ApiCredentials | null { const boundRef = resolveBoundAccountRefForCat(projectRoot, catId, catConfig); if (!boundRef) { @@ -39,12 +51,43 @@ export function resolveApiCredentials( return null; } - const profile = resolveForClient(projectRoot, 'anthropic', boundRef); + // G1: adapter declares clientFamily as `string` to leave room for future + // protocols (G2 OpenAI Chat / G3 Gemini); resolveForClient currently + // accepts a narrow `BuiltinAccountClient | AccountProtocol` union. Cast + // at this boundary — if a future adapter ships a clientFamily not in + // that union, resolveForClient will return null and credentials resolution + // will fail closed (existing behaviour for unknown profiles, AC-B1). + const profile = resolveForClient(projectRoot, clientFamily as BuiltinAccountClient, boundRef); if (!profile?.apiKey) { - log.warn(`[${catId}] Bound account "${boundRef}" did not resolve to an API key`); + log.warn(`[${catId}] Bound account "${boundRef}" did not resolve to an API key (family=${clientFamily})`); + return null; + } + + // G1 P2 fix (@gpt555 implementation review on PR #23): `clientFamily` + // must actually guard the resolved profile, not just be passed through. + // resolveForClient's `preferredAccountRef` branch (account-resolver.ts:161-162) + // returns the account by ref without verifying its family — so an OAuth + // Anthropic builtin (e.g. `claude`) could resolve under + // `clientFamily='openai'` silently, making AC-G5 cosmetic and exposing + // G2 to silent family routing failures when OpenAIChatAdapter lands. + // + // Post-check: if the profile carries a client identity (`profile.client`), + // it MUST match the requested clientFamily. Mismatch → fail closed. + // + // F159 G2 AC-G21/G22 (KD-22 resolved): `profile.client` is now set for both + // OAuth builtin accounts (via BUILTIN_ACCOUNT_MAP) AND api_key accounts that + // declare `account.clientFamily` in their schema. This guard fires on both + // paths — closing the half-coverage left by G1. Legacy api_key accounts + // without `clientFamily` still fall through with `profile.client=undefined` + // (best-effort backward compat); runtime API call surfaces protocol + // mismatch at first invocation rather than silently routing. + if (profile.client !== undefined && profile.client !== clientFamily) { + log.warn( + `[${catId}] Bound account "${boundRef}" client=${profile.client} mismatch with adapter clientFamily=${clientFamily} — fail closed`, + ); return null; } - log.info(`[${catId}] Resolved API key from bound account: ${boundRef}`); + log.info(`[${catId}] Resolved API key from bound account: ${boundRef} (family=${clientFamily})`); return { apiKey: profile.apiKey, baseURL: profile.baseUrl, source: `bound:${boundRef}` }; } diff --git a/packages/api/src/domains/cats/services/agents/providers/catagent/catagent-protocol-adapter.ts b/packages/api/src/domains/cats/services/agents/providers/catagent/catagent-protocol-adapter.ts new file mode 100644 index 0000000000..f0688ba255 --- /dev/null +++ b/packages/api/src/domains/cats/services/agents/providers/catagent/catagent-protocol-adapter.ts @@ -0,0 +1,183 @@ +/** + * CatAgent Protocol Adapter — F159 Phase G Slice G1 + * + * Vendor-neutral seam between `CatAgentService` (the generic + * "cat-as-an-agent" native provider) and protocol-specific wire implementations + * (Anthropic Messages today; OpenAI Chat Completions in G2; Gemini in G3). + * + * `CatAgentService` only depends on this interface + the neutral types in + * `catagent-protocol-types.ts`. No `Anthropic*` (or any other protocol-specific) + * identifier may appear in service-layer code (AC-G12 — enforced by grep + * verifier covering values, type imports, helper names, and local aliases). + * + * Each adapter is the truthful owner of its protocol: + * - URL / headers / body serialisation + * - Streaming response → neutral events parsing + * - Transcript codec: encode service-held neutral blocks back into protocol- + * specific message shapes (`{ role, content }` for Anthropic; OpenAI's + * `{ role: 'assistant', content, tool_calls }` + `{ role: 'tool', ... }` etc.) + * - Client family identity (used by account resolver to pick profile shape) + * - Protocol id (used for audit + Hub UI label) + */ + +import type { AdapterMessage, CatAgentNeutralBlock, CatAgentStreamEvent } from './catagent-protocol-types.js'; + +/** Credentials passed into the adapter at request build time (per-invocation). */ +export interface AdapterCredentials { + apiKey: string; + baseURL?: string; +} + +/** Tool definition surface used by `buildRequestBody`. Protocol-neutral. */ +export interface AdapterToolDefinition { + name: string; + description: string; + inputSchema: unknown; +} + +/** + * Inputs to `buildRequestBody` — protocol-neutral fields that every adapter + * must be able to render into a protocol-specific request body. + */ +export interface AdapterRequestInput { + model: string; + /** + * The full opaque transcript (initial user prompt + per-turn assistant + * encoded blocks + per-turn tool result encodings). Adapter internally + * unwraps `payload` to its known shape; service never touches it. + */ + messages: ReadonlyArray; + /** Tool definitions to expose to the model this turn. */ + tools: ReadonlyArray; + /** Optional system prompt; placed per protocol convention. */ + systemPrompt?: string; + /** Optional per-turn max output tokens cap (adapter may apply protocol default). */ + maxTokens?: number; +} + +/** + * Single tool execution result passed to `encodeToolResults` to compose the + * next-turn user message (Anthropic) or tool message batch (OpenAI). + */ +export interface AdapterToolResult { + /** Upstream protocol tool call id (echoed from the matching `CatAgentToolCallBlock.id`). */ + id: string; + content: string; + status: 'ok' | 'error'; +} + +/** + * Protocol adapter seam. + * + * Per @gpt555 design gate (KD-17): the seam covers **HTTP + stream + + * transcript codec + neutral identity** — not just HTTP/stream. Otherwise + * service still has to know assistant-turn / tool-result message shapes, + * and G2 (OpenAI Chat) would force a second refactor of the same surface. + */ +export interface CatAgentProtocolAdapter { + /** + * Account family identifier consumed by `resolveApiCredentials` to select + * the correct profile shape (`'anthropic'` for `AnthropicMessagesAdapter`; + * `'openai'` for the future `OpenAIChatAdapter`; etc.). + */ + readonly clientFamily: string; + + /** + * Protocol identifier used for audit + Hub UI label. Stable per + * adapter class (e.g. `'anthropic-messages-v1'`). + */ + readonly protocolId: string; + + // ── HTTP / wire layer ── + + /** + * Build the absolute request URL. Adapter knows its default base URL + + * endpoint path suffix; normalises trailing `/` and any redundant version + * segments (see {@link https://github.com/clowder-labs/clowder-ai/pull/15 + * develop@90810122} `buildAnthropicMessagesUrl` for prior art). + */ + buildRequestUrl(baseURL?: string): string; + + /** + * Build HTTP headers — protocol version markers, auth, content type. + * Caller provides only credentials; adapter knows its own version pins. + */ + buildRequestHeaders(credentials: AdapterCredentials): Record; + + /** + * Build the request body as a JSON-serialisable object. Adapter is the + * sole owner of body shape — service never constructs `{ model, messages, + * stream, tools, system, max_tokens }` style structures directly. + */ + buildRequestBody(input: AdapterRequestInput): unknown; + + // ── Stream layer ── + + /** + * Consume the streaming HTTP response body and yield protocol-neutral + * stream events. Internal SSE / chunked / JSONL parsing + Anthropic-shape + * mapping (input usage, content block deltas, stop_reason) all stay inside + * the adapter. + */ + parseStreamEvents(body: ReadableStream, signal?: AbortSignal): AsyncIterable; + + // ── Transcript codec (KD-17 core: the half that was missing pre-iteration) ── + + /** + * Encode the initial user prompt into an `AdapterMessage`. Always the + * first entry of `messages` in {@link AdapterRequestInput}. + */ + encodeUserPrompt(prompt: string): AdapterMessage; + + /** + * Encode an assistant turn (mix of text + tool_call blocks, in order) + * into an `AdapterMessage`. The service accumulates `CatAgentNeutralBlock[]` + * during streaming via `parseStreamEvents` and hands them off here at the + * end of each turn — replacing the pre-G1 `messages.push({ role: + * 'assistant', content: contentBlocks })` Anthropic shape leak at + * `CatAgentService.ts:229`. + */ + encodeAssistantTurn(blocks: ReadonlyArray): AdapterMessage; + + /** + * Encode tool execution results into an `AdapterMessage`. Replaces the + * pre-G1 `messages.push({ role: 'user', content: tool_result[] })` + * Anthropic shape leak at `CatAgentService.ts:231`. Whether this maps to + * Anthropic's `user`-with-`tool_result[]`, OpenAI's separate + * `tool`-role messages, or something else is an adapter-internal concern. + */ + encodeToolResults(results: ReadonlyArray): AdapterMessage; + + // ── Error formatting (protocol-neutral surface, protocol-aware text) ── + + /** + * Map a HTTP-level fetch error (status + message) into a user-facing error + * text. Replaces pre-G1 `service.handleFetchError` directly calling + * `mapAnthropicError` (which emitted `"Anthropic API error (...)"` strings + * straight from service code, leaking the protocol vendor name into a + * service-layer responsibility). + * + * Service composes the returned `errorText` into the standard `error` + + * `done` AgentMessage pair using its own context (catId, model, usage). + */ + mapError(err: { status?: number; message?: string }): { errorText: string }; + + // ── Stop-reason classification (protocol-aware terminal set) ── + + /** + * Whether a `stop` event's `stopReason` indicates a terminal turn end + * (model finished naturally, hit max tokens, refused, etc.) vs. a + * pause-for-tool-use intermediate stop. + * + * Each protocol has its own terminal set: + * - Anthropic: `end_turn` / `max_tokens` / `stop_sequence` / `refusal` / + * `model_context_window_exceeded` + * - OpenAI: `stop` / `length` / `content_filter` + * - Gemini: `STOP` / `MAX_TOKENS` / `SAFETY` / `RECITATION` + * + * Keeping this on the adapter (instead of letting service consult a shared + * `TERMINAL_STOP_REASONS` constant) avoids the pre-G1 leak where service + * had to know the Anthropic-specific terminal whitelist. + */ + isTerminalStopReason(stopReason: string | null): boolean; +} diff --git a/packages/api/src/domains/cats/services/agents/providers/catagent/catagent-protocol-factory.ts b/packages/api/src/domains/cats/services/agents/providers/catagent/catagent-protocol-factory.ts new file mode 100644 index 0000000000..95c29f4e8e --- /dev/null +++ b/packages/api/src/domains/cats/services/agents/providers/catagent/catagent-protocol-factory.ts @@ -0,0 +1,66 @@ +/** + * CatAgent Protocol Factory — F159 Phase G Slice G1 + G2 + * + * Single entry point for service to obtain a configured + * {@link CatAgentProtocolAdapter}. G2 (KD-19/KD-20) dispatches based on + * `catConfig.catAgentProtocol`: + * - `undefined` / `'anthropic-messages'` → `AnthropicMessagesAdapter` (G1 default, + * preserves G1 catagent member behavior — KD-25 first half byte-stable) + * - `'openai-chat'` → `OpenAIChatAdapter` (G2 Axis 5) + * - unknown value → **fail closed (throw)** — KD-20: no fallback, no + * runtime protocol guessing, no silent vendor routing + * + * Service code never instantiates adapters directly — it calls this factory. + * AC-G12 grep verifier asserts service contains no `new + * AnthropicMessagesAdapter` (or any other adapter constructor). + */ + +import type { CatAgentProtocol, CatConfig } from '@cat-cafe/shared'; +import { AnthropicMessagesAdapter } from './anthropic-messages-adapter.js'; +import type { CatAgentProtocolAdapter } from './catagent-protocol-adapter.js'; +import { OpenAIChatAdapter } from './openai-chat-adapter.js'; + +/** + * Select and instantiate the protocol adapter for a CatAgent member. + * + * Selection strategy = spec Slice G2 option A: explicit `catAgentProtocol` + * field on `CatConfig` (set via Hub UI / routes / catalog persistence, + * see G2 Axis 1 step 1a-1c). G2 KD-20 explicitly rejects option B + * (probing baseUrl shape) — that path is fragile, unauditable, and grows + * exponentially as protocols are added. + * + * Fail-closed contract: + * - Unknown protocol value → throw before any HTTP / credential resolution + * - Caller (invoke-single-cat host integration) surfaces the failure as + * a normal credentials/init error to user; no silent fallback + */ +export function createCatAgentProtocolAdapter(catConfig: CatConfig | null): CatAgentProtocolAdapter { + const protocol = catConfig?.catAgentProtocol; + + // Default branch: G1 catagent members (no catAgentProtocol persisted) + + // explicit 'anthropic-messages' both land here. Byte-stable with G1 + // (KD-25 AC-G31 verifies factory default branch unchanged). + if (protocol === undefined || protocol === 'anthropic-messages') { + return new AnthropicMessagesAdapter(); + } + + if (protocol === 'openai-chat') { + return new OpenAIChatAdapter(); + } + + // KD-20 fail-closed: unrecognised protocol values throw. Service surfaces + // this as a credentials/init failure; no fallback to AnthropicMessagesAdapter. + throw new CatAgentProtocolUnknownError(protocol as string); +} + +export class CatAgentProtocolUnknownError extends Error { + readonly protocol: string; + constructor(protocol: string) { + super( + `[catagent] Unknown catAgentProtocol "${protocol}" — fail-closed per KD-20 (no runtime protocol guessing). ` + + `Valid values: 'anthropic-messages' | 'openai-chat'.`, + ); + this.name = 'CatAgentProtocolUnknownError'; + this.protocol = protocol; + } +} diff --git a/packages/api/src/domains/cats/services/agents/providers/catagent/catagent-protocol-types.ts b/packages/api/src/domains/cats/services/agents/providers/catagent/catagent-protocol-types.ts new file mode 100644 index 0000000000..7c9e2678c6 --- /dev/null +++ b/packages/api/src/domains/cats/services/agents/providers/catagent/catagent-protocol-types.ts @@ -0,0 +1,110 @@ +/** + * CatAgent Protocol Types — F159 Phase G Slice G1 + * + * Protocol-neutral block / event / usage types for CatAgent. + * + * G1 把 service 层从 Anthropic-specific 类型解耦:service 只持有 neutral + * 类型;协议特定的形状(Anthropic content blocks / tool_result / usage 字段) + * 封闭在 adapter 内(见 anthropic-messages-adapter.ts)。 + * + * 这是 KD-17 / AC-G11 的实施:CatAgentStreamEvent 必须只引用 neutral 类型, + * 不再 import 任何 `Anthropic*` 类型。 + */ + +import type { TokenUsage } from '../../../types.js'; + +// ── Neutral content blocks (assistant turn state) ── + +/** Neutral text block — adapter maps from protocol-specific text content. */ +export interface CatAgentTextBlock { + type: 'text'; + text: string; +} + +/** + * Neutral tool call block — adapter maps from + * Anthropic `tool_use` / OpenAI `function_call` / Gemini `functionCall` etc. + * + * `id` is the upstream protocol's tool call ID (Anthropic `toolu_*`, + * OpenAI `call_*`). Carried back into next-turn `tool_result` / `tool_call_id` + * by `adapter.encodeToolResults`. + */ +export interface CatAgentToolCallBlock { + type: 'tool_call'; + id: string; + name: string; + input: Record; +} + +/** + * Discriminated union — service-side turn state type. + * + * Replaces `AnthropicContentBlock` as the type of `contentBlocks` in `TurnResult` + * and the per-block accumulator in `consumeTurn` (`CatAgentService.ts:253/293` + * before G1). + */ +export type CatAgentNeutralBlock = CatAgentTextBlock | CatAgentToolCallBlock; + +// ── Neutral usage delta ── + +/** + * Neutral usage delta — adapter normalises protocol-specific usage shape + * (Anthropic `input_tokens` / `output_tokens` / `cache_*`; OpenAI + * `prompt_tokens` / `completion_tokens`; Gemini `promptTokenCount` / + * `candidatesTokenCount`) before yielding `usage_update`. + * + * Carries the same fields as {@link TokenUsage} but every field is optional + * because a single `usage_update` event may only deliver part of the delta + * (e.g. Anthropic `message_start` delivers input, `message_delta` delivers + * output). The service merges these into a complete `TokenUsage` per turn. + * + * NOTE: `cacheReadTokens` / `cacheCreationTokens` are kept on this event + * surface (rather than collapsed into `inputTokens`) so audit / OTel + * downstream can keep the Anthropic prompt-cache observability without + * leaking Anthropic-shaped types into service-layer code. + */ +export interface CatAgentUsageDelta { + inputTokens?: number; + outputTokens?: number; + cacheReadTokens?: number; + cacheCreationTokens?: number; +} + +// ── Adapter message (opaque to service) ── + +/** + * Opaque transcript message — service holds `messages: AdapterMessage[]` and + * **never reads / destructures it**. The adapter's `encodeAssistantTurn` / + * `encodeToolResults` produce these; only the adapter's `buildRequestBody` + * knows how to serialise them back into the protocol-specific request shape. + * + * AC-G12: opacity is enforced by the type system (no exported shape) + grep + * verifier (service module must not import / alias / inspect any + * `Anthropic*` typedef). The `__adapterMessage` brand exists purely to make + * the type nominal — accidental object literals don't satisfy it. + */ +export interface AdapterMessage { + readonly __adapterMessage: true; + /** Adapter-internal payload — concrete adapters use whatever shape they need. */ + readonly payload: unknown; +} + +// ── Stream event surface (replaces AnthropicContentBlock-bearing variant) ── + +/** + * Protocol-neutral stream event. + * + * All `CatAgentProtocolAdapter.parseStreamEvents` implementations yield this + * discriminated union; service consumes it without any Anthropic-specific + * knowledge. + * + * Moved here from `catagent-stream-parser.ts` as part of G1 to enforce + * neutrality at the type boundary (AC-G11). The parser file becomes a private + * implementation helper of `AnthropicMessagesAdapter`. + */ +export type CatAgentStreamEvent = + | { type: 'text_delta'; text: string; blockIndex: number } + | { type: 'content_block_complete'; block: CatAgentNeutralBlock; blockIndex: number } + | { type: 'usage_update'; usage: CatAgentUsageDelta } + | { type: 'stop'; stopReason: string | null } + | { type: 'stream_error'; error: string }; diff --git a/packages/api/src/domains/cats/services/agents/providers/catagent/catagent-read-tools.ts b/packages/api/src/domains/cats/services/agents/providers/catagent/catagent-read-tools.ts index 1d52c0d8f3..8d24a357d0 100644 --- a/packages/api/src/domains/cats/services/agents/providers/catagent/catagent-read-tools.ts +++ b/packages/api/src/domains/cats/services/agents/providers/catagent/catagent-read-tools.ts @@ -6,18 +6,28 @@ * - list_files: list directory contents (resolveSecurePath + isDenylisted filter) * - search_content: ripgrep search (buildSafeCommand + denylist exclude + result filter) * - * ADR-001 boundary: no write/edit/delete, no shell/exec, no network tools. + * Phase F adds gated side-effect tools: + * - L1: write_file / patch_file + * - L2: run_command with allowlist-first commandPolicy + * - F3-min: update_current_task_status host-native scoped callback */ import { execFile } from 'node:child_process'; -import { open, readdir } from 'node:fs/promises'; -import { join } from 'node:path'; +import { createHash, randomUUID } from 'node:crypto'; +import { lstat, mkdir, open, readdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'; +import { dirname, join, relative } from 'node:path'; import { promisify } from 'node:util'; +import type { CommandPolicyEntry, TaskStatus } from '@cat-cafe/shared'; import { isDenylisted } from '../../../../../../domains/workspace/workspace-security.js'; import { buildSafeCommand } from './catagent-tool-guard.js'; -import type { CatAgentTool, ToolSchema } from './catagent-tools.js'; -import { resolveSecurePath } from './catagent-tools.js'; +import type { + CatAgentTool, + CatAgentToolAuditEvent, + CatAgentToolRegistryOptions, + ToolSchema, +} from './catagent-tools.js'; +import { resolveCreatePath, resolveSecurePath } from './catagent-tools.js'; const execFileAsync = promisify(execFile); @@ -27,6 +37,12 @@ const HARD_MAX_BYTES = 32_768; /** Max bytes buffered per read_file call — enforced BEFORE line splitting to prevent OOM. */ const READ_BUDGET_BYTES = 1_048_576; // 1 MiB const MAX_SEARCH_RESULTS = 50; +const MAX_WRITE_BYTES = 256 * 1024; +const DEFAULT_COMMAND_TIMEOUT_MS = 30_000; +const DEFAULT_COMMAND_KILL_GRACE_MS = 3_000; +const COMMAND_MAX_BUFFER = 512 * 1024; +const LEVEL_RANK = { L0: 0, L1: 1, L2: 2 } as const; +const TASK_STATUSES = new Set(['todo', 'doing', 'blocked', 'done']); /** Denylist globs for rg pre-filtering (isDenylisted is the authoritative filter) */ const RG_DENYLIST_GLOBS = ['!.env*', '!*.pem', '!*.key', '!id_rsa*', '!.git', '!secrets']; @@ -201,16 +217,513 @@ function filterAndTruncate(stdout: string): string { return `${lines.slice(0, MAX_SEARCH_RESULTS).join('\n')}\n\n[Truncated: ${MAX_SEARCH_RESULTS}/${lines.length} matches shown.]`; } +// ── write_file / patch_file ── + +const writeFileSchema: ToolSchema = { + name: 'write_file', + description: 'Write a UTF-8 file within the workspace using an atomic tmp+rename operation. Max 256 KiB.', + input_schema: { + type: 'object', + properties: { + path: { type: 'string', description: 'Relative path to write' }, + content: { type: 'string', description: 'Full file content' }, + }, + required: ['path', 'content'] as const, + }, +}; + +const patchFileSchema: ToolSchema = { + name: 'patch_file', + description: + 'Patch a workspace file by replacing one unique old_text occurrence after expected_hash compare-and-swap.', + input_schema: { + type: 'object', + properties: { + path: { type: 'string', description: 'Relative path to patch' }, + old_text: { type: 'string', description: 'Text that must match exactly once' }, + new_text: { type: 'string', description: 'Replacement text' }, + expected_hash: { type: 'string', description: 'SHA-256 hash or prefix of the current file content' }, + }, + required: ['path', 'old_text', 'new_text', 'expected_hash'] as const, + }, +}; + +function hashContent(content: Buffer | string): string { + return createHash('sha256').update(content).digest('hex'); +} + +function normalizedRel(workDir: string, resolvedPath: string): string { + return relative(workDir, resolvedPath).replace(/\\/g, '/'); +} + +async function emitAudit(options: CatAgentToolRegistryOptions, event: CatAgentToolAuditEvent): Promise { + await options.audit?.(event); +} + +async function rejectWithAudit( + options: CatAgentToolRegistryOptions, + event: Omit, + message: string, +): Promise { + await emitAudit(options, { ...event, outcome: 'rejected', rejectReason: message, timestamp: Date.now() }); + throw new Error(message); +} + +async function existingFileHash(resolvedPath: string): Promise { + try { + const st = await lstat(resolvedPath); + if (st.isSymbolicLink()) throw new Error('Refusing to overwrite symlink path'); + if (st.isDirectory()) throw new Error('Refusing to overwrite directory path'); + return hashContent(await readFile(resolvedPath)); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw err; + } +} + +async function writeAtomicUtf8(resolvedPath: string, content: string): Promise { + const parent = dirname(resolvedPath); + await mkdir(parent, { recursive: true }); + const tmpPath = join(parent, `.catagent-${process.pid}-${randomUUID()}.tmp`); + try { + await writeFile(tmpPath, content, { encoding: 'utf-8', flag: 'wx' }); + await rename(tmpPath, resolvedPath); + } catch (err) { + await unlink(tmpPath).catch(() => undefined); + throw err; + } +} + +async function executeWriteFile( + input: Record, + workDir: string, + options: CatAgentToolRegistryOptions, +): Promise { + const content = input.content as string; + const bytes = Buffer.byteLength(content, 'utf-8'); + if (bytes > MAX_WRITE_BYTES) { + await rejectWithAudit( + options, + { tool: 'write_file', path: input.path as string, bytes }, + 'write_file exceeds 256 KiB', + ); + } + + const resolved = await resolveCreatePath(workDir, input.path as string); + const relPath = normalizedRel(workDir, resolved); + const hashBefore = await existingFileHash(resolved); + await writeAtomicUtf8(resolved, content); + const hashAfter = hashContent(content); + await emitAudit(options, { + tool: 'write_file', + outcome: 'ok', + timestamp: Date.now(), + path: relPath, + bytes, + hashBefore, + hashAfter, + }); + return `Wrote ${bytes} bytes to ${relPath} (sha256:${hashAfter.slice(0, 12)})`; +} + +interface TextSpanMatch { + start: number; + end: number; + matches: number; +} + +function findUniqueTextSpan(haystack: string, needle: string): TextSpanMatch { + if (needle.length === 0) return { start: -1, end: -1, matches: 0 }; + let count = 0; + let first = -1; + let idx = haystack.indexOf(needle); + while (idx !== -1) { + count++; + if (first === -1) first = idx; + if (count > 1) break; + idx = haystack.indexOf(needle, idx + 1); + } + return { start: first, end: first + needle.length, matches: count }; +} + +function replaceTextSpan(haystack: string, span: TextSpanMatch, replacement: string): string { + return `${haystack.slice(0, span.start)}${replacement}${haystack.slice(span.end)}`; +} + +async function executePatchFile( + input: Record, + workDir: string, + options: CatAgentToolRegistryOptions, +): Promise { + const resolved = await resolveSecurePath(workDir, input.path as string); + const relPath = normalizedRel(workDir, resolved); + const oldText = input.old_text as string; + const newText = input.new_text as string; + const expectedHash = input.expected_hash as string; + if (expectedHash.length < 8) { + await rejectWithAudit(options, { tool: 'patch_file', path: relPath }, 'expected_hash must be at least 8 hex chars'); + } + + const before = await readFile(resolved, 'utf-8'); + const hashBefore = hashContent(before); + if (!hashBefore.startsWith(expectedHash)) { + await rejectWithAudit(options, { tool: 'patch_file', path: relPath, hashBefore }, 'expected_hash mismatch'); + } + const span = findUniqueTextSpan(before, oldText); + if (span.matches !== 1) { + await rejectWithAudit( + options, + { tool: 'patch_file', path: relPath, hashBefore }, + `old_text must match exactly once (found ${span.matches})`, + ); + } + + const after = replaceTextSpan(before, span, newText); + const hashAfter = hashContent(after); + await writeAtomicUtf8(resolved, after); + await emitAudit(options, { + tool: 'patch_file', + outcome: 'ok', + timestamp: Date.now(), + path: relPath, + bytes: Buffer.byteLength(after, 'utf-8'), + hashBefore, + hashAfter, + }); + return `Patched ${relPath} (sha256:${hashBefore.slice(0, 12)} -> ${hashAfter.slice(0, 12)})`; +} + +// ── run_command ── + +const runCommandSchema: ToolSchema = { + name: 'run_command', + description: 'Run one allowlisted command with structured argv. No shell, cwd locked to workspace.', + input_schema: { + type: 'object', + properties: { + binary: { type: 'string', description: 'Command binary exactly matching commandPolicy.binary' }, + args: { type: 'array', description: 'Argument vector. String command lines are not accepted.' }, + }, + required: ['binary', 'args'] as const, + }, +}; + +function compilePolicyPattern(pattern: string): RegExp { + try { + return new RegExp(pattern); + } catch { + throw new Error(`Invalid commandPolicy allowedArgPattern: ${pattern}`); + } +} + +function isAllowedByPatterns(value: string, patterns: readonly string[] | undefined): boolean { + return (patterns ?? []).some((pattern) => compilePolicyPattern(pattern).test(value)); +} + +function findCommandPolicyEntry(binary: string, policy: readonly CommandPolicyEntry[] | undefined): CommandPolicyEntry { + if (!policy || policy.length === 0) throw new Error('No command policy configured'); + const entry = policy.find((p) => p.binary === binary); + if (!entry) throw new Error(`Command binary "${binary}" is not allowed by command policy`); + return entry; +} + +function assertCommandArgsSafe(args: readonly string[], entry: CommandPolicyEntry): void { + const denied = new Set(entry.deniedFlags ?? []); + for (const arg of args) { + if (arg.includes('\0')) throw new Error('Command args must not contain NUL bytes'); + if (denied.has(arg)) throw new Error(`Command flag "${arg}" is denied by command policy`); + } +} + +function assertAllowedSubcommand(args: readonly string[], entry: CommandPolicyEntry): void { + const allowedSubcommands = entry.allowedSubcommands ?? []; + if (allowedSubcommands.length === 0) return; + const subcommand = args[0]; + if (!subcommand || subcommand.startsWith('-') || !allowedSubcommands.includes(subcommand)) { + throw new Error(`Command subcommand "${subcommand ?? ''}" is not allowed by command policy`); + } +} + +function assertAllowedCommandArg(arg: string, index: number, entry: CommandPolicyEntry): void { + if (index === 0 && (entry.allowedSubcommands ?? []).includes(arg)) return; + if (arg.startsWith('-')) { + if (!(entry.allowedFlags ?? []).includes(arg)) { + throw new Error(`Command flag "${arg}" is not allowed by command policy`); + } + return; + } + if (!isAllowedByPatterns(arg, entry.allowedArgPatterns)) { + throw new Error(`Command arg "${arg}" is not allowed by command policy`); + } +} + +function validateCommandPolicy( + binary: string, + args: readonly string[], + policy: readonly CommandPolicyEntry[] | undefined, +): CommandPolicyEntry { + const entry = findCommandPolicyEntry(binary, policy); + assertCommandArgsSafe(args, entry); + assertAllowedSubcommand(args, entry); + for (const [index, arg] of args.entries()) { + assertAllowedCommandArg(arg, index, entry); + } + return entry; +} + +function constrainedCommandEnv(): NodeJS.ProcessEnv { + return { + ...(process.env.PATH ? { PATH: process.env.PATH } : {}), + ...(process.env.NODE_ENV ? { NODE_ENV: process.env.NODE_ENV } : {}), + }; +} + +function formatCommandOutput(exitCode: number | null, stdout: string, stderr: string): string { + const parts = [`exitCode: ${exitCode ?? 'signal'}`]; + if (stdout) parts.push('', 'stdout:', stdout.trimEnd()); + if (stderr) parts.push('', 'stderr:', stderr.trimEnd()); + return parts.join('\n'); +} + +interface ExecFileStrictResult { + stdout: string; + stderr: string; +} + +interface ExecFileStrictError extends Error { + code?: number | string | null; + signal?: NodeJS.Signals | null; + stdout?: string; + stderr?: string; + timedOut?: boolean; +} + +function execFileWithStrictTimeout( + binary: string, + args: readonly string[], + options: { + cwd: string; + timeoutMs: number; + killGraceMs: number; + maxBuffer: number; + env: NodeJS.ProcessEnv; + }, +): Promise { + return new Promise((resolve, reject) => { + let timedOut = false; + let timeoutHandle: NodeJS.Timeout | undefined; + let killHandle: NodeJS.Timeout | undefined; + const child = execFile( + binary, + [...args], + { + cwd: options.cwd, + maxBuffer: options.maxBuffer, + env: options.env, + }, + (err, stdout, stderr) => { + if (timeoutHandle) clearTimeout(timeoutHandle); + if (killHandle) clearTimeout(killHandle); + if (err) { + const error = err as ExecFileStrictError; + error.stdout = String(stdout ?? ''); + error.stderr = String(stderr ?? ''); + if (timedOut) error.timedOut = true; + reject(error); + return; + } + if (timedOut) { + const error = new Error(`Command timed out after ${options.timeoutMs}ms`) as ExecFileStrictError; + error.stdout = String(stdout ?? ''); + error.stderr = String(stderr ?? ''); + error.timedOut = true; + reject(error); + return; + } + resolve({ stdout: String(stdout ?? ''), stderr: String(stderr ?? '') }); + }, + ); + + timeoutHandle = setTimeout(() => { + timedOut = true; + child.kill('SIGTERM'); + killHandle = setTimeout(() => { + child.kill('SIGKILL'); + }, options.killGraceMs); + killHandle.unref?.(); + }, options.timeoutMs); + timeoutHandle.unref?.(); + }); +} + +async function resolvePolicyEntry( + binary: string, + args: readonly string[], + options: CatAgentToolRegistryOptions, +): Promise { + try { + return validateCommandPolicy(binary, args, options.commandPolicy); + } catch (err) { + await rejectWithAudit(options, { tool: 'run_command', binary, args }, (err as Error).message); + throw err; + } +} + +async function executeRunCommand( + input: Record, + workDir: string, + options: CatAgentToolRegistryOptions, +): Promise { + const binary = input.binary as string; + const rawArgs = input.args as unknown[]; + if (!Array.isArray(rawArgs) || rawArgs.some((arg) => typeof arg !== 'string')) { + await rejectWithAudit(options, { tool: 'run_command', binary }, 'run_command args must be an array of strings'); + } + const args = rawArgs as string[]; + const policyEntry = await resolvePolicyEntry(binary, args, options); + + const startedAt = Date.now(); + const timeout = options.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS; + const killGrace = options.commandKillGraceMs ?? DEFAULT_COMMAND_KILL_GRACE_MS; + try { + const { stdout, stderr } = await execFileWithStrictTimeout(binary, args, { + cwd: workDir, + timeoutMs: timeout, + killGraceMs: killGrace, + maxBuffer: COMMAND_MAX_BUFFER, + env: constrainedCommandEnv(), + }); + await emitAudit(options, { + tool: 'run_command', + outcome: 'ok', + timestamp: Date.now(), + binary, + args, + exitCode: 0, + durationMs: Date.now() - startedAt, + stdoutBytes: Buffer.byteLength(stdout), + stderrBytes: Buffer.byteLength(stderr), + policyEntry: policyEntry.binary, + }); + return formatCommandOutput(0, stdout, stderr); + } catch (err) { + const e = err as ExecFileStrictError; + const stdout = e.stdout ?? ''; + const stderr = e.stderr ?? ''; + const timedOut = e.timedOut === true; + await emitAudit(options, { + tool: 'run_command', + outcome: 'error', + timestamp: Date.now(), + binary, + args, + exitCode: typeof e.code === 'number' ? e.code : null, + durationMs: Date.now() - startedAt, + stdoutBytes: Buffer.byteLength(stdout), + stderrBytes: Buffer.byteLength(stderr), + policyEntry: policyEntry.binary, + rejectReason: timedOut ? `timed out after ${timeout}ms; sent SIGTERM then SIGKILL` : e.message, + }); + if (timedOut) throw new Error(`Command timed out after ${timeout}ms`); + throw new Error(formatCommandOutput(typeof e.code === 'number' ? e.code : null, stdout, stderr)); + } +} + +// ── update_current_task_status ── + +const updateCurrentTaskStatusSchema: ToolSchema = { + name: 'update_current_task_status', + description: 'Update the current task status/progress/summary. Scoped to the current invocation and current task.', + input_schema: { + type: 'object', + properties: { + status: { type: 'string', description: 'Task status: todo, doing, blocked, or done' }, + progress: { type: 'number', description: 'Progress percentage, 0-100' }, + summary: { type: 'string', description: 'Short task progress summary' }, + }, + required: [] as const, + }, +}; + +async function executeUpdateCurrentTaskStatus( + input: Record, + options: CatAgentToolRegistryOptions, +): Promise { + const currentTask = options.scopedCallbacks?.currentTask; + if (!currentTask) throw new Error('No current task is bound for this invocation'); + + const patch: { status?: TaskStatus; progress?: number; summary?: string } = {}; + if (input.status !== undefined) { + if (!TASK_STATUSES.has(input.status as TaskStatus)) throw new Error(`Unsupported task status "${input.status}"`); + patch.status = input.status as TaskStatus; + } + if (input.progress !== undefined) { + const progress = input.progress as number; + if (!Number.isFinite(progress) || progress < 0 || progress > 100) { + throw new Error('progress must be a finite number between 0 and 100'); + } + patch.progress = progress; + } + if (input.summary !== undefined) { + const summary = (input.summary as string).trim(); + if (!summary || summary.length > 500) throw new Error('summary must be 1-500 characters'); + patch.summary = summary; + } + const changedFields = Object.keys(patch); + if (changedFields.length === 0) throw new Error('At least one of status, progress, or summary is required'); + + await currentTask.updateCurrentTaskStatus(patch); + await emitAudit(options, { + tool: 'update_current_task_status', + outcome: 'ok', + timestamp: Date.now(), + invocationId: currentTask.invocationId, + currentTaskId: currentTask.currentTaskId, + changedFields, + }); + return `Updated current task ${currentTask.currentTaskId}: ${changedFields.join(', ')}`; +} + // ── Registry ── -export async function buildToolRegistry(workDir: string): Promise { - const tools: CatAgentTool[] = [ - { schema: readFileSchema, execute: (i) => executeReadFile(i, workDir), permission: 'allow' }, - { schema: listFilesSchema, execute: (i) => executeListFiles(i, workDir), permission: 'allow' }, - ]; - if (await checkRgAvailable()) { +function levelAtLeast(level: CatAgentToolRegistryOptions['nativeToolLevel'], required: 'L1' | 'L2'): boolean { + return LEVEL_RANK[level ?? 'L0'] >= LEVEL_RANK[required]; +} + +export async function buildToolRegistry( + workDir?: string, + options: CatAgentToolRegistryOptions = {}, +): Promise { + const tools: CatAgentTool[] = []; + if (workDir) { + tools.push( + { schema: readFileSchema, execute: (i) => executeReadFile(i, workDir), permission: 'allow' }, + { schema: listFilesSchema, execute: (i) => executeListFiles(i, workDir), permission: 'allow' }, + ); + } + if (workDir && (await checkRgAvailable())) { tools.push({ schema: searchContentSchema, execute: (i) => executeSearchContent(i, workDir), permission: 'allow' }); } + if (workDir && levelAtLeast(options.nativeToolLevel, 'L1')) { + tools.push( + { schema: writeFileSchema, execute: (i) => executeWriteFile(i, workDir, options), permission: 'allow' }, + { schema: patchFileSchema, execute: (i) => executePatchFile(i, workDir, options), permission: 'allow' }, + ); + } + if (workDir && levelAtLeast(options.nativeToolLevel, 'L2')) { + tools.push({ + schema: runCommandSchema, + execute: (i) => executeRunCommand(i, workDir, options), + permission: 'allow', + }); + } + if (options.scopedCallbacks?.currentTask) { + tools.push({ + schema: updateCurrentTaskStatusSchema, + execute: (i) => executeUpdateCurrentTaskStatus(i, options), + permission: 'allow', + }); + } return tools; } diff --git a/packages/api/src/domains/cats/services/agents/providers/catagent/catagent-stream-parser.ts b/packages/api/src/domains/cats/services/agents/providers/catagent/catagent-stream-parser.ts index d59ca94e85..a33ccfbead 100644 --- a/packages/api/src/domains/cats/services/agents/providers/catagent/catagent-stream-parser.ts +++ b/packages/api/src/domains/cats/services/agents/providers/catagent/catagent-stream-parser.ts @@ -1,23 +1,46 @@ /** - * CatAgent SSE Stream Parser — F159 Phase E + * CatAgent SSE Stream Parser — F159 Phase E (G1: now yields neutral events) + * + * Parses Anthropic Messages API SSE stream into protocol-neutral + * `CatAgentStreamEvent`s. Anthropic-shaped types (`AnthropicContentBlock`, + * `AnthropicUsage`) stay strictly inside this file as Anthropic-specific + * intermediate types; the parser maps them to neutral + * `CatAgentNeutralBlock` / `CatAgentUsageDelta` before yielding. + * + * G1 design note: This file is logically a private implementation detail of + * `AnthropicMessagesAdapter` — but is kept at this path to minimise rename + * churn during the refactor. The grep verifier (AC-G12) treats this file as + * adapter-owned, not service-layer, so `Anthropic*` imports here are + * expected and allowed. * - * Parses Anthropic Messages API SSE stream into typed events. * Handles proper SSE framing (multi-line data, CRLF, comments, event:error). * No @anthropic-ai/sdk dependency — raw fetch + TextDecoder. */ import type { AnthropicContentBlock, AnthropicUsage } from './catagent-event-bridge.js'; +import { mapAnthropicUsage } from './catagent-event-bridge.js'; +import type { CatAgentNeutralBlock, CatAgentStreamEvent, CatAgentUsageDelta } from './catagent-protocol-types.js'; const MAX_TOOL_INPUT_BYTES = 65_536; -// ── Stream event types ── +// ── Neutral block / usage normalisation (Anthropic → neutral, adapter-private) ── -export type CatAgentStreamEvent = - | { type: 'text_delta'; text: string; blockIndex: number } - | { type: 'content_block_complete'; block: AnthropicContentBlock; blockIndex: number } - | { type: 'usage_update'; inputUsage?: AnthropicUsage; outputTokens?: number } - | { type: 'stop'; stopReason: string | null } - | { type: 'stream_error'; error: string }; +function anthropicBlockToNeutral(block: AnthropicContentBlock): CatAgentNeutralBlock { + if (block.type === 'text') return { type: 'text', text: block.text }; + // block.type === 'tool_use' + return { type: 'tool_call', id: block.id, name: block.name, input: block.input }; +} + +function anthropicInputUsageToNeutral(usage: AnthropicUsage): CatAgentUsageDelta { + // Reuse mapAnthropicUsage to keep the cache-aware totalInput convention + // (raw + cache_read + cache_creation) — single source of truth for + // Anthropic prompt-cache observability normalisation. + const tokenUsage = mapAnthropicUsage(usage); + const delta: CatAgentUsageDelta = { inputTokens: tokenUsage.inputTokens }; + if (tokenUsage.cacheReadTokens !== undefined) delta.cacheReadTokens = tokenUsage.cacheReadTokens; + if (tokenUsage.cacheCreationTokens !== undefined) delta.cacheCreationTokens = tokenUsage.cacheCreationTokens; + return delta; +} // ── SSE line parser state ── @@ -157,7 +180,7 @@ function* handleSSEEvent(evt: ParsedSSEEvent, ctx: StreamContext): Iterable | undefined; const usage = message?.usage as AnthropicUsage | undefined; - if (usage) yield { type: 'usage_update', inputUsage: usage }; + if (usage) yield { type: 'usage_update', usage: anthropicInputUsageToNeutral(usage) }; return; } @@ -202,7 +225,11 @@ function* handleSSEEvent(evt: ParsedSSEEvent, ctx: StreamContext): Iterable = {}; if (block.toolInputBytes > MAX_TOOL_INPUT_BYTES) { @@ -216,7 +243,7 @@ function* handleSSEEvent(evt: ParsedSSEEvent, ctx: StreamContext): Iterable | undefined; const usage = parsed.usage as Record | undefined; const outputTokens = usage?.output_tokens as number | undefined; - if (outputTokens !== undefined) yield { type: 'usage_update', outputTokens }; + if (outputTokens !== undefined) yield { type: 'usage_update', usage: { outputTokens } }; const stopReason = delta?.stop_reason as string | null | undefined; if (stopReason !== undefined) yield { type: 'stop', stopReason }; return; diff --git a/packages/api/src/domains/cats/services/agents/providers/catagent/catagent-tools.ts b/packages/api/src/domains/cats/services/agents/providers/catagent/catagent-tools.ts index ec1a577a24..449f508862 100644 --- a/packages/api/src/domains/cats/services/agents/providers/catagent/catagent-tools.ts +++ b/packages/api/src/domains/cats/services/agents/providers/catagent/catagent-tools.ts @@ -4,11 +4,15 @@ * Thin delegation to shared resolveWorkspacePath (workspace-security.ts). * Ensures a single path-validation implementation across all providers. * - * Tool registry (read_file / list_files / search_content) ships in Phase D. - * ADR-001 F159 boundary: no write/edit/delete, no shell/exec, no network tools. + * Tool registry (read_file / list_files / search_content) shipped in Phase D. + * Phase F adds gated write/exec tools under nativeToolLevel and commandPolicy. */ -import { resolveWorkspacePath } from '../../../../../../domains/workspace/workspace-security.js'; +import type { CommandPolicyEntry, NativeToolLevel, TaskStatus } from '@cat-cafe/shared'; +import { + resolveWorkspaceCreatePath, + resolveWorkspacePath, +} from '../../../../../../domains/workspace/workspace-security.js'; /** Anthropic tool schema shape (inline to avoid SDK dependency in this slice) */ export interface ToolSchema { @@ -27,6 +31,52 @@ export interface CatAgentTool { permission: ToolPermission; } +export interface CatAgentToolAuditEvent { + tool: string; + outcome: 'ok' | 'error' | 'rejected'; + timestamp: number; + path?: string; + bytes?: number; + hashBefore?: string | null; + hashAfter?: string; + binary?: string; + args?: readonly string[]; + exitCode?: number | null; + durationMs?: number; + stdoutBytes?: number; + stderrBytes?: number; + policyEntry?: string; + rejectReason?: string; + invocationId?: string; + currentTaskId?: string; + changedFields?: readonly string[]; +} + +export type CatAgentToolAuditSink = (event: CatAgentToolAuditEvent) => void | Promise; + +export interface CatAgentCurrentTaskCallback { + invocationId: string; + currentTaskId: string; + updateCurrentTaskStatus: (patch: { + status?: TaskStatus; + progress?: number; + summary?: string; + }) => void | Promise; +} + +export interface CatAgentScopedCallbacks { + currentTask?: CatAgentCurrentTaskCallback; +} + +export interface CatAgentToolRegistryOptions { + nativeToolLevel?: NativeToolLevel; + commandPolicy?: readonly CommandPolicyEntry[]; + commandTimeoutMs?: number; + commandKillGraceMs?: number; + audit?: CatAgentToolAuditSink; + scopedCallbacks?: CatAgentScopedCallbacks; +} + /** * Resolve and validate a path within the working directory. * Pure delegation to resolveWorkspacePath — no error translation, @@ -35,3 +85,12 @@ export interface CatAgentTool { export async function resolveSecurePath(workingDirectory: string, filePath: string): Promise { return resolveWorkspacePath(workingDirectory, filePath); } + +/** + * Resolve and validate a path intended for file creation/replacement. + * This validates the nearest existing ancestor realpath, closing ENOENT + + * symlink-parent escapes before write_file can create a new path. + */ +export async function resolveCreatePath(workingDirectory: string, filePath: string): Promise { + return resolveWorkspaceCreatePath(workingDirectory, filePath); +} diff --git a/packages/api/src/domains/cats/services/agents/providers/catagent/openai-chat-adapter.ts b/packages/api/src/domains/cats/services/agents/providers/catagent/openai-chat-adapter.ts new file mode 100644 index 0000000000..3c249b254b --- /dev/null +++ b/packages/api/src/domains/cats/services/agents/providers/catagent/openai-chat-adapter.ts @@ -0,0 +1,344 @@ +/** + * OpenAI Chat Adapter — F159 Phase G Slice G2 Axis 5 + * + * Concrete implementation of {@link CatAgentProtocolAdapter} for OpenAI-style + * Chat Completions streaming (`POST /v1/chat/completions`, Bearer auth, SSE + * `data:` frames ending with `[DONE]`). + * + * Mirrors the Anthropic adapter's contract layers: + * - URL / headers / request body + * - Streaming chunk → neutral events + * - Transcript codec for assistant turns + tool results + * - Truthful client family / protocol identifiers + */ + +import type { + AdapterCredentials, + AdapterRequestInput, + AdapterToolDefinition, + AdapterToolResult, + CatAgentProtocolAdapter, +} from './catagent-protocol-adapter.js'; +import type { AdapterMessage, CatAgentNeutralBlock, CatAgentStreamEvent } from './catagent-protocol-types.js'; + +const DEFAULT_BASE_URL = 'https://api.openai.com'; +const DEFAULT_MAX_TOKENS = 4096; +const MAX_TOOL_INPUT_BYTES = 65_536; +const TERMINAL_STOP_REASONS = new Set(['stop', 'length', 'content_filter']); + +interface OpenAIChatToolCall { + id: string; + type: 'function'; + function: { + name: string; + arguments: string; + }; +} + +interface OpenAIChatMessage { + role: 'system' | 'user' | 'assistant' | 'tool'; + content: string | null; + tool_calls?: OpenAIChatToolCall[]; + tool_call_id?: string; +} + +interface OpenAIChoiceDeltaToolCall { + index?: number; + id?: string; + function?: { + name?: string; + arguments?: string; + }; +} + +interface OpenAIStreamContext { + text: string; + textSeen: boolean; + toolCalls: Map; + sawAnyEvent: boolean; + sawDone: boolean; +} + +interface OpenAIToolCallAccumulator { + id: string; + name: string; + argumentsJson: string; + argumentBytes: number; +} + +function buildOpenAIChatCompletionsUrl(baseURL?: string): string { + const rawBaseUrl = baseURL?.trim() || DEFAULT_BASE_URL; + const root = rawBaseUrl.replace(/\/+$/, '').replace(/\/v1$/i, ''); + return `${root}/v1/chat/completions`; +} + +function wrapMessages(payload: OpenAIChatMessage[]): AdapterMessage { + return { __adapterMessage: true, payload }; +} + +function unwrapMessages(message: AdapterMessage): OpenAIChatMessage[] { + return message.payload as OpenAIChatMessage[]; +} + +function buildToolDefinitions(tools: ReadonlyArray): unknown[] { + return tools.map((tool) => ({ + type: 'function', + function: { + name: tool.name, + description: tool.description, + parameters: tool.inputSchema, + }, + })); +} + +function neutralToolCallToOpenAI(block: Extract): OpenAIChatToolCall { + return { + id: block.id, + type: 'function', + function: { + name: block.name, + arguments: JSON.stringify(block.input), + }, + }; +} + +function finaliseToolCallInput(acc: OpenAIToolCallAccumulator): Record { + if (acc.argumentBytes > MAX_TOOL_INPUT_BYTES) return { _error: 'Tool input exceeded size limit' }; + try { + return JSON.parse(acc.argumentsJson || '{}'); + } catch { + return { _error: 'Invalid tool input JSON' }; + } +} + +async function* parseOpenAIChatStream( + body: ReadableStream, + signal?: AbortSignal, +): AsyncIterable { + const reader = body.getReader(); + const decoder = new TextDecoder('utf-8', { fatal: false }); + let buffer = ''; + const dataLines: string[] = []; + const ctx: OpenAIStreamContext = { + text: '', + textSeen: false, + toolCalls: new Map(), + sawAnyEvent: false, + sawDone: false, + }; + + try { + while (true) { + if (signal?.aborted) { + yield { type: 'stream_error', error: 'Request aborted' }; + return; + } + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split(/\r?\n/); + buffer = lines.pop() ?? ''; + + for (const line of lines) { + const maybeEvent = flushDataLine(line, dataLines); + if (maybeEvent !== undefined) { + yield* handleOpenAIDataEvent(maybeEvent, ctx); + } + } + } + + if (buffer.trim()) { + const maybeEvent = flushDataLine(buffer, dataLines); + if (maybeEvent !== undefined) yield* handleOpenAIDataEvent(maybeEvent, ctx); + } + if (dataLines.length > 0) yield* handleOpenAIDataEvent(dataLines.join('\n'), ctx); + + yield* emitOpenAIContentBlocks(ctx); + + if (ctx.sawAnyEvent && !ctx.sawDone) { + yield { type: 'stream_error', error: 'Stream ended without [DONE]' }; + } + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + yield { type: 'stream_error', error: `Stream read error: ${msg}` }; + } finally { + reader.releaseLock(); + } +} + +function flushDataLine(line: string, dataLines: string[]): string | undefined { + if (line === '') { + if (dataLines.length === 0) return undefined; + const event = dataLines.join('\n'); + dataLines.length = 0; + return event; + } + if (line.startsWith(':')) return undefined; + if (line.startsWith('data:')) { + dataLines.push(line.slice(5).trimStart()); + } + return undefined; +} + +function* handleOpenAIDataEvent(eventData: string, ctx: OpenAIStreamContext): Iterable { + if (!eventData) return; + if (eventData === '[DONE]') { + ctx.sawDone = true; + return; + } + + let parsed: Record; + try { + parsed = JSON.parse(eventData); + } catch { + return; + } + + ctx.sawAnyEvent = true; + + const usage = parsed.usage as Record | undefined; + if (usage) { + const promptTokensDetails = usage.prompt_tokens_details as Record | undefined; + yield { + type: 'usage_update', + usage: { + ...(typeof usage.prompt_tokens === 'number' ? { inputTokens: usage.prompt_tokens } : {}), + ...(typeof usage.completion_tokens === 'number' ? { outputTokens: usage.completion_tokens } : {}), + ...(typeof promptTokensDetails?.cached_tokens === 'number' + ? { cacheReadTokens: promptTokensDetails.cached_tokens as number } + : {}), + }, + }; + } + + const choices = Array.isArray(parsed.choices) ? parsed.choices : []; + const choice = choices[0] as Record | undefined; + if (!choice) return; + + const delta = choice.delta as Record | undefined; + if (delta && typeof delta.content === 'string' && delta.content.length > 0) { + ctx.textSeen = true; + ctx.text += delta.content; + yield { type: 'text_delta', text: delta.content, blockIndex: 0 }; + } + + if (delta && Array.isArray(delta.tool_calls)) { + for (const entry of delta.tool_calls) { + if (!entry || typeof entry !== 'object') continue; + const toolCall = entry as OpenAIChoiceDeltaToolCall; + const index = typeof toolCall.index === 'number' ? toolCall.index : 0; + const acc = ctx.toolCalls.get(index) ?? { id: '', name: '', argumentsJson: '', argumentBytes: 0 }; + if (typeof toolCall.id === 'string' && toolCall.id.length > 0) acc.id ||= toolCall.id; + if (toolCall.function?.name) acc.name += toolCall.function.name; + if (typeof toolCall.function?.arguments === 'string') { + acc.argumentBytes += Buffer.byteLength(toolCall.function.arguments); + if (acc.argumentBytes <= MAX_TOOL_INPUT_BYTES) acc.argumentsJson += toolCall.function.arguments; + } + ctx.toolCalls.set(index, acc); + } + } + + const finishReason = choice.finish_reason; + if (finishReason !== undefined && finishReason !== null) { + yield { type: 'stop', stopReason: String(finishReason) }; + } +} + +function* emitOpenAIContentBlocks(ctx: OpenAIStreamContext): Iterable { + let nextIndex = 0; + if (ctx.textSeen) { + yield { + type: 'content_block_complete', + block: { type: 'text', text: ctx.text }, + blockIndex: nextIndex, + }; + nextIndex++; + } + for (const [index, acc] of [...ctx.toolCalls.entries()].sort((a, b) => a[0] - b[0])) { + yield { + type: 'content_block_complete', + block: { + type: 'tool_call', + id: acc.id || `call_${index}`, + name: acc.name || 'unknown_tool', + input: finaliseToolCallInput(acc), + }, + blockIndex: nextIndex + index, + }; + } +} + +export class OpenAIChatAdapter implements CatAgentProtocolAdapter { + readonly clientFamily = 'openai' as const; + readonly protocolId = 'openai-chat-v1' as const; + + buildRequestUrl(baseURL?: string): string { + return buildOpenAIChatCompletionsUrl(baseURL); + } + + buildRequestHeaders(credentials: AdapterCredentials): Record { + return { + 'Content-Type': 'application/json', + Authorization: `Bearer ${credentials.apiKey}`, + }; + } + + buildRequestBody(input: AdapterRequestInput): unknown { + const messages = input.messages.flatMap((message) => unwrapMessages(message)); + const body: Record = { + model: input.model, + max_tokens: input.maxTokens ?? DEFAULT_MAX_TOKENS, + messages: input.systemPrompt ? [{ role: 'system' as const, content: input.systemPrompt }, ...messages] : messages, + stream: true, + stream_options: { include_usage: true }, + }; + if (input.tools.length > 0) body.tools = buildToolDefinitions(input.tools); + return body; + } + + parseStreamEvents(body: ReadableStream, signal?: AbortSignal): AsyncIterable { + return parseOpenAIChatStream(body, signal); + } + + encodeUserPrompt(prompt: string): AdapterMessage { + return wrapMessages([{ role: 'user', content: prompt }]); + } + + encodeAssistantTurn(blocks: ReadonlyArray): AdapterMessage { + const text = blocks + .filter((block): block is Extract => block.type === 'text') + .map((block) => block.text) + .join(''); + const toolCalls = blocks + .filter((block): block is Extract => block.type === 'tool_call') + .map((block) => neutralToolCallToOpenAI(block)); + return wrapMessages([ + { + role: 'assistant', + content: text.length > 0 ? text : null, + ...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}), + }, + ]); + } + + encodeToolResults(results: ReadonlyArray): AdapterMessage { + return wrapMessages( + results.map((result) => ({ + role: 'tool' as const, + tool_call_id: result.id, + content: result.content, + })), + ); + } + + mapError(err: { status?: number; message?: string }): { errorText: string } { + const status = err.status ?? 0; + const msg = err.message ?? 'Unknown API error'; + return { errorText: `OpenAI API error (${status}): ${msg}` }; + } + + isTerminalStopReason(stopReason: string | null): boolean { + return stopReason != null && TERMINAL_STOP_REASONS.has(stopReason); + } +} diff --git a/packages/api/src/domains/cats/services/agents/providers/cli-jsonl/CliJsonlAgentService.ts b/packages/api/src/domains/cats/services/agents/providers/cli-jsonl/CliJsonlAgentService.ts new file mode 100644 index 0000000000..67f21a576c --- /dev/null +++ b/packages/api/src/domains/cats/services/agents/providers/cli-jsonl/CliJsonlAgentService.ts @@ -0,0 +1,256 @@ +/** + * F241 Phase A: host-owned CLI JSONL AgentService. + * + * This is a constrained smoke transport for already-installed external agent + * runtimes. Prompt text is delivered through stdin, not argv. + */ + +import { type CatId, createCatId } from '@cat-cafe/shared'; +import { formatCliExitError } from '../../../../../../utils/cli-format.js'; +import { formatCliNotFoundError, resolveCliCommand } from '../../../../../../utils/cli-resolve.js'; +import { isCliError, isCliTimeout, isLivenessWarning, spawnCli } from '../../../../../../utils/cli-spawn.js'; +import type { SpawnFn } from '../../../../../../utils/cli-types.js'; +import { CliRawArchive } from '../../../session/CliRawArchive.js'; +import type { AgentMessage, AgentService, AgentServiceOptions, MessageMetadata } from '../../../types.js'; +import { type RawArchiveSink, sanitizeRawEvent } from '../codex-audit-hooks.js'; +import { transformCliJsonlEvent } from './cli-jsonl-event-transform.js'; + +export type CliJsonlSessionPolicy = 'resume' | 'stateless'; + +export interface CliJsonlAgentServiceOptions { + catId: CatId | string; + providerName: string; + modelName: string; + command: string; + startupArgs?: readonly string[]; + resumeArgs?: readonly string[]; + sessionPolicy?: CliJsonlSessionPolicy; + timeoutMs?: number; + spawnFn?: SpawnFn; + rawArchive?: RawArchiveSink; +} + +const DEFAULT_RESUME_ARGS = ['resume', '{sessionId}', '--json'] as const; + +function withMetadata(msg: AgentMessage, metadata: MessageMetadata): AgentMessage { + if (!msg.metadata) return { ...msg, metadata }; + return { + ...msg, + metadata: { + ...msg.metadata, + provider: metadata.provider, + model: metadata.model, + ...(metadata.sessionId ? { sessionId: metadata.sessionId } : {}), + ...(msg.metadata.usage ? { usage: msg.metadata.usage } : {}), + }, + }; +} + +function buildPrompt(prompt: string, options?: AgentServiceOptions): string { + if (!options?.systemPrompt) return prompt; + return `${options.systemPrompt}\n\n${prompt}`; +} + +function buildResumeArgs(template: readonly string[], sessionId: string): string[] { + return template.map((arg) => arg.replaceAll('{sessionId}', sessionId)); +} + +function containsLineBreak(prompt: string): boolean { + return /[\r\n]/.test(prompt); +} + +export class CliJsonlAgentService implements AgentService { + readonly catId: CatId; + private readonly providerName: string; + private readonly modelName: string; + private readonly command: string; + private readonly startupArgs: readonly string[]; + private readonly resumeArgs: readonly string[]; + private readonly sessionPolicy: CliJsonlSessionPolicy; + private readonly timeoutMs: number | undefined; + private readonly spawnFn: SpawnFn | undefined; + private readonly rawArchive: RawArchiveSink; + + constructor(options: CliJsonlAgentServiceOptions) { + this.catId = createCatId(options.catId as string); + this.providerName = options.providerName; + this.modelName = options.modelName; + this.command = options.command; + this.startupArgs = options.startupArgs ?? []; + this.resumeArgs = options.resumeArgs ?? DEFAULT_RESUME_ARGS; + this.sessionPolicy = options.sessionPolicy ?? 'resume'; + this.timeoutMs = options.timeoutMs; + this.spawnFn = options.spawnFn; + this.rawArchive = options.rawArchive ?? new CliRawArchive(); + } + + async *invoke(prompt: string, options?: AgentServiceOptions): AsyncIterable { + const metadata: MessageMetadata = { provider: this.providerName, model: this.modelName }; + const command = options?.spawnCliOverride ? this.command : resolveCliCommand(this.command); + if (!command) { + yield { + type: 'error', + catId: this.catId, + error: formatCliNotFoundError(this.command), + metadata, + timestamp: Date.now(), + }; + yield { type: 'done', catId: this.catId, metadata, timestamp: Date.now() }; + return; + } + + const env: Record = { ...(options?.callbackEnv ?? {}) }; + if (options?.accountEnv) { + for (const [key, value] of Object.entries(options.accountEnv)) env[key] = value; + } + + const promptInput = buildPrompt(prompt, options); + const requestedSessionId = typeof options?.sessionId === 'string' ? options.sessionId.trim() : ''; + const resumeRequested = requestedSessionId.length > 0; + const resumeBlockedByPromptShape = + this.sessionPolicy === 'resume' && resumeRequested && containsLineBreak(promptInput); + const resumeEnabled = this.sessionPolicy === 'resume' && resumeRequested && !resumeBlockedByPromptShape; + const sessionContinuityDegraded = + resumeRequested && (this.sessionPolicy === 'stateless' || resumeBlockedByPromptShape); + if (sessionContinuityDegraded) { + yield { + type: 'system_info', + catId: this.catId, + content: JSON.stringify({ + type: 'session_continuity_degraded', + reason: resumeBlockedByPromptShape ? 'cli_jsonl_resume_requires_single_line_prompt' : 'cli_jsonl_stateless', + requestedSessionId, + }), + metadata, + timestamp: Date.now(), + }; + } + + const invocationId = options?.invocationId ?? options?.auditContext?.invocationId; + const cliOpts = { + command, + args: resumeEnabled ? buildResumeArgs(this.resumeArgs, requestedSessionId) : this.startupArgs, + stdinInput: promptInput, + ...(options?.workingDirectory ? { cwd: options.workingDirectory } : {}), + ...(Object.keys(env).length > 0 ? { env } : {}), + ...(this.timeoutMs !== undefined ? { timeoutMs: this.timeoutMs } : {}), + ...(options?.signal ? { signal: options.signal } : {}), + ...(invocationId ? { invocationId } : {}), + ...(options?.cliSessionId ? { cliSessionId: options.cliSessionId } : {}), + ...(options?.livenessProbe ? { livenessProbe: options.livenessProbe } : {}), + ...(options?.parentSpan ? { parentSpan: options.parentSpan } : {}), + ...(invocationId && this.rawArchive.getPath ? { rawArchivePath: this.rawArchive.getPath(invocationId) } : {}), + }; + const events = options?.spawnCliOverride + ? options.spawnCliOverride(cliOpts) + : spawnCli(cliOpts, this.spawnFn ? { spawnFn: this.spawnFn } : undefined); + + let semanticEventSeen = false; + let textSeen = false; + let errorSeen = false; + + try { + for await (const event of events) { + if (invocationId) { + this.rawArchive.append(invocationId, sanitizeRawEvent(event)).catch(() => { + // Raw archive is diagnostic-only; invocation output must not fail on archive I/O. + }); + } + + if (isCliTimeout(event)) { + errorSeen = true; + yield { + type: 'system_info', + catId: this.catId, + content: JSON.stringify({ + type: 'timeout_diagnostics', + silenceDurationMs: event.silenceDurationMs, + processAlive: event.processAlive, + lastEventType: event.lastEventType, + firstEventAt: event.firstEventAt, + lastEventAt: event.lastEventAt, + cliSessionId: event.cliSessionId, + invocationId: event.invocationId, + rawArchivePath: event.rawArchivePath, + }), + metadata, + timestamp: Date.now(), + }; + yield { + type: 'error', + catId: this.catId, + error: `${this.providerName} CLI 响应超时 (${Math.round(event.timeoutMs / 1000)}s)`, + metadata: event.cliDiagnostics ? { ...metadata, cliDiagnostics: event.cliDiagnostics } : metadata, + timestamp: Date.now(), + }; + continue; + } + + if (isLivenessWarning(event)) { + yield { + type: 'system_info', + catId: this.catId, + content: JSON.stringify({ type: 'liveness_warning', ...event }), + metadata, + timestamp: Date.now(), + }; + continue; + } + + if (isCliError(event)) { + errorSeen = true; + yield { + type: 'error', + catId: this.catId, + error: formatCliExitError(`${this.providerName} CLI`, event), + metadata: event.cliDiagnostics ? { ...metadata, cliDiagnostics: event.cliDiagnostics } : metadata, + timestamp: Date.now(), + }; + continue; + } + + const messages = transformCliJsonlEvent(event, this.catId, { + emitSessionInit: this.sessionPolicy === 'resume', + ephemeralSession: false, + }); + if (messages.length === 0) continue; + semanticEventSeen = true; + for (const msg of messages) { + if (msg.type === 'session_init' && msg.sessionId) metadata.sessionId = msg.sessionId; + if (msg.type === 'text') textSeen = true; + if (msg.type === 'error') errorSeen = true; + yield withMetadata(msg, metadata); + } + } + + if (!semanticEventSeen && !errorSeen) { + yield { + type: 'error', + catId: this.catId, + error: `${this.providerName} CLI exited without a JSONL turn_result event`, + metadata, + timestamp: Date.now(), + }; + errorSeen = true; + } else if (semanticEventSeen && !textSeen && !errorSeen) { + yield { + type: 'error', + catId: this.catId, + error: `${this.providerName} CLI completed without text output`, + metadata, + timestamp: Date.now(), + }; + } + yield { type: 'done', catId: this.catId, metadata, timestamp: Date.now() }; + } catch (err) { + yield { + type: 'error', + catId: this.catId, + error: err instanceof Error ? err.message : String(err), + metadata, + timestamp: Date.now(), + }; + yield { type: 'done', catId: this.catId, metadata, timestamp: Date.now() }; + } + } +} diff --git a/packages/api/src/domains/cats/services/agents/providers/cli-jsonl/CliJsonlProviderTransportFactory.ts b/packages/api/src/domains/cats/services/agents/providers/cli-jsonl/CliJsonlProviderTransportFactory.ts new file mode 100644 index 0000000000..121ebd9477 --- /dev/null +++ b/packages/api/src/domains/cats/services/agents/providers/cli-jsonl/CliJsonlProviderTransportFactory.ts @@ -0,0 +1,118 @@ +/** + * F241 Phase A: CLI JSONL as a host-owned provider transport. + * + * Intended first smoke path for clowder-code while ACP/A2A support lands in the + * external runtime. The host owns process spawn, cwd, env injection, timeout, + * and JSONL-to-AgentMessage mapping. + */ + +import type { FastifyBaseLogger } from 'fastify'; +import type { ProviderTransportFactory } from '../transport/ProviderTransportRegistry.js'; +import { CliJsonlAgentService, type CliJsonlSessionPolicy } from './CliJsonlAgentService.js'; + +export interface CliJsonlProviderTransportFactoryDeps { + log: Pick; +} + +interface ParsedCliJsonlTransportConfig { + command: string; + startupArgs: string[]; + resumeArgs: string[]; + sessionPolicy: CliJsonlSessionPolicy; + outputProfile: 'clowder-code-turn-result-v1'; + timeoutMs?: number; +} + +const DEFAULT_STARTUP_ARGS = ['--json', '--non-interactive']; +const DEFAULT_RESUME_ARGS = ['resume', '{sessionId}', '--json']; +const OUTPUT_PROFILE = 'clowder-code-turn-result-v1'; + +function parseStringArray(value: unknown): string[] | null { + return Array.isArray(value) && value.every((arg) => typeof arg === 'string') ? value : null; +} + +function parseTimeoutMs(value: unknown): number | null | undefined { + if (value === undefined) return undefined; + if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) return null; + return value; +} + +function parseSessionPolicy(value: unknown): CliJsonlSessionPolicy | null { + const sessionPolicy = value ?? 'resume'; + return sessionPolicy === 'resume' || sessionPolicy === 'stateless' ? sessionPolicy : null; +} + +function parseResumeArgs(value: unknown, sessionPolicy: CliJsonlSessionPolicy): string[] | null { + const resumeArgs = value === undefined ? DEFAULT_RESUME_ARGS : parseStringArray(value); + if (!resumeArgs) return null; + if (sessionPolicy === 'resume' && !resumeArgs.some((arg) => arg.includes('{sessionId}'))) return null; + return resumeArgs; +} + +function parseOutputProfile(value: unknown): typeof OUTPUT_PROFILE | null { + const outputProfile = value ?? OUTPUT_PROFILE; + return outputProfile === OUTPUT_PROFILE ? outputProfile : null; +} + +function parseCliJsonlTransportConfig(value: unknown): ParsedCliJsonlTransportConfig | null { + if (typeof value !== 'object' || value === null) return null; + const raw = value as Record; + if (raw.transport !== 'cli-jsonl') return null; + if (typeof raw.command !== 'string' || raw.command.trim().length === 0) return null; + const startupArgs = raw.startupArgs === undefined ? DEFAULT_STARTUP_ARGS : parseStringArray(raw.startupArgs); + if (!startupArgs) return null; + const sessionPolicy = parseSessionPolicy(raw.sessionPolicy); + if (!sessionPolicy) return null; + const resumeArgs = parseResumeArgs(raw.resumeArgs, sessionPolicy); + if (!resumeArgs) return null; + const outputProfile = parseOutputProfile(raw.outputProfile); + if (!outputProfile) return null; + const timeoutMs = parseTimeoutMs(raw.timeoutMs); + if (timeoutMs === null) return null; + return { + command: raw.command.trim(), + startupArgs, + resumeArgs, + sessionPolicy, + outputProfile, + ...(timeoutMs !== undefined ? { timeoutMs } : {}), + }; +} + +export function createCliJsonlProviderTransportFactory( + deps: CliJsonlProviderTransportFactoryDeps, +): ProviderTransportFactory { + return { + id: 'cli-jsonl', + async create(input) { + if (typeof input.providerTransport !== 'object' || input.providerTransport === null) { + return { handled: false }; + } + const raw = input.providerTransport as { transport?: unknown }; + if (raw.transport !== 'cli-jsonl') return { handled: false }; + + const parsed = parseCliJsonlTransportConfig(input.providerTransport); + if (!parsed) { + deps.log.warn( + { profileId: input.profileId }, + 'Invalid cli-jsonl provider transport declaration; profile will not be routable', + ); + return { handled: true, service: null }; + } + + return { + handled: true, + service: new CliJsonlAgentService({ + catId: input.config.id, + providerName: input.profileId, + modelName: input.config.defaultModel || parsed.outputProfile, + command: parsed.command, + startupArgs: parsed.startupArgs, + resumeArgs: parsed.resumeArgs, + sessionPolicy: parsed.sessionPolicy, + timeoutMs: parsed.timeoutMs, + }), + }; + }, + }; +} diff --git a/packages/api/src/domains/cats/services/agents/providers/cli-jsonl/cli-jsonl-event-transform.ts b/packages/api/src/domains/cats/services/agents/providers/cli-jsonl/cli-jsonl-event-transform.ts new file mode 100644 index 0000000000..18be8af622 --- /dev/null +++ b/packages/api/src/domains/cats/services/agents/providers/cli-jsonl/cli-jsonl-event-transform.ts @@ -0,0 +1,103 @@ +/** + * F241 Phase A: generic CLI JSONL event mapping. + * + * The first supported profile is clowder-code's one-shot `turn_result` line. + */ + +import type { CatId } from '@cat-cafe/shared'; +import type { AgentMessage, TokenUsage } from '../../../types.js'; + +interface CliJsonlTurnResult { + type: 'turn_result'; + response?: unknown; + terminal?: { kind?: unknown }; + stats?: { + sessionId?: unknown; + tokensUsed?: unknown; + inputTokens?: unknown; + outputTokens?: unknown; + }; +} + +export interface CliJsonlTransformOptions { + emitSessionInit?: boolean; + ephemeralSession?: boolean; +} + +function isTurnResult(value: unknown): value is CliJsonlTurnResult { + return typeof value === 'object' && value !== null && (value as { type?: unknown }).type === 'turn_result'; +} + +function num(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} + +function buildUsage(stats: CliJsonlTurnResult['stats']): TokenUsage | undefined { + const inputTokens = num(stats?.inputTokens); + const outputTokens = num(stats?.outputTokens); + const totalTokens = num(stats?.tokensUsed); + if (inputTokens == null && outputTokens == null && totalTokens == null) return undefined; + return { + ...(inputTokens != null ? { inputTokens, lastTurnInputTokens: inputTokens } : {}), + ...(outputTokens != null ? { outputTokens } : {}), + ...(totalTokens != null ? { totalTokens } : {}), + }; +} + +function isSuccessfulTerminal(kind: string | undefined): boolean { + return kind === undefined || kind === 'completed' || kind === 'completed_with_evidence'; +} + +export function transformCliJsonlEvent( + event: unknown, + catId: CatId, + options: CliJsonlTransformOptions = {}, +): AgentMessage[] { + if (!isTurnResult(event)) return []; + + const now = Date.now(); + const messages: AgentMessage[] = []; + const sessionId = typeof event.stats?.sessionId === 'string' ? event.stats.sessionId : undefined; + if (sessionId && options.emitSessionInit !== false) { + messages.push({ + type: 'session_init', + catId, + sessionId, + ephemeralSession: options.ephemeralSession ?? false, + timestamp: now, + }); + } + + const usage = buildUsage(event.stats); + if (usage) { + messages.push({ + type: 'agent_loop', + catId, + timestamp: now, + metadata: { provider: 'cli-jsonl', model: '', usage }, + }); + } + + const response = typeof event.response === 'string' ? event.response : ''; + if (response.length > 0) { + messages.push({ + type: 'text', + catId, + content: response, + timestamp: now, + }); + } + + const terminalKind = typeof event.terminal?.kind === 'string' ? event.terminal.kind : undefined; + if (!isSuccessfulTerminal(terminalKind)) { + messages.push({ + type: 'error', + catId, + error: `External agent stopped with terminal kind: ${terminalKind}`, + errorCode: 'terminal_not_completed', + timestamp: now, + }); + } + + return messages; +} diff --git a/packages/api/src/domains/cats/services/agents/providers/transport/ProviderTransportRegistry.ts b/packages/api/src/domains/cats/services/agents/providers/transport/ProviderTransportRegistry.ts new file mode 100644 index 0000000000..28b345c2e6 --- /dev/null +++ b/packages/api/src/domains/cats/services/agents/providers/transport/ProviderTransportRegistry.ts @@ -0,0 +1,178 @@ +/** + * F241 Phase A: host-owned provider transport registry. + * + * Provider transports are selected before the legacy clientId switch. A handled + * transport with a null service is still terminal: callers must not fall back to + * a provider-specific branch after a declared transport failed validation. + */ + +import type { CatConfig } from '@cat-cafe/shared'; +import type { AgentService } from '../../../types.js'; + +export interface ProviderTransportInput { + projectRoot: string; + profileId: string; + config: CatConfig; + providerTransport?: unknown; + reservedRouteableIds?: ReadonlySet; + reservedRouteableIdentityError?: string; +} + +export interface ProviderTransportCreateResult { + handled: boolean; + service?: AgentService | null; +} + +export type ProviderTransportResolution = + | { handled: false } + | { handled: true; transportId: string; service: AgentService | null; rejectionReason?: string }; + +export interface ProviderTransportCloseStaleOptions { + reason?: string; + onCloseError?: (err: unknown, transportId: string, profileId: string, reason: string) => void; +} + +export interface ProviderTransportFactory { + readonly id: string; + create(input: ProviderTransportInput): Promise; + closeStale?(activeProfileIds: ReadonlySet, options?: ProviderTransportCloseStaleOptions): Promise; +} + +function declaredTransportId(providerTransport: unknown): string | null { + if (typeof providerTransport !== 'object' || providerTransport === null) return null; + const transport = (providerTransport as { transport?: unknown }).transport; + return typeof transport === 'string' && transport.trim().length > 0 ? transport.trim() : null; +} + +const BUILTIN_CLIENT_IDS = new Set([ + 'anthropic', + 'openai', + 'google', + 'kimi', + 'dare', + 'antigravity', + 'opencode', + 'a2a', + 'catagent', + 'acp', +]); + +export function deriveReservedProviderTransportIdentities(input: { + configs: Readonly>; + providerTransportsByProfileId: ReadonlyMap; + templateBuiltinIds: ReadonlySet; +}): Set { + const reserved = new Set(input.templateBuiltinIds); + for (const id of Object.keys(input.configs)) { + const providerTransport = input.providerTransportsByProfileId.get(id); + if (providerTransport === undefined || providerTransport === null) { + reserved.add(id); + } + } + return reserved; +} + +function reservedIdentityReason(input: ProviderTransportInput): string | null { + if (input.reservedRouteableIdentityError) return 'reserved-routeable-identities-unavailable'; + + const clientId = String(input.config.clientId ?? ''); + if (BUILTIN_CLIENT_IDS.has(clientId)) return `builtin-client:${clientId}`; + + const reservedRouteableIds = input.reservedRouteableIds; + const configId = String(input.config.id ?? ''); + if (reservedRouteableIds?.has(configId)) return `builtin-cat:${configId}`; + + if (reservedRouteableIds?.has(input.profileId)) return `builtin-profile:${input.profileId}`; + + return null; +} + +export function markActiveProviderTransportProfile( + activeProfileIdsByTransport: Map>, + transportId: string, + profileId: string, +): void { + const existing = activeProfileIdsByTransport.get(transportId); + if (existing) { + existing.add(profileId); + return; + } + activeProfileIdsByTransport.set(transportId, new Set([profileId])); +} + +export class ProviderTransportRegistry { + private readonly factories = new Map(); + + register(factory: ProviderTransportFactory): void { + if (!factory.id.trim()) { + throw new Error('Provider transport factory id must not be blank'); + } + if (this.factories.has(factory.id)) { + throw new Error(`Provider transport factory '${factory.id}' already registered`); + } + this.factories.set(factory.id, factory); + } + + has(transportId: string): boolean { + return this.factories.has(transportId); + } + + async createServiceForConfig(input: ProviderTransportInput): Promise { + if (input.providerTransport !== undefined && input.providerTransport !== null) { + return this.createServiceForDeclaredTransport(input, input.providerTransport); + } + + return this.createServiceByFactoryProbe(input); + } + + private async createServiceForDeclaredTransport( + input: ProviderTransportInput, + providerTransport: unknown, + ): Promise { + const transportId = declaredTransportId(providerTransport); + if (!transportId) { + return { handled: true, transportId: 'invalid', service: null, rejectionReason: 'invalid-declaration' }; + } + + const identityReason = reservedIdentityReason(input); + if (identityReason) { + return { handled: true, transportId, service: null, rejectionReason: identityReason }; + } + + const factory = this.factories.get(transportId); + if (!factory) { + return { handled: true, transportId, service: null, rejectionReason: 'unknown-transport' }; + } + + const result = await factory.create(input); + return { + handled: true, + transportId, + service: result.handled ? (result.service ?? null) : null, + ...(!result.handled || !result.service ? { rejectionReason: 'factory-rejected' } : {}), + }; + } + + private async createServiceByFactoryProbe(input: ProviderTransportInput): Promise { + for (const factory of this.factories.values()) { + const result = await factory.create(input); + if (!result.handled) continue; + return { + handled: true, + transportId: factory.id, + service: result.service ?? null, + }; + } + return { handled: false }; + } + + async closeStale( + activeProfileIdsByTransport: ReadonlyMap>, + options: ProviderTransportCloseStaleOptions = {}, + ): Promise { + for (const factory of this.factories.values()) { + if (!factory.closeStale) continue; + await factory.closeStale(activeProfileIdsByTransport.get(factory.id) ?? new Set(), options); + } + } +} diff --git a/packages/api/src/domains/cats/services/agents/routing/AgentRouter.ts b/packages/api/src/domains/cats/services/agents/routing/AgentRouter.ts index b1e2f08db7..c318201658 100644 --- a/packages/api/src/domains/cats/services/agents/routing/AgentRouter.ts +++ b/packages/api/src/domains/cats/services/agents/routing/AgentRouter.ts @@ -1585,6 +1585,8 @@ export class AgentRouter { /** #949 P2: Whether verdict-without-pass warning fires at route end. * true/undefined = warn (default). false = suppress for connector-sourced flows only. */ verdictPassWarningEnabled?: boolean; + /** Whether event-driven external waits are backed by verified callback/tracking coverage. */ + eventDrivenExternalWaitCoverage?: boolean; /** F254 B3: Freshness re-invoke enqueue for routing layer consumption */ freshnessReinvokeEnqueue?: RouteOptions['freshnessReinvokeEnqueue']; }, @@ -1707,6 +1709,9 @@ export class AgentRouter { ...(options?.verdictPassWarningEnabled !== undefined ? { verdictPassWarningEnabled: options.verdictPassWarningEnabled } : {}), + ...(options?.eventDrivenExternalWaitCoverage !== undefined + ? { eventDrivenExternalWaitCoverage: options.eventDrivenExternalWaitCoverage } + : {}), }; try { diff --git a/packages/api/src/domains/cats/services/agents/routing/final-routing-slot.ts b/packages/api/src/domains/cats/services/agents/routing/final-routing-slot.ts index 2f9e366a78..c313bb4aa1 100644 --- a/packages/api/src/domains/cats/services/agents/routing/final-routing-slot.ts +++ b/packages/api/src/domains/cats/services/agents/routing/final-routing-slot.ts @@ -24,6 +24,8 @@ export interface ValidationInput { readonly structuredTargetCats: readonly string[]; /** Roster handle whitelist (from cat-config). Non-roster @ mentions are ignored. */ readonly rosterHandles: readonly string[]; + /** True only when the route has verified callback/EYES coverage for a 2b event-driven wait. */ + readonly hasEventDrivenExternalWaitCoverage?: boolean; } export type ValidationResult = @@ -37,6 +39,50 @@ export type ValidationResult = const MARKDOWN_LINE_PREFIX_RE = /^(?:(?:>\s*)|(?:[-*+]\s+)|(?:\d+[.)]\s+))+/; const URL_RE = /https?:\/\/[^\s)\]]+/g; const FENCED_CODE_RE = /```[\s\S]*?```/g; +const EVENT_DRIVEN_EXTERNAL_WAIT_RE = + /^(?:(?:[-*+]\s+)|(?:\d+[.)]\s+))?External Wait\s*:\s*event-driven\s*\((?!\s*\))[^)\r\n]+\)\s*$/i; +const CAT_SIGNATURE_LINE_RE = /^\s*\[(?:[^[\]\n]+\/[^[\]\n]+|[^[\]\n]+🐾)\]\s*$/u; + +/** + * Strip trailing cat-signature paragraphs so final-slot checks land on the last + * content paragraph. Body bracket tokens like `[Phase B]` are preserved because + * they do not match the slashed-or-paw signature shape. + */ +export function stripTrailingCatSignatures(text: string): string { + if (!text) return text; + const lines = text.split(/\r?\n/); + let lastContentIdx = lines.length - 1; + while (lastContentIdx >= 0) { + const line = lines[lastContentIdx] ?? ''; + if (line.trim() === '' || CAT_SIGNATURE_LINE_RE.test(line)) { + lastContentIdx--; + continue; + } + break; + } + if (lastContentIdx < 0) return ''; + return lines.slice(0, lastContentIdx + 1).join('\n'); +} + +function selectFinalRoutingSlot(text: string, options: { stripUrls: boolean }): string { + if (!text) return ''; + + const noFence = text.replace(FENCED_CODE_RE, ''); + + const noQuote = noFence + .split(/\r?\n/) + .filter((line) => !/^\s*>/.test(line)) + .join('\n'); + + const slotSource = options.stripUrls ? noQuote.replace(URL_RE, '') : noQuote; + + const paragraphs = slotSource + .split(/\n\s*\n/) + .map((p) => p.trim()) + .filter((p) => p.length > 0); + + return paragraphs.length > 0 ? paragraphs[paragraphs.length - 1]! : ''; +} /** * Extract final routing slot = structurally-stripped last non-empty paragraph. @@ -53,23 +99,26 @@ const FENCED_CODE_RE = /```[\s\S]*?```/g; * later (via optional param), this function's signature can be extended. */ export function finalRoutingSlot(text: string): string { - if (!text) return ''; - - const noFence = text.replace(FENCED_CODE_RE, ''); - - const noQuote = noFence - .split(/\r?\n/) - .filter((line) => !/^\s*>/.test(line)) - .join('\n'); + return selectFinalRoutingSlot(text, { stripUrls: true }); +} - const noUrl = noQuote.replace(URL_RE, ''); +function finalRoutingSlotPreservingUrls(text: string): string { + return selectFinalRoutingSlot(text, { stripUrls: false }); +} - const paragraphs = noUrl - .split(/\n\s*\n/) - .map((p) => p.trim()) - .filter((p) => p.length > 0); +function slotHasEventDrivenExternalWaitExit(slot: string): boolean { + if (!slot) return false; + return slot.split(/\r?\n/).some((line) => EVENT_DRIVEN_EXTERNAL_WAIT_RE.test(line.trim())); +} - return paragraphs.length > 0 ? paragraphs[paragraphs.length - 1]! : ''; +/** + * True iff the final routing slot contains the documented structural 2b external-wait exit. + * + * This is deliberately a slot-template check, not a natural-language intent classifier. + */ +export function hasEventDrivenExternalWaitExit(text: string | undefined): boolean { + if (!text) return false; + return slotHasEventDrivenExternalWaitExit(finalRoutingSlotPreservingUrls(stripTrailingCatSignatures(text))); } /** @@ -132,6 +181,7 @@ export function findInlineMentionsInSlot(slot: string, rosterHandles: readonly s * - legitimate line-start @mention present * - hold_ball tool call present * - structured MCP routing (targetCats / multi_mention targets) present + * - structural 2b external wait slot present with verified callback coverage * - no inline @handle inside final routing slot * * Returns `invalid_route_syntax` when NONE of the above AND slot has inline @handle. @@ -142,6 +192,8 @@ export function validateRoutingSyntax(input: ValidationInput): ValidationResult if (input.structuredTargetCats.length > 0) return { kind: 'ok' }; const slot = finalRoutingSlot(input.text); + if (input.hasEventDrivenExternalWaitCoverage && hasEventDrivenExternalWaitExit(input.text)) return { kind: 'ok' }; + const inlineMentions = findInlineMentionsInSlot(slot, input.rosterHandles); if (inlineMentions.length === 0) return { kind: 'ok' }; diff --git a/packages/api/src/domains/cats/services/agents/routing/guards/routing-guard-remedial.ts b/packages/api/src/domains/cats/services/agents/routing/guards/routing-guard-remedial.ts index 44c1574434..bc20a213d4 100644 --- a/packages/api/src/domains/cats/services/agents/routing/guards/routing-guard-remedial.ts +++ b/packages/api/src/domains/cats/services/agents/routing/guards/routing-guard-remedial.ts @@ -12,6 +12,8 @@ * KD-8 safe:只看"有无机械出口信号",零意图分类器。 */ +import { hasEventDrivenExternalWaitExit } from '../final-routing-slot.js'; + /** Routing-tool substrings that count as a legitimate exit (持球/群发传球). */ const ROUTING_TOOL_SUBSTRINGS = ['hold_ball', 'multi_mention'] as const; @@ -23,6 +25,8 @@ function hasRoutingToolCall(toolNames: readonly string[]): boolean { } export interface RoutingExitInput { + /** Stored output text. Only the final routing slot is inspected for structural external-wait exits. */ + readonly text?: string; /** Line-start @cat mentions parsed this turn (parseA2AMentions). */ readonly lineStartMentions: readonly string[]; /** Tool names invoked this turn (scan for hold_ball / multi_mention). */ @@ -31,18 +35,22 @@ export interface RoutingExitInput { readonly structuredTargetCats: readonly string[]; /** Line-start @co-creator / @co-creator escalation to co-creator. */ readonly hasCoCreatorLineStartMention?: boolean; + /** True only when the route has verified callback/EYES coverage for a 2b event-driven wait. */ + readonly hasEventDrivenExternalWaitCoverage?: boolean; } /** * True iff the turn has a legitimate routing exit (传球 / 持球 / 升级). * Mirrors the suppression set of evaluateVoidHold + F177-G hook - * (line-start @, hold_ball, multi_mention, targetCats, co-creator). + * (line-start @, hold_ball, multi_mention, targetCats, co-creator, structural + * 2b external wait slot with verified callback coverage). */ export function hasValidRoutingExit(input: RoutingExitInput): boolean { if (input.lineStartMentions.length > 0) return true; if (input.structuredTargetCats.length > 0) return true; if (input.hasCoCreatorLineStartMention) return true; if (hasRoutingToolCall(input.toolNames)) return true; + if (input.hasEventDrivenExternalWaitCoverage && hasEventDrivenExternalWaitExit(input.text)) return true; return false; } @@ -73,6 +81,8 @@ export const REMEDIAL_PROMPT = '[路由守卫] 你刚才的回复没有合法的路由出口(既没有行首 @句柄传球,也没有调用 cat_cafe_hold_ball 持球)。\n' + '请只补一个出口,不要重做刚才的工作:\n' + '- 传球:另起一行,行首独立写 @句柄(如 @opus48)\n' + + '- 持球等外部条件:调用 cat_cafe_hold_ball\n' + + '- 事件驱动外部等待(已有结构化回调 + EYES>0):另起一行写 External Wait: event-driven ()\n' + '- 等co-creator / 等另一只猫回复 → 用 @co-creator / @句柄,不要 hold_ball:对方的消息会触发你,hold 定时器只是冗余的第二次触发\n' + '- 持球等**无回调**的外部条件(远端 CI / cloud verdict / webhook;本地长命令用 wakeWhen):调用 cat_cafe_hold_ball\n' + '- 升级co-creator:另起一行行首写 @co-creator'; diff --git a/packages/api/src/domains/cats/services/agents/routing/route-helpers.ts b/packages/api/src/domains/cats/services/agents/routing/route-helpers.ts index f43b8ed41d..e3142cc792 100644 --- a/packages/api/src/domains/cats/services/agents/routing/route-helpers.ts +++ b/packages/api/src/domains/cats/services/agents/routing/route-helpers.ts @@ -167,6 +167,10 @@ export interface RouteOptions { * Separate from frustrationAutoIssueEligible because A2A/multi-mention callbacks * suppress frustration issues but still need verdict-pass handoff guards. */ verdictPassWarningEnabled?: boolean | undefined; + /** Whether `External Wait: event-driven (...)` may count as a routing exit. + * Must be true only when the caller has verified callback/tracking coverage for + * the external id; text alone does not create a wake-up. */ + eventDrivenExternalWaitCoverage?: boolean | undefined; /** F254 B3: Freshness re-invoke enqueue — called when doneMsg.metadata.freshnessReinvoke.shouldReinvoke * is true. Enqueues a new invocation for the same (cat, thread) to address unseen messages. */ freshnessReinvokeEnqueue?: diff --git a/packages/api/src/domains/cats/services/agents/routing/route-serial.ts b/packages/api/src/domains/cats/services/agents/routing/route-serial.ts index f68ba601c1..6e3d4bb130 100644 --- a/packages/api/src/domains/cats/services/agents/routing/route-serial.ts +++ b/packages/api/src/domains/cats/services/agents/routing/route-serial.ts @@ -117,7 +117,11 @@ import { import { accumulateTextAggregate } from '../text-aggregation.js'; import { formatA2AHandoffContent } from './a2a-handoff-label.js'; import { extractContextEvalSignals } from './context-eval.js'; -import { validateRoutingSyntax } from './final-routing-slot.js'; +import { + hasEventDrivenExternalWaitExit, + stripTrailingCatSignatures, + validateRoutingSyntax, +} from './final-routing-slot.js'; import { buildBriefingMessage } from './format-briefing.js'; import { buildRemedialPrompt, hasValidRoutingExit, shouldRemediateRouting } from './guards/routing-guard-remedial.js'; import { extractRichFromText, isValidRichBlock } from './rich-block-extract.js'; @@ -222,14 +226,23 @@ function stripMarkdownRoutePrefix(line: string): string { return line.replace(/^(?:[-*+]\s+|>\s*|\d+[.)]\s+)/, '').trim(); } -function normalizeRouteOnlyRemedialText(text: string): string | null { - const lines = text +function normalizeRouteOnlyRemedialText(text: string, hasEventDrivenExternalWaitCoverage: boolean): string | null { + const lines = stripTrailingCatSignatures(text) .trim() .split(/\r?\n/) .map((line) => stripMarkdownRoutePrefix(line)) .filter((line) => line.length > 0); if (lines.length !== 1) return null; - return ROUTE_ONLY_REMEDIAL_TEXT_RE.test(lines[0]) ? lines[0] : null; + const line = lines[0]!; + if (ROUTE_ONLY_REMEDIAL_TEXT_RE.test(line)) return line; + return hasEventDrivenExternalWaitCoverage && hasEventDrivenExternalWaitExit(line) ? line : null; +} + +function buildRoutingAnalysisContent(storedContent: string, routingContent: string): string { + if (!routingContent.trim()) return storedContent; + if (!storedContent.trim()) return routingContent; + if (routingContent.trim() === storedContent.trim()) return storedContent; + return `${storedContent}\n\n${routingContent}`; } function collectStructuredTargetCatsFromInput(input: unknown): string[] { @@ -272,6 +285,13 @@ function isCrossPostMessageToolName(toolName: string | undefined): boolean { return toolName === 'mcp:cat-cafe/cross_post_message' || toolName === 'cat_cafe_cross_post_message'; } +function isSameTurnEventDrivenCoverageToolName(toolName: string | undefined): boolean { + const normalized = normalizeMcpToolName(toolName); + // PR tracking registration only proves the watcher was registered. The actual + // 2b condition requires a later review/CI callback with pickup coverage. + return normalized === 'register_issue_tracking'; +} + function isCallbackContentRoutingToolName(toolName: string | undefined): boolean { return isPostMessageToolName(toolName) || isCrossPostMessageToolName(toolName); } @@ -482,6 +502,7 @@ export async function* routeSerial( } = options; const previousResponses: { catId: CatId; content: string }[] = []; const thinkingMode = options.thinkingMode ?? 'play'; + const initialEventDrivenExternalWaitCoverage = options.eventDrivenExternalWaitCoverage === true; // P2-3 fix: also consider default MCP server path (ClaudeAgentService has fallback resolution) const mcpServerPath = process.env.CAT_CAFE_MCP_SERVER_PATH || resolveDefaultClaudeMcpServerPath(); const incrementalMode = Boolean(currentUserMessageId && deps.deliveryCursorStore); @@ -610,6 +631,9 @@ export async function* routeSerial( // Only pass images/uploads for the first cat (user's original target) const isOriginalTarget = index < targetCats.length; + // Event-driven wait coverage proves a wake path for the current invocation target, + // not for later A2A worklist entries. + let hasEventDrivenExternalWaitCoverage = initialEventDrivenExternalWaitCoverage && isOriginalTarget; const targetContentBlocks = isOriginalTarget ? routeContentBlocksForCat(catId, contentBlocks) : undefined; const targetUploadDir = targetContentBlocks ? uploadDir : undefined; @@ -1427,6 +1451,9 @@ export async function* routeSerial( if (callbackResult.messageId) callbackPostMessageId = callbackResult.messageId; } if (completedToolName) { + if (callbackResult.confirmed && isSameTurnEventDrivenCoverageToolName(completedToolName.toolName)) { + hasEventDrivenExternalWaitCoverage = true; + } const settledExit = settleCallbackRoutingExit(completedToolName, callbackResult.confirmed); emitConfirmedCallbackBallHandedCvo( callbackResult.confirmed, @@ -1704,6 +1731,7 @@ export async function* routeSerial( allRichBlocks: RichBlock[]; a2aMentions: CatId[]; hasCoCreatorLineStartMention: boolean; + routingContent: string; hasLocalCoCreatorLineStartMention: boolean; streamEvents: AgentMessage[]; }> => { @@ -1869,6 +1897,9 @@ export async function* routeSerial( if (callbackResult.messageId) callbackPostMessageId = callbackResult.messageId; } if (completedToolName) { + if (callbackResult.confirmed && isSameTurnEventDrivenCoverageToolName(completedToolName.toolName)) { + hasEventDrivenExternalWaitCoverage = true; + } const settledExit = settleCallbackRoutingExit(completedToolName, callbackResult.confirmed); emitConfirmedCallbackBallHandedCvo( callbackResult.confirmed, @@ -1894,7 +1925,9 @@ export async function* routeSerial( const remedialSanitized = sanitizeInjectedContent(textContent); const remedialExtracted = extractRichFromText(remedialSanitized); const remedialCleanText = remedialExtracted.cleanText; - const remedialRouteOnlyContent = remedialCleanText ? normalizeRouteOnlyRemedialText(remedialCleanText) : null; + const remedialRouteOnlyContent = remedialCleanText + ? normalizeRouteOnlyRemedialText(remedialCleanText, hasEventDrivenExternalWaitCoverage) + : null; const remedialIsRouteOnly = remedialRouteOnlyContent !== null; // Route-only remedial text (`@cat` / `@co-creator`) is an exit patch, not a replacement artifact. // Use it for routing validation, but keep first-pass visible content so F5/history hydration @@ -1986,6 +2019,8 @@ export async function* routeSerial( allRichBlocks: remedialAllRichBlocks, a2aMentions: remedialA2aMentions, hasCoCreatorLineStartMention: remedialHasCoCreatorLineStartMention, + routingContent: remedialRoutingContent, + // Exit-only remedials validate the original text instead of replacing it; surface it after validation. hasLocalCoCreatorLineStartMention: remedialHasLocalCoCreatorLineStartMention, // Exit-only remedials validate preserved content instead of replacing it; replay the visible // text and routing-exit evidence before the remedial boundary can replace that turn. @@ -2007,10 +2042,12 @@ export async function* routeSerial( shouldRemediateRouting({ needsGuard: needsServerRoutingGuard, attempted: routingGuardAttempted, + text: '', lineStartMentions: getRoutingExitLineStartMentions(), toolNames: collectedToolNames, structuredTargetCats: [...structuredTargetCats], hasCoCreatorLineStartMention: hasRoutingExitCoCreatorLineStartMention(''), + hasEventDrivenExternalWaitCoverage, }) ) { const result = await runRoutingGuardRemedial( @@ -2023,10 +2060,12 @@ export async function* routeSerial( noTextBlocksOverride = result.allRichBlocks; if ( !hasValidRoutingExit({ + text: result.routingContent, lineStartMentions: getRoutingExitLineStartMentions(result.a2aMentions), toolNames: collectedToolNames, structuredTargetCats: [...structuredTargetCats], hasCoCreatorLineStartMention: result.hasCoCreatorLineStartMention, + hasEventDrivenExternalWaitCoverage, }) ) { await appendRoutingGuardFailureNotice(); @@ -2040,6 +2079,7 @@ export async function* routeSerial( // F22: Extract cc_rich blocks from text (Route B fallback for non-MCP cats) const { cleanText, blocks: textBlocks } = extractRichFromText(sanitized); let storedContent = cleanText; + let routingAnalysisContent = storedContent; let allRichBlocks = [...bufferedBlocks, ...textBlocks, ...streamRichBlocks]; // F34-b: Resolve voice blocks (audio with text, no url) — Route B path. @@ -2075,16 +2115,19 @@ export async function* routeSerial( shouldRemediateRouting({ needsGuard: needsServerRoutingGuard, attempted: routingGuardAttempted, + text: storedContent, lineStartMentions: routingExitLineStartMentions, toolNames: collectedToolNames, structuredTargetCats: [...structuredTargetCats], hasCoCreatorLineStartMention: routingExitHasCoCreatorLineStartMention, + hasEventDrivenExternalWaitCoverage, }) ) { const result = await runRoutingGuardRemedial(storedContent, allRichBlocks, [...collectedToolEvents]); for (const event of result.streamEvents) yield event; await flushDeferredVoice(); storedContent = result.storedContent; + routingAnalysisContent = buildRoutingAnalysisContent(storedContent, result.routingContent); allRichBlocks = result.allRichBlocks; a2aMentions = result.a2aMentions; routingExitLineStartMentions = getRoutingExitLineStartMentions(a2aMentions); @@ -2093,10 +2136,12 @@ export async function* routeSerial( if ( !hasValidRoutingExit({ + text: routingAnalysisContent, lineStartMentions: routingExitLineStartMentions, toolNames: collectedToolNames, structuredTargetCats: [...structuredTargetCats], hasCoCreatorLineStartMention: routingExitHasCoCreatorLineStartMention, + hasEventDrivenExternalWaitCoverage, }) ) { await appendRoutingGuardFailureNotice(); @@ -2129,11 +2174,12 @@ export async function* routeSerial( } } const phaseHResult = validateRoutingSyntax({ - text: storedContent, + text: routingAnalysisContent, lineStartMentions: routingExitLineStartMentions, toolNames: collectedToolNames, structuredTargetCats: [...structuredTargetCats], rosterHandles: phaseHRosterHandles, + hasEventDrivenExternalWaitCoverage, }); const phaseHHit = phaseHResult.kind === 'invalid_route_syntax'; if (phaseHHit && phaseHResult.kind === 'invalid_route_syntax') { @@ -2283,11 +2329,12 @@ export async function* routeSerial( // frustrationAutoIssueEligible=false but still need verdict-pass handoff guards. options.verdictPassWarningEnabled !== false && shouldWarnVerdictWithoutPass({ - text: storedContent, + text: routingAnalysisContent, lineStartMentions: routingExitLineStartMentions, toolNames: collectedToolNames, structuredTargetCats: [...structuredTargetCats], hasCoCreatorLineStartMention: routingExitHasCoCreatorLineStartMention, + hasEventDrivenExternalWaitCoverage, }) ) { try { @@ -2308,7 +2355,7 @@ export async function* routeSerial( }); const verdictFireAttr: Record = { ...c2BaseAttr, - [TRIGGER]: detectMatchedVerdictKeyword(storedContent) ?? 'unknown', + [TRIGGER]: detectMatchedVerdictKeyword(routingAnalysisContent) ?? 'unknown', }; c2VerdictHintEmitted.add(1, verdictFireAttr); c2VerdictWithoutPassCount.add(1, verdictFireAttr); @@ -2345,11 +2392,12 @@ export async function* routeSerial( // hold-claim message, so drilldown lands on the original content, not on the hint. let pendingC2VoidHoldSampleTrigger: string | null = null; const voidHoldEval = evaluateVoidHold({ - text: storedContent, + text: routingAnalysisContent, toolNames: collectedToolNames, lineStartMentions: routingExitLineStartMentions, structuredTargetCats: [...structuredTargetCats], hasCoCreatorLineStartMention: routingExitHasCoCreatorLineStartMention, + hasEventDrivenExternalWaitCoverage, }); if (voidHoldEval.shouldEmit) { try { diff --git a/packages/api/src/domains/cats/services/agents/routing/verdict-detect.ts b/packages/api/src/domains/cats/services/agents/routing/verdict-detect.ts index 6833773aac..b79a740860 100644 --- a/packages/api/src/domains/cats/services/agents/routing/verdict-detect.ts +++ b/packages/api/src/domains/cats/services/agents/routing/verdict-detect.ts @@ -14,7 +14,7 @@ */ import { stripTrailingCatSignatures } from './cat-signature-strip.js'; -import { finalRoutingSlot } from './final-routing-slot.js'; +import { finalRoutingSlot, hasEventDrivenExternalWaitExit } from './final-routing-slot.js'; // 2026-06-20 verdict eval:a2a C2 void-hold English fix: signature stripping // extracted to `cat-signature-strip.ts` so void-hold-detect.ts can share the @@ -145,6 +145,8 @@ export interface VerdictWarningInput { * pass to co-creator) was being flagged as "verdict without pass". */ readonly hasCoCreatorLineStartMention?: boolean; + /** True only when the route has verified callback/EYES coverage for a 2b event-driven wait. */ + readonly hasEventDrivenExternalWaitCoverage?: boolean; } /** @@ -163,5 +165,6 @@ export function shouldWarnVerdictWithoutPass(input: VerdictWarningInput): boolea if (hasHoldBallCall(input.toolNames)) return false; if (input.structuredTargetCats.length > 0) return false; if (input.hasCoCreatorLineStartMention) return false; + if (input.hasEventDrivenExternalWaitCoverage && hasEventDrivenExternalWaitExit(input.text)) return false; return true; } diff --git a/packages/api/src/domains/cats/services/agents/routing/void-hold-detect.ts b/packages/api/src/domains/cats/services/agents/routing/void-hold-detect.ts index 08efe14933..bd157eadbe 100644 --- a/packages/api/src/domains/cats/services/agents/routing/void-hold-detect.ts +++ b/packages/api/src/domains/cats/services/agents/routing/void-hold-detect.ts @@ -23,7 +23,7 @@ */ import { stripTrailingCatSignatures } from './cat-signature-strip.js'; -import { finalRoutingSlot } from './final-routing-slot.js'; +import { finalRoutingSlot, hasEventDrivenExternalWaitExit } from './final-routing-slot.js'; /** * Hold pattern catalog. Order matters: more-specific patterns first so a @@ -100,6 +100,8 @@ export interface VoidHoldInput { readonly lineStartMentions: readonly string[]; readonly structuredTargetCats: readonly string[]; readonly hasCoCreatorLineStartMention?: boolean; + /** True only when the route has verified callback/EYES coverage for a 2b event-driven wait. */ + readonly hasEventDrivenExternalWaitCoverage?: boolean; } export interface VoidHoldEvaluation { @@ -117,7 +119,8 @@ export interface VoidHoldEvaluation { /** * Full evaluation: returns both emission decision and matched trigger. * Emission is suppressed if any legitimate exit is present (hold_ball tool, - * line-start @cat / co-creator mention, or structured MCP routing). + * line-start @cat / co-creator mention, structured MCP routing, or a verified + * event-driven external wait). */ export function evaluateVoidHold(input: VoidHoldInput): VoidHoldEvaluation { const matched = matchHoldPattern(input.text); @@ -126,6 +129,9 @@ export function evaluateVoidHold(input: VoidHoldInput): VoidHoldEvaluation { if (input.lineStartMentions.length > 0) return { shouldEmit: false, matchedPattern: matched }; if (input.structuredTargetCats.length > 0) return { shouldEmit: false, matchedPattern: matched }; if (input.hasCoCreatorLineStartMention) return { shouldEmit: false, matchedPattern: matched }; + if (input.hasEventDrivenExternalWaitCoverage && hasEventDrivenExternalWaitExit(input.text)) { + return { shouldEmit: false, matchedPattern: matched }; + } return { shouldEmit: true, matchedPattern: matched }; } diff --git a/packages/api/src/domains/cats/services/orchestration/EventAuditLog.ts b/packages/api/src/domains/cats/services/orchestration/EventAuditLog.ts index 170b1c0b2a..fda9d4b3bc 100644 --- a/packages/api/src/domains/cats/services/orchestration/EventAuditLog.ts +++ b/packages/api/src/domains/cats/services/orchestration/EventAuditLog.ts @@ -216,6 +216,8 @@ export const AuditEventTypes = { CLI_TOOL_STARTED: 'cli_tool_started', /** CLI 工具执行完成(command_execution completed) */ CLI_TOOL_COMPLETED: 'cli_tool_completed', + /** CatAgent native side-effect tool execution/rejection (F159 Phase F) */ + CATAGENT_SIDE_EFFECT: 'catagent_side_effect', // === 记忆治理 (Phase 5.0 Step 2a) === diff --git a/packages/api/src/domains/cats/services/session/SessionSealer.ts b/packages/api/src/domains/cats/services/session/SessionSealer.ts index 65a5bb5381..7bce28c3a8 100644 --- a/packages/api/src/domains/cats/services/session/SessionSealer.ts +++ b/packages/api/src/domains/cats/services/session/SessionSealer.ts @@ -10,7 +10,7 @@ * SessionSealer is responsible for the lifecycle state machine. */ -import type { CatId, SealResult, SessionStatus } from '@cat-cafe/shared'; +import type { CatId, SealResult } from '@cat-cafe/shared'; import { createModuleLogger } from '../../../../infrastructure/logger.js'; import { extractRecentArtifacts } from '../agents/routing/artifact-tracking.js'; import { AuditEventTypes, getEventAuditLog } from '../orchestration/EventAuditLog.js'; @@ -44,7 +44,7 @@ export interface ISessionSealer { * Request seal of a session. Idempotent: returns accepted=false if already sealing/sealed. * Fast path: only changes status + clears active pointer. */ - requestSeal(args: { sessionId: string; reason: SealReason }): Promise; + requestSeal(args: { sessionId: string; reason: SealReason; expectedCliSessionId?: string }): Promise; /** * Finalize a sealing session: write transcript, generate digest, mark sealed. @@ -110,46 +110,40 @@ export class SessionSealer implements ISessionSealer { this.postSealHooks.push(hook); } - async requestSeal(args: { sessionId: string; reason: SealReason }): Promise { - const record = await this.store.get(args.sessionId); - if (!record) { - return { accepted: false, status: 'sealed' }; - } - - // CAS: only active sessions can be sealed - // Snapshot status before mutation (memory store returns live reference) - const currentStatus: SessionStatus = record.status; - if (currentStatus !== 'active') { - return { accepted: false, status: currentStatus }; - } - - // Transition active → sealing + async requestSeal(args: { + sessionId: string; + reason: SealReason; + expectedCliSessionId?: string; + }): Promise { const now = Date.now(); - const updated = await this.store.update(args.sessionId, { - status: 'sealing', + const updated = await this.store.compareAndMarkSealing(args.sessionId, { sealReason: args.reason, updatedAt: now, + ...(args.expectedCliSessionId !== undefined ? { expectedCliSessionId: args.expectedCliSessionId } : {}), }); - if (!updated || updated.status !== 'sealing') { - // Race condition: another caller got there first - return { accepted: false, status: updated?.status ?? 'sealed' }; + if (!updated) { + const current = await this.store.get(args.sessionId); + if (current) { + return { accepted: false, status: current.status }; + } + return { accepted: false, status: 'sealed' }; } log.info( - { sessionId: args.sessionId, catId: record.catId, threadId: record.threadId, reason: args.reason }, + { sessionId: args.sessionId, catId: updated.catId, threadId: updated.threadId, reason: args.reason }, 'session seal requested', ); getEventAuditLog() .append({ type: AuditEventTypes.SEAL_REQUESTED, - threadId: record.threadId, + threadId: updated.threadId, data: { sessionId: args.sessionId, - catId: record.catId, - cliSessionId: record.cliSessionId, + catId: updated.catId, + cliSessionId: updated.cliSessionId, reason: args.reason, - seq: record.seq, + seq: updated.seq, }, }) .catch(() => {}); diff --git a/packages/api/src/domains/cats/services/stores/ports/SessionChainStore.ts b/packages/api/src/domains/cats/services/stores/ports/SessionChainStore.ts index e7f54f8f6f..1d52f0a837 100644 --- a/packages/api/src/domains/cats/services/stores/ports/SessionChainStore.ts +++ b/packages/api/src/domains/cats/services/stores/ports/SessionChainStore.ts @@ -61,6 +61,18 @@ export interface ISessionChainStore { getChainByThread(threadId: string): SessionRecord[] | Promise; /** Update partial fields */ update(id: string, patch: SessionRecordPatch): SessionRecord | null | Promise; + /** + * Atomically transition an active session to sealing, optionally requiring + * that the runtime session id still matches the caller's resume target. + */ + compareAndMarkSealing( + id: string, + input: { + sealReason: SessionRecord['sealReason']; + updatedAt: number; + expectedCliSessionId?: string; + }, + ): SessionRecord | null | Promise; /** Look up by CLI session ID */ getByCliSessionId(cliSessionId: string): SessionRecord | null | Promise; /** @@ -192,6 +204,9 @@ export class SessionChainStore implements ISessionChainStore { if (!record) return null; if (patch.cliSessionId !== undefined) { + if (record.status !== 'active') return null; + const key = this.catThreadKey(record.catId, record.threadId); + if (this.activeIndex.get(key) !== id) return null; // Update CLI index this.cliIndex.delete(record.cliSessionId); record.cliSessionId = patch.cliSessionId; @@ -232,6 +247,30 @@ export class SessionChainStore implements ISessionChainStore { return record; } + compareAndMarkSealing( + id: string, + input: { + sealReason: SessionRecord['sealReason']; + updatedAt: number; + expectedCliSessionId?: string; + }, + ): SessionRecord | null { + const record = this.records.get(id); + if (!record) return null; + if (record.status !== 'active') return null; + const key = this.catThreadKey(record.catId, record.threadId); + if (this.activeIndex.get(key) !== id) return null; + if (input.expectedCliSessionId !== undefined && record.cliSessionId !== input.expectedCliSessionId) return null; + + record.status = 'sealing'; + record.sealReason = input.sealReason; + record.updatedAt = input.updatedAt; + + this.activeIndex.delete(key); + + return record; + } + getByCliSessionId(cliSessionId: string): SessionRecord | null { const id = this.cliIndex.get(cliSessionId); if (!id) return null; diff --git a/packages/api/src/domains/cats/services/stores/redis/RedisSessionChainStore.ts b/packages/api/src/domains/cats/services/stores/redis/RedisSessionChainStore.ts index 6030650dcb..a974c4e5be 100644 --- a/packages/api/src/domains/cats/services/stores/redis/RedisSessionChainStore.ts +++ b/packages/api/src/domains/cats/services/stores/redis/RedisSessionChainStore.ts @@ -77,6 +77,59 @@ redis.call('HSET', KEYS[1], 'updatedAt', ARGV[1]) return newCount `; +/** + * Lua: atomic active → sealing transition with optional cliSessionId CAS. + * KEYS[1] = detail key, KEYS[2] = active key + * ARGV[1] = id, ARGV[2] = sealReason, ARGV[3] = updatedAt, + * ARGV[4] = expectedCliSessionId ('' = no expected check) + * + * Returns: {'updated', 'sealing'}, {'missing', ''}, {'status', currentStatus}, + * {'stale', currentStatus}, or {'mismatch', currentStatus}. + */ +const MARK_SEALING_LUA = ` +if redis.call('EXISTS', KEYS[1]) == 0 then return {'missing', ''} end +local currentStatus = redis.call('HGET', KEYS[1], 'status') or 'active' +if currentStatus ~= 'active' then return {'status', currentStatus} end +if redis.call('GET', KEYS[2]) ~= ARGV[1] then return {'stale', currentStatus} end +if ARGV[4] ~= '' and redis.call('HGET', KEYS[1], 'cliSessionId') ~= ARGV[4] then + return {'mismatch', currentStatus} +end +redis.call('HSET', KEYS[1], 'status', 'sealing', 'sealReason', ARGV[2], 'updatedAt', ARGV[3]) +if redis.call('GET', KEYS[2]) == ARGV[1] then + redis.call('DEL', KEYS[2]) +end +return {'updated', 'sealing'} +`; + +/** + * Lua: atomically rotate cliSessionId and its reverse index for an active record. + * KEYS[1] = detail key, KEYS[2] = new CLI index key, KEYS[3] = active key + * ARGV[1] = id, ARGV[2] = new cliSessionId, ARGV[3] = keyPrefix, + * ARGV[4] = updatedAt, ARGV[5] = ttlSeconds ('0' = persistent) + * + * Lua-built old CLI index keys must include keyPrefix explicitly. + */ +const UPDATE_CLI_SESSION_ID_LUA = ` +if redis.call('EXISTS', KEYS[1]) == 0 then return {'missing', ''} end +local currentStatus = redis.call('HGET', KEYS[1], 'status') or 'active' +if currentStatus ~= 'active' then return {'status', currentStatus} end +if redis.call('GET', KEYS[3]) ~= ARGV[1] then return {'stale', currentStatus} end +local oldCliSessionId = redis.call('HGET', KEYS[1], 'cliSessionId') or '' +if oldCliSessionId ~= '' then + local oldCliKey = ARGV[3] .. 'session-cli:' .. oldCliSessionId + if redis.call('GET', oldCliKey) == ARGV[1] then + redis.call('DEL', oldCliKey) + end +end +redis.call('HSET', KEYS[1], 'cliSessionId', ARGV[2], 'updatedAt', ARGV[4]) +if ARGV[5] ~= '0' then + redis.call('SET', KEYS[2], ARGV[1], 'EX', tonumber(ARGV[5])) +else + redis.call('SET', KEYS[2], ARGV[1]) +end +return {'updated', oldCliSessionId} +`; + export class RedisSessionChainStore implements ISessionChainStore { private readonly redis: RedisClient; @@ -223,18 +276,25 @@ export class RedisSessionChainStore implements ISessionChainStore { const pairs: string[] = []; const deleteFields: string[] = []; - pairs.push('updatedAt', String(patch.updatedAt ?? Date.now())); + const updatedAt = patch.updatedAt ?? Date.now(); + pairs.push('updatedAt', String(updatedAt)); if (patch.cliSessionId !== undefined) { - // Update CLI index: delete old, set new - const oldCliId = await this.redis.hget(detailKey, 'cliSessionId'); - if (oldCliId) await this.redis.del(SessionChainKeys.byCli(oldCliId)); - if (DEFAULT_TTL_SECONDS > 0) { - await this.redis.set(SessionChainKeys.byCli(patch.cliSessionId), id, 'EX', DEFAULT_TTL_SECONDS); - } else { - await this.redis.set(SessionChainKeys.byCli(patch.cliSessionId), id); - } - pairs.push('cliSessionId', patch.cliSessionId); + const [catId, threadId] = await this.redis.hmget(detailKey, 'catId', 'threadId'); + if (!catId || !threadId) return null; + const result = (await this.redis.eval( + UPDATE_CLI_SESSION_ID_LUA, + 3, + detailKey, + SessionChainKeys.byCli(patch.cliSessionId), + SessionChainKeys.active(catId, threadId), + id, + patch.cliSessionId, + this.keyPrefix, + String(updatedAt), + String(DEFAULT_TTL_SECONDS), + )) as [string, string]; + if (result[0] !== 'updated') return null; } if (patch.workingDirectory !== undefined) { pairs.push('workingDirectory', patch.workingDirectory); @@ -304,6 +364,37 @@ export class RedisSessionChainStore implements ISessionChainStore { return this.get(id); } + async compareAndMarkSealing( + id: string, + input: { + sealReason: SessionRecord['sealReason']; + updatedAt: number; + expectedCliSessionId?: string; + }, + ): Promise { + const detailKey = SessionChainKeys.detail(id); + const [catId, threadId] = await this.redis.hmget(detailKey, 'catId', 'threadId'); + if (!catId || !threadId) return null; + + const result = (await this.redis.eval( + MARK_SEALING_LUA, + 2, + detailKey, + SessionChainKeys.active(catId, threadId), + id, + input.sealReason ?? '', + String(input.updatedAt), + input.expectedCliSessionId ?? '', + )) as [string, string]; + + if (result[0] !== 'updated') return null; + return this.get(id); + } + + private get keyPrefix(): string { + return (this.redis.options as { keyPrefix?: string }).keyPrefix ?? ''; + } + async getByCliSessionId(cliSessionId: string): Promise { const id = await this.redis.get(SessionChainKeys.byCli(cliSessionId)); if (!id) return null; diff --git a/packages/api/src/domains/cats/services/types.ts b/packages/api/src/domains/cats/services/types.ts index 0c437ea39a..2367f5e49c 100644 --- a/packages/api/src/domains/cats/services/types.ts +++ b/packages/api/src/domains/cats/services/types.ts @@ -3,7 +3,7 @@ * Agent 服务的共享类型定义 */ -import type { CatId, MessageContent, ReplyPreview } from '@cat-cafe/shared'; +import type { CatId, MessageContent, ReplyPreview, TaskStatus } from '@cat-cafe/shared'; import type { Span } from '@opentelemetry/api'; import type { CliDiagnostics } from '../../../utils/cli-diagnostics.js'; import type { CliSpawnOptions } from '../../../utils/cli-types.js'; @@ -118,6 +118,19 @@ export interface AuditContext { catId: CatId; } +/** F159 Phase F: scoped host-native callbacks available to CatAgent tools. */ +export interface CatAgentScopedCallbackOptions { + currentTask?: { + invocationId: string; + currentTaskId: string; + updateCurrentTaskStatus: (patch: { + status?: TaskStatus; + progress?: number; + summary?: string; + }) => void | Promise; + }; +} + /** * Types of messages that can be yielded from an agent */ @@ -269,6 +282,8 @@ export interface AgentServiceOptions { signal?: AbortSignal; /** Correlation context for audit logging and raw trace linking */ auditContext?: AuditContext; + /** F159 Phase F: host-owned scoped callbacks for CatAgent native tools. */ + catAgentScopedCallbacks?: CatAgentScopedCallbackOptions; /** Static identity prompt (Claude: --append-system-prompt, others: prepend to prompt) */ systemPrompt?: string; /** Static identity prompt used only if a resumed carrier creates a fresh fallback session. */ diff --git a/packages/api/src/domains/limb/ble/BleAdapters.ts b/packages/api/src/domains/limb/ble/BleAdapters.ts new file mode 100644 index 0000000000..12db3e2a36 --- /dev/null +++ b/packages/api/src/domains/limb/ble/BleAdapters.ts @@ -0,0 +1,220 @@ +import type { LimbCapability, LimbCommandSchema } from '@cat-cafe/shared'; + +export interface BleGattCharacteristic { + uuid: string; + properties: string[]; +} + +export interface BleGattService { + uuid: string; + characteristics: BleGattCharacteristic[]; +} + +export interface BleAdapterCommand { + command: string; + capability: string; + serviceUuid: string; + characteristicUuid: string; + mode: 'read' | 'notify'; + decode(value: Buffer): BleDecodedValue; +} + +export interface BleAdapterDefinition { + id: string; + displayName: string; + commands: readonly BleAdapterCommand[]; +} + +export interface BleDecodedValue { + kind: 'battery' | 'temperature' | 'humidity' | 'button'; + value: number | 'press' | 'double_press' | 'hold'; + unit: '%' | '°C' | 'event'; +} + +const BLUETOOTH_BASE_SUFFIX = '-0000-1000-8000-00805f9b34fb'; + +export function normalizeGattUuid(uuid: string): string { + const normalized = uuid.trim().toLowerCase(); + if (normalized.startsWith('0000') && normalized.endsWith(BLUETOOTH_BASE_SUFFIX)) { + return normalized.slice(4, 8); + } + return normalized; +} + +function requireLength(value: Buffer, expected: number, label: string): void { + if (value.byteLength !== expected) { + throw new Error(`${label} value has invalid length: expected ${expected}, received ${value.byteLength}`); + } +} + +function requireRange(value: number, min: number, max: number, label: string): number { + if (!Number.isFinite(value) || value < min || value > max) { + throw new Error(`${label} value ${value} is outside ${min}..${max}`); + } + return value; +} + +function decodeBattery(value: Buffer): BleDecodedValue { + requireLength(value, 1, 'Battery'); + return { kind: 'battery', value: requireRange(value.readUInt8(0), 0, 100, 'Battery'), unit: '%' }; +} + +function decodeTemperature(value: Buffer): BleDecodedValue { + requireLength(value, 2, 'Temperature'); + const decoded = value.readInt16LE(0) / 100; + return { kind: 'temperature', value: requireRange(decoded, -273.15, 200, 'Temperature'), unit: '°C' }; +} + +function decodeHumidity(value: Buffer): BleDecodedValue { + requireLength(value, 2, 'Humidity'); + const decoded = value.readUInt16LE(0) / 100; + return { kind: 'humidity', value: requireRange(decoded, 0, 100, 'Humidity'), unit: '%' }; +} + +function decodeButton(value: Buffer): BleDecodedValue { + requireLength(value, 1, 'Button'); + const event = value.readUInt8(0); + const mapped = event === 1 ? 'press' : event === 2 ? 'double_press' : event === 3 ? 'hold' : null; + if (!mapped) throw new Error(`Button value ${event} is outside the declared event set`); + return { kind: 'button', value: mapped, unit: 'event' }; +} + +const BATTERY_COMMAND: BleAdapterCommand = { + command: 'ble.battery.read', + capability: 'ble.battery', + serviceUuid: '180f', + characteristicUuid: '2a19', + mode: 'read', + decode: decodeBattery, +}; + +const ENVIRONMENTAL_COMMANDS: readonly BleAdapterCommand[] = [ + { + command: 'ble.temperature.read', + capability: 'ble.environment', + serviceUuid: '181a', + characteristicUuid: '2a6e', + mode: 'read', + decode: decodeTemperature, + }, + { + command: 'ble.humidity.read', + capability: 'ble.environment', + serviceUuid: '181a', + characteristicUuid: '2a6f', + mode: 'read', + decode: decodeHumidity, + }, +]; + +const CAT_CAFE_BUTTON_SERVICE = '7f9c0001-7d7e-4f1d-9d7b-5f2580000001'; +const CAT_CAFE_BUTTON_CHARACTERISTIC = '7f9c0002-7d7e-4f1d-9d7b-5f2580000001'; + +export const BLE_ADAPTERS: Readonly> = { + 'standard.environmental': { + id: 'standard.environmental', + displayName: 'Environmental Sensing', + commands: ENVIRONMENTAL_COMMANDS, + }, + 'standard.battery': { + id: 'standard.battery', + displayName: 'Battery Service', + commands: [BATTERY_COMMAND], + }, + 'cat-cafe.button.v1': { + id: 'cat-cafe.button.v1', + displayName: 'Cat Café Button v1', + commands: [ + { + command: 'ble.button.subscribe', + capability: 'ble.button', + serviceUuid: CAT_CAFE_BUTTON_SERVICE, + characteristicUuid: CAT_CAFE_BUTTON_CHARACTERISTIC, + mode: 'notify', + decode: decodeButton, + }, + ], + }, +}; + +export function getBleAdapter(adapterId: string): BleAdapterDefinition | null { + return BLE_ADAPTERS[adapterId] ?? null; +} + +function hasCharacteristic(services: readonly BleGattService[], command: BleAdapterCommand): boolean { + const serviceUuid = normalizeGattUuid(command.serviceUuid); + const characteristicUuid = normalizeGattUuid(command.characteristicUuid); + return services.some( + (service) => + normalizeGattUuid(service.uuid) === serviceUuid && + service.characteristics.some((characteristic) => { + if (normalizeGattUuid(characteristic.uuid) !== characteristicUuid) return false; + return command.mode === 'read' + ? characteristic.properties.includes('read') + : characteristic.properties.includes('notify') || characteristic.properties.includes('indicate'); + }), + ); +} + +export function selectBleAdapter(services: readonly BleGattService[]): BleAdapterDefinition | null { + const preference = ['standard.environmental', 'cat-cafe.button.v1', 'standard.battery']; + for (const adapterId of preference) { + const adapter = BLE_ADAPTERS[adapterId]; + if (adapter?.commands.some((command) => hasCharacteristic(services, command))) return adapter; + } + return null; +} + +export function availableBleCommands( + adapter: BleAdapterDefinition, + services?: readonly BleGattService[], +): BleAdapterCommand[] { + return adapter.commands.filter((command) => !services || hasCharacteristic(services, command)); +} + +export function findBleAdapterCommand(adapterId: string, commandName: string): BleAdapterCommand | null { + return getBleAdapter(adapterId)?.commands.find((command) => command.command === commandName) ?? null; +} + +export function decodeBleCommandValue(adapterId: string, commandName: string, value: Buffer): BleDecodedValue { + const command = findBleAdapterCommand(adapterId, commandName); + if (!command) throw new Error(`Command '${commandName}' is not declared by BLE adapter '${adapterId}'`); + return command.decode(value); +} + +export function buildBleLimbCapabilities(adapterId: string, allowedCommands?: readonly string[]): LimbCapability[] { + const adapter = getBleAdapter(adapterId); + if (!adapter) return []; + const allowed = allowedCommands ? new Set(allowedCommands) : null; + const grouped = new Map(); + for (const command of adapter.commands) { + if (allowed && !allowed.has(command.command)) continue; + const commands = grouped.get(command.capability) ?? []; + commands.push(command.command); + grouped.set(command.capability, commands); + } + return [...grouped].map(([cap, commands]) => ({ cap, commands, authLevel: 'free' as const })); +} + +export function buildBleCommandSchemas( + adapterId: string, + allowedCommands?: readonly string[], +): Record { + const adapter = getBleAdapter(adapterId); + if (!adapter) return {}; + const allowed = allowedCommands ? new Set(allowedCommands) : null; + return Object.fromEntries( + adapter.commands + .filter((command) => !allowed || allowed.has(command.command)) + .map((command) => [ + command.command, + { + description: + command.mode === 'read' + ? `Read typed ${command.capability} data from the bound BLE device.` + : `Subscribe to typed ${command.capability} events from the bound BLE device.`, + params: {}, + }, + ]), + ); +} diff --git a/packages/api/src/domains/limb/ble/BleBindingStore.ts b/packages/api/src/domains/limb/ble/BleBindingStore.ts new file mode 100644 index 0000000000..60a3402337 --- /dev/null +++ b/packages/api/src/domains/limb/ble/BleBindingStore.ts @@ -0,0 +1,134 @@ +import type { RedisClient } from '@cat-cafe/shared/utils'; + +export const BLE_BINDING_SCOPE = 'instance' as const; +export const BLE_BINDING_INDEX_KEY = (scopeId: string): string => `limb:ble:bindings:${scopeId}:index`; +export const bleBindingKey = (scopeId: string, bindingId: string): string => + `limb:ble:bindings:${scopeId}:${bindingId}`; + +export interface BleBinding { + bindingId: string; + scopeId: string; + platformDeviceId: string; + displayName: string; + adapterId: string; + commands: string[]; + nodeId: string; + createdAt: number; + lastConnectedAt: number | null; +} + +export interface IBleBindingStore { + get(scopeId: string, bindingId: string): Promise; + list(scopeId: string): Promise; + put(binding: BleBinding): Promise; + delete(scopeId: string, bindingId: string): Promise; +} + +interface LoggerLike { + warn(message: string): void; +} + +type BindingRedis = Pick; + +function isBleBinding(value: unknown): value is BleBinding { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false; + const candidate = value as Record; + return ( + typeof candidate.bindingId === 'string' && + candidate.bindingId.length > 0 && + typeof candidate.scopeId === 'string' && + candidate.scopeId.length > 0 && + typeof candidate.platformDeviceId === 'string' && + candidate.platformDeviceId.length > 0 && + typeof candidate.displayName === 'string' && + candidate.displayName.length <= 128 && + typeof candidate.adapterId === 'string' && + Array.isArray(candidate.commands) && + candidate.commands.length > 0 && + candidate.commands.length <= 32 && + candidate.commands.every((command) => typeof command === 'string' && command.length > 0 && command.length <= 128) && + typeof candidate.nodeId === 'string' && + Number.isFinite(candidate.createdAt) && + (candidate.lastConnectedAt === null || Number.isFinite(candidate.lastConnectedAt)) + ); +} + +function parseBinding(raw: string, expectedScope: string, expectedId: string): BleBinding | null { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + if (!isBleBinding(parsed) || parsed.scopeId !== expectedScope || parsed.bindingId !== expectedId) return null; + return parsed; +} + +export class RedisBleBindingStore implements IBleBindingStore { + constructor( + private readonly redis: BindingRedis, + private readonly logger: LoggerLike = console, + ) {} + + async get(scopeId: string, bindingId: string): Promise { + const raw = await this.redis.get(bleBindingKey(scopeId, bindingId)); + if (!raw) return null; + const binding = parseBinding(raw, scopeId, bindingId); + if (!binding) this.logger.warn(`Ignoring invalid BLE binding record: ${scopeId}/${bindingId}`); + return binding; + } + + async list(scopeId: string): Promise { + const ids = await this.redis.smembers(BLE_BINDING_INDEX_KEY(scopeId)); + const bindings = await Promise.all(ids.map((id) => this.get(scopeId, id))); + return bindings + .filter((binding): binding is BleBinding => binding !== null) + .sort((a, b) => a.createdAt - b.createdAt); + } + + async put(binding: BleBinding): Promise { + if (!isBleBinding(binding)) throw new Error('Invalid BLE binding'); + const transaction = this.redis.multi(); + transaction.set(bleBindingKey(binding.scopeId, binding.bindingId), JSON.stringify(binding)); + transaction.sadd(BLE_BINDING_INDEX_KEY(binding.scopeId), binding.bindingId); + const result = await transaction.exec(); + if (result === null || result.some(([error]) => error)) throw new Error('Failed to persist BLE binding'); + } + + async delete(scopeId: string, bindingId: string): Promise { + const transaction = this.redis.multi(); + transaction.del(bleBindingKey(scopeId, bindingId)); + transaction.srem(BLE_BINDING_INDEX_KEY(scopeId), bindingId); + const result = await transaction.exec(); + if (result === null || result.some(([error]) => error)) throw new Error('Failed to delete BLE binding'); + } +} + +export class MemoryBleBindingStore implements IBleBindingStore { + private readonly bindings = new Map(); + + private key(scopeId: string, bindingId: string): string { + return `${scopeId}:${bindingId}`; + } + + async get(scopeId: string, bindingId: string): Promise { + const binding = this.bindings.get(this.key(scopeId, bindingId)); + return binding ? { ...binding, commands: [...binding.commands] } : null; + } + + async list(scopeId: string): Promise { + return [...this.bindings.values()] + .filter((binding) => binding.scopeId === scopeId) + .sort((a, b) => a.createdAt - b.createdAt) + .map((binding) => ({ ...binding, commands: [...binding.commands] })); + } + + async put(binding: BleBinding): Promise { + if (!isBleBinding(binding)) throw new Error('Invalid BLE binding'); + this.bindings.set(this.key(binding.scopeId, binding.bindingId), { ...binding, commands: [...binding.commands] }); + } + + async delete(scopeId: string, bindingId: string): Promise { + this.bindings.delete(this.key(scopeId, bindingId)); + } +} diff --git a/packages/api/src/domains/limb/ble/BleDeviceManager.ts b/packages/api/src/domains/limb/ble/BleDeviceManager.ts new file mode 100644 index 0000000000..1109f703af --- /dev/null +++ b/packages/api/src/domains/limb/ble/BleDeviceManager.ts @@ -0,0 +1,339 @@ +import { Buffer } from 'node:buffer'; +import { randomUUID } from 'node:crypto'; +import { EventEmitter } from 'node:events'; +import type { LimbNodeStatus } from '@cat-cafe/shared'; +import type { LimbRegistry } from '../LimbRegistry.js'; +import { + availableBleCommands, + type BleDecodedValue, + type BleGattService, + decodeBleCommandValue, + findBleAdapterCommand, + getBleAdapter, + selectBleAdapter, +} from './BleAdapters.js'; +import { BLE_BINDING_SCOPE, type BleBinding, type IBleBindingStore } from './BleBindingStore.js'; +import type { BleHelperClientStatus } from './BleHelperClientTypes.js'; +import type { BleHelperEvent } from './BleHelperProtocol.js'; +import { BleLimbNode, type BleLimbNodeExecutor } from './BleLimbNode.js'; +import { type BleHelperRequester, BleScanSession, type BleScanSnapshot } from './BleScanSession.js'; + +export interface BleManagerHelper extends BleHelperRequester { + readonly status: BleHelperClientStatus; + on(event: 'state', listener: (status: BleHelperClientStatus) => void): this; + on(event: 'event', listener: (message: BleHelperEvent) => void): this; + off(event: 'state', listener: (status: BleHelperClientStatus) => void): this; + off(event: 'event', listener: (message: BleHelperEvent) => void): this; +} + +interface LoggerLike { + warn(message: string): void; + info(message: string): void; +} + +export interface BleDeviceManagerOptions { + helper: BleManagerHelper; + store: IBleBindingStore; + registry: LimbRegistry; + platform?: string; + logger?: LoggerLike; +} + +export interface BleBindingView { + bindingId: string; + displayName: string; + adapterId: string; + commands: string[]; + nodeId: string; + createdAt: number; + lastConnectedAt: number | null; +} + +export interface BleManagerStatus { + platform: string; + available: boolean; + state: BleHelperClientStatus['state']; + reason: string | null; + restartAttempts: number; + bindingCount: number; +} + +interface BindInput { + sessionId: string; + discoveryId: string; +} + +interface NotificationSubscription { + binding: BleBinding; + command: string; +} + +function toBindingView(binding: BleBinding): BleBindingView { + return { + bindingId: binding.bindingId, + displayName: binding.displayName, + adapterId: binding.adapterId, + commands: [...binding.commands], + nodeId: binding.nodeId, + createdAt: binding.createdAt, + lastConnectedAt: binding.lastConnectedAt, + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function parseGattServices(value: unknown): BleGattService[] { + if (!isRecord(value) || !Array.isArray(value.services) || value.services.length > 64) { + throw new Error('BLE helper returned an invalid GATT inspection'); + } + return value.services.map((service): BleGattService => { + if (!isRecord(service) || typeof service.uuid !== 'string' || service.uuid.length > 64) { + throw new Error('BLE helper returned an invalid GATT service'); + } + if (!Array.isArray(service.characteristics) || service.characteristics.length > 128) { + throw new Error('BLE helper returned an invalid characteristic list'); + } + return { + uuid: service.uuid, + characteristics: service.characteristics.map((characteristic) => { + if ( + !isRecord(characteristic) || + typeof characteristic.uuid !== 'string' || + characteristic.uuid.length > 64 || + !Array.isArray(characteristic.properties) || + !characteristic.properties.every((property) => typeof property === 'string' && property.length <= 32) + ) { + throw new Error('BLE helper returned an invalid GATT characteristic'); + } + return { uuid: characteristic.uuid, properties: [...characteristic.properties] as string[] }; + }), + }; + }); +} + +function decodeBase64Value(value: unknown): Buffer { + if (!isRecord(value) || typeof value.valueBase64 !== 'string' || value.valueBase64.length > 5_464) { + throw new Error('BLE helper returned an invalid characteristic value'); + } + if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value.valueBase64)) { + throw new Error('BLE helper returned malformed base64 data'); + } + const decoded = Buffer.from(value.valueBase64, 'base64'); + if (decoded.byteLength > 4 * 1024) throw new Error('BLE characteristic value exceeds 4 KiB'); + return decoded; +} + +function notificationKey(deviceId: string, serviceUuid: string, characteristicUuid: string): string { + return `${deviceId}:${serviceUuid.toLowerCase()}:${characteristicUuid.toLowerCase()}`; +} + +export class BleDeviceManager extends EventEmitter implements BleLimbNodeExecutor { + private readonly helper: BleManagerHelper; + private readonly store: IBleBindingStore; + private readonly registry: LimbRegistry; + private readonly platform: string; + private readonly logger: LoggerLike; + private readonly scan: BleScanSession; + private readonly bindings = new Map(); + private readonly bindingDeviceIds = new Set(); + private readonly subscriptions = new Map(); + + constructor(options: BleDeviceManagerOptions) { + super(); + this.helper = options.helper; + this.store = options.store; + this.registry = options.registry; + this.platform = options.platform ?? process.platform; + this.logger = options.logger ?? console; + this.scan = new BleScanSession(this.helper); + this.helper.on('state', this.handleHelperState); + this.helper.on('event', this.handleHelperEvent); + } + + async initialize(): Promise { + const persisted = await this.store.list(BLE_BINDING_SCOPE); + for (const binding of persisted) { + const adapter = getBleAdapter(binding.adapterId); + const declared = new Set(adapter?.commands.map((command) => command.command) ?? []); + if (!adapter || binding.commands.some((command) => !declared.has(command))) { + this.logger.warn(`Ignoring BLE binding with unknown adapter or command: ${binding.bindingId}`); + continue; + } + try { + await this.registerBinding(binding); + } catch (error) { + this.logger.warn(`Failed to hydrate BLE binding '${binding.bindingId}': ${String(error)}`); + } + } + } + + status(): BleManagerStatus { + const helperStatus = this.helper.status; + return { + platform: this.platform, + available: this.platform === 'darwin' && helperStatus.state !== 'unsupported', + state: helperStatus.state, + reason: helperStatus.reason, + restartAttempts: helperStatus.restartAttempts, + bindingCount: this.bindings.size, + }; + } + + async listBindings(): Promise { + return [...this.bindings.values()].map(toBindingView).sort((a, b) => a.createdAt - b.createdAt); + } + + startScan(): Promise<{ sessionId: string; startedAt: number; expiresAt: number }> { + return this.scan.start(); + } + + stopScan(): Promise { + return this.scan.stop(); + } + + scanSnapshot(): BleScanSnapshot { + return this.scan.snapshot(); + } + + async bind(input: BindInput): Promise { + const discovery = this.scan.resolveDiscovery(input.sessionId, input.discoveryId); + if (!discovery) throw new Error('BLE discovery is not available in the active scan session'); + const deviceId = discovery.platformDeviceId; + if ( + this.bindingDeviceIds.has(deviceId) || + [...this.bindings.values()].some((binding) => binding.platformDeviceId === deviceId) + ) { + throw new Error('BLE device is already bound or binding is already in progress'); + } + this.bindingDeviceIds.add(deviceId); + + try { + const inspection = await this.helper.request('device.inspect', { deviceId }); + const services = parseGattServices(inspection); + const adapter = selectBleAdapter(services); + if (!adapter) throw new Error('BLE device does not expose a supported adapter profile'); + const commands = availableBleCommands(adapter, services).map((command) => command.command); + if (commands.length === 0) throw new Error('BLE device has no supported readable or notifiable characteristics'); + + const bindingId = randomUUID(); + const now = Date.now(); + const binding: BleBinding = { + bindingId, + scopeId: BLE_BINDING_SCOPE, + platformDeviceId: deviceId, + displayName: discovery.name?.trim().slice(0, 128) || `BLE ${bindingId.slice(0, 8)}`, + adapterId: adapter.id, + commands, + nodeId: `ble:${bindingId}`, + createdAt: now, + lastConnectedAt: now, + }; + + await this.store.put(binding); + try { + await this.registerBinding(binding); + } catch (error) { + await this.store.delete(binding.scopeId, binding.bindingId); + throw error; + } + return toBindingView(binding); + } finally { + this.bindingDeviceIds.delete(deviceId); + } + } + + async unbind(bindingId: string): Promise { + const binding = this.bindings.get(bindingId); + if (!binding) return false; + await this.store.delete(binding.scopeId, binding.bindingId); + this.bindings.delete(bindingId); + this.registry.deregister(binding.nodeId); + for (const [key, subscription] of this.subscriptions) { + if (subscription.binding.bindingId === bindingId) this.subscriptions.delete(key); + } + try { + await this.helper.request('device.disconnect', { deviceId: binding.platformDeviceId }); + } catch (error) { + this.logger.warn(`BLE device disconnect after unbind failed: ${String(error)}`); + } + return true; + } + + async execute(binding: BleBinding, commandName: string): Promise { + const liveBinding = this.bindings.get(binding.bindingId); + if (!liveBinding || !liveBinding.commands.includes(commandName)) throw new Error('BLE binding is no longer active'); + const command = findBleAdapterCommand(liveBinding.adapterId, commandName); + if (!command) throw new Error(`BLE adapter does not declare command: ${commandName}`); + const params = { + deviceId: liveBinding.platformDeviceId, + serviceUuid: command.serviceUuid, + characteristicUuid: command.characteristicUuid, + }; + if (command.mode === 'notify') { + await this.helper.request('gatt.subscribe', params); + this.subscriptions.set( + notificationKey(liveBinding.platformDeviceId, command.serviceUuid, command.characteristicUuid), + { binding: liveBinding, command: commandName }, + ); + return { subscribed: true }; + } + const result = await this.helper.request('gatt.read', params); + return decodeBleCommandValue(liveBinding.adapterId, commandName, decodeBase64Value(result)); + } + + nodeHealth(): LimbNodeStatus { + return this.helper.status.state === 'degraded' || this.helper.status.state === 'unsupported' + ? 'degraded' + : 'online'; + } + + dispose(): void { + this.scan.dispose(); + this.helper.off('state', this.handleHelperState); + this.helper.off('event', this.handleHelperEvent); + } + + private async registerBinding(binding: BleBinding): Promise { + if (this.registry.getNode(binding.nodeId)) throw new Error(`Limb node already registered: ${binding.nodeId}`); + await this.registry.register(new BleLimbNode(binding, this)); + this.bindings.set(binding.bindingId, { ...binding, commands: [...binding.commands] }); + } + + private readonly handleHelperState = (status: BleHelperClientStatus): void => { + const nodeStatus: LimbNodeStatus = + status.state === 'degraded' || status.state === 'unsupported' ? 'degraded' : 'online'; + for (const binding of this.bindings.values()) this.registry.updateStatus(binding.nodeId, nodeStatus); + this.emit('state', this.status()); + }; + + private readonly handleHelperEvent = (message: BleHelperEvent): void => { + if (message.event !== 'gatt.notification') return; + const key = notificationKey(message.data.deviceId, message.data.serviceUuid, message.data.characteristicUuid); + const subscription = this.subscriptions.get(key); + if (!subscription) return; + try { + const value = decodeBleCommandValue( + subscription.binding.adapterId, + subscription.command, + decodeBase64Value({ valueBase64: message.data.valueBase64 }), + ); + this.emit('notification', { + bindingId: subscription.binding.bindingId, + nodeId: subscription.binding.nodeId, + command: subscription.command, + observedAt: message.data.observedAt, + value, + } satisfies { + bindingId: string; + nodeId: string; + command: string; + observedAt: number; + value: BleDecodedValue; + }); + } catch (error) { + this.logger.warn(`Ignoring invalid BLE notification: ${String(error)}`); + } + }; +} diff --git a/packages/api/src/domains/limb/ble/BleHelperClient.ts b/packages/api/src/domains/limb/ble/BleHelperClient.ts new file mode 100644 index 0000000000..13cccf809c --- /dev/null +++ b/packages/api/src/domains/limb/ble/BleHelperClient.ts @@ -0,0 +1,341 @@ +import { randomUUID } from 'node:crypto'; +import { EventEmitter } from 'node:events'; +import { StringDecoder } from 'node:string_decoder'; +import type { + BleHelperClientOptions, + BleHelperClientState, + BleHelperClientStatus, + BleHelperLogger, +} from './BleHelperClientTypes.js'; +import { asError, BleHelperCompatibilityError, isCompatibilityError } from './BleHelperErrors.js'; +import { type BleHelperProcess, spawnBleHelperProcess } from './BleHelperProcess.js'; +import { type BleHelperEvent, type BleHelperInboundMessage, encodeBleHelperRequest } from './BleHelperProtocol.js'; +import { frameBleHelperChunk, tryParseBleHelperMessage } from './BleHelperStream.js'; + +interface PendingRequest { + resolve(value: unknown): void; + reject(error: Error): void; + timer: NodeJS.Timeout; +} + +const RESTART_DELAYS_MS = [1_000, 2_000, 4_000] as const; + +export class BleHelperClient extends EventEmitter { + private readonly platform: string; + private readonly spawnProcess: () => BleHelperProcess; + private readonly handshakeTimeoutMs: number; + private readonly requestTimeoutMs: number; + private readonly setRequestTimer: (callback: () => void, ms: number) => NodeJS.Timeout; + private readonly clearRequestTimer: (timer: NodeJS.Timeout) => void; + private readonly sleep: (ms: number) => Promise; + private readonly logger: BleHelperLogger; + private process: BleHelperProcess | null = null; + private state: BleHelperClientState; + private reason: string | null = null; + private restartAttempts = 0; + private recoveryPromise: Promise | null = null; + private readonly pending = new Map(); + private stopping = false; + private lastError: Error | null = null; + + constructor(options: BleHelperClientOptions = {}) { + super(); + this.platform = options.platform ?? process.platform; + this.spawnProcess = options.spawnProcess ?? (() => spawnBleHelperProcess(options.helperPath)); + this.handshakeTimeoutMs = options.handshakeTimeoutMs ?? 3_000; + this.requestTimeoutMs = options.requestTimeoutMs ?? 10_000; + this.setRequestTimer = options.setRequestTimer ?? ((callback, ms) => setTimeout(callback, ms)); + this.clearRequestTimer = options.clearRequestTimer ?? ((timer) => clearTimeout(timer)); + this.sleep = options.sleep ?? ((ms) => new Promise((resolveSleep) => setTimeout(resolveSleep, ms))); + this.logger = options.logger ?? console; + this.state = this.platform === 'darwin' ? 'idle' : 'unsupported'; + if (this.state === 'unsupported') this.reason = 'BLE helper is only available on macOS in Phase A'; + } + + get status(): BleHelperClientStatus { + return { state: this.state, reason: this.reason, restartAttempts: this.restartAttempts }; + } + + /** @internal Test-only inspection; production callers use status and request(). */ + get currentProcessForTest(): BleHelperProcess | null { + return this.process; + } + + async start(): Promise { + if (this.state === 'ready') return; + if (this.state === 'unsupported') throw new Error(this.reason ?? 'BLE helper is unsupported'); + if (!this.recoveryPromise) this.beginRecovery(true); + await this.recoveryPromise; + if (this.status.state !== 'ready') { + throw new Error(`BLE helper unavailable: ${this.lastError?.message ?? this.reason ?? 'unknown error'}`); + } + } + + async request(command: string, params: Record): Promise { + await this.start(); + const child = this.process; + if (!child || this.state !== 'ready') throw new Error('BLE helper is not ready'); + + const requestId = randomUUID(); + const line = encodeBleHelperRequest(command, params, requestId); + return new Promise((resolveRequest, rejectRequest) => { + const timer = this.setRequestTimer(() => { + this.pending.delete(requestId); + rejectRequest(new Error(`BLE helper request timed out: ${command}`)); + }, this.requestTimeoutMs); + timer.unref(); + this.pending.set(requestId, { resolve: resolveRequest, reject: rejectRequest, timer }); + try { + child.stdin.write(line, (error) => { + if (!error) return; + const pending = this.pending.get(requestId); + if (!pending) return; + this.clearRequestTimer(pending.timer); + this.pending.delete(requestId); + pending.reject(asError(error)); + }); + } catch (error) { + this.clearRequestTimer(timer); + this.pending.delete(requestId); + rejectRequest(asError(error)); + } + }); + } + + async shutdown(): Promise { + this.stopping = true; + const activeRecovery = this.recoveryPromise; + const child = this.process; + if (child && this.state === 'ready') { + try { + await this.request('helper.shutdown', {}); + } catch { + // Continue with process termination. + } + } + this.rejectPending(new Error('BLE helper is shutting down')); + child?.kill('SIGTERM'); + this.process = null; + this.setState(this.platform === 'darwin' ? 'idle' : 'unsupported', null); + if (activeRecovery) { + // Keep the stop barrier raised until an in-flight backoff wakes and + // observes it. Otherwise a delayed recovery can spawn a new helper + // after API shutdown has already completed. + void activeRecovery + .finally(() => { + this.stopping = false; + }) + .catch(() => {}); + } else { + this.stopping = false; + } + } + + private beginRecovery(initialStart: boolean): void { + if (this.recoveryPromise) return; + const recovery = this.runRecovery(initialStart).finally(() => { + if (this.recoveryPromise === recovery) this.recoveryPromise = null; + }); + this.recoveryPromise = recovery; + } + + private async runRecovery(initialStart: boolean): Promise { + if (this.stopping) return; + this.restartAttempts = 0; + if (initialStart && (await this.attemptRecovery())) return; + for (const delayMs of RESTART_DELAYS_MS) { + if (await this.attemptRecovery(delayMs)) return; + } + this.setState('degraded', this.lastError?.message ?? 'BLE helper restart attempts exhausted'); + } + + private async attemptRecovery(delayMs?: number): Promise { + if (this.stopping) return true; + if (delayMs !== undefined) { + this.restartAttempts += 1; + await this.sleep(delayMs); + if (this.stopping) return true; + } + try { + await this.spawnAndHandshake(); + return true; + } catch (error) { + this.lastError = asError(error); + if (!isCompatibilityError(this.lastError)) return false; + this.setState('degraded', this.lastError.message); + return true; + } + } + + private spawnAndHandshake(): Promise { + this.setState('starting', null); + let child: BleHelperProcess; + try { + child = this.spawnProcess(); + } catch (error) { + return Promise.reject(asError(error)); + } + this.process = child; + + return new Promise((resolveHandshake, rejectHandshake) => { + let ready = false; + let settled = false; + let buffer = ''; + const decoder = new StringDecoder('utf8'); + + const cleanup = (): void => { + clearTimeout(handshakeTimer); + child.stdout.off('data', onData); + child.stderr.off('data', onStderr); + child.off('exit', onExit); + child.off('error', onError); + }; + + const failBeforeReady = (error: Error): void => { + if (settled) return; + settled = true; + cleanup(); + if (this.process === child) this.process = null; + child.kill('SIGTERM'); + rejectHandshake(error); + }; + + const handleRuntimeProtocolViolation = (error: Error): void => { + cleanup(); + if (this.process === child) this.process = null; + this.lastError = error; + this.rejectPending(error); + child.kill('SIGTERM'); + this.setState('degraded', error.message); + }; + + const handleProtocolError = (error: Error): boolean => { + if (!ready) { + const handshakeError = isCompatibilityError(error) ? new BleHelperCompatibilityError(error.message) : error; + failBeforeReady(handshakeError); + return true; + } + if (isCompatibilityError(error)) { + handleRuntimeProtocolViolation(new BleHelperCompatibilityError(error.message)); + return true; + } + this.logger.warn(`Ignoring invalid BLE helper message: ${error.message}`); + return false; + }; + + const acceptMessage = (message: BleHelperInboundMessage): boolean => { + if (ready) { + this.dispatchMessage(message); + return true; + } + if (message.kind !== 'hello') { + failBeforeReady(new BleHelperCompatibilityError('BLE helper did not send hello before other messages')); + return false; + } + ready = true; + settled = true; + clearTimeout(handshakeTimer); + this.lastError = null; + this.setState('ready', null); + resolveHandshake(); + return true; + }; + + const handleLine = (line: string): boolean => { + const parsed = tryParseBleHelperMessage(line); + return parsed.ok ? acceptMessage(parsed.message) : !handleProtocolError(parsed.error); + }; + + const onData = (chunk: Buffer | string): void => { + const framed = frameBleHelperChunk(buffer, Buffer.isBuffer(chunk) ? decoder.write(chunk) : chunk); + buffer = framed.rest; + if (framed.oversizedRest) { + handleProtocolError(new BleHelperCompatibilityError('BLE helper message exceeds line size limit')); + return; + } + framed.lines.every(handleLine); + }; + + const onStderr = (chunk: Buffer | string): void => { + const message = (Buffer.isBuffer(chunk) ? chunk.toString('utf8') : chunk).trim().slice(0, 512); + if (message) this.logger.warn(`BLE helper: ${message}`); + }; + + const onExit = (code: number | null, signal: NodeJS.Signals | null): void => { + const error = new Error(`BLE helper exited (code=${String(code)}, signal=${String(signal)})`); + if (!ready) { + failBeforeReady(error); + return; + } + cleanup(); + this.handleUnexpectedExit(child, error); + }; + + const onError = (error: Error): void => { + if (!ready) { + failBeforeReady(error); + return; + } + cleanup(); + this.handleUnexpectedExit(child, error); + }; + + const handshakeTimer = setTimeout( + () => failBeforeReady(new Error('BLE helper handshake timed out')), + this.handshakeTimeoutMs, + ); + handshakeTimer.unref(); + child.stdout.on('data', onData); + child.stderr.on('data', onStderr); + child.on('exit', onExit); + child.on('error', onError); + }); + } + + private dispatchMessage(message: BleHelperInboundMessage): void { + if (message.kind === 'event') { + this.emit('event', message as BleHelperEvent); + return; + } + if (message.kind === 'hello') { + this.logger.warn('Ignoring duplicate BLE helper hello'); + return; + } + const pending = this.pending.get(message.requestId); + if (!pending) { + this.logger.warn(`Ignoring BLE helper response for unknown request: ${message.requestId}`); + return; + } + this.clearRequestTimer(pending.timer); + this.pending.delete(message.requestId); + if (message.ok) pending.resolve(message.data); + else pending.reject(new Error(message.error ?? 'BLE helper request failed')); + } + + private handleUnexpectedExit(child: BleHelperProcess, error: Error): void { + if (this.process !== child) return; + this.process = null; + this.lastError = error; + this.rejectPending(error); + if (this.stopping) { + this.setState('idle', null); + return; + } + this.setState('degraded', error.message); + this.beginRecovery(false); + } + + private rejectPending(error: Error): void { + for (const pending of this.pending.values()) { + this.clearRequestTimer(pending.timer); + pending.reject(error); + } + this.pending.clear(); + } + + private setState(state: BleHelperClientState, reason: string | null): void { + this.state = state; + this.reason = reason; + this.emit('state', this.status); + } +} diff --git a/packages/api/src/domains/limb/ble/BleHelperClientTypes.ts b/packages/api/src/domains/limb/ble/BleHelperClientTypes.ts new file mode 100644 index 0000000000..2478ef9ae3 --- /dev/null +++ b/packages/api/src/domains/limb/ble/BleHelperClientTypes.ts @@ -0,0 +1,26 @@ +import type { BleHelperProcess } from './BleHelperProcess.js'; + +export type BleHelperClientState = 'idle' | 'starting' | 'ready' | 'degraded' | 'unsupported'; + +export interface BleHelperClientStatus { + state: BleHelperClientState; + reason: string | null; + restartAttempts: number; +} + +export interface BleHelperLogger { + info(message: string): void; + warn(message: string): void; +} + +export interface BleHelperClientOptions { + platform?: NodeJS.Platform | string; + spawnProcess?: () => BleHelperProcess; + helperPath?: string; + handshakeTimeoutMs?: number; + requestTimeoutMs?: number; + setRequestTimer?: (callback: () => void, ms: number) => NodeJS.Timeout; + clearRequestTimer?: (timer: NodeJS.Timeout) => void; + sleep?: (ms: number) => Promise; + logger?: BleHelperLogger; +} diff --git a/packages/api/src/domains/limb/ble/BleHelperErrors.ts b/packages/api/src/domains/limb/ble/BleHelperErrors.ts new file mode 100644 index 0000000000..742ceb8550 --- /dev/null +++ b/packages/api/src/domains/limb/ble/BleHelperErrors.ts @@ -0,0 +1,13 @@ +export class BleHelperCompatibilityError extends Error {} + +export function asError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} + +export function isCompatibilityError(error: Error): boolean { + return ( + error instanceof BleHelperCompatibilityError || + error.message.startsWith('Unsupported BLE helper protocol') || + error.message.startsWith('BLE helper message exceeds') + ); +} diff --git a/packages/api/src/domains/limb/ble/BleHelperProcess.ts b/packages/api/src/domains/limb/ble/BleHelperProcess.ts new file mode 100644 index 0000000000..49feed734c --- /dev/null +++ b/packages/api/src/domains/limb/ble/BleHelperProcess.ts @@ -0,0 +1,39 @@ +import { type ChildProcessWithoutNullStreams, spawn } from 'node:child_process'; +import { EventEmitter } from 'node:events'; +import { existsSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export interface BleHelperProcess extends EventEmitter { + stdin: { + write(data: string, callback?: (error?: Error | null) => void): boolean; + }; + stdout: EventEmitter; + stderr: EventEmitter; + kill(signal?: NodeJS.Signals): boolean; +} + +export function resolveBleHelperExecutable( + arch = process.arch, + fileExists: (path: string) => boolean = existsSync, +): string { + const moduleDir = dirname(fileURLToPath(import.meta.url)); + const apiRoot = resolve(moduleDir, '../../../..'); + const packagedArchitecture = arch === 'arm64' ? 'arm64' : 'x64'; + const localBuildArchitecture = arch === 'x64' ? 'x86_64' : packagedArchitecture; + const candidates = [ + resolve(apiRoot, 'ble-helper', 'ble-helper'), + resolve(apiRoot, '..', '..', 'bundled', `ble-helper-darwin-${packagedArchitecture}`, 'ble-helper'), + resolve(apiRoot, '..', '..', 'native', 'ble-helper', 'macos', '.build', localBuildArchitecture, 'ble-helper'), + ]; + const executable = candidates.find(fileExists); + if (!executable) { + throw new Error('BLE helper executable not found'); + } + return executable; +} + +export function spawnBleHelperProcess(helperPath?: string): BleHelperProcess { + const executable = helperPath ?? resolveBleHelperExecutable(); + return spawn(executable, [], { stdio: ['pipe', 'pipe', 'pipe'], shell: false }) as ChildProcessWithoutNullStreams; +} diff --git a/packages/api/src/domains/limb/ble/BleHelperProtocol.ts b/packages/api/src/domains/limb/ble/BleHelperProtocol.ts new file mode 100644 index 0000000000..5b968d1e5d --- /dev/null +++ b/packages/api/src/domains/limb/ble/BleHelperProtocol.ts @@ -0,0 +1,218 @@ +import { Buffer } from 'node:buffer'; +import { z } from 'zod'; + +export const BLE_HELPER_PROTOCOL = 'ble-helper' as const; +export const BLE_HELPER_PROTOCOL_VERSION = 1 as const; +export const BLE_HELPER_MAX_LINE_BYTES = 64 * 1024; +export const BLE_HELPER_MAX_NOTIFICATION_BYTES = 4 * 1024; + +export const BLE_HELPER_COMMANDS = [ + 'scan.start', + 'scan.stop', + 'device.inspect', + 'gatt.read', + 'gatt.subscribe', + 'device.disconnect', + 'helper.shutdown', +] as const; + +export type BleHelperCommand = (typeof BLE_HELPER_COMMANDS)[number]; + +const boundedId = z.string().min(1).max(128); +const boundedUuid = z.string().min(1).max(64); +const boundedBase64Value = z + .string() + .max(Math.ceil(BLE_HELPER_MAX_NOTIFICATION_BYTES / 3) * 4) + .regex(/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/); +const base = { + protocol: z.literal(BLE_HELPER_PROTOCOL), + version: z.literal(BLE_HELPER_PROTOCOL_VERSION), +}; + +const helloSchema = z + .object({ + ...base, + kind: z.literal('hello'), + }) + .strict(); + +const responseSchema = z + .object({ + ...base, + kind: z.literal('response'), + requestId: boundedId, + ok: z.boolean(), + data: z.unknown().optional(), + error: z.string().max(512).optional(), + }) + .strict() + .superRefine((value, context) => { + if (!value.ok && !value.error) { + context.addIssue({ code: z.ZodIssueCode.custom, message: 'Failed response requires an error' }); + } + }); + +const scanDiscoveredEventSchema = z + .object({ + ...base, + kind: z.literal('event'), + event: z.literal('scan.discovered'), + data: z + .object({ + sessionId: boundedId, + deviceId: boundedId, + name: z.string().max(128).nullable(), + rssi: z.number().int().min(-127).max(20), + serviceUuids: z.array(boundedUuid).max(64), + }) + .strict(), + }) + .strict(); + +const scanStateEventSchema = z + .object({ + ...base, + kind: z.literal('event'), + event: z.literal('scan.state'), + data: z + .object({ + sessionId: boundedId, + state: z.enum(['started', 'stopped', 'timeout']), + }) + .strict(), + }) + .strict(); + +const adapterStateEventSchema = z + .object({ + ...base, + kind: z.literal('event'), + event: z.literal('adapter.state'), + data: z + .object({ + state: z.enum(['poweredOn', 'poweredOff', 'unauthorized', 'unsupported', 'resetting', 'unknown']), + }) + .strict(), + }) + .strict(); + +const disconnectedEventSchema = z + .object({ + ...base, + kind: z.literal('event'), + event: z.literal('device.disconnected'), + data: z + .object({ + deviceId: boundedId, + reason: z.string().max(256).nullable(), + }) + .strict(), + }) + .strict(); + +const notificationEventSchema = z + .object({ + ...base, + kind: z.literal('event'), + event: z.literal('gatt.notification'), + data: z + .object({ + deviceId: boundedId, + serviceUuid: boundedUuid, + characteristicUuid: boundedUuid, + valueBase64: boundedBase64Value, + observedAt: z.number().int().nonnegative(), + }) + .strict(), + }) + .strict() + .superRefine((value, context) => { + const decoded = Buffer.from(value.data.valueBase64, 'base64'); + if (decoded.byteLength > BLE_HELPER_MAX_NOTIFICATION_BYTES) { + context.addIssue({ code: z.ZodIssueCode.custom, message: 'Notification payload exceeds 4 KiB' }); + } + }); + +const inboundSchema = z.union([ + helloSchema, + responseSchema, + scanDiscoveredEventSchema, + scanStateEventSchema, + adapterStateEventSchema, + disconnectedEventSchema, + notificationEventSchema, +]); + +export type BleHelperHello = z.infer; +export type BleHelperResponse = z.infer; +export type BleHelperEvent = + | z.infer + | z.infer + | z.infer + | z.infer + | z.infer; +export type BleHelperInboundMessage = BleHelperHello | BleHelperResponse | BleHelperEvent; + +const requestParamsSchemas: Record>> = { + 'scan.start': z.object({ sessionId: boundedId, timeoutMs: z.number().int().min(1).max(30_000) }).strict(), + 'scan.stop': z.object({ sessionId: boundedId }).strict(), + 'device.inspect': z.object({ deviceId: boundedId }).strict(), + 'gatt.read': z.object({ deviceId: boundedId, serviceUuid: boundedUuid, characteristicUuid: boundedUuid }).strict(), + 'gatt.subscribe': z + .object({ deviceId: boundedId, serviceUuid: boundedUuid, characteristicUuid: boundedUuid }) + .strict(), + 'device.disconnect': z.object({ deviceId: boundedId }).strict(), + 'helper.shutdown': z.object({}).strict(), +}; + +export function encodeBleHelperRequest(command: string, params: Record, requestId: string): string { + if (!BLE_HELPER_COMMANDS.includes(command as BleHelperCommand)) { + throw new Error(`Unsupported BLE helper command: ${command}`); + } + const parsedParams = requestParamsSchemas[command as BleHelperCommand].safeParse(params); + if (!parsedParams.success) { + throw new Error(`Invalid BLE helper params for ${command}: ${parsedParams.error.message}`); + } + const parsedRequestId = boundedId.safeParse(requestId); + if (!parsedRequestId.success) throw new Error('Invalid BLE helper requestId'); + const line = JSON.stringify({ + protocol: BLE_HELPER_PROTOCOL, + version: BLE_HELPER_PROTOCOL_VERSION, + requestId, + command, + params: parsedParams.data, + }); + if (Buffer.byteLength(line, 'utf8') > BLE_HELPER_MAX_LINE_BYTES) { + throw new Error('BLE helper request exceeds line size limit'); + } + return `${line}\n`; +} + +export function parseBleHelperMessage(line: string): BleHelperInboundMessage { + if (Buffer.byteLength(line, 'utf8') > BLE_HELPER_MAX_LINE_BYTES) { + throw new Error(`BLE helper message exceeds ${BLE_HELPER_MAX_LINE_BYTES} bytes`); + } + + let value: unknown; + try { + value = JSON.parse(line); + } catch { + throw new Error('Invalid BLE helper JSON'); + } + + if ( + typeof value === 'object' && + value !== null && + 'version' in value && + value.version !== BLE_HELPER_PROTOCOL_VERSION + ) { + throw new Error(`Unsupported BLE helper protocol version: ${String(value.version)}`); + } + if (typeof value === 'object' && value !== null && 'protocol' in value && value.protocol !== BLE_HELPER_PROTOCOL) { + throw new Error(`Unsupported BLE helper protocol: ${String(value.protocol)}`); + } + + const parsed = inboundSchema.safeParse(value); + if (!parsed.success) throw new Error(`Invalid BLE helper message: ${parsed.error.message}`); + return parsed.data as BleHelperInboundMessage; +} diff --git a/packages/api/src/domains/limb/ble/BleHelperStream.ts b/packages/api/src/domains/limb/ble/BleHelperStream.ts new file mode 100644 index 0000000000..1c47e4d313 --- /dev/null +++ b/packages/api/src/domains/limb/ble/BleHelperStream.ts @@ -0,0 +1,28 @@ +import { Buffer } from 'node:buffer'; +import { BLE_HELPER_MAX_LINE_BYTES, type BleHelperInboundMessage, parseBleHelperMessage } from './BleHelperProtocol.js'; + +export interface BleHelperFrame { + rest: string; + lines: string[]; + oversizedRest: boolean; +} + +export type BleHelperParseResult = { ok: true; message: BleHelperInboundMessage } | { ok: false; error: Error }; + +export function frameBleHelperChunk(previous: string, chunk: string): BleHelperFrame { + const parts = `${previous}${chunk}`.split('\n'); + const rest = parts.pop() ?? ''; + return { + rest, + lines: parts.map((line) => line.replace(/\r$/, '')).filter(Boolean), + oversizedRest: Buffer.byteLength(rest, 'utf8') > BLE_HELPER_MAX_LINE_BYTES, + }; +} + +export function tryParseBleHelperMessage(line: string): BleHelperParseResult { + try { + return { ok: true, message: parseBleHelperMessage(line) }; + } catch (error) { + return { ok: false, error: error instanceof Error ? error : new Error(String(error)) }; + } +} diff --git a/packages/api/src/domains/limb/ble/BleLimbNode.ts b/packages/api/src/domains/limb/ble/BleLimbNode.ts new file mode 100644 index 0000000000..ccf7604c3b --- /dev/null +++ b/packages/api/src/domains/limb/ble/BleLimbNode.ts @@ -0,0 +1,48 @@ +import type { ILimbNode, LimbCapability, LimbCommandSchema, LimbInvokeResult, LimbNodeStatus } from '@cat-cafe/shared'; +import { buildBleCommandSchemas, buildBleLimbCapabilities } from './BleAdapters.js'; +import type { BleBinding } from './BleBindingStore.js'; + +export interface BleLimbNodeExecutor { + execute(binding: BleBinding, command: string): Promise; + nodeHealth(): LimbNodeStatus; +} + +export class BleLimbNode implements ILimbNode { + readonly nodeId: string; + readonly displayName: string; + readonly platform = 'macos-ble'; + readonly capabilities: LimbCapability[]; + readonly commandSchemas: Readonly>; + + constructor( + private readonly binding: BleBinding, + private readonly executor: BleLimbNodeExecutor, + ) { + this.nodeId = binding.nodeId; + this.displayName = binding.displayName; + this.capabilities = buildBleLimbCapabilities(binding.adapterId, binding.commands); + this.commandSchemas = buildBleCommandSchemas(binding.adapterId, binding.commands); + } + + async register(): Promise {} + + async invoke(command: string, params: Record): Promise { + if (!this.binding.commands.includes(command)) { + return { success: false, error: `BLE command is not allowed by binding '${this.binding.bindingId}': ${command}` }; + } + if (Object.keys(params).length > 0) { + return { success: false, error: `BLE command '${command}' does not accept agent-supplied parameters` }; + } + try { + return { success: true, data: await this.executor.execute(this.binding, command) }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } + } + + async healthCheck(): Promise { + return this.executor.nodeHealth(); + } + + async deregister(): Promise {} +} diff --git a/packages/api/src/domains/limb/ble/BleScanSession.ts b/packages/api/src/domains/limb/ble/BleScanSession.ts new file mode 100644 index 0000000000..1863cf3609 --- /dev/null +++ b/packages/api/src/domains/limb/ble/BleScanSession.ts @@ -0,0 +1,150 @@ +import { randomUUID } from 'node:crypto'; +import type { BleHelperEvent } from './BleHelperProtocol.js'; + +export const BLE_SCAN_TIMEOUT_MS = 30_000; + +export interface BleHelperRequester { + request(command: string, params: Record): Promise; + on(event: 'event', listener: (message: BleHelperEvent) => void): this; + off(event: 'event', listener: (message: BleHelperEvent) => void): this; +} + +export interface BleDiscoveryView { + discoveryId: string; + name: string | null; + rssi: number; + serviceUuids: string[]; +} + +export interface BleResolvedDiscovery extends BleDiscoveryView { + platformDeviceId: string; +} + +export interface BleScanSnapshot { + active: boolean; + sessionId: string | null; + startedAt: number | null; + expiresAt: number | null; + discoveries: BleDiscoveryView[]; +} + +interface ActiveScan { + sessionId: string; + startedAt: number; + expiresAt: number; +} + +interface TimerOptions { + now?: () => number; + setTimer?: (callback: () => void, ms: number) => NodeJS.Timeout | number; + clearTimer?: (timer: NodeJS.Timeout | number) => void; +} + +export class BleScanSession { + private active: ActiveScan | null = null; + private timer: NodeJS.Timeout | number | null = null; + private readonly discoveries = new Map(); + private readonly platformToDiscovery = new Map(); + private readonly now: () => number; + private readonly setTimer: (callback: () => void, ms: number) => NodeJS.Timeout | number; + private readonly clearTimer: (timer: NodeJS.Timeout | number) => void; + + constructor( + private readonly helper: BleHelperRequester, + options: TimerOptions = {}, + ) { + this.now = options.now ?? Date.now; + this.setTimer = options.setTimer ?? ((callback, ms) => setTimeout(callback, ms)); + this.clearTimer = options.clearTimer ?? ((timer) => clearTimeout(timer)); + this.helper.on('event', this.handleHelperEvent); + } + + async start(): Promise { + if (this.active) throw new Error('A BLE scan session is already active'); + const startedAt = this.now(); + const active = { + sessionId: randomUUID(), + startedAt, + expiresAt: startedAt + BLE_SCAN_TIMEOUT_MS, + }; + this.active = active; + this.discoveries.clear(); + this.platformToDiscovery.clear(); + try { + await this.helper.request('scan.start', { sessionId: active.sessionId, timeoutMs: BLE_SCAN_TIMEOUT_MS }); + } catch (error) { + this.clearLocalState(); + throw error; + } + // The helper can report an early stop immediately after acknowledging + // scan.start (for example when Bluetooth powers off). Do not resurrect a + // local timeout after that event already cleared the session. + if (this.active !== active) return active; + this.timer = this.setTimer(() => { + void this.stop('timeout'); + }, BLE_SCAN_TIMEOUT_MS); + if (typeof this.timer === 'object' && 'unref' in this.timer) this.timer.unref(); + return active; + } + + async stop(_reason: 'explicit' | 'timeout' = 'explicit'): Promise { + const session = this.active; + if (!session) return; + this.clearLocalState(); + try { + await this.helper.request('scan.stop', { sessionId: session.sessionId }); + } catch { + // Privacy state is already cleared. A helper failure must not restore discoveries. + } + } + + snapshot(): BleScanSnapshot { + return { + active: this.active !== null, + sessionId: this.active?.sessionId ?? null, + startedAt: this.active?.startedAt ?? null, + expiresAt: this.active?.expiresAt ?? null, + discoveries: [...this.discoveries.values()].map(({ platformDeviceId: _privateId, ...view }) => ({ ...view })), + }; + } + + resolveDiscovery(sessionId: string, discoveryId: string): BleResolvedDiscovery | null { + if (!this.active || this.active.sessionId !== sessionId) return null; + const discovery = this.discoveries.get(discoveryId); + return discovery ? { ...discovery, serviceUuids: [...discovery.serviceUuids] } : null; + } + + dispose(): void { + this.clearLocalState(); + this.helper.off('event', this.handleHelperEvent); + } + + private readonly handleHelperEvent = (message: BleHelperEvent): void => { + if (!this.active) return; + if (message.event === 'scan.state') { + if (message.data.sessionId === this.active.sessionId && message.data.state !== 'started') this.clearLocalState(); + return; + } + if (message.event !== 'scan.discovered' || message.data.sessionId !== this.active.sessionId) return; + let discoveryId = this.platformToDiscovery.get(message.data.deviceId); + if (!discoveryId) { + discoveryId = randomUUID(); + this.platformToDiscovery.set(message.data.deviceId, discoveryId); + } + this.discoveries.set(discoveryId, { + discoveryId, + platformDeviceId: message.data.deviceId, + name: message.data.name, + rssi: message.data.rssi, + serviceUuids: [...message.data.serviceUuids], + }); + }; + + private clearLocalState(): void { + if (this.timer !== null) this.clearTimer(this.timer); + this.timer = null; + this.active = null; + this.discoveries.clear(); + this.platformToDiscovery.clear(); + } +} diff --git a/packages/api/src/domains/plugin/PluginRegistry.ts b/packages/api/src/domains/plugin/PluginRegistry.ts index 54d675d51d..3fe073baf3 100644 --- a/packages/api/src/domains/plugin/PluginRegistry.ts +++ b/packages/api/src/domains/plugin/PluginRegistry.ts @@ -142,11 +142,17 @@ export class PluginRegistry { (c) => c.pluginId === manifest.id && c.type === r.type && normalizeCapId(c.id) === resourceCapId(manifest.id, r), ); - return { + const base: PluginResourceStatus = { type: r.type, path: r.path, name: r.name, enabled: capEntry?.enabled ?? false, + ...(capEntry?.agentProvider?.state ? { agentProviderState: capEntry.agentProvider.state } : {}), + }; + if (r.type !== 'agentProvider') return base; + return { + ...base, + ...buildAgentProviderResourceProjection(manifest.id, r, capEntry?.agentProvider), }; }); @@ -169,6 +175,70 @@ export class PluginRegistry { } } +type AgentProviderCapDescriptor = NonNullable; +type AgentProviderResource = NonNullable; + +/** Project host-owned routeable + binding state from the persisted capability row. */ +function projectHostOwnedAgentProviderFields( + ap: AgentProviderCapDescriptor | undefined, +): Partial { + if (!ap) return {}; + const out: Partial = {}; + if (typeof ap.routeable === 'boolean') out.agentProviderRouteable = ap.routeable; + if (typeof ap.routeableApproved === 'boolean') out.agentProviderRouteableApproved = ap.routeableApproved; + if (ap.routeableBinding) { + const b = ap.routeableBinding; + out.agentProviderBinding = { + catId: b.catId, + ...(b.profileId ? { profileId: b.profileId } : {}), + ...(b.mentionPatterns ? { mentionPatterns: [...b.mentionPatterns] } : {}), + }; + } + if (ap.descriptorHash) out.agentProviderDescriptorHash = ap.descriptorHash; + if (ap.health?.passed === false && ap.health.failureReason) { + out.agentProviderHealthFailureReason = ap.health.failureReason; + } + // F241 Phase C (PR #42 round-1 review @codex P2): surface persisted sync + // failure separately from health probe failure. Sync failures fire AFTER + // approval + health both pass, during the Step 6 AgentRegistry projection + // (post-approval sync hook). Without this, the Hub renders + // `approved=true / healthy / routeable=false` with no inline explanation. + if (ap.lastSyncError) { + out.agentProviderLastSyncError = { + message: ap.lastSyncError.message, + occurredAt: ap.lastSyncError.occurredAt, + }; + } + return out; +} + +/** Project manifest-declared identity claims (PR #39) — Hub UI uses these as form defaults. */ +function projectAgentProviderClaims(c: AgentProviderResource | undefined): Partial { + if (!c) return {}; + const claims = { + ...(c.providerId ? { providerId: c.providerId } : {}), + ...(c.displayName ? { displayName: c.displayName } : {}), + ...(c.mentionPatterns ? { mentionPatterns: [...c.mentionPatterns] } : {}), + }; + return Object.keys(claims).length > 0 ? { agentProviderClaims: claims } : {}; +} + +/** + * F241 Phase C — Compose the agentProvider extension fields for `PluginResourceStatus`. + * Pure: never touches `base`, just returns the additional fields to merge. + */ +function buildAgentProviderResourceProjection( + pluginId: string, + resource: PluginManifest['resources'][number], + ap: AgentProviderCapDescriptor | undefined, +): Partial { + return { + capId: resourceCapId(pluginId, resource), + ...projectHostOwnedAgentProviderFields(ap), + ...projectAgentProviderClaims(resource.agentProvider), + }; +} + export function resourceCapId(pluginId: string, resource: { type: string; path?: string; name?: string }): string { if (resource.type === 'skill' && resource.path) { return resourcePathBasename(resource.path); diff --git a/packages/api/src/domains/plugin/PluginResourceActivator.ts b/packages/api/src/domains/plugin/PluginResourceActivator.ts index 68d2097900..57f2e35140 100644 --- a/packages/api/src/domains/plugin/PluginResourceActivator.ts +++ b/packages/api/src/domains/plugin/PluginResourceActivator.ts @@ -3,6 +3,7 @@ import { realpath, stat } from 'node:fs/promises'; import { homedir } from 'node:os'; import { dirname, isAbsolute, join, relative } from 'node:path'; import { + type AgentProviderCapabilityDescriptor, type CapabilitiesConfig, type CapabilityEntry, type ILimbNode, @@ -22,6 +23,7 @@ import { addSkill, cascadeToProjects, removeSkill } from '../../skills/skill-man import { classifyMountPath } from '../../skills/skill-sync-engine.js'; import { buildSkillMountTargets } from '../../utils/skill-mount.js'; import type { LimbRegistry } from '../limb/LimbRegistry.js'; +import { computeAgentProviderDescriptorHash } from './agent-provider-descriptor-hash.js'; import { normalizeCapId, resolvePluginResourcePath, resourceCapId, resourcePathBasename } from './PluginRegistry.js'; import { resolvePluginEnv } from './plugin-config-store.js'; import type { ScheduleFactoryDeps, ScheduleFactoryRegistry } from './ScheduleFactoryRegistry.js'; @@ -74,6 +76,8 @@ export interface PluginResourceActivatorDeps { taskRunner?: ScheduleTaskRunner; /** F202 Phase 2: Dependencies injected into schedule factory createTaskSpec */ scheduleFactoryDeps?: ScheduleFactoryDeps; + /** F241 Phase B Slice 2a: host-owned provider transport registry. */ + providerTransportRegistry?: { has(transportId: string): boolean }; /** F228: cat-cafe-skills source dir for cascading skill changes to external projects. */ skillsSourceDir?: string; } @@ -275,6 +279,9 @@ export class PluginResourceActivator { case 'schedule': await this.activateSchedule(manifest, resource); break; + case 'agentProvider': + await this.activateAgentProvider(manifest, resource); + break; default: throw new Error(`Unsupported resource type: ${resource.type}`); } @@ -294,6 +301,9 @@ export class PluginResourceActivator { case 'schedule': await this.deactivateSchedule(manifest, resource); break; + case 'agentProvider': + await this.deactivateAgentProvider(manifest, resource); + break; default: throw new Error(`Unsupported resource type: ${resource.type}`); } @@ -565,12 +575,85 @@ export class PluginResourceActivator { } } + private async activateAgentProvider(manifest: PluginManifest, resource: PluginResourceDef): Promise { + if (!resource.agentProvider) throw new Error('AgentProvider resource must have an agentProvider descriptor'); + if (!this.deps.providerTransportRegistry) throw new Error('ProviderTransportRegistry not configured'); + if (!this.deps.providerTransportRegistry.has(resource.agentProvider.transport)) { + throw new Error(`Unknown agentProvider transport '${resource.agentProvider.transport}'`); + } + + // F241 Phase B Slice 2b: compute the canonical descriptor hash so we can decide + // whether to preserve host-owned state (routeableApproved / health / lastSyncError) + // or reset it. The activator NEVER writes positive approval — it only resets to + // `false` on descriptor delta. Operator must re-approve through the explicit + // synchronous path. See F241 doc § Phase B Slice 2b Design Notes. + const capId = resourceCapId(manifest.id, resource); + const descriptorHash = computeAgentProviderDescriptorHash({ + pluginId: manifest.id, + capId, + resource: resource.agentProvider, + }); + const existing = await this.readExistingAgentProviderDescriptor(manifest.id, capId); + + const agentProvider: AgentProviderCapabilityDescriptor = + existing && existing.descriptorHash === descriptorHash + ? { + // Descriptor unchanged — preserve host-owned state verbatim. Spread `existing` + // first to keep host fields, then overlay manifest fields from `resource` so + // any non-hash-contributing manifest field (none today, but future-proofing) + // is refreshed without disturbing host state. + ...existing, + ...resource.agentProvider, + state: existing.state, + routeable: existing.routeable, + routeableApproved: existing.routeableApproved, + descriptorHash, + ...(existing.health !== undefined ? { health: existing.health } : {}), + ...(existing.lastSyncError !== undefined ? { lastSyncError: existing.lastSyncError } : {}), + } + : { + // New activation or descriptor delta — reset host-owned state to fail-closed + // defaults. `health` and `lastSyncError` are intentionally omitted (undefined). + ...resource.agentProvider, + state: 'transportReady', + routeable: false, + routeableApproved: false, + descriptorHash, + }; + + await this.upsertCapabilityEntry(manifest, resource, true, undefined, undefined, agentProvider); + } + + /** + * F241 Phase B Slice 2b: read the existing agentProvider descriptor for a given + * (pluginId, capId), if any. Returns undefined when no capability row exists, when + * the row belongs to a different plugin, or when the row is not an agentProvider. + * Pure read — no mutation, no side effects. + */ + private async readExistingAgentProviderDescriptor( + pluginId: string, + capId: string, + ): Promise { + const config = await this.deps.readCapabilities(); + if (!config) return undefined; + const entry = config.capabilities.find((c) => normalizeCapId(c.id) === capId); + if (!entry || entry.type !== 'agentProvider' || entry.pluginId !== pluginId) { + return undefined; + } + return entry.agentProvider; + } + + private async deactivateAgentProvider(manifest: PluginManifest, resource: PluginResourceDef): Promise { + await this.removeCapabilityEntry(manifest, resource); + } + private async upsertCapabilityEntry( manifest: PluginManifest, resource: PluginResourceDef, enabled: boolean, limbNodeId?: string, scheduleTaskId?: string, + agentProvider?: AgentProviderCapabilityDescriptor, skillsSource?: string, ): Promise { return this.deps.withCapabilityLock(async () => { @@ -613,14 +696,22 @@ export class PluginResourceActivator { if (resource.type === 'mcp') { delete existing.limbNodeId; delete existing.scheduleTaskId; + delete existing.agentProvider; existing.mcpServer = this.buildMcpServer(manifest, resource); } else if (resource.type === 'schedule') { delete existing.mcpServer; delete existing.limbNodeId; + delete existing.agentProvider; if (scheduleTaskId) existing.scheduleTaskId = scheduleTaskId; + } else if (resource.type === 'agentProvider') { + delete existing.mcpServer; + delete existing.limbNodeId; + delete existing.scheduleTaskId; + if (agentProvider) existing.agentProvider = agentProvider; } else { delete existing.mcpServer; delete existing.scheduleTaskId; + delete existing.agentProvider; if (resource.type === 'limb' && limbNodeId !== undefined) { existing.limbNodeId = limbNodeId; } else { @@ -639,6 +730,7 @@ export class PluginResourceActivator { pluginId: manifest.id, ...(limbNodeId ? { limbNodeId } : {}), ...(scheduleTaskId ? { scheduleTaskId } : {}), + ...(agentProvider ? { agentProvider } : {}), ...(skillsSource ? { skillsSource } : {}), }; diff --git a/packages/api/src/domains/plugin/RoutingAdmissionService.ts b/packages/api/src/domains/plugin/RoutingAdmissionService.ts new file mode 100644 index 0000000000..d7db0ebb4d --- /dev/null +++ b/packages/api/src/domains/plugin/RoutingAdmissionService.ts @@ -0,0 +1,195 @@ +/** + * F241 Phase B Slice 2b: Routing admission service. + * + * Pure function that decides whether a candidate agentProvider capability is + * eligible to be promoted to `routeable: true`. Called by two paths + * (per F241 doc § Phase B Slice 2b Design Notes): + * + * - owner approval path (early UX failure before persisting approval) + * - syncAgentRegistry projection path (re-validate before injecting + * synthetic cat-config into runtime maps) + * + * RED LINE: callers MUST compute the snapshot with the candidate explicitly + * excluded. Failing to do so re-introduces the parsing-order self-exemption + * hole that Slice 1 closed in ProviderTransportRegistry. + * + * The function is intentionally side-effect free and snapshot-driven. It + * does NOT read cat-config / capability store / template files on its own — + * the caller owns the snapshot construction. This keeps admission + * deterministic, testable, and re-runnable inside the serialized sync + * coordinator without surprising I/O. + */ + +import type { AgentProviderHealthCheckRequest } from '@cat-cafe/shared'; + +/** + * Routeable identity claims the candidate wants to bind. + * + * Combines manifest declarations (providerId, mentionPatterns) with the + * host's binding decision (catId / profileId) made at approval time. The + * design treats `catId` as host-owned binding, decoupled from manifest; + * the caller is responsible for merging the operator-chosen binding with + * the manifest-declared claims into this candidate. + */ +export interface RoutingAdmissionCandidate { + /** Plugin id that owns the capability row. */ + readonly pluginId: string; + /** Capability id (resource name) within the plugin. */ + readonly capId: string; + /** Manifest claim: provider identifier (e.g. 'clowder-code'). */ + readonly providerId: string; + /** Host binding: catId routed to this provider. */ + readonly catId: string; + /** Optional: profile id binding. */ + readonly profileId?: string; + /** Manifest claim: @-mention patterns the provider responds to. */ + readonly mentionPatterns?: readonly string[]; + /** + * Health check declaration from the descriptor. Routeable admission + * REQUIRES this present — there is no default probe substitute. + */ + readonly healthCheck?: AgentProviderHealthCheckRequest; +} + +/** + * Snapshot of the host's current routeable identity universe, computed + * EXCLUDING the candidate. Mirrors Slice 1's pattern in + * `deriveReservedProviderTransportIdentities`: template baseline + + * non-providerTransport active profiles, plus existing routeable plugins. + */ +export interface RoutingAdmissionSnapshot { + /** + * Built-in cat IDs derived from `cat-template.json`. These are the + * reserved baseline — a plugin can never claim one of these. + */ + readonly templateBaselineIds: ReadonlySet; + /** + * Routeable identities (catId / providerId / profileId / mentionPatterns) + * already in use by other routeable agentProvider capabilities. The + * candidate's own identities MUST NOT appear here — callers must + * explicitly exclude the candidate when building this set. + */ + readonly existingRouteableIdentities: ReadonlySet; + /** + * Cat IDs from cat-config that are active and NOT providerTransport + * candidates. Mirrors Slice 1's filter: anything currently routeable + * through the legacy / builtin path cannot be re-claimed by a plugin. + */ + readonly activeNonProviderTransportIdentities: ReadonlySet; +} + +/** Denial reason — keep stable so callers can branch on it for UI / logging. */ +export type RoutingAdmissionDenialReason = + | 'missing-health-check' + | 'invalid-identity-claim' + | 'reserved-baseline-collision' + | 'existing-routeable-collision' + | 'active-cat-collision'; + +/** Admission result — either admitted, or denied with a structured reason. */ +export type RoutingAdmissionResult = + | { readonly admitted: true } + | { + readonly admitted: false; + readonly reason: RoutingAdmissionDenialReason; + /** Specific identity string that caused the collision (when applicable). */ + readonly conflictingIdentity?: string; + /** Short human-readable explanation suitable for admin UI / logs. */ + readonly details: string; + }; + +/** + * Decide whether the candidate may be promoted to `routeable: true`. + * + * Order of checks is deliberate: cheapest / most-fundamental first. + * + * 1. healthCheck must be declared (Step 5 admission precondition). + * 2. At least one identity claim must be present. + * 3. No claim may collide with the reserved template baseline. + * 4. No claim may collide with an existing routeable agentProvider identity. + * 5. No claim may collide with an active non-providerTransport cat. + * + * Returns the first failure encountered; a fully admitted candidate + * returns `{ admitted: true }`. + */ +export function admitForRouting( + candidate: RoutingAdmissionCandidate, + snapshot: RoutingAdmissionSnapshot, +): RoutingAdmissionResult { + if (!candidate.healthCheck) { + return { + admitted: false, + reason: 'missing-health-check', + details: `Candidate ${candidate.pluginId}/${candidate.capId} requires a declared healthCheck to be routeable; none provided.`, + }; + } + + const claims = collectIdentityClaims(candidate); + if (claims.length === 0) { + return { + admitted: false, + reason: 'invalid-identity-claim', + details: `Candidate ${candidate.pluginId}/${candidate.capId} has no routeable identity claims (providerId / catId / profileId / mentionPatterns).`, + }; + } + + for (const claim of claims) { + if (snapshot.templateBaselineIds.has(claim)) { + return { + admitted: false, + reason: 'reserved-baseline-collision', + conflictingIdentity: claim, + details: `Identity '${claim}' is reserved by the cat-template baseline and cannot be claimed by a plugin.`, + }; + } + } + + for (const claim of claims) { + if (snapshot.existingRouteableIdentities.has(claim)) { + return { + admitted: false, + reason: 'existing-routeable-collision', + conflictingIdentity: claim, + details: `Identity '${claim}' is already claimed by another routeable agentProvider capability.`, + }; + } + } + + for (const claim of claims) { + if (snapshot.activeNonProviderTransportIdentities.has(claim)) { + return { + admitted: false, + reason: 'active-cat-collision', + conflictingIdentity: claim, + details: `Identity '${claim}' is already in use by an active cat that is not a providerTransport candidate.`, + }; + } + } + + return { admitted: true }; +} + +/** + * Collect distinct, non-empty identity claims from the candidate. Order is + * stable (providerId, catId, profileId, mentionPatterns[]) so that + * `conflictingIdentity` in the denial result is reproducible. + */ +function collectIdentityClaims(candidate: RoutingAdmissionCandidate): string[] { + const seen = new Set(); + const ordered: string[] = []; + const push = (value: string | undefined): void => { + if (!value) return; + const trimmed = value.trim(); + if (trimmed.length === 0) return; + if (seen.has(trimmed)) return; + seen.add(trimmed); + ordered.push(trimmed); + }; + push(candidate.providerId); + push(candidate.catId); + push(candidate.profileId); + for (const pattern of candidate.mentionPatterns ?? []) { + push(pattern); + } + return ordered; +} diff --git a/packages/api/src/domains/plugin/agent-provider-admission-snapshot.ts b/packages/api/src/domains/plugin/agent-provider-admission-snapshot.ts new file mode 100644 index 0000000000..f1259e82d3 --- /dev/null +++ b/packages/api/src/domains/plugin/agent-provider-admission-snapshot.ts @@ -0,0 +1,106 @@ +/** + * F241 Phase B Slice 2b: Default builder for RoutingAdmission snapshots. + * + * The snapshot is what `RoutingAdmissionService.admitForRouting` consumes. + * Per F241 doc § Phase B Slice 2b Design Notes (RoutingAdmissionService), + * the snapshot must: + * - Include the template baseline (`cat-template.json` builtin ids), + * mirroring Slice 1's reserved derivation pattern. + * - Include identities currently in use by routeable agentProvider + * capabilities, EXCLUDING the candidate (so a re-approval of an + * already-routeable capability doesn't self-collide). + * - Include active non-providerTransport cat ids (Slice 1 pattern parity). + * + * Lives in its own file so the route wiring stays thin and the snapshot + * derivation can be unit-tested independently of the orchestration loop. + */ + +import type { AgentProviderCapabilityDescriptor, CapabilitiesConfig, CatConfig } from '@cat-cafe/shared'; +import { normalizeCapId } from './PluginRegistry.js'; +import type { RoutingAdmissionSnapshot } from './RoutingAdmissionService.js'; + +export interface AgentProviderAdmissionSnapshotInputs { + /** Capability config snapshot (the source of existing routeable identities). */ + readonly capabilitiesConfig: CapabilitiesConfig | null; + /** Active cat configs (the source of active-cat identity collisions). */ + readonly activeCatConfigs: Readonly>; + /** Cat-template baseline ids — provided by host loader, mirrors Slice 1 input. */ + readonly templateBaselineIds: ReadonlySet; + /** Returns true if the given cat id has a `providerTransport` config — + * such ids are NOT counted as "active non-providerTransport" candidates + * (Slice 1 parity: providerTransport candidates can be reclaimed by 2b). */ + readonly hasProviderTransportConfig: (catId: string) => boolean; + /** The candidate being admitted — its identities are EXCLUDED so a + * re-approval (descriptor unchanged) doesn't self-collide. */ + readonly candidatePluginId: string; + readonly candidateCapId: string; +} + +/** + * Build a complete admission snapshot for the candidate. Pure-ish: only + * reads from the provided inputs, no I/O. The host wiring (`index.ts`) + * supplies the inputs via existing accessors (`catRegistry.getAllConfigs`, + * `getTemplateBuiltinCatIds`, `getProviderTransportConfig`, ...). + */ +export function buildAgentProviderAdmissionSnapshot( + inputs: AgentProviderAdmissionSnapshotInputs, +): RoutingAdmissionSnapshot { + const candidateCapIdNormalized = normalizeCapId(inputs.candidateCapId); + + // P1.3 fix: collect ALL identity surfaces of existing routeable agentProviders, + // not just the descriptor name. Two plugins must not be able to claim the same + // routeableBinding catId / profileId / mentionPatterns even if their `name` + // fields differ. + const existingRouteableIdentities = new Set(); + for (const cap of inputs.capabilitiesConfig?.capabilities ?? []) { + if (cap.type !== 'agentProvider' || !cap.agentProvider) continue; + // Skip the candidate itself — Slice 1 red line: never let the snapshot + // include the row currently being admitted, or the admission self-collides. + if (cap.pluginId === inputs.candidatePluginId && normalizeCapId(cap.id) === candidateCapIdNormalized) { + continue; + } + const descriptor = cap.agentProvider as AgentProviderCapabilityDescriptor; + if (!descriptor.routeable) continue; + if (descriptor.name) existingRouteableIdentities.add(descriptor.name); + const binding = descriptor.routeableBinding; + if (binding) { + if (binding.catId) existingRouteableIdentities.add(binding.catId); + if (binding.profileId) existingRouteableIdentities.add(binding.profileId); + for (const pattern of binding.mentionPatterns ?? []) { + if (pattern) existingRouteableIdentities.add(pattern); + } + } + } + + // P1.3 fix: include active cats' mentionPatterns (and id) so a plugin cannot + // claim @opus / @sonnet / @opus47 alias of a real cat just because the + // mentionPattern doesn't match the cat id literally. + // + // P1.4 follow-up #2 (codex twice-around review): on subsequent syncs the + // `activeCatConfigs` map is `catRegistry.getAllConfigs()` which already + // contains synthetic plugin-projected configs from the previous sync. + // Those carry a `pluginProjection` marker (see agent-provider-projection.ts + // → synthesizeCatConfig). They are PROJECTION OUTPUTS, not active "other" + // cats — feeding them back into the snapshot would make the candidate + // collide with its own previously-projected synthetic catId / mention + // patterns, projection's admission re-run would deny, and stale-cleanup + // would unregister the still-valid synthetic. So we skip them here: + // catalog cats are kept; plugin-projected synthetics are filtered out + // (they are independently represented via `existingRouteableIdentities` + // built from the capabilities config and properly excluding the candidate). + const activeNonProviderTransportIdentities = new Set(); + for (const [id, config] of Object.entries(inputs.activeCatConfigs)) { + if (inputs.hasProviderTransportConfig(id)) continue; + if ((config as { pluginProjection?: unknown }).pluginProjection !== undefined) continue; + activeNonProviderTransportIdentities.add(id); + for (const pattern of config.mentionPatterns ?? []) { + if (pattern) activeNonProviderTransportIdentities.add(pattern); + } + } + + return { + templateBaselineIds: new Set(inputs.templateBaselineIds), + existingRouteableIdentities, + activeNonProviderTransportIdentities, + }; +} diff --git a/packages/api/src/domains/plugin/agent-provider-approval-service.ts b/packages/api/src/domains/plugin/agent-provider-approval-service.ts new file mode 100644 index 0000000000..e10e911e68 --- /dev/null +++ b/packages/api/src/domains/plugin/agent-provider-approval-service.ts @@ -0,0 +1,298 @@ +/** + * F241 Phase B Slice 2b: Approval orchestration service. + * + * The single explicit synchronous path that may promote a capability from + * `routeable: false` to `routeable: true`. Per F241 doc § Phase B Slice 2b + * Design Notes (Background actor permission split): the orchestration + * service is the host-owned authority that operators interact with; no + * background flow can substitute for it. + * + * Flow per Step 4 of the 6-step gate: + * 1. Locate the capability row by (pluginId, capId). + * 2. Build admission snapshot, EXCLUDING the candidate. + * 3. Run RoutingAdmissionService — denial → return early, no state change. + * 4. Run the injected health executor (bound to current descriptorHash). + * 5. On health pass: atomic write of routeableApproved=true + + * health=passed + routeable=true + state='healthy'. + * 6. On health fail: write `health=failed` (telemetry) but keep + * routeableApproved=false / routeable=false (fail-closed). Operator + * sees the failure reason and may fix and re-approve. + * + * Race protection: the entire admission + health + write cycle runs under + * the same `withCapabilityLock` used by the activator, so a concurrent + * `activateAgentProvider` cannot interleave a descriptor delta between + * admission and atomic write. If the descriptor hash changes mid-flight + * (shouldn't happen under the lock, but defended for clarity), we abort + * with a `descriptor-changed` error so the operator re-requests. + */ + +import type { + AgentProviderCapabilityDescriptor, + AgentProviderHealthResult, + CapabilitiesConfig, +} from '@cat-cafe/shared'; +import type { + AgentProviderHealthExecutionContext, + AgentProviderHealthExecutor, +} from './agent-provider-health-executor.js'; +import { transportAvailabilityHealthExecutor } from './agent-provider-health-executor.js'; +import { normalizeCapId } from './PluginRegistry.js'; +import { + admitForRouting, + type RoutingAdmissionCandidate, + type RoutingAdmissionDenialReason, + type RoutingAdmissionSnapshot, +} from './RoutingAdmissionService.js'; + +/** Dependencies the orchestration service needs from the host. */ +export interface AgentProviderApprovalDeps { + /** Read the persisted capabilities snapshot. */ + readonly readCapabilities: () => Promise; + /** Write the capabilities snapshot back. */ + readonly writeCapabilities: (next: CapabilitiesConfig) => Promise; + /** Same lock the activator uses — guarantees atomicity vs concurrent activation. */ + readonly withCapabilityLock: (fn: () => Promise) => Promise; + /** Build the admission snapshot for the candidate, EXCLUDING the candidate itself. */ + readonly buildAdmissionSnapshot: ( + pluginId: string, + capId: string, + config: CapabilitiesConfig | null, + ) => Promise; + /** Run the declared health check probe. Defaults to transport-availability. */ + readonly healthExecutor?: AgentProviderHealthExecutor; + /** Build the bound transport-registry view the health executor needs. */ + readonly getHealthExecutorContext: ( + descriptor: AgentProviderCapabilityDescriptor, + descriptorHash: string, + ) => Pick; + /** + * F241 Phase B Slice 2b Step 5a — post-approval sync hook. Fires AFTER a + * successful atomic promotion to `routeable=true`, while still under + * `withCapabilityLock`. The host wires this to the existing serialized + * sync coordinator (e.g. `syncAgentRegistry(catRegistry.getAllConfigs())`) + * so the new routeable capability gets projected into AgentRegistry. + * + * Failures inside this hook are caught and recorded as `lastSyncError` + * on the descriptor (rolling back effective `routeable` to false), per + * the Step 6 failure-recovery rule in the design notes. `routeableApproved` + * and `health` are preserved — owner intent is not invalidated by a host + * wiring failure. + * + * Optional: when omitted, approval still writes routeable=true but the + * caller is responsible for triggering sync separately (used in tests + * + deployments without the host wiring). + */ + readonly onRouteablePromoted?: (capability: AgentProviderCapabilityDescriptor) => Promise; +} + +/** The operator's binding decision at approval time. */ +export interface AgentProviderApprovalRequest { + /** Plugin id that owns the capability. */ + readonly pluginId: string; + /** Capability id (resource name) within the plugin. */ + readonly capId: string; + /** Host-owned binding: catId this capability is routed under. */ + readonly catId: string; + /** Optional profile id binding. */ + readonly profileId?: string; + /** Manifest-declared @-mention patterns the operator confirms binding to. + * Slice 2b does not yet require these to be in the manifest schema — + * the operator passes them through as the canonical claim set so + * admission can collision-check them. */ + readonly mentionPatterns?: readonly string[]; +} + +/** Reason for a denied approval — stable codes for UI / logging. */ +export type AgentProviderApprovalDenialReason = + | 'capability-not-found' + | 'capability-not-agent-provider' + | 'descriptor-hash-missing' + | 'admission-snapshot-unavailable' + | RoutingAdmissionDenialReason + | 'health-check-failed' + | 'post-approval-sync-failed'; + +/** Result of an approval attempt. */ +export type AgentProviderApprovalResult = + | { + readonly ok: true; + readonly capability: AgentProviderCapabilityDescriptor; + } + | { + readonly ok: false; + readonly reason: AgentProviderApprovalDenialReason; + readonly details: string; + /** Populated when health probe failed — provides telemetry to the operator. */ + readonly health?: AgentProviderHealthResult; + /** Populated when admission denied with a conflicting identity. */ + readonly conflictingIdentity?: string; + }; + +/** + * Approval service. Holds dependencies and exposes the single + * `approveRouteable` entry point that operators (HTTP / CLI) invoke. + */ +export class AgentProviderApprovalService { + private readonly deps: AgentProviderApprovalDeps; + + constructor(deps: AgentProviderApprovalDeps) { + this.deps = deps; + } + + async approveRouteable(request: AgentProviderApprovalRequest): Promise { + return this.deps.withCapabilityLock(async () => { + const config = await this.deps.readCapabilities(); + const capId = normalizeCapId(request.capId); + const entry = config?.capabilities.find((c) => normalizeCapId(c.id) === capId && c.pluginId === request.pluginId); + + if (!entry) { + return { + ok: false, + reason: 'capability-not-found', + details: `No capability '${request.pluginId}/${request.capId}' found.`, + }; + } + if (entry.type !== 'agentProvider' || !entry.agentProvider) { + return { + ok: false, + reason: 'capability-not-agent-provider', + details: `Capability '${request.pluginId}/${request.capId}' is not an agentProvider.`, + }; + } + + const descriptor = entry.agentProvider; + if (!descriptor.descriptorHash) { + return { + ok: false, + reason: 'descriptor-hash-missing', + details: `Capability '${request.pluginId}/${request.capId}' has no descriptorHash — re-enable the plugin first so Slice 2b activator can fill it in.`, + }; + } + + // Step 3: admission with candidate EXCLUDED from snapshot. + // P1.2 fix: if the snapshot builder throws (e.g. baseline read failed), + // fail closed with an explicit denial reason rather than crashing. + // Slice 1's red line is preserved: no admission proceeds without the + // reserved baseline. + let snapshot: RoutingAdmissionSnapshot; + try { + snapshot = await this.deps.buildAdmissionSnapshot(request.pluginId, request.capId, config); + } catch (err) { + return { + ok: false, + reason: 'admission-snapshot-unavailable', + details: `Admission snapshot unavailable — cannot fail-closed: ${err instanceof Error ? err.message : String(err)}`, + }; + } + const candidate: RoutingAdmissionCandidate = { + pluginId: request.pluginId, + capId: request.capId, + providerId: descriptor.name, + catId: request.catId, + profileId: request.profileId, + mentionPatterns: request.mentionPatterns, + healthCheck: descriptor.healthCheck, + }; + const admission = admitForRouting(candidate, snapshot); + if (!admission.admitted) { + return { + ok: false, + reason: admission.reason, + details: admission.details, + conflictingIdentity: admission.conflictingIdentity, + }; + } + + // Step 5: blocking health check, bound to current descriptorHash. + const executor = this.deps.healthExecutor ?? transportAvailabilityHealthExecutor; + const executorContext = this.deps.getHealthExecutorContext(descriptor, descriptor.descriptorHash); + const health = await executor({ + resource: descriptor, + descriptorHash: descriptor.descriptorHash, + providerTransportRegistry: executorContext.providerTransportRegistry, + now: executorContext.now, + }); + + if (!health.passed) { + // Telemetry write: persist the failed health result so the operator + // can see WHY it failed. routeableApproved / routeable stay false. + const updated: AgentProviderCapabilityDescriptor = { + ...descriptor, + health, + }; + await this.persistDescriptor(config, entry.id, updated); + return { + ok: false, + reason: 'health-check-failed', + details: health.failureReason ?? 'health-check failed without a specific reason', + health, + }; + } + + // Step 6: atomic write of the routeable promotion. Activator preserves + // these on re-activation when descriptorHash matches (Slice 2b Step 3). + // routeableBinding captures the operator's catId/profileId/mentionPatterns + // choice so Slice 2b projection (Step 5b) can build the synthetic + // cat-config from a host-owned source — never from the manifest directly. + const promoted: AgentProviderCapabilityDescriptor = { + ...descriptor, + state: 'healthy', + routeable: true, + routeableApproved: true, + health, + lastSyncError: undefined, + routeableBinding: { + catId: request.catId, + profileId: request.profileId, + mentionPatterns: request.mentionPatterns, + }, + }; + await this.persistDescriptor(config, entry.id, promoted); + + // F241 Phase B Slice 2b Step 5a — fire post-approval sync hook. Failures + // here roll back effective `routeable=false` and record `lastSyncError`, + // but preserve `routeableApproved` + `health` (Step 6 failure-recovery + // table in the design notes: host wiring failure ≠ operator withdrawing + // approval; retry doesn't need re-approval). + if (this.deps.onRouteablePromoted) { + try { + await this.deps.onRouteablePromoted(promoted); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const rolledBack: AgentProviderCapabilityDescriptor = { + ...promoted, + routeable: false, + // Keep state='healthy' so the operator can see the health pass + // was real; routeable flipping false signals the sync issue. + lastSyncError: { + message: `post-approval-sync-failed: ${message}`, + occurredAt: Date.now(), + }, + }; + await this.persistDescriptor(await this.deps.readCapabilities(), entry.id, rolledBack); + return { + ok: false, + reason: 'post-approval-sync-failed', + details: `post-approval sync hook failed: ${message}`, + health, + }; + } + } + + return { ok: true, capability: promoted }; + }); + } + + private async persistDescriptor( + config: CapabilitiesConfig | null, + entryId: string, + next: AgentProviderCapabilityDescriptor, + ): Promise { + const base: CapabilitiesConfig = config ? structuredClone(config) : { version: 1, capabilities: [] }; + const target = base.capabilities.find((c) => c.id === entryId); + if (target) { + target.agentProvider = next; + } + await this.deps.writeCapabilities(base); + } +} diff --git a/packages/api/src/domains/plugin/agent-provider-descriptor-hash.ts b/packages/api/src/domains/plugin/agent-provider-descriptor-hash.ts new file mode 100644 index 0000000000..9d954b0cee --- /dev/null +++ b/packages/api/src/domains/plugin/agent-provider-descriptor-hash.ts @@ -0,0 +1,86 @@ +/** + * F241 Phase B Slice 2b: Canonical descriptor hash for agentProvider capability rows. + * + * Computed by the activator on every upsert. When the hash differs from the + * existing capability row's stored hash, the activator MUST reset + * `routeableApproved=false`, invalidate `health`, and clear `lastSyncError`. + * The operator must then re-approve through the explicit synchronous path + * (see F241 doc § Phase B Slice 2b Design Notes — Background actor permission split). + * + * The hash schema is versioned (v: 1). Future extensions (e.g. plugin + * fingerprint integration, routeable identity claim fields once the manifest + * schema gains them) bump the schema version so older hashes don't silently + * collide with the new shape. + */ + +import { createHash } from 'node:crypto'; +import type { PluginAgentProviderResource } from '@cat-cafe/shared'; + +/** + * Inputs that contribute to the agentProvider descriptor hash. The hash MUST + * change when any of these fields change, because each one materially affects + * the runtime behavior of the routeable cat (security, capability, identity). + */ +export interface AgentProviderDescriptorHashInputs { + /** Plugin id that owns the capability row. */ + readonly pluginId: string; + /** Capability id (resource name) within the plugin — i.e. the canonical capId. */ + readonly capId: string; + /** The 2a manifest-side descriptor fields. */ + readonly resource: PluginAgentProviderResource; + /** + * Optional plugin/package fingerprint (npm tarball hash, git SHA). If + * provided, the hash changes when the plugin body mutates, even with an + * identical manifest. Tracked as F241 follow-on hardening; pass `undefined` + * in 2b when no fingerprint source is wired yet. + */ + readonly pluginFingerprint?: string; +} + +/** + * Compute the canonical sha256 descriptor hash for an agentProvider capability. + * + * Determinism rules: + * - All keys appear in a fixed order via the explicit object literal below. + * - Optional fields are normalized to `null` (never omitted) so absence vs. + * presence is distinguishable from "field missing on read". + * - Array fields with set-semantics (e.g. `mcpWhitelistRequest`, + * `mentionPatterns`) are sorted. Positional arrays (`startupArgs`, + * `resumeArgs`) preserve insertion order. + * + * Versioning: `v` is bumped whenever the canonical input shape changes. A + * descriptor stored under `v: N-1` MUST NOT collide with the same descriptor + * under `v: N` so that older approvals are invalidated by the activator + * when the runtime is upgraded. + * + * Version history: + * - v: 1 (2b shipped) — original 2b inputs. + * - v: 2 (2c manifest identity claims) — adds `providerId / displayName / + * mentionPatterns` so any claim change forces re-approval. + */ +export function computeAgentProviderDescriptorHash(inputs: AgentProviderDescriptorHashInputs): string { + const r = inputs.resource; + const canonical = { + v: 2 as const, + pluginId: inputs.pluginId, + capId: inputs.capId, + transport: r.transport, + command: r.command, + startupArgs: [...r.startupArgs], + resumeArgs: r.resumeArgs ? [...r.resumeArgs] : null, + sessionPolicy: r.sessionPolicy ?? null, + outputProfile: r.outputProfile ?? null, + timeoutMs: r.timeoutMs ?? null, + mcpWhitelistRequest: r.mcpWhitelistRequest ? [...r.mcpWhitelistRequest].slice().sort() : null, + sandboxRequest: r.sandboxRequest ?? null, + healthCheck: r.healthCheck ?? null, + // F241 Phase C 2c — Manifest identity claims feed the hash so any claim + // change forces re-approval (operator must re-confirm the new identity + // claim). Per F241 doc § Phase B 2b "Routeable identity ownership". + providerId: r.providerId ?? null, + displayName: r.displayName ?? null, + mentionPatterns: r.mentionPatterns ? [...r.mentionPatterns].slice().sort() : null, + pluginFingerprint: inputs.pluginFingerprint ?? null, + }; + return createHash('sha256').update(JSON.stringify(canonical)).digest('hex'); +} diff --git a/packages/api/src/domains/plugin/agent-provider-health-executor.ts b/packages/api/src/domains/plugin/agent-provider-health-executor.ts new file mode 100644 index 0000000000..4f1739c143 --- /dev/null +++ b/packages/api/src/domains/plugin/agent-provider-health-executor.ts @@ -0,0 +1,301 @@ +/** + * F241 Phase B Slice 2b: Host-owned agentProvider health check executor. + * + * The activator declares which `healthCheck` type the operator's approval + * must satisfy (`acpInitialize` / `cliProbe`). This file owns the EXECUTION + * side — given a candidate descriptor + host transport context, it runs + * the declared probe and returns a structured `AgentProviderHealthResult`. + * + * Per F241 doc § Phase B Slice 2b Design Notes (Health timing): + * - On approval: synchronous, blocking. Success → atomic write of + * `routeableApproved=true + health.fresh + routeable=true`. + * - On TTL expiry during startup/sync: synchronous refresh. Failure → + * effective `routeable=false`, log error. + * - Background actors NEVER promote `routeable: false → true`; they may + * only refresh telemetry / degrade. Enforcing that boundary belongs + * to the orchestration service; the executor here is the pure + * "run a probe, return a result" primitive. + * + * Slice 2b first cut: ships a TRANSPORT-AVAILABILITY probe — confirms + * the host transport is registered and a service instance can be + * constructed (best-effort one-shot). Real ACP-initialize / CLI-probe + * semantics that actually start the runtime and verify a turn round-trip + * are tracked as Step 4 follow-on hardening. The orchestration service + * does not care which probe family the executor uses; it only needs the + * structured `passed` + bound `descriptorHash` it returns. + */ + +import { type ChildProcess, spawn as nodeSpawn, type SpawnOptions } from 'node:child_process'; +import { tmpdir } from 'node:os'; +import type { + AgentProviderHealthCheckRequest, + AgentProviderHealthResult, + PluginAgentProviderResource, +} from '@cat-cafe/shared'; +import { resolveCliCommand } from '../../utils/cli-resolve.js'; +import type { ProviderTransportRegistry } from '../cats/services/agents/providers/transport/ProviderTransportRegistry.js'; + +/** Inputs to a single health check run. */ +export interface AgentProviderHealthExecutionContext { + /** The descriptor whose `healthCheck` declaration we are honoring. */ + readonly resource: PluginAgentProviderResource; + /** Canonical descriptor hash; bound into the result so a later descriptor + * delta invalidates this health snapshot per the Q3 convergence rule. */ + readonly descriptorHash: string; + /** Host transport registry (read-only here — the executor does NOT + * register/close transports, only inspects availability). */ + readonly providerTransportRegistry: Pick; + /** Optional clock injection for deterministic tests. */ + readonly now?: () => number; +} + +/** Default TTL applied when the executor produces a fresh health result. + * 15 minutes — long enough that sync-time refresh isn't constant churn, + * short enough that a degraded transport can't keep `routeable=true` + * indefinitely. Callers may override per-deployment if/when policy lands. */ +export const DEFAULT_HEALTH_TTL_MS = 15 * 60 * 1000; + +/** + * Functional shape of a health executor. Pure-ish (no side effects in the + * default impl; future impls may spawn the runtime — they MUST be host-owned + * and bounded by timeouts). Returns the structured result the orchestration + * service writes into the capability row. + */ +export type AgentProviderHealthExecutor = ( + context: AgentProviderHealthExecutionContext, +) => Promise; + +/** + * Default transport-availability executor. Honors the declared `healthCheck` + * type only structurally: a declared `acpInitialize` requires the `acp` + * transport to be registered; `cliProbe` requires the `cli-jsonl` transport. + * + * This is intentionally a thin first cut — it lets the rest of the 2b + * pipeline (orchestration, atomic write, route) be exercised end-to-end + * with a real `passed` signal. Replacing the executor with a runtime-probing + * implementation is a drop-in swap via the orchestration service's + * injectable executor dependency. + */ +export const transportAvailabilityHealthExecutor: AgentProviderHealthExecutor = async (context) => { + const now = context.now ?? Date.now; + const declared: AgentProviderHealthCheckRequest | undefined = context.resource.healthCheck; + if (!declared) { + return { + passed: false, + checkedAt: now(), + ttlMs: DEFAULT_HEALTH_TTL_MS, + descriptorHash: context.descriptorHash, + failureReason: 'no-healthcheck-declared', + }; + } + + const requiredTransport = healthCheckTypeToTransport(declared.type); + if (requiredTransport && !context.providerTransportRegistry.has(requiredTransport)) { + return { + passed: false, + checkedAt: now(), + ttlMs: DEFAULT_HEALTH_TTL_MS, + descriptorHash: context.descriptorHash, + failureReason: `transport-not-registered:${requiredTransport}`, + }; + } + + if (!context.providerTransportRegistry.has(context.resource.transport)) { + return { + passed: false, + checkedAt: now(), + ttlMs: DEFAULT_HEALTH_TTL_MS, + descriptorHash: context.descriptorHash, + failureReason: `descriptor-transport-not-registered:${context.resource.transport}`, + }; + } + + return { + passed: true, + checkedAt: now(), + ttlMs: DEFAULT_HEALTH_TTL_MS, + descriptorHash: context.descriptorHash, + }; +}; + +/** + * Map a declared `healthCheck.type` to the host transport that must be + * registered for the probe to be meaningful. `acpInitialize` corresponds + * to the ACP transport; `cliProbe` corresponds to `cli-jsonl`. + */ +function healthCheckTypeToTransport(type: AgentProviderHealthCheckRequest['type']): string | null { + switch (type) { + case 'acpInitialize': + return 'acp'; + case 'cliProbe': + return 'cli-jsonl'; + default: + return null; + } +} + +/** + * F241 Phase C — Real `cliProbe` executor (bounded spawn + exit-code check). + * + * Per F241 doc § 2b "Health executor ships as transport-availability probe; + * real `acpInitialize` (runtime initialize handshake) and `cliProbe` (bounded + * spawn + exit-code check) probes are tracked as Slice 2c follow-on hardening + * — the executor is a drop-in DI swap (`AgentProviderHealthExecutor` interface + * in `agent-provider-health-executor.ts`), no further redesign needed." + * + * Semantics: + * 1. Gate (cheap): run the transport-availability check first. If the host + * transport for the declared `healthCheck.type` is not registered, fail + * fast WITHOUT spawning — same observable failure as the 2b stub. + * 2. For `cliProbe`-declared resources: spawn `resource.command --version` + * with stdin closed, in `os.tmpdir()`, with a `PATH`-only minimal env. + * Bounded by `probeTimeoutMs` (default 10s). Exit code 0 ⇒ passed. + * Non-zero ⇒ `cli-probe-nonzero-exit:N`. Spawn error ⇒ + * `cli-probe-spawn-error:`. Timeout ⇒ `cli-probe-timeout:Nms` + + * `child.kill('SIGTERM')` so a lingering probe doesn't leak. + * 3. For `acpInitialize`-declared resources: fall through to transport- + * availability result — the ACP carrier (F161 PR #899) owns the real + * initialize handshake once it lands; spawning a CLI here would be wrong. + * + * Why `--version`: standard CLI convention, fast-exit, no side effects, no + * stdin requirement, no callback config / MCP injection needed. Compatible + * with clowder-code (verified) and any well-formed CLI runtime. If a future + * runtime needs a different probe argv, extend `healthCheck` schema with an + * optional `probeArgs` field (tracked as a separate 2c follow-on; the + * reference runtime is covered by `--version` today). + * + * Why a factory: lets production wiring inject deterministic spawn + a tight + * timeout in tests, while keeping the default production semantics simple. + */ +export interface RealCliProbeDeps { + /** Test seam — replaces `node:child_process.spawn` for unit tests. */ + readonly spawnFn?: typeof nodeSpawn; + /** Test seam — replaces `resolveCliCommand` for unit tests so they don't + * depend on real `which` / filesystem state. Production keeps the + * default resolver so probe + invocation stay in lock-step. */ + readonly resolveFn?: typeof resolveCliCommand; + /** Override the probe timeout. Default 10s — generous for `--version` + * on cold-cache filesystems, tight enough that a hung binary doesn't + * block the approval RPC for minutes. */ + readonly probeTimeoutMs?: number; +} + +const DEFAULT_CLI_PROBE_TIMEOUT_MS = 10_000; + +/** Build a passed health result with the standard TTL + descriptor binding. */ +function buildPassed(now: () => number, descriptorHash: string): AgentProviderHealthResult { + return { passed: true, checkedAt: now(), ttlMs: DEFAULT_HEALTH_TTL_MS, descriptorHash }; +} + +/** Build a failed health result with a structured `failureReason`. */ +function buildFailed(now: () => number, descriptorHash: string, failureReason: string): AgentProviderHealthResult { + return { passed: false, checkedAt: now(), ttlMs: DEFAULT_HEALTH_TTL_MS, descriptorHash, failureReason }; +} + +/** + * Bounded spawn of `--version` against the (already-resolved) binary path. + * Pure-ish: takes settled-closure inputs, returns a Promise. Extracted from + * `createRealCliProbeHealthExecutor` to keep the executor body under the + * Biome cognitive-complexity budget (P2 review feedback) and to make the + * spawn lifecycle easier to read in isolation. + */ +function spawnVersionProbe(args: { + spawnFn: typeof nodeSpawn; + resolvedCommand: string; + probeTimeoutMs: number; + now: () => number; + descriptorHash: string; +}): Promise { + const { spawnFn, resolvedCommand, probeTimeoutMs, now, descriptorHash } = args; + return new Promise((resolvePromise) => { + const spawnOptions: SpawnOptions = { + cwd: tmpdir(), + // Minimal env: PATH only. The health probe must NOT inherit cat-cafe + // callback / MCP credentials — it is a liveness check, not a real + // invocation. PATH is required so a bare command like `clowder-code` + // (npm-linked) still resolves on a system where the user's $PATH + // sees it but the resolver fallback also covers GUI / nvm cases. + env: { PATH: process.env.PATH ?? '' }, + stdio: ['ignore', 'pipe', 'pipe'], + }; + + let child: ChildProcess; + try { + child = spawnFn(resolvedCommand, ['--version'], spawnOptions); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + resolvePromise(buildFailed(now, descriptorHash, `cli-probe-spawn-error:${message}`)); + return; + } + + let settled = false; + const settle = (result: AgentProviderHealthResult): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (!result.passed) { + // Best-effort cleanup so a hung probe doesn't outlive its own result. + try { + child.kill('SIGTERM'); + } catch { + /* already dead */ + } + } + resolvePromise(result); + }; + + const timer = setTimeout(() => { + settle(buildFailed(now, descriptorHash, `cli-probe-timeout:${probeTimeoutMs}ms`)); + }, probeTimeoutMs); + + child.on('error', (err) => { + settle(buildFailed(now, descriptorHash, `cli-probe-spawn-error:${err.message}`)); + }); + + child.on('exit', (code) => { + if (code === 0) { + settle(buildPassed(now, descriptorHash)); + } else { + settle(buildFailed(now, descriptorHash, `cli-probe-nonzero-exit:${code}`)); + } + }); + }); +} + +export function createRealCliProbeHealthExecutor(deps?: RealCliProbeDeps): AgentProviderHealthExecutor { + const spawnFn = deps?.spawnFn ?? nodeSpawn; + const resolveFn = deps?.resolveFn ?? resolveCliCommand; + const probeTimeoutMs = deps?.probeTimeoutMs ?? DEFAULT_CLI_PROBE_TIMEOUT_MS; + + return async (context) => { + const now = context.now ?? Date.now; + // Step 1: transport availability gate (same as the 2b stub semantics). + const gateResult = await transportAvailabilityHealthExecutor(context); + if (!gateResult.passed) return gateResult; + + // Step 2: only `cliProbe` declarations get a real spawn. `acpInitialize` + // and any future type fall through to the transport-availability result. + if (context.resource.healthCheck?.type !== 'cliProbe') return gateResult; + + // Step 3: resolve the command using the SAME resolver the real cli-jsonl + // invocation uses (CliJsonlAgentService → resolveCliCommand). Probing the + // raw `context.resource.command` directly would create a split-brain when + // the binary lives in a non-$PATH location like `~/.local/bin` or an nvm + // version dir (cli-resolve fallback paths). With this in place, an + // approve-time probe and a real invocation share command resolution + // semantics, so they pass / fail consistently. (P2 review @codex on PR #38.) + const resolvedCommand = resolveFn(context.resource.command); + if (!resolvedCommand) { + return buildFailed(now, context.descriptorHash, `cli-probe-cli-not-found:${context.resource.command}`); + } + + // Step 4: bounded spawn + exit-code check (extracted helper). + return spawnVersionProbe({ + spawnFn, + resolvedCommand, + probeTimeoutMs, + now, + descriptorHash: context.descriptorHash, + }); + }; +} diff --git a/packages/api/src/domains/plugin/agent-provider-health-refresh.ts b/packages/api/src/domains/plugin/agent-provider-health-refresh.ts new file mode 100644 index 0000000000..5bf3d21ba4 --- /dev/null +++ b/packages/api/src/domains/plugin/agent-provider-health-refresh.ts @@ -0,0 +1,137 @@ +/** + * F241 Phase B Slice 2b P1.5: synchronous TTL refresh on startup/sync. + * + * Per F241 doc § Phase B Slice 2b Design Notes (Health timing): when an + * already-approved capability has health.checkedAt + ttlMs < now, the + * startup/sync path MUST re-run the health executor synchronously. On + * success, the refreshed health is persisted and routeable stays true. + * On failure, `routeable=false` + `lastSyncError` is persisted (the row + * degrades; operator must re-approve to re-promote). + * + * This is the ONLY non-approval code path that may flip routeable from + * the host side, and it strictly degrades (never promotes false → true, + * which remains the orchestration service's exclusive privilege — + * per Q3 S1/S2 convergence). + */ + +import type { AgentProviderCapabilityDescriptor, CapabilitiesConfig } from '@cat-cafe/shared'; +import type { + AgentProviderHealthExecutionContext, + AgentProviderHealthExecutor, +} from './agent-provider-health-executor.js'; +import type { RouteableAgentProviderRow } from './agent-provider-projection.js'; + +export interface RefreshExpiredHealthInputs { + /** Latest persisted capabilities snapshot. */ + readonly capabilities: CapabilitiesConfig | null; + /** Pre-filtered list of approved routeable rows (from `listApprovedRouteableRows`). */ + readonly rows: readonly RouteableAgentProviderRow[]; + /** Current epoch ms — injected for testability. */ + readonly now: () => number; + /** Health executor; same one the orchestration service uses on approval. */ + readonly healthExecutor: AgentProviderHealthExecutor; + /** Bind the executor to the live transport registry view. */ + readonly getHealthExecutorContext: ( + descriptor: AgentProviderCapabilityDescriptor, + descriptorHash: string, + ) => Pick; + /** Persist a mutated capabilities snapshot. */ + readonly persist: (next: CapabilitiesConfig) => Promise; + /** Optional structured logger. */ + readonly log?: (level: 'info' | 'warn' | 'error', payload: Record, msg: string) => void; +} + +/** + * Returns the mutated capabilities snapshot if any row was refreshed (so the + * caller can re-derive its routeable row list). Returns `null` if nothing + * needed refreshing (caller can keep using the input snapshot). + */ +export async function refreshExpiredHealthInPlace( + inputs: RefreshExpiredHealthInputs, +): Promise { + if (!inputs.capabilities) return null; + const now = inputs.now(); + let mutated = false; + const next = structuredClone(inputs.capabilities); + + for (const row of inputs.rows) { + const persistedEntry = next.capabilities.find( + (c) => c.pluginId === row.pluginId && c.id === row.capId && c.type === 'agentProvider' && c.agentProvider, + ); + if (!persistedEntry || !persistedEntry.agentProvider) continue; + + const descriptor = persistedEntry.agentProvider as AgentProviderCapabilityDescriptor; + // Only refresh when: + // - approval is still live (routeableApproved=true) + // - descriptorHash matches (health is still bound to this shape) + // - TTL is actually expired + if (!descriptor.routeableApproved || !descriptor.descriptorHash) continue; + if (!descriptor.health) continue; + if (descriptor.health.descriptorHash !== descriptor.descriptorHash) continue; + if (now <= descriptor.health.checkedAt + descriptor.health.ttlMs) continue; + + const ctx = inputs.getHealthExecutorContext(descriptor, descriptor.descriptorHash); + let refreshed; + try { + refreshed = await inputs.healthExecutor({ + resource: descriptor, + descriptorHash: descriptor.descriptorHash, + providerTransportRegistry: ctx.providerTransportRegistry, + now: ctx.now, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + inputs.log?.( + 'error', + { pluginId: row.pluginId, capId: row.capId, err: message }, + '[F241] health refresh: executor threw — degrading routeable=false', + ); + const degradedThrown: AgentProviderCapabilityDescriptor = { + ...descriptor, + routeable: false, + lastSyncError: { + message: `ttl-refresh-executor-threw: ${message}`, + occurredAt: now, + }, + }; + persistedEntry.agentProvider = degradedThrown; + mutated = true; + continue; + } + + if (refreshed.passed) { + inputs.log?.( + 'info', + { pluginId: row.pluginId, capId: row.capId }, + '[F241] health refresh: refreshed and still routeable', + ); + persistedEntry.agentProvider = { ...descriptor, health: refreshed }; + mutated = true; + } else { + inputs.log?.( + 'warn', + { + pluginId: row.pluginId, + capId: row.capId, + failureReason: refreshed.failureReason, + }, + '[F241] health refresh: failed — degrading routeable=false (approval intent preserved)', + ); + const degraded: AgentProviderCapabilityDescriptor = { + ...descriptor, + routeable: false, + health: refreshed, + lastSyncError: { + message: `ttl-refresh-failed: ${refreshed.failureReason ?? 'unknown'}`, + occurredAt: now, + }, + }; + persistedEntry.agentProvider = degraded; + mutated = true; + } + } + + if (!mutated) return null; + await inputs.persist(next); + return next; +} diff --git a/packages/api/src/domains/plugin/agent-provider-manifest.ts b/packages/api/src/domains/plugin/agent-provider-manifest.ts new file mode 100644 index 0000000000..63806c3063 --- /dev/null +++ b/packages/api/src/domains/plugin/agent-provider-manifest.ts @@ -0,0 +1,261 @@ +import type { PluginResourceDef } from '@cat-cafe/shared'; + +const AGENT_PROVIDER_TRANSPORTS = new Set(['acp', 'cli-jsonl']); +const AGENT_PROVIDER_SESSION_POLICIES = new Set(['resume', 'stateless']); +const AGENT_PROVIDER_OUTPUT_PROFILES = new Set(['clowder-code-turn-result-v1']); +const AGENT_PROVIDER_SANDBOX_REQUESTS = new Set(['workspace-read', 'workspace-write']); +const AGENT_PROVIDER_HEALTH_CHECK_TYPES = new Set(['acpInitialize', 'cliProbe']); + +type AgentProviderResource = NonNullable; + +function requireNonBlankString(value: unknown, fieldName: string, yamlPath: string): string { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new Error(`AgentProvider resource in ${yamlPath} must have a non-empty '${fieldName}' field`); + } + return value.trim(); +} + +function parseStringArrayField(value: unknown, fieldName: string, yamlPath: string): string[] { + if (!Array.isArray(value) || !value.every((arg) => typeof arg === 'string' && arg.length > 0)) { + throw new Error(`Invalid agentProvider ${fieldName} in ${yamlPath}: must be an array of non-empty strings`); + } + return value as string[]; +} + +function parseOptionalStringArrayField(value: unknown, fieldName: string, yamlPath: string): string[] | undefined { + if (value === undefined) return undefined; + return parseStringArrayField(value, fieldName, yamlPath); +} + +function parseAgentProviderHealthCheck( + value: unknown, + yamlPath: string, +): AgentProviderResource['healthCheck'] | undefined { + if (value === undefined) return undefined; + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error(`Invalid agentProvider healthCheck in ${yamlPath}: must be an object`); + } + const rawType = (value as Record).type; + if (typeof rawType !== 'string' || !AGENT_PROVIDER_HEALTH_CHECK_TYPES.has(rawType)) { + throw new Error(`Invalid agentProvider healthCheck.type in ${yamlPath}: must be 'acpInitialize' or 'cliProbe'`); + } + return { type: rawType as NonNullable['type'] }; +} + +function parseAgentProviderTransport( + raw: Record, + yamlPath: string, +): AgentProviderResource['transport'] { + const transport = requireNonBlankString(raw.transport, 'transport', yamlPath); + if (!AGENT_PROVIDER_TRANSPORTS.has(transport)) { + throw new Error(`Invalid agentProvider transport '${transport}' in ${yamlPath}`); + } + return transport as AgentProviderResource['transport']; +} + +function parseAgentProviderSessionPolicy( + value: unknown, + yamlPath: string, +): AgentProviderResource['sessionPolicy'] | undefined { + if (value === undefined) return undefined; + if (typeof value !== 'string' || !AGENT_PROVIDER_SESSION_POLICIES.has(value)) { + throw new Error(`Invalid agentProvider sessionPolicy in ${yamlPath}: must be 'resume' or 'stateless'`); + } + return value as AgentProviderResource['sessionPolicy']; +} + +function parseAgentProviderOutputProfile( + value: unknown, + yamlPath: string, +): AgentProviderResource['outputProfile'] | undefined { + if (value === undefined) return undefined; + if (typeof value !== 'string' || !AGENT_PROVIDER_OUTPUT_PROFILES.has(value)) { + throw new Error(`Invalid agentProvider outputProfile in ${yamlPath}: must be 'clowder-code-turn-result-v1'`); + } + return value as AgentProviderResource['outputProfile']; +} + +function parseAgentProviderSessionFields( + raw: Record, + transport: AgentProviderResource['transport'], + yamlPath: string, +): Pick { + const resumeArgs = parseOptionalStringArrayField(raw.resumeArgs, 'resumeArgs', yamlPath); + const sessionPolicy = parseAgentProviderSessionPolicy(raw.sessionPolicy, yamlPath); + const outputProfile = parseAgentProviderOutputProfile(raw.outputProfile, yamlPath); + if (transport === 'cli-jsonl') { + return validateCliJsonlSessionFields(resumeArgs, sessionPolicy, outputProfile, yamlPath); + } + rejectAcpCliJsonlOnlyFields(resumeArgs, sessionPolicy, outputProfile, yamlPath); + return {}; +} + +function validateCliJsonlSessionFields( + resumeArgs: string[] | undefined, + sessionPolicy: AgentProviderResource['sessionPolicy'], + outputProfile: AgentProviderResource['outputProfile'], + yamlPath: string, +): Pick { + if (!sessionPolicy) { + throw new Error(`AgentProvider cli-jsonl resource in ${yamlPath} must have a 'sessionPolicy' field`); + } + if (!outputProfile) { + throw new Error(`AgentProvider cli-jsonl resource in ${yamlPath} must have an 'outputProfile' field`); + } + if (sessionPolicy === 'resume' && !resumeArgs?.some((arg) => arg.includes('{sessionId}'))) { + throw new Error(`AgentProvider cli-jsonl resumeArgs in ${yamlPath} must include '{sessionId}'`); + } + return { + ...(resumeArgs ? { resumeArgs } : {}), + sessionPolicy, + outputProfile, + }; +} + +function rejectAcpCliJsonlOnlyFields( + resumeArgs: string[] | undefined, + sessionPolicy: AgentProviderResource['sessionPolicy'], + outputProfile: AgentProviderResource['outputProfile'], + yamlPath: string, +): void { + if (resumeArgs !== undefined || sessionPolicy !== undefined || outputProfile !== undefined) { + throw new Error(`AgentProvider acp resource in ${yamlPath} must not declare cli-jsonl-only fields`); + } +} + +function parseAgentProviderTimeoutMs(value: unknown, yamlPath: string): number | undefined { + if (value === undefined) return undefined; + if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) { + throw new Error(`Invalid agentProvider timeoutMs in ${yamlPath}: must be a non-negative integer`); + } + return value; +} + +function parseAgentProviderMcpWhitelistRequest(value: unknown, yamlPath: string): string[] | undefined { + if (value === undefined) return undefined; + const mcpWhitelistRequest = parseStringArrayField(value, 'mcpWhitelist', yamlPath); + if (new Set(mcpWhitelistRequest).size !== mcpWhitelistRequest.length) { + throw new Error(`Invalid agentProvider mcpWhitelist in ${yamlPath}: duplicate entries are not allowed`); + } + return mcpWhitelistRequest; +} + +function parseAgentProviderSandboxRequest( + value: unknown, + yamlPath: string, +): AgentProviderResource['sandboxRequest'] | undefined { + if (value === undefined) return undefined; + if (typeof value !== 'string' || !AGENT_PROVIDER_SANDBOX_REQUESTS.has(value)) { + throw new Error(`Invalid agentProvider sandbox in ${yamlPath}: must be 'workspace-read' or 'workspace-write'`); + } + return value as AgentProviderResource['sandboxRequest']; +} + +function parseAgentProviderProviderId(value: unknown, yamlPath: string): string | undefined { + if (value === undefined) return undefined; + if (typeof value !== 'string' || value.trim().length === 0) { + throw new Error(`Invalid agentProvider providerId in ${yamlPath}: must be a non-empty string`); + } + const trimmed = value.trim(); + if (/[/\\]/.test(trimmed)) { + throw new Error( + `Invalid agentProvider providerId '${trimmed}' in ${yamlPath}: must not contain path separators (/ or \\)`, + ); + } + return trimmed; +} + +function parseAgentProviderDisplayName(value: unknown, yamlPath: string): string | undefined { + if (value === undefined) return undefined; + if (typeof value !== 'string' || value.trim().length === 0) { + throw new Error(`Invalid agentProvider displayName in ${yamlPath}: must be a non-empty string`); + } + return value.trim(); +} + +function parseAgentProviderMentionPatterns(value: unknown, yamlPath: string): string[] | undefined { + if (value === undefined) return undefined; + if (!Array.isArray(value) || value.length === 0) { + throw new Error( + `Invalid agentProvider mentionPatterns in ${yamlPath}: must be a non-empty array of '@name' strings`, + ); + } + const patterns: string[] = []; + // P2 review (@codex on PR #39): runtime mention matching is case-insensitive + // (CatConfig side normalizes lowercased), so duplicates must be detected on + // the lowercased form — otherwise `@clowder` and `@Clowder` slip through the + // parser but collide downstream. Sibling check below: reject bare `@` (must + // have at least one name character after the prefix per the `@name` contract). + const seenLowercased = new Set(); + for (const entry of value) { + if (typeof entry !== 'string' || entry.trim().length === 0) { + throw new Error(`Invalid agentProvider mentionPattern in ${yamlPath}: each entry must be a non-empty string`); + } + const trimmed = entry.trim(); + if (!trimmed.startsWith('@')) { + throw new Error(`Invalid agentProvider mentionPattern '${trimmed}' in ${yamlPath}: must start with '@'`); + } + if (trimmed.length < 2) { + throw new Error( + `Invalid agentProvider mentionPattern '${trimmed}' in ${yamlPath}: must have at least one character after '@'`, + ); + } + if (/\s/.test(trimmed)) { + throw new Error(`Invalid agentProvider mentionPattern '${trimmed}' in ${yamlPath}: must not contain whitespace`); + } + const lowered = trimmed.toLowerCase(); + if (seenLowercased.has(lowered)) { + throw new Error( + `Invalid agentProvider mentionPatterns in ${yamlPath}: duplicate entries are not allowed (case-insensitive match on '${trimmed}')`, + ); + } + seenLowercased.add(lowered); + patterns.push(trimmed); + } + return patterns; +} + +export function parseAgentProviderResource( + raw: Record, + name: string | undefined, + yamlPath: string, +): AgentProviderResource { + if (!name || name.trim().length === 0) { + throw new Error(`AgentProvider resource in ${yamlPath} must have a 'name' field`); + } + if (/[/\\]/.test(name)) { + throw new Error(`AgentProvider resource name '${name}' in ${yamlPath} must not contain path separators (/ or \\)`); + } + + const transport = parseAgentProviderTransport(raw, yamlPath); + const command = requireNonBlankString(raw.command, 'command', yamlPath); + const startupArgs = parseStringArrayField(raw.startupArgs, 'startupArgs', yamlPath); + const sessionFields = parseAgentProviderSessionFields(raw, transport, yamlPath); + const timeoutMs = parseAgentProviderTimeoutMs(raw.timeoutMs, yamlPath); + const mcpWhitelistRequest = parseAgentProviderMcpWhitelistRequest(raw.mcpWhitelist, yamlPath); + const sandboxRequest = parseAgentProviderSandboxRequest(raw.sandbox, yamlPath); + const healthCheck = parseAgentProviderHealthCheck(raw.healthCheck, yamlPath); + // F241 Phase C 2c — Optional manifest identity claims. + // Pure schema additions: they feed the descriptor hash (any change forces + // re-approval) and become available for Hub UI pre-fill, but do NOT bypass + // admission or auto-promote routeability — host-owned `routeableBinding` + // remains the only routing truth source. + const providerId = parseAgentProviderProviderId(raw.providerId, yamlPath); + const displayName = parseAgentProviderDisplayName(raw.displayName, yamlPath); + const mentionPatterns = parseAgentProviderMentionPatterns(raw.mentionPatterns, yamlPath); + + return { + name, + transport, + command, + startupArgs, + ...sessionFields, + ...(timeoutMs !== undefined ? { timeoutMs } : {}), + ...(mcpWhitelistRequest ? { mcpWhitelistRequest } : {}), + ...(sandboxRequest ? { sandboxRequest } : {}), + ...(healthCheck ? { healthCheck } : {}), + ...(providerId ? { providerId } : {}), + ...(displayName ? { displayName } : {}), + ...(mentionPatterns ? { mentionPatterns } : {}), + }; +} diff --git a/packages/api/src/domains/plugin/agent-provider-projection.ts b/packages/api/src/domains/plugin/agent-provider-projection.ts new file mode 100644 index 0000000000..aa93ab4ceb --- /dev/null +++ b/packages/api/src/domains/plugin/agent-provider-projection.ts @@ -0,0 +1,193 @@ +/** + * F241 Phase B Slice 2b Step 5b: Routeable agentProvider projection. + * + * Turns persisted routeable agentProvider capabilities into synthetic + * `CatConfig` entries that `syncAgentRegistry` consumes, so a freshly-approved + * plugin agentProvider actually becomes `@`-able at runtime. + * + * Red line (per F241 doc § Phase B Slice 2b Design Notes — + * RoutingAdmissionService): admission MUST run again here before the + * synthetic config is injected into the runtime configs map. Skipping this + * re-introduces parsing-order self-exemption — the exact hole Slice 1 closed. + * + * A capability is projected when ALL of: + * - `routeable === true` (so admission + health passed at approval time) + * - `routeableApproved === true` (explicit operator action) + * - `routeableBinding` is present (operator chose a catId) + * - `health` is fresh (not expired by TTL) + * - admission RE-RUNS green on the current snapshot + * + * Otherwise the capability is skipped silently — the operator can re-approve + * to fix it. The projection NEVER promotes — that's the orchestration + * service's exclusive privilege. + */ + +import type { AgentProviderCapabilityDescriptor, CapabilitiesConfig, CatConfig } from '@cat-cafe/shared'; +import { + admitForRouting, + type RoutingAdmissionCandidate, + type RoutingAdmissionSnapshot, +} from './RoutingAdmissionService.js'; + +export interface RouteableAgentProviderRow { + readonly pluginId: string; + readonly capId: string; + readonly descriptor: AgentProviderCapabilityDescriptor; +} + +/** + * Pull every capability row that the operator has approved as routeable. + * Pre-projection filter — the projection function additionally re-runs + * admission and checks health freshness. + */ +export function listApprovedRouteableRows(capabilities: CapabilitiesConfig | null): RouteableAgentProviderRow[] { + if (!capabilities) return []; + const out: RouteableAgentProviderRow[] = []; + for (const cap of capabilities.capabilities) { + if (cap.type !== 'agentProvider' || !cap.agentProvider) continue; + const d = cap.agentProvider as AgentProviderCapabilityDescriptor; + if (!d.routeable || !d.routeableApproved) continue; + if (!d.routeableBinding) continue; + if (!cap.pluginId) continue; + out.push({ pluginId: cap.pluginId, capId: cap.id, descriptor: d }); + } + return out; +} + +export interface AgentProviderProjectionInputs { + readonly rows: readonly RouteableAgentProviderRow[]; + /** Build admission snapshot for the given candidate (must EXCLUDE the candidate). */ + readonly buildSnapshot: (pluginId: string, capId: string) => RoutingAdmissionSnapshot; + /** Current epoch ms — injected for testability. */ + readonly now: () => number; + /** API sync enforces TTL refresh/degrade; read-only mirrors such as L0 bootstrap must not independently de-route. */ + readonly enforceHealthTtl?: boolean; + /** Optional logger for skip reasons. */ + readonly onSkip?: (pluginId: string, capId: string, reason: string) => void; +} + +export interface AgentProviderProjectionResult { + /** Synthetic CatConfig entries safe to merge into syncAgentRegistry's configs map. */ + readonly configs: Record; + /** Capabilities that passed all projection gates. */ + readonly admitted: RouteableAgentProviderRow[]; + /** Capabilities skipped, with reason — useful for telemetry/log. */ + readonly skipped: Array<{ pluginId: string; capId: string; reason: string }>; +} + +/** + * Build the synthetic CatConfig map. Pure, side-effect free (except onSkip logging). + * Caller owns merging the result into `syncAgentRegistry(configs)`. + */ +export function projectRouteableAgentProviders(inputs: AgentProviderProjectionInputs): AgentProviderProjectionResult { + const configs: Record = {}; + const admitted: RouteableAgentProviderRow[] = []; + const skipped: Array<{ pluginId: string; capId: string; reason: string }> = []; + const now = inputs.now(); + + for (const row of inputs.rows) { + const { descriptor } = row; + const binding = descriptor.routeableBinding; + if (!binding) { + skipped.push({ pluginId: row.pluginId, capId: row.capId, reason: 'missing-binding' }); + inputs.onSkip?.(row.pluginId, row.capId, 'missing-binding'); + continue; + } + + // Health freshness gate — TTL expired means routeable can no longer be + // trusted (per Q3 convergence). The orchestration service is the only + // path that can re-promote; here we degrade silently. + if (!descriptor.health || !descriptor.health.passed) { + skipped.push({ pluginId: row.pluginId, capId: row.capId, reason: 'health-not-fresh-or-failed' }); + inputs.onSkip?.(row.pluginId, row.capId, 'health-not-fresh-or-failed'); + continue; + } + if (descriptor.health.descriptorHash !== descriptor.descriptorHash) { + skipped.push({ pluginId: row.pluginId, capId: row.capId, reason: 'health-descriptor-mismatch' }); + inputs.onSkip?.(row.pluginId, row.capId, 'health-descriptor-mismatch'); + continue; + } + if (inputs.enforceHealthTtl !== false && now > descriptor.health.checkedAt + descriptor.health.ttlMs) { + skipped.push({ pluginId: row.pluginId, capId: row.capId, reason: 'health-ttl-expired' }); + inputs.onSkip?.(row.pluginId, row.capId, 'health-ttl-expired'); + continue; + } + + // Red line: re-run admission on the live snapshot before injecting. + const snapshot = inputs.buildSnapshot(row.pluginId, row.capId); + const candidate: RoutingAdmissionCandidate = { + pluginId: row.pluginId, + capId: row.capId, + providerId: descriptor.name, + catId: binding.catId, + profileId: binding.profileId, + mentionPatterns: binding.mentionPatterns, + healthCheck: descriptor.healthCheck, + }; + const admission = admitForRouting(candidate, snapshot); + if (!admission.admitted) { + skipped.push({ + pluginId: row.pluginId, + capId: row.capId, + reason: `admission-rerun-denied:${admission.reason}`, + }); + inputs.onSkip?.(row.pluginId, row.capId, `admission-rerun-denied:${admission.reason}`); + continue; + } + + // All gates green — build the synthetic CatConfig. + configs[binding.catId] = synthesizeCatConfig(row, binding); + admitted.push(row); + } + + return { configs, admitted, skipped }; +} + +/** + * Build a synthetic CatConfig from a routeable agentProvider row + binding. + * The config flows into ProviderTransportRegistry.createServiceForConfig + * via the existing syncAgentRegistry loop, which already understands the + * `providerTransport` shape from Slice 1. + */ +function synthesizeCatConfig( + row: RouteableAgentProviderRow, + binding: NonNullable, +): CatConfig { + const d = row.descriptor; + // Mark synthetic origins so loaders / debugging can distinguish plugin-projected + // configs from operator-authored ones. We do NOT route through the clientId + // switch — the ProviderTransportRegistry handler runs BEFORE that switch + // (per Slice 1 design), so a synthetic providerTransport entry is sufficient. + // CatConfig is a branded structural type with many required fields whose + // values are not meaningful for synthetic plugin-projected entries; fill + // them with manifest-derived defaults so any consumer that does inspect + // them sees something stable + auditable rather than `undefined`. + const synthetic = { + id: binding.catId, + name: d.name, + displayName: d.name, + avatar: '🧩', + color: 'gray', + mentionPatterns: [...(binding.mentionPatterns ?? [])], + clientId: d.name, + defaultModel: '', + mcpSupport: true, + roleDescription: `Plugin-projected agentProvider (${row.pluginId}/${row.capId})`, + personality: '', + providerTransport: { + transport: d.transport, + command: d.command, + startupArgs: [...d.startupArgs], + ...(d.resumeArgs ? { resumeArgs: [...d.resumeArgs] } : {}), + ...(d.sessionPolicy ? { sessionPolicy: d.sessionPolicy } : {}), + ...(d.outputProfile ? { outputProfile: d.outputProfile } : {}), + ...(d.timeoutMs !== undefined ? { timeoutMs: d.timeoutMs } : {}), + }, + pluginProjection: { + pluginId: row.pluginId, + capId: row.capId, + descriptorHash: d.descriptorHash, + }, + }; + return synthetic as unknown as CatConfig; +} diff --git a/packages/api/src/domains/plugin/plugin-manifest.ts b/packages/api/src/domains/plugin/plugin-manifest.ts index d4f16f3380..d737a537f6 100644 --- a/packages/api/src/domains/plugin/plugin-manifest.ts +++ b/packages/api/src/domains/plugin/plugin-manifest.ts @@ -3,6 +3,7 @@ import { posix, win32 } from 'node:path'; import type { PluginHealthCheck, PluginManifest, PluginResourceDef, ValueConfigField } from '@cat-cafe/shared'; import { parse as parseYaml } from 'yaml'; import { getValueFields, parseConfigFields } from '../../infrastructure/config-field-parser.js'; +import { parseAgentProviderResource } from './agent-provider-manifest.js'; import { resourceCapId } from './PluginRegistry.js'; const SYSTEM_ENV_DENYLIST_PREFIXES = [ @@ -19,7 +20,7 @@ const SYSTEM_ENV_DENYLIST_PREFIXES = [ const SYSTEM_ENV_DENYLIST_EXACT = new Set(['NODE_OPTIONS', 'NODE_ENV', 'PATH', 'HOME', 'SHELL', 'PORT']); -const SUPPORTED_RESOURCE_TYPES = new Set(['skill', 'mcp', 'limb', 'schedule']); +const SUPPORTED_RESOURCE_TYPES = new Set(['skill', 'mcp', 'limb', 'schedule', 'agentProvider']); const DEFERRED_RESOURCE_TYPES = new Set(); export const BUILTIN_PLUGIN_IDS = new Set(); @@ -196,6 +197,7 @@ export function parsePluginManifest(yamlPath: string): PluginManifest { throw new Error(`Schedule resource name "${name}" in ${yamlPath} must not contain backslashes`); } } + const agentProvider = type === 'agentProvider' ? parseAgentProviderResource(rr, name, yamlPath) : undefined; // F202 Phase 2 follow-up: parse optional flag for resources const optional = rr['optional'] === true; @@ -203,6 +205,7 @@ export function parsePluginManifest(yamlPath: string): PluginManifest { resources.push({ type: type as PluginResourceDef['type'], ...(type === 'schedule' && factoryId ? { factoryId } : {}), + ...(agentProvider ? { agentProvider } : {}), ...(optional ? { optional } : {}), path, name, diff --git a/packages/api/src/domains/workspace/workspace-security.ts b/packages/api/src/domains/workspace/workspace-security.ts index 9a9ec98f74..fa0d31e4fa 100644 --- a/packages/api/src/domains/workspace/workspace-security.ts +++ b/packages/api/src/domains/workspace/workspace-security.ts @@ -10,6 +10,69 @@ const DENYLIST_PATTERNS = [/^\.env/, /\.pem$/, /\.key$/, /^id_rsa/]; const DENYLIST_DIRS = new Set(['.git', 'secrets']); +function assertDenylistAllowed(relPath: string): void { + for (const seg of relPath.split(sep)) { + if (!seg) continue; + if (DENYLIST_DIRS.has(seg)) { + throw new WorkspaceSecurityError(`Access denied: ${seg}`, 'DENIED'); + } + for (const pat of DENYLIST_PATTERNS) { + if (pat.test(seg)) { + throw new WorkspaceSecurityError(`Access denied: ${seg}`, 'DENIED'); + } + } + } +} + +function assertInsideRoot(root: string, resolved: string): string { + const relFromRoot = relative(root, resolved); + if (relFromRoot.startsWith('..') || resolve(root, relFromRoot) !== resolved) { + throw new WorkspaceSecurityError('Path outside workspace root', 'TRAVERSAL'); + } + return relFromRoot; +} + +function assertRealPathInside(realRoot: string, real: string): void { + if (!real.startsWith(realRoot + sep) && real !== realRoot) { + throw new WorkspaceSecurityError('Symlink escapes workspace root', 'TRAVERSAL'); + } +} + +async function isExistingDirectory(path: string): Promise { + try { + const pathStat = await stat(path); + if (!pathStat.isDirectory()) { + throw new WorkspaceSecurityError('Parent path is not a directory', 'TRAVERSAL'); + } + return true; + } catch (err) { + if (err instanceof WorkspaceSecurityError) throw err; + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return false; + throw err; + } +} + +async function findExistingDirectoryAncestor(resolvedRoot: string, initialAncestor: string): Promise { + let ancestor = initialAncestor; + for (;;) { + const relAncestor = assertInsideRoot(resolvedRoot, ancestor); + assertDenylistAllowed(relAncestor); + + if (await isExistingDirectory(ancestor)) { + return ancestor; + } + + if (ancestor === resolvedRoot) { + throw new WorkspaceSecurityError('Workspace root does not exist', 'NOT_FOUND'); + } + const parent = dirname(ancestor); + if (parent === ancestor) { + throw new WorkspaceSecurityError('Path outside workspace root', 'TRAVERSAL'); + } + ancestor = parent; + } +} + /** * In-memory registry: worktreeId → absolute root path. * Populated when /api/workspace/worktrees lists foreign repos. @@ -39,23 +102,8 @@ export class WorkspaceSecurityError extends Error { export async function resolveWorkspacePath(root: string, userPath: string): Promise { const decoded = decodeURIComponent(userPath); const resolved = resolve(root, decoded); - const relFromRoot = relative(root, resolved); - - if (relFromRoot.startsWith('..') || resolve(root, relFromRoot) !== resolved) { - throw new WorkspaceSecurityError('Path outside workspace root', 'TRAVERSAL'); - } - - const segments = relFromRoot.split(sep); - for (const seg of segments) { - if (DENYLIST_DIRS.has(seg)) { - throw new WorkspaceSecurityError(`Access denied: ${seg}`, 'DENIED'); - } - for (const pat of DENYLIST_PATTERNS) { - if (pat.test(seg)) { - throw new WorkspaceSecurityError(`Access denied: ${seg}`, 'DENIED'); - } - } - } + const relFromRoot = assertInsideRoot(root, resolved); + assertDenylistAllowed(relFromRoot); // Symlink escape check: resolve the FULL real path (follows all symlinks // in every segment, not just the final one). This catches both @@ -64,23 +112,12 @@ export async function resolveWorkspacePath(root: string, userPath: string): Prom // symlinks (e.g. macOS /tmp → /private/tmp). try { const [real, realRoot] = await Promise.all([realpath(resolved), realpath(root)]); - if (!real.startsWith(realRoot + sep) && real !== realRoot) { - throw new WorkspaceSecurityError('Symlink escapes workspace root', 'TRAVERSAL'); - } + assertRealPathInside(realRoot, real); // Re-check denylist on the realpath result — a symlink named "safe" // pointing to ".env" would pass the pre-realpath check above but the // resolved target must still be denied. const realRel = relative(realRoot, real); - for (const seg of realRel.split(sep)) { - if (DENYLIST_DIRS.has(seg)) { - throw new WorkspaceSecurityError(`Access denied: ${seg}`, 'DENIED'); - } - for (const pat of DENYLIST_PATTERNS) { - if (pat.test(seg)) { - throw new WorkspaceSecurityError(`Access denied: ${seg}`, 'DENIED'); - } - } - } + assertDenylistAllowed(realRel); } catch (e) { if (e instanceof WorkspaceSecurityError) throw e; // ENOENT = file doesn't exist yet; traversal check above covers it @@ -92,6 +129,29 @@ export async function resolveWorkspacePath(root: string, userPath: string): Prom return resolved; } +/** + * Resolve a path that may be created by a write operation. + * + * Unlike resolveWorkspacePath(), this validates the nearest existing ancestor + * instead of accepting ENOENT after only lexical traversal checks. That closes + * the "workspace/safe-link/new.txt" case where "safe-link" is a symlink to a + * directory outside the workspace. + */ +export async function resolveWorkspaceCreatePath(root: string, userPath: string): Promise { + const decoded = decodeURIComponent(userPath); + const resolvedRoot = resolve(root); + const resolved = resolve(resolvedRoot, decoded); + const relFromRoot = assertInsideRoot(resolvedRoot, resolved); + assertDenylistAllowed(relFromRoot); + + const realRoot = await realpath(resolvedRoot); + const ancestor = await findExistingDirectoryAncestor(resolvedRoot, dirname(resolved)); + const realAncestor = await realpath(ancestor); + assertRealPathInside(realRoot, realAncestor); + assertDenylistAllowed(relative(realRoot, realAncestor)); + return resolved; +} + /** * Check if a relative path matches the denylist (for filtering search results). * Returns true if the path should be blocked. diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 6b8ef781b9..0295c8214e 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -3,6 +3,10 @@ * 后端 API 入口 */ +// Side-effect import: must be FIRST — installs global fetch proxy dispatcher +// before any code calls fetch(). See proxy-dispatcher.ts for rationale. +import './infrastructure/proxy-dispatcher.js'; + import { join } from 'node:path'; import { type CatConfig, @@ -24,9 +28,10 @@ import { resolveBoundAccountRefForCat } from './config/cat-account-binding.js'; import { getCatContextBudget } from './config/cat-budgets.js'; import { bootstrapDefaultCatCatalog, - getAcpConfig, getConfigSessionStrategy, getDefaultCatId, + getProviderTransportConfig, + getTemplateBuiltinCatIds, isCatAvailable, toAllCatConfigs, } from './config/cat-config-loader.js'; @@ -54,18 +59,21 @@ import type { import { QueueProcessor } from './domains/cats/services/agents/invocation/QueueProcessor.js'; import { SessionContinuationCoordinator } from './domains/cats/services/agents/invocation/SessionContinuationCoordinator.js'; import { SessionMutex } from './domains/cats/services/agents/invocation/SessionMutex.js'; -import { - type AcpPoolRegistry, - createAcpServiceForConfig, -} from './domains/cats/services/agents/providers/acp/AcpServiceFactory.js'; -import { closeStaleAcpPools } from './domains/cats/services/agents/providers/acp/acp-pool-registry.js'; +import { createAcpProviderTransportFactory } from './domains/cats/services/agents/providers/acp/AcpProviderTransportFactory.js'; +import { type AcpPoolRegistry } from './domains/cats/services/agents/providers/acp/AcpServiceFactory.js'; import { AntigravityAgentService } from './domains/cats/services/agents/providers/antigravity/AntigravityAgentService.js'; import { RedisAntigravitySupervisorStore } from './domains/cats/services/agents/providers/antigravity/AntigravitySupervisorStore.js'; +import { createCliJsonlProviderTransportFactory } from './domains/cats/services/agents/providers/cli-jsonl/CliJsonlProviderTransportFactory.js'; import { clearL0Cache, resolveL0CompilerScriptPath, warmL0Cache, } from './domains/cats/services/agents/providers/l0-compiler.js'; +import { + deriveReservedProviderTransportIdentities, + markActiveProviderTransportProfile, + ProviderTransportRegistry, +} from './domains/cats/services/agents/providers/transport/ProviderTransportRegistry.js'; import { AgentRegistry } from './domains/cats/services/agents/registry/AgentRegistry.js'; import { AuthorizationManager } from './domains/cats/services/auth/AuthorizationManager.js'; import { createFreshnessReinvokeCheck } from './domains/cats/services/freshness/createFreshnessReinvokeCheck.js'; @@ -1233,38 +1241,214 @@ async function main(): Promise { // ── F149 Phase C: ACP process pool registry (variantId → AcpProcessPool) ── const acpPoolRegistry: AcpPoolRegistry = new Map(); + const providerTransportRegistry = new ProviderTransportRegistry(); + providerTransportRegistry.register( + createAcpProviderTransportFactory({ poolRegistry: acpPoolRegistry, log: app.log }), + ); + providerTransportRegistry.register(createCliJsonlProviderTransportFactory({ log: app.log })); // ── F32-b: AgentRegistry (catId → AgentService) — one instance per cat ── // Each cat gets its own AgentService instance with its catId + model. const agentRegistry = new AgentRegistry(); let router!: AgentRouter; - const syncAgentRegistry = async (configs: Record) => { + // F241 Phase B Slice 2b P1.4 fix: track catRegistry IDs registered by + // plugin projection so we can unregister stale ones on the next sync. + // catalog-sourced cats are never in this set — only synthetic plugin-projected + // ones, so the unregister loop can never strip a real cat. + const pluginProjectedCatIds = new Set(); + const syncAgentRegistry = async (configsInput: Record) => { agentRegistry.reset(); clearL0Cache(); // Invalidate stale L0 compilations from previous sync const projectRoot = resolveActiveProjectRoot(); - const activeAcpProfileIds = new Set(); + const activeProfileIdsByTransport = new Map>(); + + // F241 Phase B Slice 2b Step 5b: merge plugin-projected routeable + // agentProvider rows into the configs map BEFORE the loop. The projection + // re-runs admission on the live snapshot (per design notes red line) so + // a manifest-mutated row that lost admission gets silently dropped here. + const { listApprovedRouteableRows, projectRouteableAgentProviders } = await import( + './domains/plugin/agent-provider-projection.js' + ); + const { buildAgentProviderAdmissionSnapshot } = await import( + './domains/plugin/agent-provider-admission-snapshot.js' + ); + const { refreshExpiredHealthInPlace } = await import('./domains/plugin/agent-provider-health-refresh.js'); + const { createRealCliProbeHealthExecutor } = await import('./domains/plugin/agent-provider-health-executor.js'); + // F241 Phase C — Real cliProbe (drop-in DI swap from the 2b transport- + // availability stub per F241 doc L340). The factory caches the spawnFn + // reference so the sync loop reuses the same instance for every refresh. + const realCliProbeExecutor = createRealCliProbeHealthExecutor(); + const { readCapabilitiesConfig: readCaps, writeCapabilitiesConfig: writeCaps } = await import( + './config/capabilities/capability-orchestrator.js' + ); + let configs: Record = configsInput; + const newlyProjectedCatIds = new Set(); + try { + const capabilitiesConfig = await readCaps(projectRoot); + const rows = listApprovedRouteableRows(capabilitiesConfig); + if (rows.length > 0) { + // P1.2 fix: baseline unavailable → SKIP projection entirely (fail + // closed). Routeable rows stay persisted-as-routeable but won't be + // projected this sync — once the baseline is back, the next sync + // will re-project. Never proceed with empty baseline. + let templateBaselineIds: ReadonlySet; + try { + templateBaselineIds = getTemplateBuiltinCatIds(projectRoot); + } catch (err) { + app.log.error( + { err }, + '[F241] projection: template baseline unavailable — skipping projection (fail-closed)', + ); + throw err; + } + + // P1.5 fix: synchronously refresh TTL-expired health BEFORE projection. + // Per Q3 design notes: startup/sync of an already-approved capability + // with expired TTL must synchronously re-run the health executor. + // Refresh failure → persist routeable=false (degrade), don't skip-silently. + const refreshedCapabilities = await refreshExpiredHealthInPlace({ + capabilities: capabilitiesConfig, + rows, + now: () => Date.now(), + healthExecutor: realCliProbeExecutor, + getHealthExecutorContext: () => ({ + providerTransportRegistry: { has: (id) => providerTransportRegistry.has(id) }, + }), + persist: async (next) => writeCaps(projectRoot, next), + log: (level, payload, msg) => app.log[level](payload, msg), + }); + const liveCapabilities = refreshedCapabilities ?? capabilitiesConfig; + const liveRows = refreshedCapabilities ? listApprovedRouteableRows(refreshedCapabilities) : rows; + + const projection = projectRouteableAgentProviders({ + rows: liveRows, + buildSnapshot: (pluginId, capId) => + buildAgentProviderAdmissionSnapshot({ + capabilitiesConfig: liveCapabilities, + activeCatConfigs: configsInput, + templateBaselineIds, + hasProviderTransportConfig: (id) => { + const pt = getProviderTransportConfig(id, projectRoot); + return pt !== undefined && pt !== null; + }, + candidatePluginId: pluginId, + candidateCapId: capId, + }), + now: () => Date.now(), + onSkip: (pluginId, capId, reason) => { + app.log.warn({ pluginId, capId, reason }, '[F241] projection: skipping routeable agentProvider row'); + }, + }); + if (Object.keys(projection.configs).length > 0) { + configs = { ...configsInput, ...projection.configs }; + for (const id of Object.keys(projection.configs)) newlyProjectedCatIds.add(id); + app.log.info( + { admitted: projection.admitted.length, skipped: projection.skipped.length }, + '[F241] projection: merged routeable plugin agentProvider rows', + ); + } + } + } catch (err) { + app.log.error( + { err }, + '[F241] projection: failed to project routeable agentProvider rows — falling back to base configs', + ); + } + + // P1.4 fix: sync plugin-projected synthetic configs into the GLOBAL + // catRegistry so mention parsing (AgentRouter / a2a-mentions) can resolve + // @. Unregister stale projections from previous syncs + // (e.g. plugin disabled, descriptor delta reset approval), then register + // current ones. + // + // P1.4 follow-up (codex review): the stale-cleanup loop MUST verify the + // currently-registered entry is still plugin-owned before unregistering. + // If between sync calls a real catalog cat got registered with the same + // id as a previously-projected synthetic, deleting it by id alone would + // wipe the real cat. Ownership marker: synthetic configs carry a + // `pluginProjection` field (see agent-provider-projection.ts); catalog + // configs do not. We only unregister when the registered entry still + // bears that marker — otherwise it has been taken over by a real cat + // and stays. + for (const staleId of pluginProjectedCatIds) { + if (newlyProjectedCatIds.has(staleId)) continue; + const current = catRegistry.tryGet(staleId)?.config as (CatConfig & { pluginProjection?: unknown }) | undefined; + if (current && current.pluginProjection !== undefined) { + catRegistry.unregister(staleId); + } else if (current) { + app.log.info( + { staleId }, + '[F241] stale-projection cleanup: skipping unregister — id has been re-registered as a non-plugin cat (likely catalog takeover)', + ); + } + } + pluginProjectedCatIds.clear(); + for (const id of newlyProjectedCatIds) { + catRegistry.registerOrReplace(id, configs[id]); + pluginProjectedCatIds.add(id); + } + + const providerTransportsByProfileId = new Map(); + for (const id of Object.keys(configs)) { + // For synthetic / plugin-projected configs, providerTransport is inline + // on the merged CatConfig; for on-disk configs, fall back to the loader. + const inline = (configs[id] as CatConfig & { providerTransport?: unknown }).providerTransport; + if (inline !== undefined) { + providerTransportsByProfileId.set(id, inline); + } else { + providerTransportsByProfileId.set(id, getProviderTransportConfig(id, projectRoot)); + } + } + let reservedRouteableIdentityError: string | undefined; + let templateBuiltinIds: ReadonlySet = new Set(); + try { + templateBuiltinIds = getTemplateBuiltinCatIds(projectRoot); + } catch (err) { + reservedRouteableIdentityError = err instanceof Error ? err.message : String(err); + app.log.error({ err }, '[api] Provider transport builtin identity baseline unavailable'); + } + const reservedRouteableIds = reservedRouteableIdentityError + ? new Set() + : deriveReservedProviderTransportIdentities({ + configs, + providerTransportsByProfileId, + templateBuiltinIds, + }); for (const [id, config] of Object.entries(configs)) { const catId = config.id; // F32-b P1 fix: do NOT pass model here — let constructors resolve via // getCatModel(catId) which respects env override (CAT_*_MODEL > config > fallback) let service: AgentService; - // ── F161: Generic ACP transport path (provider-agnostic) ── - // Any clientId with an `acp` config section uses AcpAgentService. - // This check runs BEFORE the clientId switch — ACP is a transport, not a provider. - const acpConfig = getAcpConfig(id, projectRoot); - if (acpConfig) { - activeAcpProfileIds.add(id); - const acpService = await createAcpServiceForConfig({ - projectRoot, - profileId: id, - config, - acpConfig, - poolRegistry: acpPoolRegistry, - log: app.log, - }); - if (!acpService) continue; - service = acpService; + // ── F241 Phase A: host-owned provider transports ── + // Transport selection runs BEFORE the clientId switch. ACP remains the + // first registered host transport from F161; future agentProvider + // manifests may only reference allowlisted host transports here. + const providerTransport = await providerTransportRegistry.createServiceForConfig({ + projectRoot, + profileId: id, + config, + providerTransport: providerTransportsByProfileId.get(id), + reservedRouteableIds, + reservedRouteableIdentityError, + }); + if (providerTransport.handled) { + if (!providerTransport.service) { + if (providerTransport.rejectionReason) { + app.log.warn( + { + catId: id, + clientId: config.clientId, + transportId: providerTransport.transportId, + reason: providerTransport.rejectionReason, + }, + '[api] Provider transport rejected; cat will not be routable', + ); + } + continue; + } + markActiveProviderTransportProfile(activeProfileIdsByTransport, providerTransport.transportId, id); + service = providerTransport.service; } else switch (config.clientId) { // ── Provider-specific CLI paths (non-ACP) ── @@ -1326,10 +1510,10 @@ async function main(): Promise { } agentRegistry.register(id, service); } - await closeStaleAcpPools(acpPoolRegistry, activeAcpProfileIds, { + await providerTransportRegistry.closeStale(activeProfileIdsByTransport, { reason: 'config-sync', - onCloseError: (err, profileId, reason) => { - app.log.warn({ err, profileId, reason }, 'ACP registry sync failed to close stale member pool'); + onCloseError: (err, transportId, profileId, reason) => { + app.log.warn({ err, transportId, profileId, reason }, 'Provider transport sync failed to close stale resource'); }, }); if (router) router.refreshFromRegistry(agentRegistry); @@ -2232,6 +2416,40 @@ async function main(): Promise { const limbPairingStore = new LimbPairingStore(); registerLimbNodeRoutes(app, { limbRegistry, pairingStore: limbPairingStore }); + // F258 Phase A: macOS CoreBluetooth Limb. Persistent bindings fail closed + // without Redis; the helper itself stays lazy and is not spawned at startup. + const { registerBleRoutes } = await import('./routes/ble-routes.js'); + if (process.platform === 'darwin' && redis) { + const [{ BleHelperClient }, { RedisBleBindingStore }, { BleDeviceManager }] = await Promise.all([ + import('./domains/limb/ble/BleHelperClient.js'), + import('./domains/limb/ble/BleBindingStore.js'), + import('./domains/limb/ble/BleDeviceManager.js'), + ]); + const bleHelper = new BleHelperClient({ logger: app.log }); + const bleManager = new BleDeviceManager({ + helper: bleHelper, + store: new RedisBleBindingStore(redis, app.log), + registry: limbRegistry, + logger: app.log, + }); + await bleManager.initialize(); + registerBleRoutes(app, { manager: bleManager, platform: process.platform }); + app.addHook('onClose', async () => { + bleManager.dispose(); + await bleHelper.shutdown(); + }); + app.log.info(`[api] F258: hydrated ${bleManager.status().bindingCount} persistent BLE binding(s)`); + } else { + registerBleRoutes(app, { + manager: null, + platform: process.platform, + unavailableReason: + process.platform === 'darwin' + ? 'Redis is required for persistent BLE bindings' + : 'BLE helper is only available on macOS in Phase A', + }); + } + // F202-2B: Hoisted for late-binding GitHub schedule rehydration (closure set inside F202 block) let rehydrateGitHubSchedules: ((githubDeps: Record) => Promise) | undefined; let getGitHubPluginEnv: () => Record = () => ({}); @@ -2372,6 +2590,7 @@ async function main(): Promise { // F202-2B: Mutable deps ref — populated via rehydrateGitHubSchedules after GitHub services created scheduleFactoryDeps: scheduleFactoryDeps as import('./domains/plugin/ScheduleFactoryRegistry.js').ScheduleFactoryDeps, + providerTransportRegistry, }); const startupCaps = await readCapabilitiesConfig(resolveActiveProjectRoot()); @@ -2493,7 +2712,77 @@ async function main(): Promise { }); }; - registerPluginRoutes(app, { pluginRegistry, pluginActivator, limbRegistry, pluginsDir }); + // F241 Phase B Slice 2b — approval orchestration service. Holds the single + // explicit synchronous promotion path for routeable=true. Snapshot builder + // closes over host accessors (catRegistry, getTemplateBuiltinCatIds, + // getProviderTransportConfig) so the orchestration stays decoupled from + // those file-system concerns. + const { AgentProviderApprovalService } = await import('./domains/plugin/agent-provider-approval-service.js'); + const { buildAgentProviderAdmissionSnapshot } = await import( + './domains/plugin/agent-provider-admission-snapshot.js' + ); + // F241 Phase C — same real cliProbe executor for the approval orchestration + // path so operator-driven approve-routeable goes through the same probe + // semantics as background TTL refresh (no split-brain between sync paths). + const { createRealCliProbeHealthExecutor: createRealCliProbeForApproval } = await import( + './domains/plugin/agent-provider-health-executor.js' + ); + const approvalCliProbeExecutor = createRealCliProbeForApproval(); + const agentProviderApprovalService = new AgentProviderApprovalService({ + readCapabilities: () => readCapabilitiesConfig(resolveActiveProjectRoot()), + writeCapabilities: async (config) => { + const root = resolveActiveProjectRoot(); + await writeCapabilitiesConfig(root, config); + const { paths } = resolveStartupCliConfigContext(root); + await generateCliConfigs(config, paths, root); + }, + withCapabilityLock: (fn) => withCapabilityLock(resolveActiveProjectRoot(), fn), + buildAdmissionSnapshot: async (pluginId, capId, capabilitiesConfig) => { + const projectRoot = resolveActiveProjectRoot(); + // P1.2 fix: baseline unavailable → fail closed by throwing. The + // approval service catches and surfaces the error to the operator + // rather than admitting with an empty reserved set (which would + // reopen the Slice 1 self-exemption hole). + const templateBaselineIds: ReadonlySet = getTemplateBuiltinCatIds(projectRoot); + return buildAgentProviderAdmissionSnapshot({ + capabilitiesConfig, + activeCatConfigs: catRegistry.getAllConfigs(), + templateBaselineIds, + hasProviderTransportConfig: (id) => { + const pt = getProviderTransportConfig(id, projectRoot); + return pt !== undefined && pt !== null; + }, + candidatePluginId: pluginId, + candidateCapId: capId, + }); + }, + healthExecutor: approvalCliProbeExecutor, + getHealthExecutorContext: () => ({ + providerTransportRegistry: { has: (id) => providerTransportRegistry.has(id) }, + }), + // F241 Phase B Slice 2b Step 5a — post-approval sync hook. + // Re-runs the existing AgentRegistry sync so the freshly-routeable + // capability gets projected into the runtime. The actual synthetic + // cat-config projection for plugin agentProvider rows is Step 5b + // follow-on work; this hook is the architectural commitment that + // approval triggers sync (per design notes' "post-write enqueues + // to the existing serialized sync coordinator" contract). + onRouteablePromoted: async (capability) => { + app.log.info( + { pluginId: capability.descriptorHash ? 'agentProvider' : 'unknown', capability: capability.name }, + '[F241] agentProvider approved as routeable — triggering AgentRegistry sync', + ); + await syncAgentRegistry(catRegistry.getAllConfigs()); + }, + }); + + registerPluginRoutes(app, { + pluginRegistry, + pluginActivator, + limbRegistry, + pluginsDir, + agentProviderApprovalService, + }); } // F174 D2b-1 — single notifier instance shared between callback auth preHandler // (posts in-context surface on 401) and the hide-similar debug endpoint diff --git a/packages/api/src/infrastructure/email/CiCdCheckTaskSpec.ts b/packages/api/src/infrastructure/email/CiCdCheckTaskSpec.ts index 3bdcb4b255..22f7e31c22 100644 --- a/packages/api/src/infrastructure/email/CiCdCheckTaskSpec.ts +++ b/packages/api/src/infrastructure/email/CiCdCheckTaskSpec.ts @@ -87,12 +87,17 @@ export function createCiCdCheckTaskSpec(opts: CiCdCheckTaskSpecOptions): TaskSpe const routeResult = await opts.cicdRouter.route(pollResult); if (routeResult.kind !== 'notified' || !opts.invokeTrigger) return; + const intent = signal.task.automationState?.intent ?? 'review'; + // CI fail → always wake (urgent, must fix) — independent of intent. + // Event-driven wait coverage is stricter: only merge intent guarantees + // the follow-up CI-pass transition will invoke this cat again. if (routeResult.bucket === 'fail') { const policy: ConnectorTriggerPolicy = { priority: 'urgent', reason: 'github_ci_failure', sourceCategory: 'ci', + eventDrivenExternalWaitCoverage: intent === 'merge', }; void opts.invokeTrigger .trigger( @@ -113,7 +118,6 @@ export function createCiCdCheckTaskSpec(opts: CiCdCheckTaskSpecOptions): TaskSpe // 'review' (default): the cat is waiting on review feedback → CI-pass is noise. CiCdRouter has // already posted the "CI 通过" thread message (visible whenever the cat looks), so stay silent. // 'merge': the cat is waiting on CI-green to merge → CI-pass is the action signal → merge-gate. - const intent = signal.task.automationState?.intent ?? 'review'; if (intent !== 'merge') { opts.log.info( `[cicd-check] CI pass for ${routeResult.catId} — silent (intent=${intent}; thread message only)`, @@ -126,6 +130,7 @@ export function createCiCdCheckTaskSpec(opts: CiCdCheckTaskSpecOptions): TaskSpe reason: 'github_ci_pass', sourceCategory: 'ci', suggestedSkill: 'merge-gate', + eventDrivenExternalWaitCoverage: true, }; void opts.invokeTrigger .trigger( diff --git a/packages/api/src/infrastructure/email/CiCdRouter.ts b/packages/api/src/infrastructure/email/CiCdRouter.ts index b4b6f4f16e..0b7405017d 100644 --- a/packages/api/src/infrastructure/email/CiCdRouter.ts +++ b/packages/api/src/infrastructure/email/CiCdRouter.ts @@ -202,12 +202,16 @@ export class CiCdRouter { threadId: string; ownerCatId: string | null; userId?: string; - automationState?: { trackingInstructions?: string }; + automationState?: { trackingInstructions?: string; trackingInstructionsHeadSha?: string }; }, fingerprint: string, ): Promise { const { taskStore, log } = this.opts; - const content = buildCiMessageContent(poll, task.automationState?.trackingInstructions); + const content = buildCiMessageContent( + poll, + task.automationState?.trackingInstructions, + task.automationState?.trackingInstructionsHeadSha, + ); const source: ConnectorSource = { connector: 'github-ci', @@ -249,7 +253,11 @@ export class CiCdRouter { } } -export function buildCiMessageContent(poll: CiPollResult, trackingInstructions?: string): string { +export function buildCiMessageContent( + poll: CiPollResult, + trackingInstructions?: string, + trackingInstructionsHeadSha?: string, +): string { const bucketEmoji = poll.aggregateBucket === 'pass' ? '✅' : '❌'; const bucketLabel = poll.aggregateBucket === 'pass' ? 'CI 通过' : 'CI 失败'; @@ -275,9 +283,20 @@ export function buildCiMessageContent(poll: CiPollResult, trackingInstructions?: } // F202 Phase 2C (AC-C2): append user-provided tracking instructions - if (trackingInstructions) { + if (shouldAppendTrackingInstructions(trackingInstructions, poll.headSha, trackingInstructionsHeadSha)) { lines.push('', '📌 **Tracking Instructions**', trackingInstructions); } return lines.join('\n'); } + +function shouldAppendTrackingInstructions( + trackingInstructions: string | undefined, + currentHeadSha: string | undefined, + instructionsHeadSha: string | undefined, +): trackingInstructions is string { + if (!trackingInstructions) return false; + if (!instructionsHeadSha) return true; + if (!currentHeadSha) return false; + return instructionsHeadSha === currentHeadSha; +} diff --git a/packages/api/src/infrastructure/email/ConnectorInvokeTrigger.ts b/packages/api/src/infrastructure/email/ConnectorInvokeTrigger.ts index 83f6a719f8..7f7f094a8f 100644 --- a/packages/api/src/infrastructure/email/ConnectorInvokeTrigger.ts +++ b/packages/api/src/infrastructure/email/ConnectorInvokeTrigger.ts @@ -55,6 +55,12 @@ export interface ConnectorTriggerPolicy { readonly sourceCategory?: 'ci' | 'review' | 'conflict' | 'scheduled' | 'a2a' | 'issue'; /** F140 Phase C: hint which Skill to auto-load (not a hard constraint — cat can override) */ readonly suggestedSkill?: string; + /** + * True only when this connector wake comes from a structured external callback/tracking + * path that can wake the cat again for the waited condition. Plain bound-chat connector + * messages do not imply 2b event-driven wait coverage. + */ + readonly eventDrivenExternalWaitCoverage?: boolean; /** * Optional queue coalescing key for connector bursts that supersede earlier queued work. * Later hits reuse the first queued entry: messageIds are merged, but the original content/body stays in place. @@ -113,6 +119,7 @@ export class ConnectorInvokeTrigger { ): Promise { const { invocationTracker } = this.opts; const priority = policy?.priority ?? 'normal'; + const eventDrivenExternalWaitCoverage = policy?.eventDrivenExternalWaitCoverage === true; // F185 AC-1: thread-level queue/processingSlots gate if (this.opts.queueProcessor?.isThreadBusy(threadId)) { @@ -127,6 +134,7 @@ export class ConnectorInvokeTrigger { policy?.sourceCategory, policy?.suggestedSkill, policy?.coalesceKey, + eventDrivenExternalWaitCoverage, ); } @@ -144,6 +152,7 @@ export class ConnectorInvokeTrigger { policy?.sourceCategory, policy?.suggestedSkill, policy?.coalesceKey, + eventDrivenExternalWaitCoverage, ); } @@ -159,6 +168,7 @@ export class ConnectorInvokeTrigger { policy?.suggestedSkill, sender, controller, + eventDrivenExternalWaitCoverage, ).catch((err) => { this.opts.log.error(`[ConnectorInvokeTrigger] Unhandled: ${err instanceof Error ? err.message : String(err)}`); }); @@ -176,6 +186,7 @@ export class ConnectorInvokeTrigger { sourceCategory?: string, suggestedSkill?: string, coalesceKey?: string, + eventDrivenExternalWaitCoverage = false, ): Promise<'full' | 'enqueued'> { const { invocationQueue, socketManager, log } = this.opts; @@ -206,6 +217,7 @@ export class ConnectorInvokeTrigger { : {}), ...(sender ? { senderMeta: sender } : {}), ...(suggestedSkill ? { suggestedSkill } : {}), + eventDrivenExternalWaitCoverage, }); if (result.outcome === 'full') { @@ -262,6 +274,7 @@ export class ConnectorInvokeTrigger { suggestedSkill?: string, sender?: { id: string; name?: string }, preAcquiredController?: AbortController, + eventDrivenExternalWaitCoverage = false, ): Promise { const { router, socketManager, invocationRecordStore, invocationTracker, invocationQueue, log } = this.opts; const targetCats: CatId[] = [catId]; @@ -386,6 +399,8 @@ export class ConnectorInvokeTrigger { frustrationAutoIssueEligible: false, // #949 P2: Connector-sourced flows have no ball-pass expectation — suppress verdict warning verdictPassWarningEnabled: false, + // Only policy-backed connector wakes prove a future callback/tracking path. + eventDrivenExternalWaitCoverage, })) { // #768: Broadcast intent_mode on first CLI event — proves CLI is alive. if (!intentModeBroadcast) { diff --git a/packages/api/src/infrastructure/email/IssueCommentTaskSpec.ts b/packages/api/src/infrastructure/email/IssueCommentTaskSpec.ts index b6e958e287..6a16c7984f 100644 --- a/packages/api/src/infrastructure/email/IssueCommentTaskSpec.ts +++ b/packages/api/src/infrastructure/email/IssueCommentTaskSpec.ts @@ -26,6 +26,7 @@ export interface IssueCommentSignal { repoFullName: string; issueNumber: number; newComments: IssueComment[]; + eventDrivenExternalWaitCoverage?: boolean; commitCursor: () => Promise; } @@ -279,6 +280,7 @@ export function createIssueCommentTaskSpec(opts: IssueCommentTaskSpecOptions): T repoFullName, issueNumber, newComments: pendingDelivery, + eventDrivenExternalWaitCoverage: false, commitCursor: async () => { await advanceDeliveryCursor(task.id, issueKey, maxDeliveryId); // Cloud R15 P1: only mark done when collection is COMPLETE. @@ -346,6 +348,7 @@ export function createIssueCommentTaskSpec(opts: IssueCommentTaskSpecOptions): T newComments: pendingDelivery, // In dual-cursor mode, commitCursor only advances the delivery cursor. // The collection cursor was already advanced above in the collection pass. + eventDrivenExternalWaitCoverage: true, commitCursor: () => advanceDeliveryCursor(task.id, issueKey, maxDeliveryId), }, subjectKey: task.subjectKey!, @@ -381,6 +384,7 @@ export function createIssueCommentTaskSpec(opts: IssueCommentTaskSpecOptions): T repoFullName, issueNumber, newComments, + eventDrivenExternalWaitCoverage: false, commitCursor: async () => { await advanceCursor(task.id, issueKey, maxCommentId, 'memoryFirst'); await opts.taskStore.update(task.id, { status: 'done' }); @@ -409,6 +413,7 @@ export function createIssueCommentTaskSpec(opts: IssueCommentTaskSpecOptions): T repoFullName, issueNumber, newComments, + eventDrivenExternalWaitCoverage: true, commitCursor: () => advanceCursor(task.id, issueKey, maxCommentId, 'memoryFirst'), }, subjectKey: task.subjectKey!, @@ -471,6 +476,7 @@ export function createIssueCommentTaskSpec(opts: IssueCommentTaskSpecOptions): T priority: 'normal', reason: 'github_issue_comment', sourceCategory: 'issue', + eventDrivenExternalWaitCoverage: signal.eventDrivenExternalWaitCoverage === true, coalesceKey: `${subjectKey}:issue-comment:${coalesceTargetCatId}`, }; void opts.invokeTrigger diff --git a/packages/api/src/infrastructure/email/ReviewFeedbackRouter.ts b/packages/api/src/infrastructure/email/ReviewFeedbackRouter.ts index e65e629746..a943d2f968 100644 --- a/packages/api/src/infrastructure/email/ReviewFeedbackRouter.ts +++ b/packages/api/src/infrastructure/email/ReviewFeedbackRouter.ts @@ -48,6 +48,7 @@ export interface ReviewFeedbackRoutingAudit { export interface ReviewFeedbackSignal { readonly repoFullName: string; readonly prNumber: number; + readonly headSha?: string; readonly routingAudit?: ReviewFeedbackRoutingAudit; readonly newComments: readonly PrFeedbackComment[]; readonly newDecisions: readonly PrReviewDecision[]; @@ -73,13 +74,23 @@ export class ReviewFeedbackRouter { async route( signal: ReviewFeedbackSignal, - tracking: { threadId: string; catId: string; userId: string; trackingInstructions?: string }, + tracking: { + threadId: string; + catId: string; + userId: string; + trackingInstructions?: string; + trackingInstructionsHeadSha?: string; + }, ): Promise { if (signal.newComments.length === 0 && signal.newDecisions.length === 0 && !signal.routingAudit) { return { kind: 'skipped', reason: 'no new feedback' }; } - const content = buildReviewFeedbackContent(signal, tracking.trackingInstructions); + const content = buildReviewFeedbackContent( + signal, + tracking.trackingInstructions, + tracking.trackingInstructionsHeadSha, + ); const source: ConnectorSource = { connector: 'github-review-feedback', @@ -113,7 +124,11 @@ export class ReviewFeedbackRouter { // ── Message Formatting (OQ-2: three-section aggregation) ─────────── -export function buildReviewFeedbackContent(signal: ReviewFeedbackSignal, trackingInstructions?: string): string { +export function buildReviewFeedbackContent( + signal: ReviewFeedbackSignal, + trackingInstructions?: string, + trackingInstructionsHeadSha?: string, +): string { const lines: string[] = []; // F140 Phase E.1: prepend severity header when comments/decisions contain @@ -181,13 +196,24 @@ export function buildReviewFeedbackContent(signal: ReviewFeedbackSignal, trackin } // F202 Phase 2C (AC-C2): append user-provided tracking instructions - if (trackingInstructions) { + if (shouldAppendTrackingInstructions(trackingInstructions, signal.headSha, trackingInstructionsHeadSha)) { lines.push('', '📌 **Tracking Instructions**', trackingInstructions); } return lines.join('\n'); } +function shouldAppendTrackingInstructions( + trackingInstructions: string | undefined, + currentHeadSha: string | undefined, + instructionsHeadSha: string | undefined, +): trackingInstructions is string { + if (!trackingInstructions) return false; + if (!instructionsHeadSha) return true; + if (!currentHeadSha) return false; + return instructionsHeadSha === currentHeadSha; +} + function formatRoutingAudit(audit: ReviewFeedbackRoutingAudit): string[] { switch (audit.kind) { case 'legacy-auto-rotated-repaired': diff --git a/packages/api/src/infrastructure/email/ReviewFeedbackTaskSpec.ts b/packages/api/src/infrastructure/email/ReviewFeedbackTaskSpec.ts index 8c4bbb8f01..d1f8aa8aa1 100644 --- a/packages/api/src/infrastructure/email/ReviewFeedbackTaskSpec.ts +++ b/packages/api/src/infrastructure/email/ReviewFeedbackTaskSpec.ts @@ -31,6 +31,7 @@ export interface ReviewFeedbackSignal { repairedTask: TaskItem; repoFullName: string; prNumber: number; + headSha?: string; routingAudit?: ReviewFeedbackRoutingAudit; newComments: PrFeedbackComment[]; newDecisions: PrReviewDecision[]; @@ -553,6 +554,7 @@ export function createReviewFeedbackTaskSpec(opts: ReviewFeedbackTaskSpecOptions repairedTask: trackingTask, repoFullName, prNumber, + headSha: prMetadata?.headSha, routingAudit: repairResult.routingAudit, newComments, newDecisions, @@ -598,6 +600,7 @@ export function createReviewFeedbackTaskSpec(opts: ReviewFeedbackTaskSpecOptions { repoFullName: signal.repoFullName, prNumber: signal.prNumber, + headSha: signal.headSha, routingAudit: signal.routingAudit, newComments: signal.newComments, newDecisions: signal.newDecisions, @@ -607,6 +610,7 @@ export function createReviewFeedbackTaskSpec(opts: ReviewFeedbackTaskSpecOptions catId: repairedTask.ownerCatId ?? '', userId: repairedTask.userId ?? '', trackingInstructions: repairedTask.automationState?.trackingInstructions, + trackingInstructionsHeadSha: repairedTask.automationState?.trackingInstructionsHeadSha, }, ); @@ -622,12 +626,15 @@ export function createReviewFeedbackTaskSpec(opts: ReviewFeedbackTaskSpecOptions const hasApproved = !hasChangesRequested && signal.newDecisions.some((d) => d.state === 'APPROVED'); const suggestedSkill = hasChangesRequested ? 'receive-review' : hasApproved ? 'merge-gate' : undefined; const coalesceTargetCatId = routeResult.catId || repairedTask.ownerCatId || 'unassigned'; + const intent = repairedTask.automationState?.intent ?? 'review'; + const eventDrivenExternalWaitCoverage = hasApproved ? intent === 'merge' : true; const policy: ConnectorTriggerPolicy = { priority: hasChangesRequested ? 'urgent' : 'normal', reason: 'github_review_feedback', sourceCategory: 'review', suggestedSkill, + eventDrivenExternalWaitCoverage, coalesceKey: `${subjectKey}:review-feedback:${coalesceTargetCatId}`, }; void opts.invokeTrigger diff --git a/packages/api/src/infrastructure/harness-eval/publish-verdict/git-worktree-publisher.ts b/packages/api/src/infrastructure/harness-eval/publish-verdict/git-worktree-publisher.ts index b6531e688c..dda9f249d0 100644 --- a/packages/api/src/infrastructure/harness-eval/publish-verdict/git-worktree-publisher.ts +++ b/packages/api/src/infrastructure/harness-eval/publish-verdict/git-worktree-publisher.ts @@ -7,6 +7,37 @@ import type { GitPublisher, PublishOnIsolatedWorktreeOpts } from './publish-verd const exec = promisify(execFile); +export function parseGitHubRepoFromRemoteUrl(remoteUrl: string): string | null { + const trimmed = remoteUrl.trim(); + if (!trimmed) return null; + + const scpLike = /^git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/.exec(trimmed); + if (scpLike) return `${scpLike[1]}/${scpLike[2]}`; + + try { + const url = new URL(trimmed); + if (url.hostname !== 'github.com') return null; + const [owner, repoWithSuffix] = url.pathname.replace(/^\/+/, '').split('/'); + if (!owner || !repoWithSuffix) return null; + return `${owner}/${repoWithSuffix.replace(/\.git$/, '')}`; + } catch { + return null; + } +} + +async function resolveOriginGitHubRepo(repoRoot: string): Promise { + try { + const result = await exec('git', ['-C', repoRoot, 'remote', 'get-url', '--push', 'origin'], { timeout: 10_000 }); + return parseGitHubRepoFromRemoteUrl(result.stdout); + } catch { + return null; + } +} + +function withGhRepo(args: string[], repo: string | null): string[] { + return repo ? [...args, '--repo', repo] : args; +} + /** * F192 Phase H — Real GitPublisher impl using `git worktree add` + `gh pr create`. * @@ -42,10 +73,12 @@ export function createGitWorktreePublisher(deps: GitWorktreePublisherDeps): GitP let pushSucceeded = false; let prUrl: string | null = null; let branchExistedBefore = false; + let ghRepo: string | null = null; try { // 1. Fetch latest origin/main to ensure isolated worktree is current await exec('git', ['-C', deps.repoRoot, 'fetch', 'origin', 'main'], { timeout: 60_000 }); + ghRepo = await resolveOriginGitHubRepo(deps.repoRoot); // Probe upfront so partial-failure cleanup never deletes a pre-existing branch. try { @@ -95,9 +128,9 @@ export function createGitWorktreePublisher(deps: GitWorktreePublisherDeps): GitP const commitSha = shaResult.stdout.trim(); // 7. Open auto-PR via gh. - // 砚砚 R4 P1 cloud: `--repo .` is NOT valid gh syntax (fails with - // 'expected the "[HOST/]OWNER/REPO" format'). Rely on cwd inside the - // worktree — gh auto-detects owner/repo from the git remote. + // Derive `--repo` from origin's push URL. In multi-remote worktrees gh can + // auto-detect an upstream/fork remote while the branch was pushed to origin, + // which makes PR creation look for a head branch in the wrong repository. // // PR-3 (砚砚 R2): pass each label via separate `--label` flag (gh CLI accepts // repeated --label X; not comma-separated). `computePublishPolicy` decides @@ -120,7 +153,7 @@ export function createGitWorktreePublisher(deps: GitWorktreePublisherDeps): GitP }; for (const label of labels ?? []) { const meta = standardLabelMeta[label]; - const args = ['label', 'create', label, '--force']; + const args = withGhRepo(['label', 'create', label, '--force'], ghRepo); if (meta) { args.push('--color', meta.color, '--description', meta.description); } @@ -135,19 +168,22 @@ export function createGitWorktreePublisher(deps: GitWorktreePublisherDeps): GitP const labelFlags = (labels ?? []).flatMap((label) => ['--label', label]); const prResult = await exec( 'gh', - [ - 'pr', - 'create', - '--base', - 'main', - '--head', - opts.branchName, - '--title', - prTitle, - '--body', - prBody, - ...labelFlags, - ], + withGhRepo( + [ + 'pr', + 'create', + '--base', + 'main', + '--head', + opts.branchName, + '--title', + prTitle, + '--body', + prBody, + ...labelFlags, + ], + ghRepo, + ), { cwd: worktreePath, timeout: 60_000 }, ); prUrl = @@ -225,7 +261,10 @@ export function createGitWorktreePublisher(deps: GitWorktreePublisherDeps): GitP try { const probe = await exec( 'gh', - ['pr', 'list', '--head', opts.branchName, '--state', 'open', '--json', 'state', '--limit', '1'], + withGhRepo( + ['pr', 'list', '--head', opts.branchName, '--state', 'open', '--json', 'state', '--limit', '1'], + ghRepo, + ), { cwd: deps.repoRoot, timeout: 30_000 }, ); const parsed = JSON.parse(probe.stdout) as Array<{ state?: string }>; diff --git a/packages/api/src/infrastructure/proxy-dispatcher.ts b/packages/api/src/infrastructure/proxy-dispatcher.ts new file mode 100644 index 0000000000..acd232ac41 --- /dev/null +++ b/packages/api/src/infrastructure/proxy-dispatcher.ts @@ -0,0 +1,36 @@ +/** + * Global fetch proxy dispatcher setup. + * + * Node.js v22 native `fetch()` does NOT honor `HTTPS_PROXY` / `HTTP_PROXY` + * environment variables. This module bridges that gap by installing undici's + * `EnvHttpProxyAgent` as the global dispatcher when proxy env vars are detected. + * + * `EnvHttpProxyAgent` automatically reads: + * - `HTTP_PROXY` / `http_proxy` + * - `HTTPS_PROXY` / `https_proxy` + * - `NO_PROXY` / `no_proxy` (bypass list; typically `localhost,127.0.0.1,::1`) + * + * Import this module as a side-effect import at the top of the server entry + * point — before any code that calls `fetch()`. + * + * Why global: CatAgentService (and potentially other outbound HTTP clients) + * use native `fetch()`. A global dispatcher is the only way to make native + * `fetch()` proxy-aware without patching every call site. + */ + +import { EnvHttpProxyAgent, setGlobalDispatcher } from 'undici'; +import { createModuleLogger } from './logger.js'; + +const log = createModuleLogger('proxy-dispatcher'); + +const proxyUrl = process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY || process.env.http_proxy; + +if (proxyUrl) { + try { + setGlobalDispatcher(new EnvHttpProxyAgent()); + log.info(`Global fetch proxy enabled: ${proxyUrl}`); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + log.error(`Failed to set global proxy dispatcher: ${msg}`); + } +} diff --git a/packages/api/src/routes/ble-routes.ts b/packages/api/src/routes/ble-routes.ts new file mode 100644 index 0000000000..9d48221bcc --- /dev/null +++ b/packages/api/src/routes/ble-routes.ts @@ -0,0 +1,148 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import { z } from 'zod'; +import type { BleDeviceManager } from '../domains/limb/ble/BleDeviceManager.js'; +import { isDirectLoopbackRequest } from '../utils/loopback-request.js'; +import { resolveOwnerGate } from '../utils/owner-gate.js'; + +export interface BleRoutesOptions { + manager: BleDeviceManager | null; + platform?: string; + unavailableReason?: string; +} + +const bindSchema = z + .object({ + sessionId: z.string().min(1).max(128), + discoveryId: z.string().min(1).max(128), + }) + .strict(); + +const bindingParamsSchema = z.object({ bindingId: z.string().min(1).max(128) }).strict(); + +function sessionUserId(request: FastifyRequest): string | null { + const userId = (request as FastifyRequest & { sessionUserId?: string }).sessionUserId; + return typeof userId === 'string' && userId.trim() ? userId.trim() : null; +} + +function requireReadIdentity(request: FastifyRequest, reply: FastifyReply): string | null { + const userId = sessionUserId(request); + if (!userId) { + reply.status(401).send({ error: 'Authentication required' }); + return null; + } + return userId; +} + +function requireWriteIdentity(request: FastifyRequest, reply: FastifyReply): string | null { + const userId = sessionUserId(request); + if (!userId) { + reply.status(401).send({ error: 'Authentication required' }); + return null; + } + if (!isDirectLoopbackRequest(request) && !process.env.DEFAULT_OWNER_USER_ID?.trim()) { + reply.status(403).send({ + error: 'BLE device changes from non-localhost require DEFAULT_OWNER_USER_ID to be configured', + }); + return null; + } + const ownerError = resolveOwnerGate(userId, { + errorMessage: 'BLE device changes can only be performed by the configured owner', + }); + if (ownerError) { + reply.status(ownerError.status).send({ error: ownerError.error }); + return null; + } + return userId; +} + +function unavailable(reply: FastifyReply): void { + reply.status(503).send({ error: 'BLE binding storage is unavailable' }); +} + +function errorStatus(error: Error): number { + if (error.message.includes('already active') || error.message.includes('already bound')) return 409; + if (error.message.includes('not available') || error.message.includes('not expose')) return 422; + if (error.message.includes('timed out')) return 504; + if (error.message.includes('unsupported') || error.message.includes('unavailable')) return 503; + return 502; +} + +function safeError(error: unknown): string { + const message = error instanceof Error ? error.message : 'BLE operation failed'; + return message.slice(0, 512); +} + +export function registerBleRoutes(app: FastifyInstance, options: BleRoutesOptions): void { + const platform = options.platform ?? process.platform; + const unavailableReason = + options.unavailableReason ?? + (platform === 'darwin' + ? 'Redis is required for persistent BLE bindings' + : 'BLE helper is only available on macOS in Phase A'); + + app.get('/api/limb/ble/status', async (request, reply) => { + if (!requireReadIdentity(request, reply)) return; + if (options.manager) return reply.send(options.manager.status()); + return reply.send({ + platform, + available: false, + state: platform === 'darwin' ? 'degraded' : 'unsupported', + reason: unavailableReason, + restartAttempts: 0, + bindingCount: 0, + }); + }); + + app.get('/api/limb/ble/bindings', async (request, reply) => { + if (!requireReadIdentity(request, reply)) return; + if (!options.manager) return unavailable(reply); + return reply.send({ bindings: await options.manager.listBindings() }); + }); + + app.get('/api/limb/ble/scan', async (request, reply) => { + if (!requireReadIdentity(request, reply)) return; + if (!options.manager) return unavailable(reply); + return reply.send(options.manager.scanSnapshot()); + }); + + app.post('/api/limb/ble/scan', async (request, reply) => { + if (!requireWriteIdentity(request, reply)) return; + if (!options.manager) return unavailable(reply); + try { + return reply.status(201).send(await options.manager.startScan()); + } catch (error) { + const message = safeError(error); + return reply.status(errorStatus(new Error(message))).send({ error: message }); + } + }); + + app.delete('/api/limb/ble/scan', async (request, reply) => { + if (!requireWriteIdentity(request, reply)) return; + if (!options.manager) return unavailable(reply); + await options.manager.stopScan(); + return reply.status(204).send(); + }); + + app.post('/api/limb/ble/bindings', async (request, reply) => { + if (!requireWriteIdentity(request, reply)) return; + if (!options.manager) return unavailable(reply); + const parsed = bindSchema.safeParse(request.body); + if (!parsed.success) return reply.status(400).send({ error: parsed.error.message }); + try { + return reply.status(201).send(await options.manager.bind(parsed.data)); + } catch (error) { + const message = safeError(error); + return reply.status(errorStatus(new Error(message))).send({ error: message }); + } + }); + + app.delete('/api/limb/ble/bindings/:bindingId', async (request, reply) => { + if (!requireWriteIdentity(request, reply)) return; + if (!options.manager) return unavailable(reply); + const parsed = bindingParamsSchema.safeParse(request.params); + if (!parsed.success) return reply.status(400).send({ error: parsed.error.message }); + const removed = await options.manager.unbind(parsed.data.bindingId); + if (!removed) return reply.status(404).send({ error: 'BLE binding not found' }); + return reply.status(204).send(); + }); +} diff --git a/packages/api/src/routes/callbacks.ts b/packages/api/src/routes/callbacks.ts index 3f9aad6778..415298ba41 100644 --- a/packages/api/src/routes/callbacks.ts +++ b/packages/api/src/routes/callbacks.ts @@ -3007,7 +3007,9 @@ export const callbacksRoutes: FastifyPluginAsync = async const existing = await taskStore.getBySubject(subjectKey); const intent = parsed.data.intent ?? existing?.automationState?.intent ?? 'review'; const shouldSeedPrBoundary = !existing || existing.status === 'done'; + const shouldBindInstructionsHead = instructions !== undefined && instructions !== ''; let seededPrBoundary: Pick | undefined; + let instructionsPrBoundary: Pick | undefined; if (shouldSeedPrBoundary) { if (!fetchPrTrackingBoundary) { reply.status(503); @@ -3023,10 +3025,36 @@ export const callbacksRoutes: FastifyPluginAsync = async reply.status(503); return { error: 'PR tracking boundary unavailable — try again later' }; } + } else if (shouldBindInstructionsHead) { + if (!fetchPrTrackingBoundary) { + reply.status(503); + return { error: 'PR tracking boundary fetcher not configured' }; + } + try { + instructionsPrBoundary = await fetchPrTrackingBoundary(repoFullName, prNumber); + } catch { + reply.status(503); + return { error: 'PR tracking boundary unavailable — try again later' }; + } + if (!instructionsPrBoundary.ci?.headSha) { + reply.status(503); + return { error: 'PR tracking boundary unavailable — try again later' }; + } } + const trackingInstructionsHeadSha = + instructions !== undefined && instructions !== '' + ? (seededPrBoundary?.ci?.headSha ?? instructionsPrBoundary?.ci?.headSha) + : instructions === '' + ? '' + : undefined; const automationState = { - ...(instructions !== undefined ? { trackingInstructions: instructions } : {}), + ...(instructions !== undefined + ? { + trackingInstructions: instructions, + ...(trackingInstructionsHeadSha !== undefined ? { trackingInstructionsHeadSha } : {}), + } + : {}), ...(seededPrBoundary ?? {}), }; diff --git a/packages/api/src/routes/cats.ts b/packages/api/src/routes/cats.ts index f9d9cca19f..e21613b939 100644 --- a/packages/api/src/routes/cats.ts +++ b/packages/api/src/routes/cats.ts @@ -171,6 +171,20 @@ const createNormalCatSchema = baseCatSchema.extend({ mcpSupport: z.boolean().optional(), cli: cliSchema.optional(), cliConfigArgs: z.array(z.string().min(1)).optional(), + nativeToolLevel: z.enum(['L0', 'L1', 'L2']).optional(), + commandPolicy: z + .array( + z.object({ + binary: z.string().min(1), + allowedSubcommands: z.array(z.string().min(1)).optional(), + allowedFlags: z.array(z.string().min(1)).optional(), + allowedArgPatterns: z.array(z.string().min(1)).optional(), + deniedFlags: z.array(z.string().min(1)).optional(), + }), + ) + .optional(), + /** F159 Phase G G2 (AC-G15): CatAgent wire protocol — only valid when clientId === 'catagent'. */ + catAgentProtocol: z.enum(['anthropic-messages', 'openai-chat']).optional(), provider: z.string().min(1).optional(), acp: acpConfigSchema.optional(), // F161: optional ACP transport for any client }); @@ -223,6 +237,21 @@ const updateCatSchema = z.object({ cli: cliSchema.nullable().optional(), commandArgs: z.array(z.string().min(1)).optional(), cliConfigArgs: z.array(z.string().min(1)).optional(), + nativeToolLevel: z.enum(['L0', 'L1', 'L2']).nullable().optional(), + commandPolicy: z + .array( + z.object({ + binary: z.string().min(1), + allowedSubcommands: z.array(z.string().min(1)).optional(), + allowedFlags: z.array(z.string().min(1)).optional(), + allowedArgPatterns: z.array(z.string().min(1)).optional(), + deniedFlags: z.array(z.string().min(1)).optional(), + }), + ) + .nullable() + .optional(), + /** F159 Phase G G2 (AC-G15): CatAgent wire protocol — nullable to allow clearing. */ + catAgentProtocol: z.enum(['anthropic-messages', 'openai-chat']).nullable().optional(), provider: z.string().min(1).nullable().optional(), voiceConfig: voiceConfigSchema.nullable().optional(), acp: acpConfigSchema.nullable().optional(), // F161: nullable to allow removing ACP transport @@ -230,6 +259,26 @@ const updateCatSchema = z.object({ type UpdateCatRequestBody = z.infer; +// F159 Phase G G2 step 1b: helpers gate catagent-only fields. Extended from +// the pre-G2 nativeToolLevel/commandPolicy pair to also cover catAgentProtocol +// — same persistence gating boundary at runtime-cat-catalog level. +const CAT_AGENT_ONLY_FIELDS = ['nativeToolLevel', 'commandPolicy', 'catAgentProtocol'] as const; + +function hasCatAgentOnlySettings(body: unknown): boolean { + if (!body || typeof body !== 'object') return false; + return CAT_AGENT_ONLY_FIELDS.some((key) => Object.hasOwn(body, key)); +} + +function hasNonNullCatAgentOnlySettings(body: unknown): boolean { + if (!body || typeof body !== 'object') return false; + const record = body as Record; + return CAT_AGENT_ONLY_FIELDS.some((key) => record[key] != null); +} + +function catAgentOnlySettingsError(clientId: ClientId): string { + return `${CAT_AGENT_ONLY_FIELDS.join(' / ')} are only supported for catagent clients (received ${clientId})`; +} + function resolveOperator(raw: unknown): string | null { if (typeof raw === 'string' && raw.trim().length > 0) return raw.trim(); if (Array.isArray(raw)) { @@ -459,6 +508,13 @@ async function toCatResponse( voiceConfig: cat.voiceConfig, commandArgs: cat.commandArgs, cliConfigArgs: cat.cliConfigArgs, + ...(cat.clientId === 'catagent' + ? { + nativeToolLevel: cat.nativeToolLevel, + commandPolicy: cat.commandPolicy, + catAgentProtocol: cat.catAgentProtocol, + } + : {}), provider: cat.provider, variantLabel: cat.variantLabel ?? undefined, isDefaultVariant: cat.isDefaultVariant ?? undefined, @@ -561,7 +617,11 @@ interface CatsRoutesOptions { } export const catsRoutes: FastifyPluginAsync = async (app, opts) => { - // GET /api/cat-templates - 获取角色模板(纯灵魂层,不含 client/model 绑定) + // GET /api/cat-templates - 获取角色模板(灵魂层 + 可选 runtimeDefaults) + // F159 G2 follow-up: 单纯"灵魂层"模板(F171 首次配置遗留)让 catagent 类猫选完 + // 模板后 form 留默认 clientId=anthropic 创建出来根本不是 catagent。把 + // breeds[].defaultVariant 的运行时身份字段作为可选 runtimeDefaults 一并返回, + // 让前端 handleTemplateSelect 把它们 patch 进 form,实现 "点模板=可用猫"。 app.get('/api/cat-templates', async () => { try { const projectRoot = resolveProjectRoot(); @@ -577,26 +637,66 @@ export const catsRoutes: FastifyPluginAsync = async (app, opt personality: string; teamStrengths?: string; }[]; + breeds?: { + id: string; + defaultVariantId: string; + variants?: { + id: string; + clientId?: string; + defaultModel?: string; + catAgentProtocol?: string; + nativeToolLevel?: string; + }[]; + }[]; clientDefaults?: Record; }; + + // Build breed.id → runtimeDefaults map from default variant (sole source of "body" defaults). + // accountRef intentionally not carried — it's environment-specific (per-user account binding). + const runtimeDefaultsByBreedId = new Map< + string, + { clientId: string; defaultModel: string; catAgentProtocol?: string; nativeToolLevel?: string } + >(); + for (const breed of raw.breeds ?? []) { + const variant = breed.variants?.find((v) => v.id === breed.defaultVariantId); + if (!variant?.clientId || !variant.defaultModel) continue; + runtimeDefaultsByBreedId.set(breed.id, { + clientId: variant.clientId, + defaultModel: variant.defaultModel, + ...(variant.catAgentProtocol ? { catAgentProtocol: variant.catAgentProtocol } : {}), + ...(variant.nativeToolLevel ? { nativeToolLevel: variant.nativeToolLevel } : {}), + }); + } + if (raw.roleTemplates && raw.roleTemplates.length > 0) { - return { templates: raw.roleTemplates, clientDefaults: raw.clientDefaults ?? {} }; + return { + templates: raw.roleTemplates.map((t) => ({ + ...t, + ...(runtimeDefaultsByBreedId.has(t.id) ? { runtimeDefaults: runtimeDefaultsByBreedId.get(t.id) } : {}), + })), + clientDefaults: raw.clientDefaults ?? {}, + }; } // Fallback: extract from breeds (legacy) const templateConfig = loadCatConfig(templatePath); const allCats = Object.values(toAllCatConfigs(templateConfig)); const templateCats = allCats.filter((c) => c.isDefaultVariant); return { - templates: templateCats.map((cat) => ({ - id: cat.breedId ?? cat.id, - name: cat.breedDisplayName ?? cat.displayName ?? cat.name, - nickname: cat.nickname, - avatar: cat.avatar, - color: cat.color, - roleDescription: cat.roleDescription, - personality: cat.personality, - teamStrengths: cat.teamStrengths, - })), + templates: templateCats.map((cat) => { + const breedId = cat.breedId ?? cat.id; + const runtimeDefaults = runtimeDefaultsByBreedId.get(breedId); + return { + id: breedId, + name: cat.breedDisplayName ?? cat.displayName ?? cat.name, + nickname: cat.nickname, + avatar: cat.avatar, + color: cat.color, + roleDescription: cat.roleDescription, + personality: cat.personality, + teamStrengths: cat.teamStrengths, + ...(runtimeDefaults ? { runtimeDefaults } : {}), + }; + }), clientDefaults: {}, }; } catch (err) { @@ -636,6 +736,11 @@ export const catsRoutes: FastifyPluginAsync = async (app, opt const managedIdsBefore = getManagedCatalogIds(projectRoot); const body = parsed.data; + if (body.clientId !== 'catagent' && hasCatAgentOnlySettings(body)) { + reply.status(400); + return { error: catAgentOnlySettingsError(body.clientId) }; + } + // Validate alias uniqueness across all existing members if (body.mentionPatterns?.length) { const allConfigs = catRegistry.getAllConfigs(); @@ -762,6 +867,9 @@ export const catsRoutes: FastifyPluginAsync = async (app, opt // F247 KD-17: cli omitted when cloud-only (Remote MCP) provider. ...(resolvedCli ? { cli: resolvedCli } : {}), ...(body.cliConfigArgs ? { cliConfigArgs: body.cliConfigArgs } : {}), + ...(body.clientId === 'catagent' && body.nativeToolLevel ? { nativeToolLevel: body.nativeToolLevel } : {}), + ...(body.clientId === 'catagent' && body.catAgentProtocol ? { catAgentProtocol: body.catAgentProtocol } : {}), + ...(body.clientId === 'catagent' && body.commandPolicy ? { commandPolicy: body.commandPolicy } : {}), ...(body.provider || providerNameForValidation ? { provider: body.provider ?? providerNameForValidation } : {}), @@ -837,6 +945,10 @@ export const catsRoutes: FastifyPluginAsync = async (app, opt return { error: `Cat "${request.params.id}" not found` }; } const effectiveClient = body.clientId ?? currentCat.clientId; + if (effectiveClient !== 'catagent' && hasNonNullCatAgentOnlySettings(body)) { + reply.status(400); + return { error: catAgentOnlySettingsError(effectiveClient) }; + } const currentEffectiveAccountRef = await resolveEffectiveAccountRef(currentCat); let targetAccountRef = resolveAccountRef(body); let effectiveAccountRef = @@ -927,6 +1039,26 @@ export const catsRoutes: FastifyPluginAsync = async (app, opt effectiveClient === 'acp' ? resolveGenericAcpMcpSupportForPatch(body.mcpSupport, body.acp, isClientSwitch) : body.mcpSupport; + const nativeToolPatch: Record = + effectiveClient === 'catagent' + ? { + ...(body.nativeToolLevel !== undefined ? { nativeToolLevel: body.nativeToolLevel } : {}), + ...(body.commandPolicy !== undefined ? { commandPolicy: body.commandPolicy } : {}), + // F159 Phase G G2 step 1b (AC-G15): catAgentProtocol透传 — only on catagent path. + ...(body.catAgentProtocol !== undefined ? { catAgentProtocol: body.catAgentProtocol } : {}), + } + : currentCat.nativeToolLevel !== undefined || + currentCat.commandPolicy !== undefined || + currentCat.catAgentProtocol !== undefined || + body.nativeToolLevel === null || + body.commandPolicy === null || + body.catAgentProtocol === null + ? { + nativeToolLevel: null, + commandPolicy: null, + catAgentProtocol: null, + } + : {}; updateRuntimeCat(projectRoot, request.params.id, { ...(body.name !== undefined ? { name: body.name } : {}), ...(body.displayName !== undefined ? { displayName: body.displayName } : {}), @@ -954,6 +1086,7 @@ export const catsRoutes: FastifyPluginAsync = async (app, opt ...(nextCli !== undefined ? { cli: nextCli } : {}), ...(body.available !== undefined ? { available: body.available } : {}), ...(body.cliConfigArgs !== undefined ? { cliConfigArgs: body.cliConfigArgs } : {}), + ...nativeToolPatch, // F161 AC-A5 / KD-1: generic ACP never carries provider — clear any stale value and // ignore incoming provider; other clients keep the explicit set/clear semantics. ...(effectiveClient === 'acp' diff --git a/packages/api/src/routes/plugin-routes.ts b/packages/api/src/routes/plugin-routes.ts index fdb2af4a57..3a83e0b0b0 100644 --- a/packages/api/src/routes/plugin-routes.ts +++ b/packages/api/src/routes/plugin-routes.ts @@ -16,6 +16,7 @@ import { import { AuditEventTypes, getEventAuditLog } from '../domains/cats/services/orchestration/EventAuditLog.js'; import type { LimbRegistry } from '../domains/limb/LimbRegistry.js'; import { loadLimbDeclaration } from '../domains/limb/limb-yaml-loader.js'; +import type { AgentProviderApprovalService } from '../domains/plugin/agent-provider-approval-service.js'; import type { PluginRegistry } from '../domains/plugin/PluginRegistry.js'; import { normalizeCapId, resolvePluginResourcePath, resourceCapId } from '../domains/plugin/PluginRegistry.js'; import type { PluginResourceActivator as PluginResourceActivatorType } from '../domains/plugin/PluginResourceActivator.js'; @@ -29,6 +30,9 @@ interface PluginRoutesOpts { pluginActivator: PluginResourceActivatorType; limbRegistry: LimbRegistry; pluginsDir: string; + /** F241 Phase B Slice 2b — optional approval service. When undefined, the + * approve-routeable route is omitted (deployments without 2b stay on 2a). */ + agentProviderApprovalService?: AgentProviderApprovalService; } function refreshPluginRegistry(pluginRegistry: PluginRegistry) { @@ -80,7 +84,7 @@ function pluginAccessError(reply: FastifyReply, error: PluginWriteAccessError): } export function registerPluginRoutes(app: FastifyInstance, opts: PluginRoutesOpts): void { - const { pluginRegistry, pluginActivator, limbRegistry, pluginsDir } = opts; + const { pluginRegistry, pluginActivator, limbRegistry, pluginsDir, agentProviderApprovalService } = opts; app.get('/api/plugins', async (request, reply) => { const access = requirePluginReadAccess(request); @@ -178,6 +182,87 @@ export function registerPluginRoutes(app: FastifyInstance, opts: PluginRoutesOpt return result; }); + // F241 Phase B Slice 2b — explicit operator approval to promote an + // agentProvider capability to routeable=true. Runs: + // 1. admission (reserved + collision) via RoutingAdmissionService + // 2. blocking health check bound to descriptorHash + // 3. atomic write of routeableApproved + health + routeable + // Background actors NEVER reach this code path (per Q3 convergence). + app.post<{ + Params: { id: string; capId: string }; + Body: { catId: string; profileId?: string; mentionPatterns?: string[] }; + }>('/api/plugins/:id/capabilities/:capId/approve-routeable', async (request, reply) => { + const access = requirePluginWriteAccess(request); + if ('error' in access) { + return pluginAccessError(reply, access); + } + const { operator } = access; + + if (!agentProviderApprovalService) { + reply.status(503); + return { error: 'agentProvider approval service is not enabled on this deployment' }; + } + + const { id: pluginId, capId } = request.params; + const body = request.body ?? ({} as { catId?: string; profileId?: string; mentionPatterns?: string[] }); + if (!body.catId || typeof body.catId !== 'string' || body.catId.trim().length === 0) { + reply.status(400); + return { error: 'Request body must include a non-empty catId binding' }; + } + if ( + body.mentionPatterns !== undefined && + (!Array.isArray(body.mentionPatterns) || body.mentionPatterns.some((p) => typeof p !== 'string')) + ) { + reply.status(400); + return { error: 'mentionPatterns must be an array of strings when provided' }; + } + + refreshPluginRegistry(pluginRegistry); + const manifest = pluginRegistry.getManifest(pluginId); + if (!manifest) { + reply.status(404); + return { error: `Plugin '${pluginId}' not found` }; + } + + const result = await agentProviderApprovalService.approveRouteable({ + pluginId, + capId, + catId: body.catId.trim(), + profileId: body.profileId, + mentionPatterns: body.mentionPatterns, + }); + + if (!result.ok) { + // 422 for admission/health failures, 404 for not-found, 409 for hash-missing. + reply.status( + result.reason === 'capability-not-found' || result.reason === 'capability-not-agent-provider' + ? 404 + : result.reason === 'descriptor-hash-missing' + ? 409 + : 422, + ); + return result; + } + + try { + const auditLog = getEventAuditLog(); + await auditLog.append({ + type: AuditEventTypes.CONFIG_UPDATED, + data: { + target: 'agentProvider-approve-routeable', + pluginId, + capId, + catId: body.catId, + operator, + }, + }); + } catch { + /* audit failure is non-critical */ + } + + return result; + }); + app.post<{ Params: { id: string }; Body: { updates: { name: string; value: string | null }[] } }>( '/api/plugins/:id/config', async (request, reply) => { diff --git a/packages/api/test/acp/acp-bootstrap-cwd.test.js b/packages/api/test/acp/acp-bootstrap-cwd.test.js index be31e176de..fe919843ea 100644 --- a/packages/api/test/acp/acp-bootstrap-cwd.test.js +++ b/packages/api/test/acp/acp-bootstrap-cwd.test.js @@ -179,14 +179,17 @@ describe('acp bootstrap cwd', () => { }); it('guards AcpServiceFactory against wiring ACP clients back to repo cwd', () => { - const indexSource = readFileSync(new URL('../../src/index.ts', import.meta.url), 'utf-8'); + const transportFactorySource = readFileSync( + new URL('../../src/domains/cats/services/agents/providers/acp/AcpProviderTransportFactory.ts', import.meta.url), + 'utf-8', + ); const factorySource = readFileSync( new URL('../../src/domains/cats/services/agents/providers/acp/AcpServiceFactory.ts', import.meta.url), 'utf-8', ); assert.ok( - indexSource.includes('createAcpServiceForConfig'), - 'REGRESSION: index.ts must keep generic ACP service construction delegated to AcpServiceFactory.', + transportFactorySource.includes('createAcpServiceForConfig'), + 'REGRESSION: AcpProviderTransportFactory must keep generic ACP service construction delegated to AcpServiceFactory.', ); assert.ok( factorySource.includes('resolveAcpBootstrapCwd'), @@ -208,18 +211,26 @@ describe('acp bootstrap cwd', () => { it('REGRESSION: ACP registry sync detects config from the active project root', () => { const indexSource = readFileSync(new URL('../../src/index.ts', import.meta.url), 'utf-8'); + const transportFactorySource = readFileSync( + new URL('../../src/domains/cats/services/agents/providers/acp/AcpProviderTransportFactory.ts', import.meta.url), + 'utf-8', + ); assert.ok( indexSource.includes('resolveActiveProjectRoot'), 'REGRESSION: index.ts must be able to resolve the active runtime project root during registry sync.', ); assert.ok( - indexSource.includes('getAcpConfig(id, projectRoot)'), - 'REGRESSION: syncAgentRegistry must pass the active project root to getAcpConfig().', + indexSource.includes('projectRoot,') && indexSource.includes('providerTransportRegistry.createServiceForConfig'), + 'REGRESSION: syncAgentRegistry must pass the active project root to the provider transport registry.', + ); + assert.ok( + transportFactorySource.includes('getAcpConfig(input.profileId, input.projectRoot)'), + 'REGRESSION: ACP transport factory must pass the active project root to getAcpConfig().', ); assert.ok( - !indexSource.includes('const acpConfig = getAcpConfig(id);'), - 'REGRESSION: syncAgentRegistry must not read ACP config from the default template root.', + !transportFactorySource.includes('getAcpConfig(input.profileId)'), + 'REGRESSION: ACP transport factory must not read ACP config from the default template root.', ); }); diff --git a/packages/api/test/agent-provider-2b-e2e.test.js b/packages/api/test/agent-provider-2b-e2e.test.js new file mode 100644 index 0000000000..9c1947f22c --- /dev/null +++ b/packages/api/test/agent-provider-2b-e2e.test.js @@ -0,0 +1,261 @@ +/** + * F241 Phase B Slice 2b: End-to-end integration test. + * + * Wires the full pipeline: + * activator → approval service → projection → synthetic CatConfig + * + * Proves the 7 invariants the slice commits to: + * 1. Activator writes descriptorHash on first activation. + * 2. Activator preserves host-owned state on identical re-activation. + * 3. Activator resets approval/health on descriptor delta. + * 4. Approval orchestration promotes routeable=true with binding persisted. + * 5. Projection skips capabilities whose health/admission no longer holds. + * 6. Projection produces a synthetic CatConfig keyed by the operator's catId. + * 7. Re-activation after approval with the SAME descriptor preserves approval + * (so a no-op restart doesn't drop a live routeable plugin). + */ + +import './helpers/setup-cat-registry.js'; +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +const { PluginResourceActivator } = await import('../dist/domains/plugin/PluginResourceActivator.js'); +const { AgentProviderApprovalService } = await import('../dist/domains/plugin/agent-provider-approval-service.js'); +const { listApprovedRouteableRows, projectRouteableAgentProviders } = await import( + '../dist/domains/plugin/agent-provider-projection.js' +); + +function makeManifest(overrides = {}) { + const { agentProvider: agentProviderOverrides, ...rest } = overrides; + return { + id: 'clowder-code', + name: 'Clowder Code', + version: '1.0.0', + builtin: false, + config: [], + resources: [ + { + type: 'agentProvider', + name: 'clowder-code', + agentProvider: { + name: 'clowder-code', + transport: 'cli-jsonl', + command: 'clowder-code', + startupArgs: ['--json'], + resumeArgs: ['resume', '{sessionId}'], + sessionPolicy: 'resume', + outputProfile: 'clowder-code-turn-result-v1', + mcpWhitelistRequest: ['cat-cafe-collab'], + sandboxRequest: 'workspace-write', + healthCheck: { type: 'cliProbe' }, + ...agentProviderOverrides, + }, + }, + ], + ...rest, + }; +} + +function makeStore() { + let state = null; + return { + read: async () => (state ? structuredClone(state) : null), + write: async (next) => { + state = structuredClone(next); + }, + get: () => state, + }; +} + +function makePipeline() { + const capStore = makeStore(); + const providerTransportRegistry = { has: (id) => id === 'cli-jsonl' || id === 'acp' }; + const activator = new PluginResourceActivator({ + resolveProjectRoot: () => '/tmp/project', + pluginsDir: '/tmp/project/plugins', + limbRegistry: { register: async () => {}, deregister: () => {} }, + readCapabilities: capStore.read, + writeCapabilities: capStore.write, + withCapabilityLock: async (fn) => fn(), + providerTransportRegistry, + }); + const buildSnapshotFn = (_pluginId, _capId, _config) => ({ + templateBaselineIds: new Set(['anthropic', 'openai', 'google', 'kimi']), + existingRouteableIdentities: new Set(), + activeNonProviderTransportIdentities: new Set(['opus', 'codex']), + }); + const approvalService = new AgentProviderApprovalService({ + readCapabilities: capStore.read, + writeCapabilities: capStore.write, + withCapabilityLock: async (fn) => fn(), + buildAdmissionSnapshot: async (pluginId, capId, config) => buildSnapshotFn(pluginId, capId, config), + getHealthExecutorContext: () => ({ + providerTransportRegistry, + now: () => 1_700_000_000_000, + }), + }); + return { capStore, activator, approvalService, buildSnapshotFn }; +} + +describe('F241 Phase B Slice 2b — end-to-end pipeline', () => { + it('full happy path: activate → approve → project produces a synthetic CatConfig keyed by binding.catId', async () => { + const { capStore, activator, approvalService, buildSnapshotFn } = makePipeline(); + + // 1. Activate the plugin — writes descriptorHash, routeable=false, approved=false. + const activateResult = await activator.enablePlugin(makeManifest()); + assert.equal(activateResult.status, 'success'); + const activatedRow = capStore.get().capabilities[0]; + const activatedDescriptor = activatedRow.agentProvider; + const writtenCapId = activatedRow.id; // resourceCapId — opaque, use the actual value + assert.ok(activatedDescriptor.descriptorHash); + assert.equal(activatedDescriptor.routeable, false); + assert.equal(activatedDescriptor.routeableApproved, false); + + // 2. Operator approves the routeable promotion with a binding. + const approveResult = await approvalService.approveRouteable({ + pluginId: 'clowder-code', + capId: writtenCapId, + catId: 'clowder-cat', + mentionPatterns: ['clowder'], + }); + assert.equal(approveResult.ok, true); + const approvedDescriptor = capStore.get().capabilities[0].agentProvider; + assert.equal(approvedDescriptor.routeable, true); + assert.equal(approvedDescriptor.routeableApproved, true); + assert.equal(approvedDescriptor.routeableBinding.catId, 'clowder-cat'); + assert.equal(approvedDescriptor.health.passed, true); + assert.equal(approvedDescriptor.health.descriptorHash, approvedDescriptor.descriptorHash); + + // 3. Projection turns the row into a synthetic CatConfig. + const rows = listApprovedRouteableRows(capStore.get()); + assert.equal(rows.length, 1); + const projection = projectRouteableAgentProviders({ + rows, + buildSnapshot: (pluginId, capId) => buildSnapshotFn(pluginId, capId, capStore.get()), + now: () => 1_700_000_000_000 + 5_000, // 5s after approval; well within TTL + }); + assert.equal(projection.admitted.length, 1); + assert.equal(projection.skipped.length, 0); + const synth = projection.configs['clowder-cat']; + assert.ok(synth, 'projection should produce a synthetic CatConfig keyed by binding.catId'); + assert.equal(synth.id, 'clowder-cat'); + assert.equal(synth.providerTransport.transport, 'cli-jsonl'); + assert.equal(synth.providerTransport.command, 'clowder-code'); + assert.equal(synth.pluginProjection.pluginId, 'clowder-code'); + assert.equal(synth.pluginProjection.descriptorHash, approvedDescriptor.descriptorHash); + }); + + it('descriptor delta after approval: activator resets approval + invalidates health, projection skips', async () => { + const { capStore, activator, approvalService, buildSnapshotFn } = makePipeline(); + + // 1. Activate + approve as usual. + await activator.enablePlugin(makeManifest()); + const writtenCapId = capStore.get().capabilities[0].id; + const approveResult = await approvalService.approveRouteable({ + pluginId: 'clowder-code', + capId: writtenCapId, + catId: 'clowder-cat', + }); + assert.equal(approveResult.ok, true, 'baseline approval should succeed'); + const approvedHash = capStore.get().capabilities[0].agentProvider.descriptorHash; + + // 2. Manifest mutates (e.g. command changed). Re-activate. + await activator.enablePlugin(makeManifest({ agentProvider: { command: '/usr/local/bin/clowder-code-NEXT' } })); + const afterMutation = capStore.get().capabilities[0].agentProvider; + assert.notEqual(afterMutation.descriptorHash, approvedHash, 'descriptorHash must change'); + assert.equal(afterMutation.routeableApproved, false, 'approval must be reset'); + assert.equal(afterMutation.routeable, false, 'routeable must be reset'); + assert.equal(afterMutation.health, undefined, 'health must be invalidated'); + + // 3. Projection sees a non-routeable row → nothing to project. + const rows = listApprovedRouteableRows(capStore.get()); + assert.equal(rows.length, 0, 'projection list filter drops the row'); + const projection = projectRouteableAgentProviders({ + rows, + buildSnapshot: (pluginId, capId) => buildSnapshotFn(pluginId, capId, capStore.get()), + now: () => 1_700_000_000_000, + }); + assert.equal(projection.admitted.length, 0); + assert.deepEqual(projection.configs, {}); + }); + + it('re-activation with identical descriptor preserves an already-approved routeable row', async () => { + const { capStore, activator, approvalService } = makePipeline(); + + // 1. Activate + approve. + await activator.enablePlugin(makeManifest()); + const writtenCapId = capStore.get().capabilities[0].id; + await approvalService.approveRouteable({ + pluginId: 'clowder-code', + capId: writtenCapId, + catId: 'clowder-cat', + }); + const approvedDescriptor = capStore.get().capabilities[0].agentProvider; + + // 2. Restart-equivalent: re-activate with the EXACT same manifest. + await activator.enablePlugin(makeManifest()); + const afterReactivation = capStore.get().capabilities[0].agentProvider; + + // Approval / health / routeable preserved (no requirement to re-approve). + assert.equal(afterReactivation.routeable, true); + assert.equal(afterReactivation.routeableApproved, true); + assert.equal(afterReactivation.state, 'healthy'); + assert.equal(afterReactivation.descriptorHash, approvedDescriptor.descriptorHash); + assert.equal(afterReactivation.health.passed, true); + assert.equal(afterReactivation.routeableBinding.catId, 'clowder-cat'); + }); + + it('approval is rejected when operator picks a catId that collides with a reserved baseline', async () => { + const { capStore, activator, approvalService } = makePipeline(); + + await activator.enablePlugin(makeManifest()); + const writtenCapId = capStore.get().capabilities[0].id; + const result = await approvalService.approveRouteable({ + pluginId: 'clowder-code', + capId: writtenCapId, + catId: 'anthropic', // baseline collision + }); + assert.equal(result.ok, false); + assert.equal(result.reason, 'reserved-baseline-collision'); + }); + + it('approval rolls back routeable + records lastSyncError if the post-approval sync hook throws', async () => { + const { capStore, activator } = makePipeline(); + const approvalService = new AgentProviderApprovalService({ + readCapabilities: capStore.read, + writeCapabilities: capStore.write, + withCapabilityLock: async (fn) => fn(), + buildAdmissionSnapshot: async () => ({ + templateBaselineIds: new Set(), + existingRouteableIdentities: new Set(), + activeNonProviderTransportIdentities: new Set(), + }), + getHealthExecutorContext: () => ({ + providerTransportRegistry: { has: () => true }, + now: () => 1_700_000_000_000, + }), + onRouteablePromoted: async () => { + throw new Error('agent registry sync failed'); + }, + }); + + await activator.enablePlugin(makeManifest()); + const writtenCapId = capStore.get().capabilities[0].id; + const result = await approvalService.approveRouteable({ + pluginId: 'clowder-code', + capId: writtenCapId, + catId: 'clowder-cat', + }); + assert.equal(result.ok, false); + assert.equal(result.reason, 'post-approval-sync-failed'); + + // Routeable rolled back; approval intent + health preserved (retry doesn't need re-approval). + const after = capStore.get().capabilities[0].agentProvider; + assert.equal(after.routeable, false); + assert.equal(after.routeableApproved, true); + assert.ok(after.health); + assert.equal(after.health.passed, true); + assert.ok(after.lastSyncError); + assert.match(after.lastSyncError.message, /agent registry sync failed/); + }); +}); diff --git a/packages/api/test/agent-provider-2b-p1-fixes.test.js b/packages/api/test/agent-provider-2b-p1-fixes.test.js new file mode 100644 index 0000000000..f7d3bd6390 --- /dev/null +++ b/packages/api/test/agent-provider-2b-p1-fixes.test.js @@ -0,0 +1,439 @@ +/** + * F241 Phase B Slice 2b: tests covering the P1 review fixes from codex. + * + * Each describe block targets one of codex's review P1 findings: + * - P1.2: baseline read failure must fail-closed (not warn-and-empty) + * - P1.3: snapshot must include routeable bindings + active-cat mention patterns + * - P1.5: TTL-expired health must refresh synchronously; failure degrades + * routeable=false; success persists refreshed health + */ + +import './helpers/setup-cat-registry.js'; +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +const sharedModule = await import('@cat-cafe/shared'); +const { CatRegistry } = sharedModule; +const { AgentProviderApprovalService } = await import('../dist/domains/plugin/agent-provider-approval-service.js'); +const { buildAgentProviderAdmissionSnapshot } = await import( + '../dist/domains/plugin/agent-provider-admission-snapshot.js' +); +const { refreshExpiredHealthInPlace } = await import('../dist/domains/plugin/agent-provider-health-refresh.js'); +const { computeAgentProviderDescriptorHash } = await import('../dist/domains/plugin/agent-provider-descriptor-hash.js'); + +function makeRow({ pluginId = 'clowder-code', capId = 'clowder-code', overrides = {} } = {}) { + const resource = { + name: 'clowder-code', + transport: 'cli-jsonl', + command: 'clowder-code', + startupArgs: ['--json'], + resumeArgs: ['resume', '{sessionId}'], + sessionPolicy: 'resume', + outputProfile: 'clowder-code-turn-result-v1', + healthCheck: { type: 'cliProbe' }, + ...overrides.resource, + }; + const descriptorHash = computeAgentProviderDescriptorHash({ pluginId, capId, resource }); + return { + pluginId, + capId, + descriptor: { + ...resource, + state: 'healthy', + routeable: true, + routeableApproved: true, + descriptorHash, + health: { + passed: true, + checkedAt: 1000, + ttlMs: 60_000, + descriptorHash, + }, + routeableBinding: { + catId: 'clowder-cat', + mentionPatterns: ['clowder'], + }, + ...overrides.descriptor, + }, + }; +} + +describe('P1.2 — baseline-unavailable fails closed', () => { + it('approval service returns admission-snapshot-unavailable when buildAdmissionSnapshot throws', async () => { + const capabilities = { + version: 1, + capabilities: [ + { + id: 'clowder-code', + type: 'agentProvider', + enabled: true, + source: 'cat-cafe', + pluginId: 'clowder-code', + agentProvider: makeRow().descriptor, + }, + ], + }; + let state = structuredClone(capabilities); + const service = new AgentProviderApprovalService({ + readCapabilities: async () => structuredClone(state), + writeCapabilities: async (next) => { + state = structuredClone(next); + }, + withCapabilityLock: async (fn) => fn(), + buildAdmissionSnapshot: async () => { + throw new Error('cat-template.json unreadable'); + }, + getHealthExecutorContext: () => ({ + providerTransportRegistry: { has: () => true }, + now: () => 1_700_000_000_000, + }), + }); + const result = await service.approveRouteable({ + pluginId: 'clowder-code', + capId: 'clowder-code', + catId: 'clowder-cat', + }); + assert.equal(result.ok, false); + assert.equal(result.reason, 'admission-snapshot-unavailable'); + assert.match(result.details, /cat-template\.json unreadable/); + }); +}); + +describe('P1.3 — snapshot must include binding + active-cat mention patterns', () => { + it('blocks a candidate that claims an existing routeable plugin binding catId (not just name)', () => { + const candidate = makeRow().descriptor; + const otherPluginEntry = { + id: 'other-plugin', + type: 'agentProvider', + enabled: true, + source: 'cat-cafe', + pluginId: 'other-plugin', + agentProvider: { + ...candidate, + name: 'other-plugin-name', // DIFFERENT name + routeableBinding: { catId: 'clowder-cat' }, // but SAME binding.catId + }, + }; + const snapshot = buildAgentProviderAdmissionSnapshot({ + capabilitiesConfig: { version: 1, capabilities: [otherPluginEntry] }, + activeCatConfigs: {}, + templateBaselineIds: new Set(), + hasProviderTransportConfig: () => false, + candidatePluginId: 'clowder-code', + candidateCapId: 'clowder-code', + }); + // The snapshot must surface 'clowder-cat' even though the candidate's + // `name` ('clowder-code') doesn't collide. + assert.ok( + snapshot.existingRouteableIdentities.has('clowder-cat'), + 'snapshot must include existing routeable bindings, not just descriptor.name', + ); + }); + + it('blocks @-alias collision with an active cat (mentionPatterns), not just the cat-id literal', () => { + const snapshot = buildAgentProviderAdmissionSnapshot({ + capabilitiesConfig: { version: 1, capabilities: [] }, + activeCatConfigs: { + opus: { id: 'opus', mentionPatterns: ['opus', 'opus47', '宪宪'] }, + }, + templateBaselineIds: new Set(), + hasProviderTransportConfig: () => false, + candidatePluginId: 'clowder-code', + candidateCapId: 'clowder-code', + }); + assert.ok(snapshot.activeNonProviderTransportIdentities.has('opus')); + assert.ok(snapshot.activeNonProviderTransportIdentities.has('opus47')); + assert.ok( + snapshot.activeNonProviderTransportIdentities.has('宪宪'), + 'plugin must not be able to claim 宪宪 alias of a real cat', + ); + }); + + it('the candidate is still EXCLUDED from existing routeable identities (regression guard)', () => { + const candidateEntry = { + id: 'clowder-code', + type: 'agentProvider', + enabled: true, + source: 'cat-cafe', + pluginId: 'clowder-code', + agentProvider: makeRow().descriptor, + }; + const snapshot = buildAgentProviderAdmissionSnapshot({ + capabilitiesConfig: { version: 1, capabilities: [candidateEntry] }, + activeCatConfigs: {}, + templateBaselineIds: new Set(), + hasProviderTransportConfig: () => false, + candidatePluginId: 'clowder-code', + candidateCapId: 'clowder-code', + }); + // The candidate's OWN identities must not appear in the snapshot. + assert.equal(snapshot.existingRouteableIdentities.has('clowder-cat'), false); + assert.equal(snapshot.existingRouteableIdentities.has('clowder'), false); + assert.equal(snapshot.existingRouteableIdentities.has('clowder-code'), false); + }); +}); + +describe('P1.5 — TTL-expired health refresh on sync', () => { + function makeCapabilities(rows) { + return { + version: 1, + capabilities: rows.map((r) => ({ + id: r.capId, + type: 'agentProvider', + enabled: true, + source: 'cat-cafe', + pluginId: r.pluginId, + agentProvider: r.descriptor, + })), + }; + } + + it('refreshes health when TTL expired and executor passes; routeable stays true', async () => { + const expiredRow = makeRow({ + overrides: { descriptor: { health: { passed: true, checkedAt: 1000, ttlMs: 1000, descriptorHash: undefined } } }, + }); + // Bind health hash to descriptor hash (test data hygiene) + expiredRow.descriptor.health.descriptorHash = expiredRow.descriptor.descriptorHash; + const capabilities = makeCapabilities([expiredRow]); + let persisted; + const result = await refreshExpiredHealthInPlace({ + capabilities, + rows: [expiredRow], + now: () => 1_000_000, // way past TTL + healthExecutor: async (ctx) => ({ + passed: true, + checkedAt: ctx.now ? ctx.now() : Date.now(), + ttlMs: 60_000, + descriptorHash: ctx.descriptorHash, + }), + getHealthExecutorContext: () => ({ + providerTransportRegistry: { has: () => true }, + now: () => 1_000_000, + }), + persist: async (next) => { + persisted = next; + }, + }); + assert.ok(result, 'mutated snapshot returned'); + assert.ok(persisted, 'persist called'); + const after = persisted.capabilities[0].agentProvider; + assert.equal(after.routeable, true, 'still routeable after successful refresh'); + assert.equal(after.health.checkedAt, 1_000_000, 'health.checkedAt updated to refresh time'); + }); + + it('degrades routeable=false + records lastSyncError when executor fails', async () => { + const expiredRow = makeRow({ + overrides: { descriptor: { health: { passed: true, checkedAt: 1000, ttlMs: 1000, descriptorHash: undefined } } }, + }); + expiredRow.descriptor.health.descriptorHash = expiredRow.descriptor.descriptorHash; + const capabilities = makeCapabilities([expiredRow]); + let persisted; + await refreshExpiredHealthInPlace({ + capabilities, + rows: [expiredRow], + now: () => 1_000_000, + healthExecutor: async (ctx) => ({ + passed: false, + checkedAt: 1_000_000, + ttlMs: 60_000, + descriptorHash: ctx.descriptorHash, + failureReason: 'simulated-runtime-failure', + }), + getHealthExecutorContext: () => ({ + providerTransportRegistry: { has: () => true }, + now: () => 1_000_000, + }), + persist: async (next) => { + persisted = next; + }, + }); + const after = persisted.capabilities[0].agentProvider; + assert.equal(after.routeable, false, 'routeable degraded on refresh failure'); + assert.equal(after.routeableApproved, true, 'approval intent preserved'); + assert.ok(after.lastSyncError); + assert.match(after.lastSyncError.message, /ttl-refresh-failed: simulated-runtime-failure/); + }); + + it('does NOT refresh when TTL is still fresh (no-op, no persist)', async () => { + const freshRow = makeRow(); + const capabilities = makeCapabilities([freshRow]); + let persistCalled = false; + const result = await refreshExpiredHealthInPlace({ + capabilities, + rows: [freshRow], + now: () => 5_000, // well within TTL (checkedAt=1000, ttl=60_000) + healthExecutor: async () => { + throw new Error('executor must not run when TTL is fresh'); + }, + getHealthExecutorContext: () => ({ + providerTransportRegistry: { has: () => true }, + }), + persist: async () => { + persistCalled = true; + }, + }); + assert.equal(result, null, 'no mutation when nothing expired'); + assert.equal(persistCalled, false, 'persist not called'); + }); + + it('P1.4 — CatRegistry.registerOrReplace + unregister supports plugin projection sync', () => { + const registry = new CatRegistry(); + const baseConfig = { id: 'a', mentionPatterns: ['a'] }; + registry.register('a', baseConfig); + assert.equal(registry.has('a'), true); + + // registerOrReplace doesn't throw on existing id (unlike register) + const updatedConfig = { id: 'a', mentionPatterns: ['a', 'A'] }; + registry.registerOrReplace('a', updatedConfig); + assert.deepEqual(registry.tryGet('a').config.mentionPatterns, ['a', 'A']); + + // unregister returns true when present, false when not + assert.equal(registry.unregister('a'), true); + assert.equal(registry.has('a'), false); + assert.equal(registry.unregister('a'), false); + }); + + it('P1.4 follow-up — stale-cleanup must NOT unregister a real catalog cat that took over the id (ownership marker check)', () => { + // Reproduces codex's review concern: between sync calls, a real catalog + // cat was registered with the same id as a previously-projected synthetic. + // The stale cleanup MUST NOT delete it — the ownership marker + // (`pluginProjection` field) distinguishes synthetic from real. + const registry = new CatRegistry(); + + // 1. First sync: plugin projected 'clowder-cat' as a synthetic config. + const syntheticConfig = { + id: 'clowder-cat', + mentionPatterns: ['clowder'], + pluginProjection: { pluginId: 'clowder-code', capId: 'clowder-code' }, + }; + registry.register('clowder-cat', syntheticConfig); + const pluginProjectedCatIds = new Set(['clowder-cat']); + + // 2. Between syncs: operator created a real catalog cat with the SAME id. + // The catalog write path called registerOrReplace, overwriting the + // synthetic with a non-plugin-marked config. + const realCatalogConfig = { + id: 'clowder-cat', + mentionPatterns: ['clowder-cat'], + // NOTE: NO pluginProjection field — this is a catalog-sourced cat. + }; + registry.registerOrReplace('clowder-cat', realCatalogConfig); + + // 3. Second sync: plugin admission denied (or projection skipped); new + // set is empty. Apply the production stale-cleanup contract. + const newlyProjectedCatIds = new Set(); + for (const staleId of pluginProjectedCatIds) { + if (newlyProjectedCatIds.has(staleId)) continue; + const current = registry.tryGet(staleId)?.config; + const isStillPluginOwned = current && current.pluginProjection !== undefined; + if (isStillPluginOwned) { + registry.unregister(staleId); + } + } + + // Assertion: the real catalog cat survives the stale cleanup. + const after = registry.tryGet('clowder-cat'); + assert.ok(after, 'real catalog cat must NOT be deleted by stale-projection cleanup'); + assert.equal(after.config.pluginProjection, undefined, 'real catalog cat is identified by missing marker'); + assert.deepEqual(after.config.mentionPatterns, ['clowder-cat']); + }); + + it('P1.4 follow-up #2 (codex twice-around) — snapshot skips configs carrying pluginProjection so re-sync does not self-collide', () => { + // Reproduces the codex-found regression: on the SECOND sync, activeCatConfigs + // (sourced from catRegistry.getAllConfigs()) contains the synthetic config + // we projected last sync. If the snapshot builder counts it as an + // "active non-providerTransport cat", the projection's admission re-run + // denies the candidate (active-cat-collision), and stale-cleanup + // unregisters the still-valid synthetic. + const syntheticConfig = { + id: 'clowder-cat', + mentionPatterns: ['clowder'], + pluginProjection: { pluginId: 'clowder-code', capId: 'clowder-code', descriptorHash: 'hash-1' }, + }; + const realCat = { + id: 'opus', + mentionPatterns: ['opus', 'opus47'], + // no pluginProjection marker + }; + const snapshot = buildAgentProviderAdmissionSnapshot({ + capabilitiesConfig: { version: 1, capabilities: [] }, + activeCatConfigs: { + 'clowder-cat': syntheticConfig, + opus: realCat, + }, + templateBaselineIds: new Set(), + hasProviderTransportConfig: () => false, + candidatePluginId: 'clowder-code', + candidateCapId: 'clowder-code', + }); + // The synthetic plugin-projected catId must NOT appear in the snapshot's + // active-cat set (it is the projection's own output, not a separate cat). + assert.equal( + snapshot.activeNonProviderTransportIdentities.has('clowder-cat'), + false, + 'plugin-projected synthetic must not self-collide on next sync', + ); + assert.equal( + snapshot.activeNonProviderTransportIdentities.has('clowder'), + false, + 'plugin-projected synthetic mentionPatterns must not self-collide either', + ); + // Real catalog cat is still represented. + assert.equal(snapshot.activeNonProviderTransportIdentities.has('opus'), true); + assert.equal(snapshot.activeNonProviderTransportIdentities.has('opus47'), true); + }); + + it('P1.4 follow-up — stale-cleanup still removes a truly stale synthetic projection (positive control)', () => { + // Positive control: when the registered entry is still plugin-owned (the + // synthetic config the projection wrote), stale cleanup removes it. + const registry = new CatRegistry(); + const syntheticConfig = { + id: 'old-plugin-cat', + mentionPatterns: ['old'], + pluginProjection: { pluginId: 'old-plugin', capId: 'old-cap' }, + }; + registry.register('old-plugin-cat', syntheticConfig); + const pluginProjectedCatIds = new Set(['old-plugin-cat']); + const newlyProjectedCatIds = new Set(); // plugin disabled / approval reset + + for (const staleId of pluginProjectedCatIds) { + if (newlyProjectedCatIds.has(staleId)) continue; + const current = registry.tryGet(staleId)?.config; + const isStillPluginOwned = current && current.pluginProjection !== undefined; + if (isStillPluginOwned) { + registry.unregister(staleId); + } + } + + assert.equal(registry.has('old-plugin-cat'), false, 'stale synthetic must be removed'); + }); + + it('does NOT refresh when health.descriptorHash mismatches current descriptorHash', async () => { + const row = makeRow({ + overrides: { + descriptor: { + descriptorHash: 'hash-NEW', + health: { passed: true, checkedAt: 1000, ttlMs: 1000, descriptorHash: 'hash-OLD' }, + }, + }, + }); + const capabilities = makeCapabilities([row]); + let persistCalled = false; + const result = await refreshExpiredHealthInPlace({ + capabilities, + rows: [row], + now: () => 1_000_000, + healthExecutor: async () => { + throw new Error('executor must not run on descriptor-hash mismatch'); + }, + getHealthExecutorContext: () => ({ + providerTransportRegistry: { has: () => true }, + }), + persist: async () => { + persistCalled = true; + }, + }); + assert.equal(result, null, 'skipped when hash mismatched'); + assert.equal(persistCalled, false); + }); +}); diff --git a/packages/api/test/agent-provider-approval-service.test.js b/packages/api/test/agent-provider-approval-service.test.js new file mode 100644 index 0000000000..355f7f369b --- /dev/null +++ b/packages/api/test/agent-provider-approval-service.test.js @@ -0,0 +1,364 @@ +/** + * F241 Phase B Slice 2b: Approval orchestration service tests. + * + * Covers the full Step 4 flow: locate row → admission → blocking health → + * atomic write (or fail-closed denial). Uses synthetic capability rows that + * already carry the Slice 2b descriptorHash, so we don't depend on the + * activator to seed them (those are covered by the activator tests). + */ + +import './helpers/setup-cat-registry.js'; +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +const { AgentProviderApprovalService } = await import('../dist/domains/plugin/agent-provider-approval-service.js'); +const { computeAgentProviderDescriptorHash } = await import('../dist/domains/plugin/agent-provider-descriptor-hash.js'); + +function makeResource(overrides = {}) { + return { + name: 'clowder-code', + transport: 'cli-jsonl', + command: 'clowder-code', + startupArgs: ['--json', '--non-interactive'], + resumeArgs: ['resume', '{sessionId}', '--json'], + sessionPolicy: 'resume', + outputProfile: 'clowder-code-turn-result-v1', + mcpWhitelistRequest: ['cat-cafe-collab'], + sandboxRequest: 'workspace-write', + healthCheck: { type: 'cliProbe' }, + ...overrides, + }; +} + +function makeCapabilityRow({ pluginId = 'clowder-code', capId = 'clowder-code', resourceOverrides = {} } = {}) { + const resource = makeResource(resourceOverrides); + const descriptorHash = computeAgentProviderDescriptorHash({ pluginId, capId, resource }); + return { + id: capId, + type: 'agentProvider', + enabled: true, + source: 'cat-cafe', + pluginId, + agentProvider: { + ...resource, + state: 'transportReady', + routeable: false, + routeableApproved: false, + descriptorHash, + }, + }; +} + +function makeStore(initial = { version: 1, capabilities: [] }) { + let state = structuredClone(initial); + return { + read: async () => structuredClone(state), + write: async (next) => { + state = structuredClone(next); + }, + get: () => state, + }; +} + +function makeService({ + capabilities = [makeCapabilityRow()], + hasTransport = (id) => id === 'cli-jsonl' || id === 'acp', + buildSnapshot = () => ({ + templateBaselineIds: new Set(['anthropic', 'openai', 'google', 'kimi']), + existingRouteableIdentities: new Set(), + activeNonProviderTransportIdentities: new Set(), + }), + healthExecutor, + now = () => 1_700_000_000_000, + onRouteablePromoted, +} = {}) { + const store = makeStore({ version: 1, capabilities }); + const service = new AgentProviderApprovalService({ + readCapabilities: store.read, + writeCapabilities: store.write, + withCapabilityLock: async (fn) => fn(), + buildAdmissionSnapshot: async (_pluginId, _capId, _config) => buildSnapshot(), + healthExecutor, + getHealthExecutorContext: () => ({ + providerTransportRegistry: { has: hasTransport }, + now, + }), + onRouteablePromoted, + }); + return { service, store }; +} + +describe('AgentProviderApprovalService.approveRouteable', () => { + describe('happy path', () => { + it('promotes capability to routeable=true with fresh health on admission + health pass', async () => { + const { service, store } = makeService(); + const result = await service.approveRouteable({ + pluginId: 'clowder-code', + capId: 'clowder-code', + catId: 'clowder-cat', + profileId: 'clowder-profile', + mentionPatterns: ['clowder'], + }); + assert.equal(result.ok, true); + assert.equal(result.capability.routeable, true); + assert.equal(result.capability.routeableApproved, true); + assert.equal(result.capability.state, 'healthy'); + assert.ok(result.capability.health); + assert.equal(result.capability.health.passed, true); + assert.equal(result.capability.health.descriptorHash, result.capability.descriptorHash); + // Step 5b: binding persisted on success so projection can build the synthetic config. + assert.ok(result.capability.routeableBinding); + assert.equal(result.capability.routeableBinding.catId, 'clowder-cat'); + assert.equal(result.capability.routeableBinding.profileId, 'clowder-profile'); + assert.deepEqual(result.capability.routeableBinding.mentionPatterns, ['clowder']); + + // Persisted state matches the returned capability + const persisted = store.get().capabilities[0].agentProvider; + assert.equal(persisted.routeable, true); + assert.equal(persisted.routeableApproved, true); + assert.equal(persisted.state, 'healthy'); + assert.equal(persisted.routeableBinding.catId, 'clowder-cat'); + }); + }); + + describe('not-found / wrong-type / missing-hash denials', () => { + it('denies when capability does not exist', async () => { + const { service } = makeService({ capabilities: [] }); + const result = await service.approveRouteable({ + pluginId: 'clowder-code', + capId: 'clowder-code', + catId: 'clowder-cat', + }); + assert.equal(result.ok, false); + assert.equal(result.reason, 'capability-not-found'); + }); + + it('denies when capability is not an agentProvider', async () => { + const nonAgent = { + id: 'clowder-code', + type: 'mcp', + enabled: true, + source: 'cat-cafe', + pluginId: 'clowder-code', + }; + const { service } = makeService({ capabilities: [nonAgent] }); + const result = await service.approveRouteable({ + pluginId: 'clowder-code', + capId: 'clowder-code', + catId: 'clowder-cat', + }); + assert.equal(result.ok, false); + assert.equal(result.reason, 'capability-not-agent-provider'); + }); + + it('denies when descriptorHash is missing (pre-2b row)', async () => { + const row = makeCapabilityRow(); + row.agentProvider.descriptorHash = undefined; + const { service } = makeService({ capabilities: [row] }); + const result = await service.approveRouteable({ + pluginId: 'clowder-code', + capId: 'clowder-code', + catId: 'clowder-cat', + }); + assert.equal(result.ok, false); + assert.equal(result.reason, 'descriptor-hash-missing'); + }); + }); + + describe('admission denials', () => { + it('propagates reserved-baseline collision from admission service', async () => { + const { service, store } = makeService(); + const result = await service.approveRouteable({ + pluginId: 'clowder-code', + capId: 'clowder-code', + catId: 'anthropic', // collides with template baseline + }); + assert.equal(result.ok, false); + assert.equal(result.reason, 'reserved-baseline-collision'); + assert.equal(result.conflictingIdentity, 'anthropic'); + + // Persisted state unchanged + const persisted = store.get().capabilities[0].agentProvider; + assert.equal(persisted.routeable, false); + assert.equal(persisted.routeableApproved, false); + }); + + it('propagates active-cat collision', async () => { + const { service } = makeService({ + buildSnapshot: () => ({ + templateBaselineIds: new Set(), + existingRouteableIdentities: new Set(), + activeNonProviderTransportIdentities: new Set(['opus']), + }), + }); + const result = await service.approveRouteable({ + pluginId: 'clowder-code', + capId: 'clowder-code', + catId: 'opus', + }); + assert.equal(result.ok, false); + assert.equal(result.reason, 'active-cat-collision'); + assert.equal(result.conflictingIdentity, 'opus'); + }); + }); + + describe('health failures', () => { + it('does NOT promote when health check fails; persists failed telemetry', async () => { + // Force health failure by returning a failing executor result. + const failingExecutor = async (ctx) => ({ + passed: false, + checkedAt: 1_700_000_000_000, + ttlMs: 60_000, + descriptorHash: ctx.descriptorHash, + failureReason: 'forced-test-failure', + }); + const { service, store } = makeService({ healthExecutor: failingExecutor }); + + const result = await service.approveRouteable({ + pluginId: 'clowder-code', + capId: 'clowder-code', + catId: 'clowder-cat', + }); + + assert.equal(result.ok, false); + assert.equal(result.reason, 'health-check-failed'); + assert.equal(result.health.passed, false); + assert.equal(result.health.failureReason, 'forced-test-failure'); + + // Persisted: failed health written for telemetry; approval still false; routeable still false. + const persisted = store.get().capabilities[0].agentProvider; + assert.equal(persisted.routeableApproved, false); + assert.equal(persisted.routeable, false); + assert.ok(persisted.health); + assert.equal(persisted.health.passed, false); + assert.equal(persisted.health.failureReason, 'forced-test-failure'); + }); + + it('default transport-availability probe fails when host transport is not registered', async () => { + const { service, store } = makeService({ + hasTransport: () => false, // no transports registered + }); + const result = await service.approveRouteable({ + pluginId: 'clowder-code', + capId: 'clowder-code', + catId: 'clowder-cat', + }); + assert.equal(result.ok, false); + assert.equal(result.reason, 'health-check-failed'); + assert.match(result.details, /transport-not-registered/); + const persisted = store.get().capabilities[0].agentProvider; + assert.equal(persisted.routeable, false); + }); + }); + + describe('snapshot exclusion', () => { + it('admission service receives a snapshot WITHOUT the candidate itself, so self-collision is impossible', async () => { + // Build snapshot that intentionally includes the candidate's own providerId. + // The orchestration service relies on the snapshot builder to exclude; + // we simulate that contract by NOT including 'clowder-code' here. + const { service } = makeService({ + buildSnapshot: () => ({ + templateBaselineIds: new Set(), + existingRouteableIdentities: new Set([ + 'some-other-routeable-plugin', // sibling, not the candidate + ]), + activeNonProviderTransportIdentities: new Set(), + }), + }); + const result = await service.approveRouteable({ + pluginId: 'clowder-code', + capId: 'clowder-code', + catId: 'clowder-cat', + }); + assert.equal(result.ok, true); + }); + }); + + // F241 Phase B Slice 2b Step 5a — post-approval sync hook contract. + describe('post-approval sync hook (onRouteablePromoted)', () => { + it('fires onRouteablePromoted with the promoted descriptor on successful approval', async () => { + const seen = []; + const { service } = makeService({ + onRouteablePromoted: async (capability) => { + seen.push(capability); + }, + }); + const result = await service.approveRouteable({ + pluginId: 'clowder-code', + capId: 'clowder-code', + catId: 'clowder-cat', + }); + assert.equal(result.ok, true); + assert.equal(seen.length, 1, 'hook should fire exactly once'); + assert.equal(seen[0].routeable, true); + assert.equal(seen[0].routeableApproved, true); + assert.equal(seen[0].state, 'healthy'); + }); + + it('does NOT fire onRouteablePromoted when admission fails', async () => { + const seen = []; + const { service } = makeService({ + onRouteablePromoted: async (capability) => { + seen.push(capability); + }, + }); + const result = await service.approveRouteable({ + pluginId: 'clowder-code', + capId: 'clowder-code', + catId: 'anthropic', // reserved-baseline collision + }); + assert.equal(result.ok, false); + assert.equal(seen.length, 0, 'hook must not fire on admission denial'); + }); + + it('does NOT fire onRouteablePromoted when health fails', async () => { + const seen = []; + const failingExecutor = async (ctx) => ({ + passed: false, + checkedAt: 1, + ttlMs: 60_000, + descriptorHash: ctx.descriptorHash, + failureReason: 'test-induced', + }); + const { service } = makeService({ + healthExecutor: failingExecutor, + onRouteablePromoted: async (capability) => { + seen.push(capability); + }, + }); + const result = await service.approveRouteable({ + pluginId: 'clowder-code', + capId: 'clowder-code', + catId: 'clowder-cat', + }); + assert.equal(result.ok, false); + assert.equal(result.reason, 'health-check-failed'); + assert.equal(seen.length, 0, 'hook must not fire on health failure'); + }); + + it('rolls back effective routeable + records lastSyncError when the hook throws; preserves approval intent', async () => { + const { service, store } = makeService({ + onRouteablePromoted: async () => { + throw new Error('sync coordinator unavailable'); + }, + }); + const result = await service.approveRouteable({ + pluginId: 'clowder-code', + capId: 'clowder-code', + catId: 'clowder-cat', + }); + assert.equal(result.ok, false); + assert.equal(result.reason, 'post-approval-sync-failed'); + assert.match(result.details, /sync coordinator unavailable/); + + const persisted = store.get().capabilities[0].agentProvider; + // Routeable rolled back, approval intent + health preserved. + assert.equal(persisted.routeable, false); + assert.equal(persisted.routeableApproved, true); + assert.ok(persisted.health); + assert.equal(persisted.health.passed, true); + assert.ok(persisted.lastSyncError); + assert.match(persisted.lastSyncError.message, /sync coordinator unavailable/); + }); + }); +}); diff --git a/packages/api/test/agent-provider-descriptor-hash.test.js b/packages/api/test/agent-provider-descriptor-hash.test.js new file mode 100644 index 0000000000..57942d27b4 --- /dev/null +++ b/packages/api/test/agent-provider-descriptor-hash.test.js @@ -0,0 +1,140 @@ +/** + * F241 Phase B Slice 2b: Descriptor hash determinism + sensitivity tests. + * + * The hash is the trigger for resetting host-owned approval state, so two + * properties matter equally: + * - DETERMINISTIC: same descriptor input → same hash (across runs, across + * equivalent restructurings). + * - SENSITIVE: any material change → different hash (so approval is reset). + */ + +import './helpers/setup-cat-registry.js'; +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +const { computeAgentProviderDescriptorHash } = await import('../dist/domains/plugin/agent-provider-descriptor-hash.js'); + +function makeInputs(overrides = {}) { + const { resource: resourceOverrides, ...topLevelOverrides } = overrides; + return { + pluginId: 'clowder-code', + capId: 'clowder-code', + ...topLevelOverrides, + resource: { + name: 'clowder-code', + transport: 'cli-jsonl', + command: 'clowder-code', + startupArgs: ['--json', '--non-interactive'], + resumeArgs: ['resume', '{sessionId}', '--json'], + sessionPolicy: 'resume', + outputProfile: 'clowder-code-turn-result-v1', + timeoutMs: 60000, + mcpWhitelistRequest: ['cat-cafe-collab', 'cat-cafe-memory'], + sandboxRequest: 'workspace-write', + healthCheck: { type: 'cliProbe' }, + ...resourceOverrides, + }, + }; +} + +describe('computeAgentProviderDescriptorHash — determinism', () => { + it('returns the same hash for identical inputs', () => { + const a = computeAgentProviderDescriptorHash(makeInputs()); + const b = computeAgentProviderDescriptorHash(makeInputs()); + assert.equal(a, b); + }); + + it('returns the same hash regardless of mcpWhitelistRequest insertion order', () => { + const a = computeAgentProviderDescriptorHash( + makeInputs({ resource: { mcpWhitelistRequest: ['cat-cafe-collab', 'cat-cafe-memory'] } }), + ); + const b = computeAgentProviderDescriptorHash( + makeInputs({ resource: { mcpWhitelistRequest: ['cat-cafe-memory', 'cat-cafe-collab'] } }), + ); + assert.equal(a, b); + }); + + it('produces a 64-character hex sha256 digest', () => { + const h = computeAgentProviderDescriptorHash(makeInputs()); + assert.match(h, /^[0-9a-f]{64}$/); + }); +}); + +describe('computeAgentProviderDescriptorHash — sensitivity (every input matters)', () => { + const baseline = computeAgentProviderDescriptorHash(makeInputs()); + + const mutations = [ + ['pluginId', { pluginId: 'other-plugin' }], + ['capId', { capId: 'other-cap' }], + ['transport', { resource: { transport: 'acp' } }], + ['command', { resource: { command: 'other-bin' } }], + ['startupArgs (positional)', { resource: { startupArgs: ['--non-interactive', '--json'] } }], + ['resumeArgs (positional)', { resource: { resumeArgs: ['{sessionId}', 'resume', '--json'] } }], + ['sessionPolicy', { resource: { sessionPolicy: 'stateless' } }], + ['outputProfile', { resource: { outputProfile: undefined } }], + ['timeoutMs', { resource: { timeoutMs: 30000 } }], + ['mcpWhitelistRequest (membership)', { resource: { mcpWhitelistRequest: ['cat-cafe-collab'] } }], + ['sandboxRequest', { resource: { sandboxRequest: 'workspace-read' } }], + ['healthCheck type', { resource: { healthCheck: { type: 'acpInitialize' } } }], + ['pluginFingerprint added', { pluginFingerprint: 'sha256:abc' }], + // F241 Phase C 2c — manifest identity claims feed the hash so any claim + // change forces re-approval. + ['providerId added', { resource: { providerId: 'clowder-code' } }], + ['displayName added', { resource: { displayName: 'Clowder Code' } }], + ['mentionPatterns added', { resource: { mentionPatterns: ['@clowder'] } }], + ]; + + for (const [label, override] of mutations) { + it(`differs when ${label} changes`, () => { + const mutated = computeAgentProviderDescriptorHash(makeInputs(override)); + assert.notEqual(mutated, baseline, `Hash should change when ${label} mutates`); + }); + } + + it('distinguishes absent optional field from explicit null in canonical form', () => { + // If sessionPolicy is undefined vs explicit 'resume', hash MUST differ. + const withPolicy = computeAgentProviderDescriptorHash(makeInputs({ resource: { sessionPolicy: 'resume' } })); + const noPolicy = computeAgentProviderDescriptorHash(makeInputs({ resource: { sessionPolicy: undefined } })); + assert.notEqual(withPolicy, noPolicy); + }); + + it('mentionPatterns hash is order-insensitive (set semantics)', () => { + const ascending = computeAgentProviderDescriptorHash( + makeInputs({ resource: { mentionPatterns: ['@a', '@b', '@c'] } }), + ); + const descending = computeAgentProviderDescriptorHash( + makeInputs({ resource: { mentionPatterns: ['@c', '@b', '@a'] } }), + ); + assert.equal(ascending, descending, 'mentionPatterns is a set; insertion order must not change the hash'); + }); + + it('v: 2 hash differs from a pre-2c v: 1 hash for the same inputs (version-bump invalidates old approvals)', async () => { + // Recompute the v: 1 canonical shape that 2b shipped — same inputs minus + // the new identity-claim fields, with v: 1 in the version slot. If the + // production hash equals this, the version bump didn't take effect and + // operators upgrading from 2b would have their old approvals silently + // inherited under the new shape (bypassing the re-approval invariant). + const inputs = makeInputs(); + const v1Canonical = { + v: 1, + pluginId: inputs.pluginId, + capId: inputs.capId, + transport: inputs.resource.transport, + command: inputs.resource.command, + startupArgs: [...inputs.resource.startupArgs], + resumeArgs: inputs.resource.resumeArgs ? [...inputs.resource.resumeArgs] : null, + sessionPolicy: inputs.resource.sessionPolicy ?? null, + outputProfile: inputs.resource.outputProfile ?? null, + timeoutMs: inputs.resource.timeoutMs ?? null, + mcpWhitelistRequest: inputs.resource.mcpWhitelistRequest + ? [...inputs.resource.mcpWhitelistRequest].slice().sort() + : null, + sandboxRequest: inputs.resource.sandboxRequest ?? null, + healthCheck: inputs.resource.healthCheck ?? null, + pluginFingerprint: inputs.pluginFingerprint ?? null, + }; + const { createHash } = await import('node:crypto'); + const v1Hash = createHash('sha256').update(JSON.stringify(v1Canonical)).digest('hex'); + assert.notEqual(baseline, v1Hash, 'v: 2 must produce a different hash than v: 1 for the same descriptor inputs'); + }); +}); diff --git a/packages/api/test/agent-provider-health-executor.test.js b/packages/api/test/agent-provider-health-executor.test.js new file mode 100644 index 0000000000..243a4dd1b4 --- /dev/null +++ b/packages/api/test/agent-provider-health-executor.test.js @@ -0,0 +1,223 @@ +/** + * F241 Phase C — Real cliProbe health executor tests. + * + * Covers the bounded-spawn + exit-code semantics that replace the 2b + * transport-availability stub for `cliProbe`-declared resources. `acpInitialize` + * still falls through to transport-availability until F161 ACP carrier lands. + */ + +import './helpers/setup-cat-registry.js'; +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { describe, it } from 'node:test'; + +const { transportAvailabilityHealthExecutor, createRealCliProbeHealthExecutor, DEFAULT_HEALTH_TTL_MS } = await import( + '../dist/domains/plugin/agent-provider-health-executor.js' +); + +const REGISTRY_HAS_CLI_JSONL = { has: (id) => id === 'cli-jsonl' }; +const REGISTRY_HAS_NOTHING = { has: () => false }; + +function makeResource(overrides = {}) { + return { + name: 'clowder-code', + transport: 'cli-jsonl', + command: '/usr/bin/true', + startupArgs: ['--json'], + healthCheck: { type: 'cliProbe' }, + ...overrides, + }; +} + +function makeContext(resourceOverrides = {}, registry = REGISTRY_HAS_CLI_JSONL) { + return { + resource: makeResource(resourceOverrides), + descriptorHash: 'hash-A', + providerTransportRegistry: registry, + now: () => 12_345, + }; +} + +/** + * Fake `spawn` returning a controllable ChildProcess-like emitter. The caller + * triggers `emit('exit', code)` / `emit('error', err)` to drive outcomes; the + * kill() recorder lets tests assert cleanup on timeout. + */ +function makeFakeSpawn() { + const calls = []; + const fakeSpawn = (command, args, opts) => { + const child = new EventEmitter(); + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + let killed = false; + child.kill = (signal) => { + killed = true; + child.lastKillSignal = signal; + }; + Object.defineProperty(child, 'killed', { get: () => killed }); + calls.push({ command, args, opts, child }); + return child; + }; + return { fakeSpawn, calls }; +} + +describe('transportAvailabilityHealthExecutor (regression — 2b stub still works)', () => { + it('passes when declared transport is registered', async () => { + const result = await transportAvailabilityHealthExecutor(makeContext()); + assert.equal(result.passed, true); + assert.equal(result.descriptorHash, 'hash-A'); + assert.equal(result.ttlMs, DEFAULT_HEALTH_TTL_MS); + }); + + it('fails when no healthCheck is declared', async () => { + const result = await transportAvailabilityHealthExecutor(makeContext({ healthCheck: undefined })); + assert.equal(result.passed, false); + assert.equal(result.failureReason, 'no-healthcheck-declared'); + }); + + it('fails when the cliProbe-required transport is not registered', async () => { + const result = await transportAvailabilityHealthExecutor(makeContext({}, REGISTRY_HAS_NOTHING)); + assert.equal(result.passed, false); + assert.match(result.failureReason, /transport-not-registered:cli-jsonl/); + }); +}); + +describe('createRealCliProbeHealthExecutor — bounded spawn + exit-code check', () => { + /** + * Default test wiring: identity resolver (treats the declared command as + * the resolved absolute path) so unit tests are deterministic + offline. + * Production wires the real `resolveCliCommand` via the default factory. + */ + const makeIdentityResolver = () => { + const seen = []; + const resolveFn = (command) => { + seen.push(command); + return command; + }; + return { resolveFn, seen }; + }; + + const buildExec = (probeTimeoutMs = 5_000, extra = {}) => { + const { fakeSpawn, calls } = makeFakeSpawn(); + const { resolveFn, seen } = makeIdentityResolver(); + const exec = createRealCliProbeHealthExecutor({ spawnFn: fakeSpawn, resolveFn, probeTimeoutMs, ...extra }); + return { exec, calls, resolverCalls: seen }; + }; + + it('passes when the spawned binary exits 0', async () => { + const { exec, calls } = buildExec(); + const promise = exec(makeContext()); + setImmediate(() => calls[0].child.emit('exit', 0, null)); + const result = await promise; + assert.equal(result.passed, true); + assert.equal(result.descriptorHash, 'hash-A'); + assert.equal(result.ttlMs, DEFAULT_HEALTH_TTL_MS); + assert.equal(calls[0].command, '/usr/bin/true'); + assert.deepEqual(calls[0].args, ['--version']); + assert.equal(calls[0].opts.stdio[0], 'ignore', 'cliProbe must not feed stdin'); + }); + + it('fails with cli-probe-nonzero-exit when binary exits non-zero', async () => { + const { exec, calls } = buildExec(); + const promise = exec(makeContext()); + setImmediate(() => calls[0].child.emit('exit', 7, null)); + const result = await promise; + assert.equal(result.passed, false); + assert.equal(result.failureReason, 'cli-probe-nonzero-exit:7'); + }); + + it('fails with cli-probe-spawn-error when child emits error', async () => { + const { exec, calls } = buildExec(); + const promise = exec(makeContext()); + setImmediate(() => calls[0].child.emit('error', new Error('ENOENT: not found'))); + const result = await promise; + assert.equal(result.passed, false); + assert.match(result.failureReason, /cli-probe-spawn-error:.*ENOENT/); + }); + + it('fails with cli-probe-timeout when probe exceeds bounded duration and kills child', async () => { + const { exec, calls } = buildExec(50); + const result = await exec(makeContext()); + assert.equal(result.passed, false); + assert.equal(result.failureReason, 'cli-probe-timeout:50ms'); + assert.equal(calls[0].child.killed, true, 'lingering probe must be killed on timeout'); + assert.equal(calls[0].child.lastKillSignal, 'SIGTERM'); + }); + + it('skips spawn for acpInitialize (falls through to transport-availability)', async () => { + const { fakeSpawn, calls } = makeFakeSpawn(); + const { resolveFn, seen } = makeIdentityResolver(); + const exec = createRealCliProbeHealthExecutor({ spawnFn: fakeSpawn, resolveFn, probeTimeoutMs: 5_000 }); + // acpInitialize requires ACP transport; use a registry that has it. + const ctx = makeContext( + { transport: 'acp', healthCheck: { type: 'acpInitialize' } }, + { has: (id) => id === 'acp' }, + ); + const result = await exec(ctx); + assert.equal(result.passed, true, 'acpInitialize uses transport-availability semantics for now'); + assert.equal(calls.length, 0, 'no spawn should fire for acpInitialize'); + assert.equal(seen.length, 0, 'no resolve should fire for acpInitialize either'); + }); + + it('fails fast (no spawn) when declared transport is not registered', async () => { + const { fakeSpawn, calls } = makeFakeSpawn(); + const { resolveFn } = makeIdentityResolver(); + const exec = createRealCliProbeHealthExecutor({ spawnFn: fakeSpawn, resolveFn, probeTimeoutMs: 5_000 }); + const result = await exec(makeContext({}, REGISTRY_HAS_NOTHING)); + assert.equal(result.passed, false); + assert.match(result.failureReason, /transport-not-registered/); + assert.equal(calls.length, 0, 'no spawn should fire when transport is missing'); + }); + + it('fails fast (no spawn) when no healthCheck is declared', async () => { + const { fakeSpawn, calls } = makeFakeSpawn(); + const { resolveFn } = makeIdentityResolver(); + const exec = createRealCliProbeHealthExecutor({ spawnFn: fakeSpawn, resolveFn, probeTimeoutMs: 5_000 }); + const result = await exec(makeContext({ healthCheck: undefined })); + assert.equal(result.passed, false); + assert.equal(result.failureReason, 'no-healthcheck-declared'); + assert.equal(calls.length, 0); + }); + + it('only fires once — extra exit events after timeout do not double-resolve', async () => { + const { exec, calls } = buildExec(30); + const result = await exec(makeContext()); + // Now simulate the dead-but-not-yet-reaped child eventually emitting exit. + // The executor must already have settled; this should be a no-op. + calls[0].child.emit('exit', 0, null); + assert.equal(result.failureReason, 'cli-probe-timeout:30ms'); + }); + + /** + * P2 review @codex on PR #38: probe MUST resolve the command with the same + * resolver the real cli-jsonl invocation uses, or approve / invoke split-brain + * when the binary lives outside $PATH (~/.local/bin, nvm version dirs, etc.). + * These two tests lock in the new contract. + */ + it('uses resolveFn to map the manifest command to an absolute path before spawning', async () => { + const { fakeSpawn, calls } = makeFakeSpawn(); + const resolveFn = (command) => { + assert.equal(command, 'clowder-code', 'resolver must see the raw manifest command'); + return '/home/user/.local/bin/clowder-code'; + }; + const exec = createRealCliProbeHealthExecutor({ spawnFn: fakeSpawn, resolveFn, probeTimeoutMs: 5_000 }); + const promise = exec(makeContext({ command: 'clowder-code' })); + setImmediate(() => calls[0].child.emit('exit', 0, null)); + const result = await promise; + assert.equal(result.passed, true); + assert.equal(calls[0].command, '/home/user/.local/bin/clowder-code', 'spawn must use the resolved path'); + }); + + it('fails with cli-probe-cli-not-found (no spawn) when resolver returns null', async () => { + const { fakeSpawn, calls } = makeFakeSpawn(); + const exec = createRealCliProbeHealthExecutor({ + spawnFn: fakeSpawn, + resolveFn: () => null, + probeTimeoutMs: 5_000, + }); + const result = await exec(makeContext({ command: 'never-installed-cli' })); + assert.equal(result.passed, false); + assert.equal(result.failureReason, 'cli-probe-cli-not-found:never-installed-cli'); + assert.equal(calls.length, 0, 'no spawn should fire when the binary cannot be resolved'); + }); +}); diff --git a/packages/api/test/agent-provider-projection.test.js b/packages/api/test/agent-provider-projection.test.js new file mode 100644 index 0000000000..4ccdba7b77 --- /dev/null +++ b/packages/api/test/agent-provider-projection.test.js @@ -0,0 +1,193 @@ +/** + * F241 Phase B Slice 2b Step 5b: routeable agentProvider projection tests. + * + * Covers list filter + projection gates: health freshness, descriptor-hash + * binding, TTL expiry, missing binding, and the red-line admission re-run. + */ + +import './helpers/setup-cat-registry.js'; +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +const { listApprovedRouteableRows, projectRouteableAgentProviders } = await import( + '../dist/domains/plugin/agent-provider-projection.js' +); + +function makeRow({ pluginId = 'clowder-code', capId = 'clowder-code', overrides = {} } = {}) { + const baseDescriptor = { + name: 'clowder-code', + transport: 'cli-jsonl', + command: 'clowder-code', + startupArgs: ['--json'], + resumeArgs: ['resume', '{sessionId}'], + sessionPolicy: 'resume', + outputProfile: 'clowder-code-turn-result-v1', + healthCheck: { type: 'cliProbe' }, + state: 'healthy', + routeable: true, + routeableApproved: true, + descriptorHash: 'hash-1', + health: { + passed: true, + checkedAt: 1000, + ttlMs: 60_000, + descriptorHash: 'hash-1', + }, + routeableBinding: { + catId: 'clowder-cat', + mentionPatterns: ['clowder'], + }, + ...overrides.descriptor, + }; + return { + pluginId, + capId, + descriptor: baseDescriptor, + }; +} + +function makeCapabilitiesConfig(rows) { + return { + version: 1, + capabilities: rows.map((r) => ({ + id: r.capId, + type: 'agentProvider', + enabled: true, + source: 'cat-cafe', + pluginId: r.pluginId, + agentProvider: r.descriptor, + })), + }; +} + +function admittingSnapshot() { + return { + templateBaselineIds: new Set(['anthropic', 'openai']), + existingRouteableIdentities: new Set(), + activeNonProviderTransportIdentities: new Set(), + }; +} + +describe('listApprovedRouteableRows', () => { + it('returns rows where routeable=true + approved + binding present', () => { + const config = makeCapabilitiesConfig([makeRow()]); + const rows = listApprovedRouteableRows(config); + assert.equal(rows.length, 1); + assert.equal(rows[0].pluginId, 'clowder-code'); + }); + + it('skips rows that are routeable=false', () => { + const config = makeCapabilitiesConfig([makeRow({ overrides: { descriptor: { routeable: false } } })]); + assert.equal(listApprovedRouteableRows(config).length, 0); + }); + + it('skips rows missing routeableBinding (cannot project without a catId)', () => { + const config = makeCapabilitiesConfig([makeRow({ overrides: { descriptor: { routeableBinding: undefined } } })]); + assert.equal(listApprovedRouteableRows(config).length, 0); + }); +}); + +describe('projectRouteableAgentProviders', () => { + it('happy path: projects a healthy, admitted row into a synthetic CatConfig', () => { + const rows = [makeRow()]; + const result = projectRouteableAgentProviders({ + rows, + buildSnapshot: () => admittingSnapshot(), + now: () => 30_000, + }); + assert.equal(result.admitted.length, 1); + assert.equal(result.skipped.length, 0); + const synth = result.configs['clowder-cat']; + assert.ok(synth, 'synthetic config should be keyed by binding.catId'); + assert.equal(synth.id, 'clowder-cat'); + assert.equal(synth.providerTransport.transport, 'cli-jsonl'); + assert.equal(synth.providerTransport.command, 'clowder-code'); + assert.deepEqual(synth.providerTransport.startupArgs, ['--json']); + assert.equal(synth.pluginProjection.pluginId, 'clowder-code'); + }); + + it('skips when health is missing', () => { + const rows = [makeRow({ overrides: { descriptor: { health: undefined } } })]; + const result = projectRouteableAgentProviders({ + rows, + buildSnapshot: () => admittingSnapshot(), + now: () => 30_000, + }); + assert.equal(result.admitted.length, 0); + assert.equal(result.skipped[0].reason, 'health-not-fresh-or-failed'); + }); + + it('skips when health.passed=false', () => { + const rows = [ + makeRow({ + overrides: { + descriptor: { + health: { passed: false, checkedAt: 1000, ttlMs: 60_000, descriptorHash: 'hash-1' }, + }, + }, + }), + ]; + const result = projectRouteableAgentProviders({ + rows, + buildSnapshot: () => admittingSnapshot(), + now: () => 30_000, + }); + assert.equal(result.admitted.length, 0); + assert.equal(result.skipped[0].reason, 'health-not-fresh-or-failed'); + }); + + it('skips when health.descriptorHash mismatches current descriptorHash', () => { + const rows = [ + makeRow({ + overrides: { + descriptor: { + descriptorHash: 'hash-NEW', + health: { passed: true, checkedAt: 1000, ttlMs: 60_000, descriptorHash: 'hash-OLD' }, + }, + }, + }), + ]; + const result = projectRouteableAgentProviders({ + rows, + buildSnapshot: () => admittingSnapshot(), + now: () => 30_000, + }); + assert.equal(result.admitted.length, 0); + assert.equal(result.skipped[0].reason, 'health-descriptor-mismatch'); + }); + + it('skips when TTL expired', () => { + const rows = [makeRow()]; + const result = projectRouteableAgentProviders({ + rows, + buildSnapshot: () => admittingSnapshot(), + now: () => 999_999, // way past checkedAt(1000) + ttlMs(60_000) + }); + assert.equal(result.admitted.length, 0); + assert.equal(result.skipped[0].reason, 'health-ttl-expired'); + }); + + it('RED LINE: skips when admission re-run denies (e.g. binding catId now collides with template baseline)', () => { + const rows = [makeRow({ overrides: { descriptor: { routeableBinding: { catId: 'anthropic' } } } })]; + const result = projectRouteableAgentProviders({ + rows, + buildSnapshot: () => admittingSnapshot(), // templateBaselineIds includes 'anthropic' + now: () => 30_000, + }); + assert.equal(result.admitted.length, 0); + assert.match(result.skipped[0].reason, /admission-rerun-denied:reserved-baseline-collision/); + }); + + it('calls onSkip with the skip reason for telemetry', () => { + const seen = []; + const rows = [makeRow({ overrides: { descriptor: { health: undefined } } })]; + projectRouteableAgentProviders({ + rows, + buildSnapshot: () => admittingSnapshot(), + now: () => 30_000, + onSkip: (pluginId, capId, reason) => seen.push({ pluginId, capId, reason }), + }); + assert.equal(seen.length, 1); + assert.equal(seen[0].reason, 'health-not-fresh-or-failed'); + }); +}); diff --git a/packages/api/test/anthropic-messages-adapter-golden.test.js b/packages/api/test/anthropic-messages-adapter-golden.test.js new file mode 100644 index 0000000000..818057a83c --- /dev/null +++ b/packages/api/test/anthropic-messages-adapter-golden.test.js @@ -0,0 +1,369 @@ +/** + * AnthropicMessagesAdapter Golden-Wire Contract Test — F159 Phase G G1 AC-G10 + * + * Byte-stable lock on the Anthropic Messages protocol shape: + * + * - Request URL formation (base URL normalisation: bare host, /v1 suffix, + * trailing slash, empty/undefined, custom proxy without /v1) + * - Request headers (x-api-key, anthropic-version pinned, Content-Type) + * - Request body JSON shape (model / max_tokens / messages / stream / tools / + * system field presence + value structure) + * - Stream event → neutral CatAgentStreamEvent mapping for every Anthropic + * SSE event type (message_start, content_block_*, message_delta, message_stop) + * plus boundary cases (unclosed block, missing message_stop) + * - Transcript codec output shape for encodeUserPrompt / + * encodeAssistantTurn / encodeToolResults — verified by round-tripping + * through buildRequestBody and asserting on the serialised JSON keys/values + * - mapError text format byte-stability with pre-G1 mapAnthropicError + * ("Anthropic API error (): ") + * - isTerminalStopReason classification (terminal whitelist intact) + * - clientFamily / protocolId stable identifiers + * + * Per @gpt555 design gate KD-18: refactor-only "behavior保持" cannot be + * proven by broad suite alone; this contract test locks every protocol + * detail at byte level so future G2 / G3 changes can't drift Anthropic + * wire shape silently. + */ + +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +import { AnthropicMessagesAdapter } from '../dist/domains/cats/services/agents/providers/catagent/anthropic-messages-adapter.js'; + +// ── helpers ── + +function toStream(chunks) { + let i = 0; + return new ReadableStream({ + pull(controller) { + if (i < chunks.length) { + controller.enqueue(new TextEncoder().encode(chunks[i++])); + } else { + controller.close(); + } + }, + }); +} + +function sse(event) { + return `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`; +} + +async function collect(iter) { + const out = []; + for await (const e of iter) out.push(e); + return out; +} + +// ── adapter identity ── + +describe('AnthropicMessagesAdapter: identity', () => { + test('clientFamily and protocolId are stable identifiers', () => { + const a = new AnthropicMessagesAdapter(); + assert.equal(a.clientFamily, 'anthropic'); + assert.equal(a.protocolId, 'anthropic-messages-v1'); + }); +}); + +// ── buildRequestUrl ── + +describe('AnthropicMessagesAdapter: buildRequestUrl', () => { + const a = new AnthropicMessagesAdapter(); + + test('undefined baseURL uses default', () => { + assert.equal(a.buildRequestUrl(undefined), 'https://api.anthropic.com/v1/messages'); + }); + + test('empty string baseURL falls back to default', () => { + assert.equal(a.buildRequestUrl(''), 'https://api.anthropic.com/v1/messages'); + }); + + test('bare host preserved + /v1/messages appended', () => { + assert.equal(a.buildRequestUrl('https://api.anthropic.com'), 'https://api.anthropic.com/v1/messages'); + }); + + test('trailing slash stripped', () => { + assert.equal(a.buildRequestUrl('https://api.anthropic.com/'), 'https://api.anthropic.com/v1/messages'); + }); + + test('/v1 suffix not double-prefixed (develop@90810122 fix)', () => { + assert.equal(a.buildRequestUrl('https://proxy.example/v1'), 'https://proxy.example/v1/messages'); + }); + + test('/v1/ suffix (trailing slash variant) handled', () => { + assert.equal(a.buildRequestUrl('https://proxy.example/v1/'), 'https://proxy.example/v1/messages'); + }); + + test('uppercase /V1 suffix case-insensitive', () => { + assert.equal(a.buildRequestUrl('https://proxy.example/V1'), 'https://proxy.example/v1/messages'); + }); + + test('custom proxy without /v1 keeps full path', () => { + assert.equal(a.buildRequestUrl('https://api.mycorp.com/anthropic'), 'https://api.mycorp.com/anthropic/v1/messages'); + }); +}); + +// ── buildRequestHeaders ── + +describe('AnthropicMessagesAdapter: buildRequestHeaders', () => { + test('produces exact header set with anthropic-version pinned', () => { + const a = new AnthropicMessagesAdapter(); + const headers = a.buildRequestHeaders({ apiKey: 'sk-test-abc' }); + assert.deepEqual(headers, { + 'Content-Type': 'application/json', + 'x-api-key': 'sk-test-abc', + 'anthropic-version': '2023-06-01', + }); + }); +}); + +// ── buildRequestBody (round-tripped through encode methods) ── + +describe('AnthropicMessagesAdapter: buildRequestBody', () => { + const a = new AnthropicMessagesAdapter(); + + test('minimal body (no tools, no system) — wire-stable keys', () => { + const messages = [a.encodeUserPrompt('hi')]; + const body = a.buildRequestBody({ model: 'claude-opus-4-6', messages, tools: [] }); + // Round-trip serialise to assert JSON shape byte-stably + const j = JSON.parse(JSON.stringify(body)); + assert.deepEqual(j, { + model: 'claude-opus-4-6', + max_tokens: 4096, + messages: [{ role: 'user', content: 'hi' }], + stream: true, + }); + }); + + test('body with tools — anthropic tool schema shape (name/description/input_schema)', () => { + const messages = [a.encodeUserPrompt('do thing')]; + const body = a.buildRequestBody({ + model: 'claude-opus-4-6', + messages, + tools: [{ name: 'read_file', description: 'reads a file', inputSchema: { type: 'object' } }], + }); + const j = JSON.parse(JSON.stringify(body)); + assert.deepEqual(j.tools, [{ name: 'read_file', description: 'reads a file', input_schema: { type: 'object' } }]); + }); + + test('body with systemPrompt — placed as top-level "system" field', () => { + const messages = [a.encodeUserPrompt('q')]; + const body = a.buildRequestBody({ model: 'claude-opus-4-6', messages, tools: [], systemPrompt: 'You are X.' }); + const j = JSON.parse(JSON.stringify(body)); + assert.equal(j.system, 'You are X.'); + }); + + test('maxTokens override respected', () => { + const messages = [a.encodeUserPrompt('q')]; + const body = a.buildRequestBody({ model: 'm', messages, tools: [], maxTokens: 8192 }); + const j = JSON.parse(JSON.stringify(body)); + assert.equal(j.max_tokens, 8192); + }); +}); + +// ── Transcript codec wire shape ── + +describe('AnthropicMessagesAdapter: encodeUserPrompt', () => { + test('byte-stable Anthropic user message shape', () => { + const a = new AnthropicMessagesAdapter(); + const msg = a.encodeUserPrompt('hello'); + const body = a.buildRequestBody({ model: 'm', messages: [msg], tools: [] }); + const j = JSON.parse(JSON.stringify(body)); + assert.deepEqual(j.messages, [{ role: 'user', content: 'hello' }]); + }); +}); + +describe('AnthropicMessagesAdapter: encodeAssistantTurn', () => { + test('mixed text + tool_call blocks render in Anthropic content order', () => { + const a = new AnthropicMessagesAdapter(); + const initial = a.encodeUserPrompt('go'); + const assistant = a.encodeAssistantTurn([ + { type: 'text', text: 'thinking…' }, + { type: 'tool_call', id: 'tu1', name: 'read_file', input: { path: 'a.txt' } }, + ]); + const body = a.buildRequestBody({ model: 'm', messages: [initial, assistant], tools: [] }); + const j = JSON.parse(JSON.stringify(body)); + assert.deepEqual(j.messages[1], { + role: 'assistant', + content: [ + { type: 'text', text: 'thinking…' }, + { type: 'tool_use', id: 'tu1', name: 'read_file', input: { path: 'a.txt' } }, + ], + }); + }); +}); + +describe('AnthropicMessagesAdapter: encodeToolResults', () => { + test('byte-stable Anthropic user-message-with-tool_result shape (no is_error in G1)', () => { + const a = new AnthropicMessagesAdapter(); + const initial = a.encodeUserPrompt('go'); + const tr = a.encodeToolResults([ + { id: 'tu1', content: 'file content', status: 'ok' }, + { id: 'tu2', content: 'Error: nope', status: 'error' }, + ]); + const body = a.buildRequestBody({ model: 'm', messages: [initial, tr], tools: [] }); + const j = JSON.parse(JSON.stringify(body)); + // G1 refactor-only: NO is_error field on tool_result blocks (locked + // against accidental introduction; G2+ may surface status). Pre-G1 wire + // shape preserved exactly. + assert.deepEqual(j.messages[1], { + role: 'user', + content: [ + { type: 'tool_result', tool_use_id: 'tu1', content: 'file content' }, + { type: 'tool_result', tool_use_id: 'tu2', content: 'Error: nope' }, + ], + }); + }); +}); + +// ── parseStreamEvents: SSE → neutral event mapping ── + +describe('AnthropicMessagesAdapter: parseStreamEvents — text + usage + stop', () => { + test('message_start usage → neutral CatAgentUsageDelta with cache normalisation', async () => { + const a = new AnthropicMessagesAdapter(); + const stream = + sse({ + type: 'message_start', + message: { + id: 'm1', + usage: { input_tokens: 100, cache_read_input_tokens: 50, cache_creation_input_tokens: 10 }, + }, + }) + + sse({ type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } }) + + sse({ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'hi' } }) + + sse({ type: 'content_block_stop', index: 0 }) + + sse({ type: 'message_delta', delta: { stop_reason: 'end_turn' }, usage: { output_tokens: 25 } }) + + sse({ type: 'message_stop' }); + const events = await collect(a.parseStreamEvents(toStream([stream]))); + + const usage = events.find((e) => e.type === 'usage_update' && e.usage.inputTokens !== undefined); + assert.ok(usage, 'has input usage event'); + // mapAnthropicUsage convention: inputTokens = raw + cache_read + cache_creation + assert.equal(usage.usage.inputTokens, 160); + assert.equal(usage.usage.cacheReadTokens, 50); + assert.equal(usage.usage.cacheCreationTokens, 10); + + const outUsage = events.find((e) => e.type === 'usage_update' && e.usage.outputTokens !== undefined); + assert.ok(outUsage); + assert.equal(outUsage.usage.outputTokens, 25); + }); + + test('text_delta → neutral text_delta event with blockIndex preserved', async () => { + const a = new AnthropicMessagesAdapter(); + const stream = + sse({ type: 'message_start', message: { id: 'm', usage: { input_tokens: 1 } } }) + + sse({ type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } }) + + sse({ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'hel' } }) + + sse({ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'lo' } }) + + sse({ type: 'content_block_stop', index: 0 }) + + sse({ type: 'message_delta', delta: { stop_reason: 'end_turn' }, usage: { output_tokens: 2 } }) + + sse({ type: 'message_stop' }); + const events = await collect(a.parseStreamEvents(toStream([stream]))); + const deltas = events.filter((e) => e.type === 'text_delta'); + assert.deepEqual( + deltas.map((e) => ({ text: e.text, blockIndex: e.blockIndex })), + [ + { text: 'hel', blockIndex: 0 }, + { text: 'lo', blockIndex: 0 }, + ], + ); + }); + + test('stop event carries raw stopReason (service consults isTerminalStopReason separately)', async () => { + const a = new AnthropicMessagesAdapter(); + const stream = + sse({ type: 'message_start', message: { id: 'm', usage: { input_tokens: 1 } } }) + + sse({ type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } }) + + sse({ type: 'content_block_stop', index: 0 }) + + sse({ type: 'message_delta', delta: { stop_reason: 'tool_use' }, usage: { output_tokens: 1 } }) + + sse({ type: 'message_stop' }); + const events = await collect(a.parseStreamEvents(toStream([stream]))); + const stop = events.find((e) => e.type === 'stop'); + assert.ok(stop); + assert.equal(stop.stopReason, 'tool_use'); + }); +}); + +describe('AnthropicMessagesAdapter: parseStreamEvents — tool_call mapping', () => { + test('Anthropic tool_use content block → neutral tool_call block (id/name/input preserved)', async () => { + const a = new AnthropicMessagesAdapter(); + const stream = + sse({ type: 'message_start', message: { id: 'm', usage: { input_tokens: 1 } } }) + + sse({ + type: 'content_block_start', + index: 0, + content_block: { type: 'tool_use', id: 'tu1', name: 'read_file' }, + }) + + sse({ + type: 'content_block_delta', + index: 0, + delta: { type: 'input_json_delta', partial_json: '{"path":"x.txt"}' }, + }) + + sse({ type: 'content_block_stop', index: 0 }) + + sse({ type: 'message_delta', delta: { stop_reason: 'tool_use' }, usage: { output_tokens: 1 } }) + + sse({ type: 'message_stop' }); + const events = await collect(a.parseStreamEvents(toStream([stream]))); + const complete = events.find((e) => e.type === 'content_block_complete'); + assert.ok(complete); + assert.equal(complete.block.type, 'tool_call'); // NOT 'tool_use' — neutral + assert.equal(complete.block.id, 'tu1'); + assert.equal(complete.block.name, 'read_file'); + assert.deepEqual(complete.block.input, { path: 'x.txt' }); + }); +}); + +describe('AnthropicMessagesAdapter: parseStreamEvents — boundary cases', () => { + test('unclosed content block → stream_error', async () => { + const a = new AnthropicMessagesAdapter(); + const stream = + sse({ type: 'message_start', message: { id: 'm', usage: { input_tokens: 1 } } }) + + sse({ type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } }) + + sse({ type: 'message_stop' }); // missing content_block_stop + const events = await collect(a.parseStreamEvents(toStream([stream]))); + const err = events.find((e) => e.type === 'stream_error'); + assert.ok(err); + assert.match(err.error, /unclosed content block/i); + }); + + test('missing message_stop → stream_error', async () => { + const a = new AnthropicMessagesAdapter(); + const stream = + sse({ type: 'message_start', message: { id: 'm', usage: { input_tokens: 1 } } }) + + sse({ type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } }) + + sse({ type: 'content_block_stop', index: 0 }); + // no message_delta, no message_stop + const events = await collect(a.parseStreamEvents(toStream([stream]))); + const err = events.find((e) => e.type === 'stream_error'); + assert.ok(err); + assert.match(err.error, /message_stop/i); + }); +}); + +// ── mapError text format ── + +describe('AnthropicMessagesAdapter: mapError', () => { + test('byte-stable error text matches pre-G1 mapAnthropicError format', () => { + const a = new AnthropicMessagesAdapter(); + assert.equal(a.mapError({ status: 404, message: 'Not Found' }).errorText, 'Anthropic API error (404): Not Found'); + assert.equal(a.mapError({ status: 500 }).errorText, 'Anthropic API error (500): Unknown API error'); + assert.equal(a.mapError({ message: 'Network failed' }).errorText, 'Anthropic API error (0): Network failed'); + }); +}); + +// ── isTerminalStopReason ── + +describe('AnthropicMessagesAdapter: isTerminalStopReason', () => { + const a = new AnthropicMessagesAdapter(); + + for (const r of ['end_turn', 'max_tokens', 'stop_sequence', 'refusal', 'model_context_window_exceeded']) { + test(`'${r}' is terminal`, () => { + assert.equal(a.isTerminalStopReason(r), true); + }); + } + + for (const r of ['tool_use', 'pause_turn', null, undefined, 'future_reason', '']) { + test(`${JSON.stringify(r)} is NOT terminal`, () => { + assert.equal(a.isTerminalStopReason(r), false); + }); + } +}); diff --git a/packages/api/test/ble-adapters.test.js b/packages/api/test/ble-adapters.test.js new file mode 100644 index 0000000000..ba5839b322 --- /dev/null +++ b/packages/api/test/ble-adapters.test.js @@ -0,0 +1,65 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + BLE_ADAPTERS, + decodeBleCommandValue, + normalizeGattUuid, + selectBleAdapter, +} from '../dist/domains/limb/ble/BleAdapters.js'; + +describe('BLE standard adapters', () => { + it('normalizes Bluetooth base UUIDs', () => { + assert.equal(normalizeGattUuid('0000180F-0000-1000-8000-00805F9B34FB'), '180f'); + assert.equal(normalizeGattUuid('2A19'), '2a19'); + }); + + it('decodes battery, temperature, and humidity with units', () => { + assert.deepEqual(decodeBleCommandValue('standard.battery', 'ble.battery.read', Buffer.from([87])), { + kind: 'battery', + value: 87, + unit: '%', + }); + + const temperature = Buffer.alloc(2); + temperature.writeInt16LE(2156); + assert.deepEqual(decodeBleCommandValue('standard.environmental', 'ble.temperature.read', temperature), { + kind: 'temperature', + value: 21.56, + unit: '°C', + }); + + const humidity = Buffer.alloc(2); + humidity.writeUInt16LE(5034); + assert.deepEqual(decodeBleCommandValue('standard.environmental', 'ble.humidity.read', humidity), { + kind: 'humidity', + value: 50.34, + unit: '%', + }); + }); + + it('rejects malformed and out-of-range sensor data', () => { + assert.throws(() => decodeBleCommandValue('standard.battery', 'ble.battery.read', Buffer.alloc(0)), /length/); + assert.throws(() => decodeBleCommandValue('standard.battery', 'ble.battery.read', Buffer.from([101])), /outside/); + + const humidity = Buffer.alloc(2); + humidity.writeUInt16LE(10_001); + assert.throws(() => decodeBleCommandValue('standard.environmental', 'ble.humidity.read', humidity), /outside/); + }); + + it('selects only known adapters from inspected characteristics', () => { + const environmental = selectBleAdapter([ + { uuid: '181a', characteristics: [{ uuid: '2a6e', properties: ['read'] }] }, + ]); + assert.equal(environmental?.id, 'standard.environmental'); + + const unknown = selectBleAdapter([ + { uuid: 'ffff', characteristics: [{ uuid: 'eeee', properties: ['read', 'write'] }] }, + ]); + assert.equal(unknown, null); + const writeOnlyEnvironmental = selectBleAdapter([ + { uuid: '181a', characteristics: [{ uuid: '2a6e', properties: ['write'] }] }, + ]); + assert.equal(writeOnlyEnvironmental, null); + assert.equal(Object.hasOwn(BLE_ADAPTERS, 'standard.environmental'), true); + }); +}); diff --git a/packages/api/test/ble-binding-store.test.js b/packages/api/test/ble-binding-store.test.js new file mode 100644 index 0000000000..de9922e74d --- /dev/null +++ b/packages/api/test/ble-binding-store.test.js @@ -0,0 +1,115 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + BLE_BINDING_INDEX_KEY, + bleBindingKey, + MemoryBleBindingStore, + RedisBleBindingStore, +} from '../dist/domains/limb/ble/BleBindingStore.js'; + +const BINDING = { + bindingId: 'binding-1', + scopeId: 'instance', + platformDeviceId: 'device-1', + displayName: 'Desk Sensor', + adapterId: 'standard.environmental', + commands: ['ble.temperature.read'], + nodeId: 'ble:binding-1', + createdAt: 100, + lastConnectedAt: null, +}; + +class FakeRedis { + values = new Map(); + sets = new Map(); + operations = []; + + multi() { + const queued = []; + return { + set: (key, value) => { + queued.push(['set', key, value]); + return this; + }, + sadd: (key, value) => { + queued.push(['sadd', key, value]); + return this; + }, + del: (key) => { + queued.push(['del', key]); + return this; + }, + srem: (key, value) => { + queued.push(['srem', key, value]); + return this; + }, + exec: async () => { + for (const op of queued) this.apply(op); + this.operations.push(...queued); + return queued.map(() => [null, 1]); + }, + }; + } + + apply([command, key, value]) { + if (command === 'set') this.values.set(key, value); + if (command === 'sadd') { + const set = this.sets.get(key) ?? new Set(); + set.add(value); + this.sets.set(key, set); + } + if (command === 'del') this.values.delete(key); + if (command === 'srem') this.sets.get(key)?.delete(value); + } + + async get(key) { + return this.values.get(key) ?? null; + } + + async smembers(key) { + return [...(this.sets.get(key) ?? [])]; + } +} + +describe('BLE binding stores', () => { + it('memory implementation is isolated for tests', async () => { + const store = new MemoryBleBindingStore(); + await store.put(BINDING); + assert.deepEqual(await store.get('instance', 'binding-1'), BINDING); + assert.equal((await store.list('instance')).length, 1); + await store.delete('instance', 'binding-1'); + assert.equal(await store.get('instance', 'binding-1'), null); + }); + + it('Redis implementation persists without EXPIRE and rehydrates', async () => { + const redis = new FakeRedis(); + const store = new RedisBleBindingStore(redis); + await store.put(BINDING); + + assert.deepEqual( + redis.operations.map((op) => op[0]), + ['set', 'sadd'], + ); + assert.equal( + redis.operations.some((op) => op.includes('EX') || op[0] === 'expire'), + false, + ); + assert.equal(redis.sets.get(BLE_BINDING_INDEX_KEY('instance')).has('binding-1'), true); + + const restarted = new RedisBleBindingStore(redis); + assert.deepEqual(await restarted.get('instance', 'binding-1'), BINDING); + assert.deepEqual(await restarted.list('instance'), [BINDING]); + }); + + it('skips corrupt records without deleting persistent data', async () => { + const redis = new FakeRedis(); + redis.sets.set(BLE_BINDING_INDEX_KEY('instance'), new Set(['bad'])); + redis.values.set(bleBindingKey('instance', 'bad'), '{broken-json'); + const warnings = []; + const store = new RedisBleBindingStore(redis, { warn: (message) => warnings.push(message) }); + + assert.deepEqual(await store.list('instance'), []); + assert.equal(redis.values.has(bleBindingKey('instance', 'bad')), true); + assert.equal(warnings.length, 1); + }); +}); diff --git a/packages/api/test/ble-device-manager.test.js b/packages/api/test/ble-device-manager.test.js new file mode 100644 index 0000000000..6b6cb2e894 --- /dev/null +++ b/packages/api/test/ble-device-manager.test.js @@ -0,0 +1,287 @@ +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { beforeEach, describe, it } from 'node:test'; +import { MemoryBleBindingStore } from '../dist/domains/limb/ble/BleBindingStore.js'; +import { BleDeviceManager } from '../dist/domains/limb/ble/BleDeviceManager.js'; +import { LimbAccessPolicy } from '../dist/domains/limb/LimbAccessPolicy.js'; +import { LimbActionLog } from '../dist/domains/limb/LimbActionLog.js'; +import { LimbLeaseManager } from '../dist/domains/limb/LimbLeaseManager.js'; +import { LimbRegistry } from '../dist/domains/limb/LimbRegistry.js'; + +class FakeHelper extends EventEmitter { + requests = []; + status = { state: 'idle', reason: null, restartAttempts: 0 }; + + async request(command, params) { + this.requests.push({ command, params }); + if (command === 'device.inspect') { + return { + services: [ + { + uuid: '181a', + characteristics: [ + { uuid: '2a6e', properties: ['read', 'notify'] }, + { uuid: '2a6f', properties: ['read'] }, + ], + }, + ], + }; + } + if (command === 'gatt.read') { + const value = Buffer.alloc(2); + value.writeInt16LE(2345); + return { valueBase64: value.toString('base64') }; + } + return {}; + } + + setState(state, reason = null) { + this.status = { state, reason, restartAttempts: 0 }; + this.emit('state', this.status); + } +} + +function discoveryEvent(sessionId, deviceId = 'private-corebluetooth-id') { + return { + kind: 'event', + event: 'scan.discovered', + data: { sessionId, deviceId, name: 'Desk Sensor', rssi: -45, serviceUuids: ['181a'] }, + }; +} + +describe('BleDeviceManager + BleLimbNode', () => { + let helper; + let store; + let registry; + let actionLog; + let manager; + + beforeEach(() => { + helper = new FakeHelper(); + store = new MemoryBleBindingStore(); + registry = new LimbRegistry(); + actionLog = new LimbActionLog(); + registry.setDeps({ + accessPolicy: new LimbAccessPolicy(), + leaseManager: new LimbLeaseManager(), + actionLog, + }); + manager = new BleDeviceManager({ helper, store, registry, platform: 'darwin' }); + }); + + it('hydrates persistent bindings without spawning the helper', async () => { + await store.put({ + bindingId: 'persisted', + scopeId: 'instance', + platformDeviceId: 'private-id', + displayName: 'Persisted Sensor', + adapterId: 'standard.environmental', + commands: ['ble.temperature.read'], + nodeId: 'ble:persisted', + createdAt: 100, + lastConnectedAt: null, + }); + + await manager.initialize(); + assert.ok(registry.getNode('ble:persisted')); + assert.deepEqual(helper.requests, []); + const listed = await manager.listBindings(); + assert.equal('platformDeviceId' in listed[0], false); + }); + + it('binds only a discovery from the active scan and registers a typed node', async () => { + const session = await manager.startScan(); + helper.emit('event', discoveryEvent(session.sessionId)); + const discovery = manager.scanSnapshot().discoveries[0]; + + const binding = await manager.bind({ sessionId: session.sessionId, discoveryId: discovery.discoveryId }); + assert.equal(binding.adapterId, 'standard.environmental'); + assert.deepEqual(binding.commands, ['ble.temperature.read', 'ble.humidity.read']); + assert.equal('platformDeviceId' in binding, false); + assert.ok(registry.getNode(binding.nodeId)); + assert.equal((await store.list('instance')).length, 1); + }); + + it('serializes concurrent bind attempts for the same discovered device', async () => { + const session = await manager.startScan(); + helper.emit('event', discoveryEvent(session.sessionId)); + const discovery = manager.scanSnapshot().discoveries[0]; + const originalRequest = helper.request.bind(helper); + let releaseInspection; + let markInspectionStarted; + const inspectionGate = new Promise((resolve) => { + releaseInspection = resolve; + }); + const inspectionStarted = new Promise((resolve) => { + markInspectionStarted = resolve; + }); + let inspectionCount = 0; + helper.request = async (command, params) => { + if (command === 'device.inspect') { + inspectionCount += 1; + markInspectionStarted(); + await inspectionGate; + } + return originalRequest(command, params); + }; + + const firstBind = manager.bind({ sessionId: session.sessionId, discoveryId: discovery.discoveryId }); + await inspectionStarted; + const secondBind = manager.bind({ sessionId: session.sessionId, discoveryId: discovery.discoveryId }); + await Promise.resolve(); + releaseInspection(); + + const [first, second] = await Promise.allSettled([firstBind, secondBind]); + assert.equal(first.status, 'fulfilled'); + assert.equal(second.status, 'rejected'); + assert.match(second.reason.message, /already bound|binding is already in progress/); + assert.equal(inspectionCount, 1); + assert.equal((await store.list('instance')).length, 1); + }); + + it('releases the bind reservation after inspection fails', async () => { + const session = await manager.startScan(); + helper.emit('event', discoveryEvent(session.sessionId)); + const discovery = manager.scanSnapshot().discoveries[0]; + const originalRequest = helper.request.bind(helper); + let failInspection = true; + helper.request = async (command, params) => { + if (command === 'device.inspect' && failInspection) { + failInspection = false; + throw new Error('inspection failed'); + } + return originalRequest(command, params); + }; + + await assert.rejects( + manager.bind({ sessionId: session.sessionId, discoveryId: discovery.discoveryId }), + /inspection failed/, + ); + const binding = await manager.bind({ sessionId: session.sessionId, discoveryId: discovery.discoveryId }); + + assert.equal(binding.adapterId, 'standard.environmental'); + assert.equal((await store.list('instance')).length, 1); + }); + + it('rejects stale or unknown discoveries without persistence', async () => { + await assert.rejects(manager.bind({ sessionId: 'stale', discoveryId: 'unknown' }), /not available/); + assert.deepEqual(await store.list('instance'), []); + }); + + it('reads through the F126 pipeline and records an Action Log entry', async () => { + const session = await manager.startScan(); + helper.emit('event', discoveryEvent(session.sessionId)); + const discovery = manager.scanSnapshot().discoveries[0]; + const binding = await manager.bind({ sessionId: session.sessionId, discoveryId: discovery.discoveryId }); + + const result = await registry.invoke( + binding.nodeId, + 'ble.temperature.read', + {}, + { catId: 'sol', invocationId: 'inv-1' }, + ); + assert.deepEqual(result, { + success: true, + data: { kind: 'temperature', value: 23.45, unit: '°C' }, + }); + const entries = actionLog.getByNode(binding.nodeId); + assert.equal(entries.length, 1); + assert.equal(entries[0].status, 'completed'); + assert.equal(entries[0].catId, 'sol'); + }); + + it('rejects arbitrary GATT writes before reaching the helper', async () => { + const session = await manager.startScan(); + helper.emit('event', discoveryEvent(session.sessionId)); + const binding = await manager.bind({ + sessionId: session.sessionId, + discoveryId: manager.scanSnapshot().discoveries[0].discoveryId, + }); + const before = helper.requests.length; + + const result = await registry.invoke(binding.nodeId, 'gatt.write', { valueBase64: 'AQ==' }, { catId: 'sol' }); + assert.equal(result.success, false); + assert.match(result.error, /not in any capability whitelist/); + assert.equal(helper.requests.length, before); + }); + + it('deregisters and disconnects on explicit unbind', async () => { + const session = await manager.startScan(); + helper.emit('event', discoveryEvent(session.sessionId)); + const binding = await manager.bind({ + sessionId: session.sessionId, + discoveryId: manager.scanSnapshot().discoveries[0].discoveryId, + }); + + assert.equal(await manager.unbind(binding.bindingId), true); + assert.equal(registry.getNode(binding.nodeId), undefined); + assert.equal(await store.get('instance', binding.bindingId), null); + assert.equal(helper.requests.at(-1).command, 'device.disconnect'); + }); + + it('projects helper degradation onto every BLE node', async () => { + await store.put({ + bindingId: 'persisted', + scopeId: 'instance', + platformDeviceId: 'private-id', + displayName: 'Persisted Sensor', + adapterId: 'standard.environmental', + commands: ['ble.temperature.read'], + nodeId: 'ble:persisted', + createdAt: 100, + lastConnectedAt: null, + }); + await manager.initialize(); + + helper.setState('degraded', 'helper crashed'); + assert.equal(registry.getNode('ble:persisted').status, 'degraded'); + helper.setState('ready'); + assert.equal(registry.getNode('ble:persisted').status, 'online'); + }); + + it('subscribes only to a declared button characteristic and emits typed notifications', async () => { + const buttonService = '7f9c0001-7d7e-4f1d-9d7b-5f2580000001'; + const buttonCharacteristic = '7f9c0002-7d7e-4f1d-9d7b-5f2580000001'; + const originalRequest = helper.request.bind(helper); + helper.request = async (command, params) => { + helper.requests.push({ command, params }); + if (command === 'device.inspect') { + return { + services: [ + { + uuid: buttonService, + characteristics: [{ uuid: buttonCharacteristic, properties: ['notify'] }], + }, + ], + }; + } + if (command === 'gatt.subscribe') return { subscribed: true }; + return originalRequest(command, params); + }; + + const session = await manager.startScan(); + helper.emit('event', discoveryEvent(session.sessionId, 'button-device-id')); + const binding = await manager.bind({ + sessionId: session.sessionId, + discoveryId: manager.scanSnapshot().discoveries[0].discoveryId, + }); + const result = await registry.invoke(binding.nodeId, 'ble.button.subscribe', {}, { catId: 'sol' }); + assert.deepEqual(result, { success: true, data: { subscribed: true } }); + + const notificationPromise = new Promise((resolve) => manager.once('notification', resolve)); + helper.emit('event', { + kind: 'event', + event: 'gatt.notification', + data: { + deviceId: 'button-device-id', + serviceUuid: buttonService, + characteristicUuid: buttonCharacteristic, + valueBase64: Buffer.from([1]).toString('base64'), + observedAt: 123, + }, + }); + const notification = await notificationPromise; + assert.deepEqual(notification.value, { kind: 'button', value: 'press', unit: 'event' }); + assert.equal(notification.bindingId, binding.bindingId); + }); +}); diff --git a/packages/api/test/ble-helper-client.test.js b/packages/api/test/ble-helper-client.test.js new file mode 100644 index 0000000000..a855bbbde0 --- /dev/null +++ b/packages/api/test/ble-helper-client.test.js @@ -0,0 +1,271 @@ +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { describe, it } from 'node:test'; +import { BleHelperClient } from '../dist/domains/limb/ble/BleHelperClient.js'; + +class FakeStream extends EventEmitter {} + +class FakeProcess extends EventEmitter { + stdout = new FakeStream(); + stderr = new FakeStream(); + killed = false; + writes = []; + + stdin = { + write: (line, callback) => { + this.writes.push(line); + this.onWrite?.(JSON.parse(line)); + callback?.(); + return true; + }, + }; + + send(message) { + this.stdout.emit('data', Buffer.from(`${JSON.stringify(message)}\n`)); + } + + sendHello(version = 1) { + this.send({ protocol: 'ble-helper', version, kind: 'hello' }); + } + + crash(code = 1) { + this.emit('exit', code, null); + } + + kill() { + this.killed = true; + return true; + } +} + +function respondingProcess() { + const process = new FakeProcess(); + process.onWrite = (request) => { + queueMicrotask(() => + process.send({ + protocol: 'ble-helper', + version: 1, + kind: 'response', + requestId: request.requestId, + ok: true, + data: { accepted: request.command }, + }), + ); + }; + queueMicrotask(() => process.sendHello()); + return process; +} + +describe('BleHelperClient', () => { + it('spawns lazily and correlates responses', async () => { + let spawnCount = 0; + const process = respondingProcess(); + const client = new BleHelperClient({ + platform: 'darwin', + spawnProcess: () => { + spawnCount += 1; + return process; + }, + }); + + assert.equal(client.status.state, 'idle'); + assert.equal(spawnCount, 0); + const result = await client.request('scan.start', { sessionId: 'scan-1', timeoutMs: 30_000 }); + assert.deepEqual(result, { accepted: 'scan.start' }); + assert.equal(spawnCount, 1); + assert.equal(client.status.state, 'ready'); + }); + + it('rejects an unknown handshake version without retrying', async () => { + let spawnCount = 0; + const process = new FakeProcess(); + const client = new BleHelperClient({ + platform: 'darwin', + spawnProcess: () => { + spawnCount += 1; + queueMicrotask(() => process.sendHello(2)); + return process; + }, + sleep: async () => {}, + }); + + await assert.rejects(client.start(), /Unsupported BLE helper protocol version/); + assert.equal(spawnCount, 1); + assert.equal(process.killed, true); + assert.equal(client.status.state, 'degraded'); + }); + + it('recovers from a crash after 1 second and keeps the API caller alive', async () => { + const firstProcess = respondingProcess(); + let spawnCount = 0; + const delays = []; + const client = new BleHelperClient({ + platform: 'darwin', + spawnProcess: () => { + spawnCount += 1; + return spawnCount === 1 ? firstProcess : respondingProcess(); + }, + sleep: async (ms) => delays.push(ms), + }); + + await client.start(); + const first = client.currentProcessForTest; + first.crash(); + const result = await client.request('scan.start', { sessionId: 'scan-2', timeoutMs: 30_000 }); + + assert.deepEqual(result, { accepted: 'scan.start' }); + assert.deepEqual(delays, [1_000]); + assert.equal(client.status.state, 'ready'); + }); + + it('marks the capability degraded after three failed restart attempts', async () => { + const first = respondingProcess(); + let spawnCount = 0; + const delays = []; + const client = new BleHelperClient({ + platform: 'darwin', + spawnProcess: () => { + spawnCount += 1; + if (spawnCount === 1) return first; + const failed = new FakeProcess(); + queueMicrotask(() => failed.crash()); + return failed; + }, + sleep: async (ms) => delays.push(ms), + handshakeTimeoutMs: 20, + }); + + await client.start(); + first.crash(); + await assert.rejects(client.start(), /unavailable/); + assert.equal(spawnCount, 4); + assert.deepEqual(delays, [1_000, 2_000, 4_000]); + assert.equal(client.status.state, 'degraded'); + }); + + it('does not restart after shutdown interrupts a recovery backoff', async () => { + const first = respondingProcess(); + let spawnCount = 0; + let releaseSleep; + const client = new BleHelperClient({ + platform: 'darwin', + spawnProcess: () => { + spawnCount += 1; + return spawnCount === 1 ? first : respondingProcess(); + }, + sleep: () => + new Promise((resolve) => { + releaseSleep = resolve; + }), + }); + + await client.start(); + first.crash(); + assert.equal(typeof releaseSleep, 'function'); + + await client.shutdown(); + releaseSleep(); + await new Promise((resolve) => setImmediate(resolve)); + + assert.equal(spawnCount, 1); + assert.equal(client.status.state, 'idle'); + }); + + it('times out a request without exiting the process', async () => { + const process = new FakeProcess(); + queueMicrotask(() => process.sendHello()); + let fireRequestTimeout; + let unrefCalled = false; + const requestTimer = { + unref: () => { + unrefCalled = true; + }, + }; + const client = new BleHelperClient({ + platform: 'darwin', + spawnProcess: () => process, + requestTimeoutMs: 5, + setRequestTimer: (callback, ms) => { + assert.equal(ms, 5); + fireRequestTimeout = callback; + return requestTimer; + }, + }); + + await client.start(); + const request = client.request('scan.start', { sessionId: 'scan-timeout', timeoutMs: 30_000 }); + await Promise.resolve(); + assert.equal(typeof fireRequestTimeout, 'function'); + assert.equal(unrefCalled, true); + fireRequestTimeout(); + + await assert.rejects(request, /timed out/); + assert.equal(client.status.state, 'ready'); + }); + + it('ignores malformed runtime messages and accepts the next valid response', async () => { + const warnings = []; + const process = new FakeProcess(); + process.onWrite = (request) => { + queueMicrotask(() => { + process.stdout.emit('data', Buffer.from('{bad-json}\n')); + process.send({ + protocol: 'ble-helper', + version: 1, + kind: 'response', + requestId: request.requestId, + ok: true, + data: { ok: true }, + }); + }); + }; + queueMicrotask(() => process.sendHello()); + const client = new BleHelperClient({ + platform: 'darwin', + spawnProcess: () => process, + logger: { warn: (message) => warnings.push(message), info: () => {} }, + }); + + assert.deepEqual(await client.request('scan.start', { sessionId: 'scan-3', timeoutMs: 30_000 }), { ok: true }); + assert.equal(warnings.length, 1); + assert.equal(client.status.state, 'ready'); + }); + + it('preserves UTF-8 event data split across stdout chunks', async () => { + const process = new FakeProcess(); + queueMicrotask(() => process.sendHello()); + const client = new BleHelperClient({ platform: 'darwin', spawnProcess: () => process }); + await client.start(); + + const eventPromise = new Promise((resolve) => client.once('event', resolve)); + const encoded = Buffer.from( + `${JSON.stringify({ + protocol: 'ble-helper', + version: 1, + kind: 'event', + event: 'scan.discovered', + data: { + sessionId: 'scan-utf8', + deviceId: 'device-utf8', + name: '桌面传感器', + rssi: -42, + serviceUuids: ['181a'], + }, + })}\n`, + ); + const splitAt = encoded.indexOf(Buffer.from('桌')) + 1; + process.stdout.emit('data', encoded.subarray(0, splitAt)); + process.stdout.emit('data', encoded.subarray(splitAt)); + + const event = await eventPromise; + assert.equal(event.data.name, '桌面传感器'); + }); + + it('reports unsupported without spawning on non-macOS platforms', async () => { + let spawned = false; + const client = new BleHelperClient({ platform: 'linux', spawnProcess: () => (spawned = true) }); + await assert.rejects(client.start(), /only available on macOS/); + assert.equal(spawned, false); + assert.equal(client.status.state, 'unsupported'); + }); +}); diff --git a/packages/api/test/ble-helper-process.test.js b/packages/api/test/ble-helper-process.test.js new file mode 100644 index 0000000000..a595814679 --- /dev/null +++ b/packages/api/test/ble-helper-process.test.js @@ -0,0 +1,21 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { resolveBleHelperExecutable } from '../dist/domains/limb/ble/BleHelperProcess.js'; + +describe('BleHelperProcess', () => { + it('resolves an Intel source build from the x86_64 Swift output directory', () => { + const executable = resolveBleHelperExecutable('x64', (candidate) => + candidate.endsWith('/native/ble-helper/macos/.build/x86_64/ble-helper'), + ); + + assert.match(executable, /\/native\/ble-helper\/macos\/\.build\/x86_64\/ble-helper$/); + }); + + it('keeps the packaged Intel helper directory normalized as x64', () => { + const executable = resolveBleHelperExecutable('x64', (candidate) => + candidate.endsWith('/bundled/ble-helper-darwin-x64/ble-helper'), + ); + + assert.match(executable, /\/bundled\/ble-helper-darwin-x64\/ble-helper$/); + }); +}); diff --git a/packages/api/test/ble-helper-protocol.test.js b/packages/api/test/ble-helper-protocol.test.js new file mode 100644 index 0000000000..27e8ccb837 --- /dev/null +++ b/packages/api/test/ble-helper-protocol.test.js @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + BLE_HELPER_MAX_LINE_BYTES, + encodeBleHelperRequest, + parseBleHelperMessage, +} from '../dist/domains/limb/ble/BleHelperProtocol.js'; + +describe('BleHelperProtocol', () => { + it('encodes a versioned allowlisted request', () => { + const line = encodeBleHelperRequest('scan.start', { sessionId: 'scan-1', timeoutMs: 30_000 }, 'req-1'); + assert.equal(line.endsWith('\n'), true); + assert.deepEqual(JSON.parse(line), { + protocol: 'ble-helper', + version: 1, + requestId: 'req-1', + command: 'scan.start', + params: { sessionId: 'scan-1', timeoutMs: 30_000 }, + }); + }); + + it('has no arbitrary GATT write command', () => { + assert.throws( + () => encodeBleHelperRequest('gatt.write', { deviceId: 'device-1', valueBase64: 'AQ==' }, 'req-1'), + /Unsupported BLE helper command/, + ); + }); + + it('parses the required handshake', () => { + const message = parseBleHelperMessage('{"protocol":"ble-helper","version":1,"kind":"hello"}'); + assert.equal(message.kind, 'hello'); + }); + + it('rejects an unknown protocol version', () => { + assert.throws( + () => parseBleHelperMessage('{"protocol":"ble-helper","version":2,"kind":"hello"}'), + /Unsupported BLE helper protocol version/, + ); + }); + + it('rejects malformed JSON and oversized lines', () => { + assert.throws(() => parseBleHelperMessage('{not-json}'), /Invalid BLE helper JSON/); + assert.throws(() => parseBleHelperMessage('x'.repeat(BLE_HELPER_MAX_LINE_BYTES + 1)), /exceeds/); + }); + + it('rejects notification payloads above 4 KiB', () => { + const line = JSON.stringify({ + protocol: 'ble-helper', + version: 1, + kind: 'event', + event: 'gatt.notification', + data: { + deviceId: 'device-1', + serviceUuid: '180f', + characteristicUuid: '2a19', + valueBase64: Buffer.alloc(4097).toString('base64'), + observedAt: Date.now(), + }, + }); + assert.throws(() => parseBleHelperMessage(line), /Invalid BLE helper message/); + }); + + it('rejects malformed base64 notification data', () => { + const line = JSON.stringify({ + protocol: 'ble-helper', + version: 1, + kind: 'event', + event: 'gatt.notification', + data: { + deviceId: 'device-1', + serviceUuid: '180f', + characteristicUuid: '2a19', + valueBase64: 'not base64!', + observedAt: Date.now(), + }, + }); + assert.throws(() => parseBleHelperMessage(line), /Invalid BLE helper message/); + }); +}); diff --git a/packages/api/test/ble-routes.test.js b/packages/api/test/ble-routes.test.js new file mode 100644 index 0000000000..0ce10c49ad --- /dev/null +++ b/packages/api/test/ble-routes.test.js @@ -0,0 +1,119 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import Fastify from 'fastify'; +import { registerBleRoutes } from '../dist/routes/ble-routes.js'; + +function managerStub() { + return { + status: () => ({ + platform: 'darwin', + available: true, + state: 'idle', + reason: null, + restartAttempts: 0, + bindingCount: 0, + }), + listBindings: async () => [], + scanSnapshot: () => ({ + active: false, + sessionId: null, + startedAt: null, + expiresAt: null, + discoveries: [], + }), + startScan: async () => ({ sessionId: 'scan-1', startedAt: 100, expiresAt: 30_100 }), + stopScan: async () => {}, + bind: async () => ({ + bindingId: 'binding-1', + displayName: 'Sensor', + adapterId: 'standard.environmental', + commands: ['ble.temperature.read'], + nodeId: 'ble:binding-1', + createdAt: 100, + lastConnectedAt: 100, + }), + unbind: async (bindingId) => bindingId === 'binding-1', + }; +} + +async function createApp({ manager = managerStub(), authenticated = true } = {}) { + const app = Fastify(); + if (authenticated) { + app.addHook('onRequest', async (request) => { + request.sessionUserId = 'owner'; + }); + } + registerBleRoutes(app, { manager, platform: 'darwin' }); + await app.ready(); + return app; +} + +describe('BLE operator routes', () => { + it('requires a session identity for BLE status', async () => { + const app = await createApp({ authenticated: false }); + const response = await app.inject({ method: 'GET', url: '/api/limb/ble/status' }); + assert.equal(response.statusCode, 401); + }); + + it('returns status and never exposes a platform device ID', async () => { + const app = await createApp(); + const status = await app.inject({ method: 'GET', url: '/api/limb/ble/status' }); + assert.equal(status.statusCode, 200); + assert.equal(status.json().state, 'idle'); + + const bindings = await app.inject({ method: 'GET', url: '/api/limb/ble/bindings' }); + assert.equal(bindings.statusCode, 200); + assert.equal(bindings.payload.includes('platformDeviceId'), false); + }); + + it('starts and stops an explicit scan session', async () => { + const app = await createApp(); + const started = await app.inject({ method: 'POST', url: '/api/limb/ble/scan', payload: {} }); + assert.equal(started.statusCode, 201); + assert.equal(started.json().sessionId, 'scan-1'); + + const stopped = await app.inject({ method: 'DELETE', url: '/api/limb/ble/scan' }); + assert.equal(stopped.statusCode, 204); + }); + + it('validates opaque bind input and supports explicit unbind', async () => { + const app = await createApp(); + const invalid = await app.inject({ method: 'POST', url: '/api/limb/ble/bindings', payload: {} }); + assert.equal(invalid.statusCode, 400); + + const bound = await app.inject({ + method: 'POST', + url: '/api/limb/ble/bindings', + payload: { sessionId: 'scan-1', discoveryId: 'discovery-1' }, + }); + assert.equal(bound.statusCode, 201); + assert.equal(bound.payload.includes('platformDeviceId'), false); + + const removed = await app.inject({ method: 'DELETE', url: '/api/limb/ble/bindings/binding-1' }); + assert.equal(removed.statusCode, 204); + const missing = await app.inject({ method: 'DELETE', url: '/api/limb/ble/bindings/missing' }); + assert.equal(missing.statusCode, 404); + }); + + it('fails closed for mutating routes when persistent storage is unavailable', async () => { + const app = await createApp({ manager: null }); + const status = await app.inject({ method: 'GET', url: '/api/limb/ble/status' }); + assert.equal(status.statusCode, 200); + assert.equal(status.json().available, false); + assert.match(status.json().reason, /Redis/); + + const scan = await app.inject({ method: 'POST', url: '/api/limb/ble/scan', payload: {} }); + assert.equal(scan.statusCode, 503); + }); + + it('maps an active-scan conflict without crashing the API', async () => { + const manager = managerStub(); + manager.startScan = async () => { + throw new Error('A BLE scan session is already active'); + }; + const app = await createApp({ manager }); + const response = await app.inject({ method: 'POST', url: '/api/limb/ble/scan', payload: {} }); + assert.equal(response.statusCode, 409); + assert.match(response.json().error, /already active/); + }); +}); diff --git a/packages/api/test/ble-scan-session.test.js b/packages/api/test/ble-scan-session.test.js new file mode 100644 index 0000000000..73383006f7 --- /dev/null +++ b/packages/api/test/ble-scan-session.test.js @@ -0,0 +1,132 @@ +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { describe, it } from 'node:test'; +import { BleScanSession } from '../dist/domains/limb/ble/BleScanSession.js'; + +class FakeHelper extends EventEmitter { + requests = []; + + async request(command, params) { + this.requests.push({ command, params }); + return {}; + } +} + +describe('BleScanSession', () => { + it('keeps platform device IDs private and clears discoveries on stop', async () => { + const helper = new FakeHelper(); + let timerCallback; + const scan = new BleScanSession(helper, { + now: () => 1_000, + setTimer: (callback) => { + timerCallback = callback; + return 1; + }, + clearTimer: () => {}, + }); + + const started = await scan.start(); + helper.emit('event', { + kind: 'event', + event: 'scan.discovered', + data: { + sessionId: started.sessionId, + deviceId: 'core-bluetooth-private-id', + name: 'Sensor', + rssi: -48, + serviceUuids: ['181a'], + }, + }); + + const snapshot = scan.snapshot(); + assert.equal(snapshot.discoveries.length, 1); + assert.equal('deviceId' in snapshot.discoveries[0], false); + assert.notEqual(snapshot.discoveries[0].discoveryId, 'core-bluetooth-private-id'); + assert.equal( + scan.resolveDiscovery(started.sessionId, snapshot.discoveries[0].discoveryId).platformDeviceId, + 'core-bluetooth-private-id', + ); + + await scan.stop(); + assert.deepEqual(scan.snapshot().discoveries, []); + assert.equal(scan.resolveDiscovery(started.sessionId, snapshot.discoveries[0].discoveryId), null); + assert.equal(typeof timerCallback, 'function'); + }); + + it('ends after 30 seconds and ignores events from another session', async () => { + const helper = new FakeHelper(); + let timerCallback; + let scheduledMs; + const scan = new BleScanSession(helper, { + now: () => 5_000, + setTimer: (callback, ms) => { + timerCallback = callback; + scheduledMs = ms; + return 1; + }, + clearTimer: () => {}, + }); + + const started = await scan.start(); + assert.equal(scheduledMs, 30_000); + helper.emit('event', { + kind: 'event', + event: 'scan.discovered', + data: { sessionId: 'other', deviceId: 'device', name: null, rssi: -60, serviceUuids: [] }, + }); + assert.deepEqual(scan.snapshot().discoveries, []); + + timerCallback(); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(scan.snapshot().active, false); + assert.deepEqual(scan.snapshot().discoveries, []); + assert.equal(helper.requests.at(-1).command, 'scan.stop'); + assert.equal(helper.requests.at(-1).params.sessionId, started.sessionId); + }); + + it('clears local discoveries when the helper reports an early stop', async () => { + const helper = new FakeHelper(); + const scan = new BleScanSession(helper, { setTimer: () => 1, clearTimer: () => {} }); + const started = await scan.start(); + helper.emit('event', { + kind: 'event', + event: 'scan.discovered', + data: { sessionId: started.sessionId, deviceId: 'device', name: null, rssi: -60, serviceUuids: [] }, + }); + assert.equal(scan.snapshot().discoveries.length, 1); + + helper.emit('event', { + kind: 'event', + event: 'scan.state', + data: { sessionId: started.sessionId, state: 'stopped' }, + }); + assert.equal(scan.snapshot().active, false); + assert.deepEqual(scan.snapshot().discoveries, []); + }); + + it('does not install a timeout after an early stop races with scan acknowledgement', async () => { + const helper = new FakeHelper(); + let timerInstalled = false; + helper.request = async (command, params) => { + helper.requests.push({ command, params }); + helper.emit('event', { + kind: 'event', + event: 'scan.state', + data: { sessionId: params.sessionId, state: 'stopped' }, + }); + return {}; + }; + const scan = new BleScanSession(helper, { + setTimer: () => { + timerInstalled = true; + return 1; + }, + clearTimer: () => {}, + }); + + await scan.start(); + + assert.equal(scan.snapshot().active, false); + assert.equal(timerInstalled, false); + }); +}); diff --git a/packages/api/test/callback-routes.test.js b/packages/api/test/callback-routes.test.js index a24f61c59b..b3cb190323 100644 --- a/packages/api/test/callback-routes.test.js +++ b/packages/api/test/callback-routes.test.js @@ -3696,6 +3696,149 @@ describe('Callback Routes', () => { assert.equal(stored.automationState.trackingInstructions, ''); }); + test('POST register-pr-tracking binds instructions to the seeded PR head', async () => { + const app = await createApp(); + const { invocationId, callbackToken } = await registry.create('user-1', 'opus', 'thread-pr'); + + const response = await app.inject({ + method: 'POST', + url: '/api/callbacks/register-pr-tracking', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { + repoFullName: 'zts212653/cat-cafe', + prNumber: 105, + instructions: 'Handle this head before merge.', + }, + }); + + assert.equal(response.statusCode, 200); + const body = JSON.parse(response.body); + assert.equal(body.task.automationState.trackingInstructions, 'Handle this head before merge.'); + assert.equal(body.task.automationState.trackingInstructionsHeadSha, 'test-head'); + }); + + test('POST register-pr-tracking rebinds updated instructions to the current active PR head', async () => { + const { callbacksRoutes } = await import('../dist/routes/callbacks.js'); + const app = Fastify(); + const boundaryCalls = []; + const boundaries = [ + { + review: { lastCommentCursor: 10, lastDecisionCursor: 20 }, + ci: { headSha: 'sha-old', lastFingerprint: 'sha-old:pass', lastBucket: 'pass' }, + }, + { + review: { lastCommentCursor: 110, lastDecisionCursor: 220 }, + ci: { headSha: 'sha-current', lastFingerprint: 'sha-current:pending', lastBucket: 'pending' }, + }, + ]; + await app.register(callbacksRoutes, { + registry, + messageStore, + socketManager, + taskStore, + threadStore, + evidenceStore, + reflectionService, + markerQueue, + fetchPrTrackingBoundary: async (repoFullName, prNumber) => { + boundaryCalls.push({ repoFullName, prNumber }); + return boundaries.shift(); + }, + }); + + const { invocationId, callbackToken } = await registry.create('user-1', 'opus', 'thread-pr'); + const headers = { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }; + + const first = await app.inject({ + method: 'POST', + url: '/api/callbacks/register-pr-tracking', + headers, + payload: { + repoFullName: 'zts212653/cat-cafe', + prNumber: 106, + instructions: 'Handle old head.', + }, + }); + assert.equal(first.statusCode, 200); + assert.equal(JSON.parse(first.body).task.automationState.trackingInstructionsHeadSha, 'sha-old'); + + const second = await app.inject({ + method: 'POST', + url: '/api/callbacks/register-pr-tracking', + headers, + payload: { + repoFullName: 'zts212653/cat-cafe', + prNumber: 106, + instructions: 'Handle current head.', + }, + }); + assert.equal(second.statusCode, 200); + const updated = JSON.parse(second.body).task.automationState; + assert.equal(updated.trackingInstructions, 'Handle current head.'); + assert.equal(updated.trackingInstructionsHeadSha, 'sha-current'); + assert.deepEqual(boundaryCalls, [ + { repoFullName: 'zts212653/cat-cafe', prNumber: 106 }, + { repoFullName: 'zts212653/cat-cafe', prNumber: 106 }, + ]); + }); + + test('POST register-pr-tracking rejects active instruction updates when current PR head is unavailable', async () => { + const { callbacksRoutes } = await import('../dist/routes/callbacks.js'); + const app = Fastify(); + const boundaries = [ + { + review: { lastCommentCursor: 10, lastDecisionCursor: 20 }, + ci: { headSha: 'sha-old', lastFingerprint: 'sha-old:pass', lastBucket: 'pass' }, + }, + { + review: { lastCommentCursor: 110, lastDecisionCursor: 220 }, + }, + ]; + await app.register(callbacksRoutes, { + registry, + messageStore, + socketManager, + taskStore, + threadStore, + evidenceStore, + reflectionService, + markerQueue, + fetchPrTrackingBoundary: async () => boundaries.shift(), + }); + + const { invocationId, callbackToken } = await registry.create('user-1', 'opus', 'thread-pr'); + const headers = { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }; + + const first = await app.inject({ + method: 'POST', + url: '/api/callbacks/register-pr-tracking', + headers, + payload: { + repoFullName: 'zts212653/cat-cafe', + prNumber: 107, + instructions: 'Handle old head.', + }, + }); + assert.equal(first.statusCode, 200); + + const second = await app.inject({ + method: 'POST', + url: '/api/callbacks/register-pr-tracking', + headers, + payload: { + repoFullName: 'zts212653/cat-cafe', + prNumber: 107, + instructions: 'Handle current head.', + }, + }); + assert.equal(second.statusCode, 503); + assert.deepEqual(JSON.parse(second.body), { error: 'PR tracking boundary unavailable — try again later' }); + + const stored = taskStore.getBySubject('pr:zts212653/cat-cafe#107'); + assert.equal(stored.automationState.trackingInstructions, 'Handle old head.'); + assert.equal(stored.automationState.trackingInstructionsHeadSha, 'sha-old'); + }); + test('POST register-pr-tracking seeds PR feedback and CI boundaries after unregister/re-register', async () => { const { callbacksRoutes } = await import('../dist/routes/callbacks.js'); const app = Fastify(); diff --git a/packages/api/test/cat-catalog-store.test.js b/packages/api/test/cat-catalog-store.test.js index 718f520de6..404de1dafe 100644 --- a/packages/api/test/cat-catalog-store.test.js +++ b/packages/api/test/cat-catalog-store.test.js @@ -696,6 +696,129 @@ describe('cat-catalog-store', () => { }); }); + // F159 Phase G G2 step 1a (@gpt555 P2 coverage gap fix): + // mirror the nativeToolLevel/commandPolicy persistence test matrix for the + // new catAgentProtocol truth-source field — three behaviors that matter: + // 1. create persists when clientId === 'catagent' + // 2. patch updates / null clears existing catAgentProtocol + // 3. switching clientId away from 'catagent' clears catAgentProtocol + it('persists catAgentProtocol when creating a catagent member, clears for non-catagent', async () => { + const projectRoot = mkdtempSync(join(tmpdir(), 'cat-catalog-store-')); + const templatePath = join(projectRoot, 'cat-template.json'); + writeFileSync(templatePath, JSON.stringify(validConfig(), null, 2)); + writeCatCatalog(projectRoot, validConfig()); + + // 1. catagent + catAgentProtocol → persisted + await createRuntimeCat(projectRoot, { + catId: 'protocol-cat', + breedId: 'protocol-cat', + name: '协议猫', + displayName: '协议猫', + avatar: '/avatars/protocol-cat.png', + color: { primary: '#0ea5e9', secondary: '#e0f2fe' }, + mentionPatterns: ['@protocol-cat'], + roleDescription: '协议位测试', + clientId: 'catagent', + accountRef: 'claude', + defaultModel: 'claude-opus-4-6', + mcpSupport: false, + cli: { command: 'claude', outputFormat: 'stream-json' }, + catAgentProtocol: 'openai-chat', + }); + + let catalog = readRuntimeCatCatalog(projectRoot); + const created = catalog.breeds.find((breed) => breed.catId === 'protocol-cat'); + assert.ok(created, 'protocol-cat breed should be created'); + assert.equal(created.variants[0]?.catAgentProtocol, 'openai-chat'); + + // 2. non-catagent clientId with catAgentProtocol → field NOT persisted + await createRuntimeCat(projectRoot, { + catId: 'non-catagent', + breedId: 'non-catagent', + name: '非原生猫', + displayName: '非原生猫', + avatar: '/avatars/non-catagent.png', + color: { primary: '#dc2626', secondary: '#fee2e2' }, + mentionPatterns: ['@non-catagent'], + roleDescription: '非 catagent 隔离测试', + clientId: 'openai', + defaultModel: 'gpt-5.4', + mcpSupport: true, + cli: { command: 'codex', outputFormat: 'json' }, + catAgentProtocol: 'openai-chat', + }); + + catalog = readRuntimeCatCatalog(projectRoot); + const nonCatagent = catalog.breeds.find((breed) => breed.catId === 'non-catagent'); + assert.ok(nonCatagent, 'non-catagent breed should be created'); + assert.equal( + nonCatagent.variants[0]?.catAgentProtocol, + undefined, + 'catAgentProtocol must not persist on non-catagent variants', + ); + }); + + it('updates and clears catAgentProtocol on existing catagent member, and clears on client switch away', async () => { + const projectRoot = mkdtempSync(join(tmpdir(), 'cat-catalog-store-')); + const templatePath = join(projectRoot, 'cat-template.json'); + writeFileSync(templatePath, JSON.stringify(validConfig(), null, 2)); + writeCatCatalog(projectRoot, validConfig()); + + // Seed: create catagent member with catAgentProtocol='openai-chat' + await createRuntimeCat(projectRoot, { + catId: 'patch-protocol-cat', + breedId: 'patch-protocol-cat', + name: '更新协议猫', + displayName: '更新协议猫', + avatar: '/avatars/patch-protocol-cat.png', + color: { primary: '#7c3aed', secondary: '#ede9fe' }, + mentionPatterns: ['@patch-protocol-cat'], + roleDescription: '协议位 patch 测试', + clientId: 'catagent', + accountRef: 'claude', + defaultModel: 'claude-opus-4-6', + mcpSupport: false, + cli: { command: 'claude', outputFormat: 'stream-json' }, + catAgentProtocol: 'openai-chat', + }); + + // Patch: switch protocol to anthropic-messages + await updateRuntimeCat(projectRoot, 'patch-protocol-cat', { + catAgentProtocol: 'anthropic-messages', + }); + let catalog = readRuntimeCatCatalog(projectRoot); + let updated = catalog.breeds.find((breed) => breed.catId === 'patch-protocol-cat'); + assert.equal(updated.variants[0]?.catAgentProtocol, 'anthropic-messages'); + + // Patch null clears + await updateRuntimeCat(projectRoot, 'patch-protocol-cat', { + catAgentProtocol: null, + }); + catalog = readRuntimeCatCatalog(projectRoot); + updated = catalog.breeds.find((breed) => breed.catId === 'patch-protocol-cat'); + assert.equal(updated.variants[0]?.catAgentProtocol, undefined, 'patch null must clear catAgentProtocol'); + + // Re-seed catAgentProtocol then switch clientId away from catagent + await updateRuntimeCat(projectRoot, 'patch-protocol-cat', { + catAgentProtocol: 'openai-chat', + }); + catalog = readRuntimeCatCatalog(projectRoot); + updated = catalog.breeds.find((breed) => breed.catId === 'patch-protocol-cat'); + assert.equal(updated.variants[0]?.catAgentProtocol, 'openai-chat', 're-seed sanity check'); + + await updateRuntimeCat(projectRoot, 'patch-protocol-cat', { + clientId: 'openai', + }); + catalog = readRuntimeCatCatalog(projectRoot); + updated = catalog.breeds.find((breed) => breed.catId === 'patch-protocol-cat'); + assert.equal( + updated.variants[0]?.catAgentProtocol, + undefined, + 'switching away from catagent must clear catAgentProtocol', + ); + // nativeToolLevel / commandPolicy 同样路径已被既有测试 cover;此处只断言 protocol 字段被清 + }); + it('updates an existing runtime member in place', async () => { const projectRoot = mkdtempSync(join(tmpdir(), 'cat-catalog-store-')); const templatePath = join(projectRoot, 'cat-template.json'); diff --git a/packages/api/test/cat-config-loader.test.js b/packages/api/test/cat-config-loader.test.js index d54aa04d48..18e325073b 100644 --- a/packages/api/test/cat-config-loader.test.js +++ b/packages/api/test/cat-config-loader.test.js @@ -18,6 +18,8 @@ const { buildCatIdToBreedIndex, getCatEffort, getAcpConfig, + getProviderTransportConfig, + getTemplateBuiltinCatIds, getCatFamily, bootstrapDefaultCatCatalog, _resetCachedConfig, @@ -1014,14 +1016,14 @@ describe('F32-b P4c: Sonnet variant in project config', () => { assert.deepEqual(fable.mentionPatterns, ['@fable5', '@fable-5', '@claude-fable-5', '@宪宪5', '@布偶猫5']); }); - it('total cat count is 17 (opus + sonnet + opus-45 + opus-47 + fable-5 + codex + gpt52 + spark + gpt-pro + gemini + gemini25 + gemini35 + kimi + antigravity + antig-opus + agy-opus + opencode)', () => { + it('total cat count is 18 (opus + sonnet + opus-45 + opus-47 + fable-5 + codex + gpt52 + spark + gpt-pro + gemini + gemini25 + gemini35 + kimi + antigravity + antig-opus + agy-opus + opencode + catagent)', () => { // Use template directly to avoid catalog overlay pollution from earlier tests const templatePath = process.env.CAT_TEMPLATE_PATH ?? resolve(dirname(fileURLToPath(import.meta.url)), '../../..', 'cat-template.json'); const config = loadCatConfig(templatePath); const all = toAllCatConfigs(config); - assert.equal(Object.keys(all).length, 17); + assert.equal(Object.keys(all).length, 18); assert.ok(all.opus); assert.ok(all.sonnet); assert.ok(all['opus-45']); @@ -1039,6 +1041,7 @@ describe('F32-b P4c: Sonnet variant in project config', () => { assert.ok(all['antig-opus']); // F061: Bengal cat Claude variant assert.ok(all['agy-opus']); // F210: Bengal cat AGY CLI Claude Opus variant assert.ok(all.opencode); // F105: OpenCode external agent + assert.ok(all.catagent); // CatAgent/幼仔 runtime cat }); it('keeps AGY CLI Opus under Bengal while preserving Antigravity IDE Opus', () => { @@ -1902,4 +1905,75 @@ describe('#772: template breeds must not leak into runtime', () => { _resetCachedConfig(); } }); + + it('getProviderTransportConfig returns raw Phase A transport declaration from runtime catalog', () => { + const catalogBreed = makeBreed('ragdoll', 'clowder-code', ['@clowder-code']); + catalogBreed.variants[0].clientId = 'clowder-code'; + catalogBreed.variants[0].providerTransport = { + transport: 'cli-jsonl', + command: 'clowder-code', + startupArgs: ['--json', '--non-interactive'], + outputProfile: 'clowder-code-turn-result-v1', + }; + const { templatePath } = setupProjectDir([], [catalogBreed]); + + const saved = process.env.CAT_TEMPLATE_PATH; + process.env.CAT_TEMPLATE_PATH = templatePath; + _resetCachedConfig(); + try { + assert.deepEqual(getProviderTransportConfig('clowder-code'), { + transport: 'cli-jsonl', + command: 'clowder-code', + startupArgs: ['--json', '--non-interactive'], + outputProfile: 'clowder-code-turn-result-v1', + }); + } finally { + if (saved === undefined) delete process.env.CAT_TEMPLATE_PATH; + else process.env.CAT_TEMPLATE_PATH = saved; + _resetCachedConfig(); + } + }); + + it('getTemplateBuiltinCatIds reads base template ids before runtime providerTransport overlay', () => { + const templateBreed = makeBreed('new-builtin-family', 'future-builtin', ['@future']); + templateBreed.variants.push({ + id: 'future-alt', + catId: 'future-alt', + clientId: 'anthropic', + defaultModel: 'claude-sonnet-4-6', + mcpSupport: true, + personality: 'future alternate personality', + }); + + const catalogBreed = makeBreed('new-builtin-family', 'future-builtin', ['@future']); + catalogBreed.variants[0].clientId = 'clowder-code'; + catalogBreed.variants[0].providerTransport = { + transport: 'cli-jsonl', + command: 'clowder-code', + }; + + const { projectDir } = setupProjectDir([templateBreed], [catalogBreed]); + + const ids = getTemplateBuiltinCatIds(projectDir); + + assert.equal(ids.has('future-builtin'), true, 'base template builtin remains reserved after PT overlay'); + assert.equal(ids.has('future-alt'), true, 'variant-level catId is part of the template builtin baseline'); + }); + + it('getTemplateBuiltinCatIds throws when the base template baseline is unavailable', () => { + const missingProjectDir = mkdtempSync(join(tmpdir(), 'cat-missing-template-')); + assert.throws( + () => getTemplateBuiltinCatIds(missingProjectDir), + /Failed to read cat-template\.json/, + 'missing base template must fail closed instead of returning an empty baseline', + ); + + const corruptProjectDir = mkdtempSync(join(tmpdir(), 'cat-corrupt-template-')); + writeFileSync(join(corruptProjectDir, 'cat-template.json'), '{"version": 2,'); + assert.throws( + () => getTemplateBuiltinCatIds(corruptProjectDir), + (err) => err instanceof SyntaxError, + 'corrupt base template JSON must fail closed instead of returning an empty baseline', + ); + }); }); diff --git a/packages/api/test/catagent-phase-d.test.js b/packages/api/test/catagent-phase-d.test.js index 7afeea4e05..6b7c9f5092 100644 --- a/packages/api/test/catagent-phase-d.test.js +++ b/packages/api/test/catagent-phase-d.test.js @@ -31,8 +31,11 @@ async function collect(iter) { let tmpDir; let catCafeDir; +let prevCatOpusModel; before(() => { + prevCatOpusModel = process.env.CAT_OPUS_MODEL; + process.env.CAT_OPUS_MODEL = 'claude-opus-4-6'; tmpDir = join(tmpdir(), `catagent-d-${Date.now()}`); mkdirSync(tmpDir, { recursive: true }); @@ -66,6 +69,8 @@ before(() => { }); after(() => { + if (prevCatOpusModel !== undefined) process.env.CAT_OPUS_MODEL = prevCatOpusModel; + else delete process.env.CAT_OPUS_MODEL; try { rmSync(tmpDir, { recursive: true, force: true }); } catch { diff --git a/packages/api/test/catagent-phase-e.test.js b/packages/api/test/catagent-phase-e.test.js index 42c25204e2..ca68831ad2 100644 --- a/packages/api/test/catagent-phase-e.test.js +++ b/packages/api/test/catagent-phase-e.test.js @@ -49,6 +49,34 @@ function mockStreamingApi(responses) { }; } +function openAISseEvent(data) { + return `data: ${typeof data === 'string' ? data : JSON.stringify(data)}\n\n`; +} + +function openAIStream(events) { + const text = events.map(openAISseEvent).join(''); + return new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(text)); + controller.close(); + }, + }); +} + +function mockOpenAIStreamingApi(responses, captures) { + let callIndex = 0; + return async (url, init) => { + if (captures) captures.push({ url: String(url), body: JSON.parse(init.body) }); + const events = responses[callIndex] ?? responses[responses.length - 1]; + callIndex++; + return { + ok: true, + headers: new Headers({ 'content-type': 'text/event-stream' }), + body: openAIStream(events), + }; + }; +} + function textTurnEvents(text, stopReason = 'end_turn', inputTokens = 10, outputTokens = 5) { return [ { type: 'message_start', message: { id: `msg${Date.now()}`, usage: { input_tokens: inputTokens } } }, @@ -72,20 +100,72 @@ function toolTurnEvents(toolName, toolInput, toolId = 'tu1') { ]; } +function openAITextTurnEvents(text, finishReason = 'stop', promptTokens = 10, completionTokens = 5) { + return [ + { + id: `chatcmpl-${Date.now()}`, + choices: [{ index: 0, delta: { role: 'assistant', content: text }, finish_reason: finishReason }], + }, + { + id: `chatcmpl-${Date.now()}`, + choices: [], + usage: { prompt_tokens: promptTokens, completion_tokens: completionTokens }, + }, + '[DONE]', + ]; +} + +function openAIToolTurnEvents(toolName, toolInput, toolId = 'call_1') { + const args = JSON.stringify(toolInput); + return [ + { + id: `chatcmpl-${Date.now()}`, + choices: [ + { + index: 0, + delta: { + role: 'assistant', + tool_calls: [{ index: 0, id: toolId, function: { name: toolName, arguments: args } }], + }, + finish_reason: 'tool_calls', + }, + ], + }, + '[DONE]', + ]; +} + // ── Temp workspace ── let tmpDir; +let prevCatOpusModel; before(() => { + prevCatOpusModel = process.env.CAT_OPUS_MODEL; + process.env.CAT_OPUS_MODEL = 'claude-opus-4-6'; tmpDir = join(tmpdir(), `catagent-e-${Date.now()}`); mkdirSync(tmpDir, { recursive: true }); writeFileSync(join(tmpDir, 'hello.txt'), 'line1\nline2\nline3\n'); mkdirSync(join(tmpDir, '.cat-cafe'), { recursive: true }); - writeFileSync(join(tmpDir, '.cat-cafe', 'accounts.json'), JSON.stringify({ 'test-ant': { authType: 'api_key' } })); - writeFileSync(join(tmpDir, '.cat-cafe', 'credentials.json'), JSON.stringify({ 'test-ant': { apiKey: 'sk-test-e' } })); + writeFileSync( + join(tmpDir, '.cat-cafe', 'accounts.json'), + JSON.stringify({ + 'test-ant': { authType: 'api_key' }, + 'test-ant-v1': { authType: 'api_key', baseUrl: 'https://proxy.example/v1' }, + }), + ); + writeFileSync( + join(tmpDir, '.cat-cafe', 'credentials.json'), + JSON.stringify({ + 'test-ant': { apiKey: 'sk-test-e' }, + 'test-ant-v1': { apiKey: 'sk-test-v1' }, + }), + ); }); after(() => { + if (prevCatOpusModel !== undefined) process.env.CAT_OPUS_MODEL = prevCatOpusModel; + else delete process.env.CAT_OPUS_MODEL; try { rmSync(tmpDir, { recursive: true, force: true }); } catch { @@ -343,4 +423,118 @@ describe('E4: stream error handling', () => { assert.ok(capturedBody); assert.equal(capturedBody.stream, true, 'stream: true in body'); }); + + test('baseUrl ending in /v1 is not double-prefixed', async () => { + let capturedUrl = null; + globalThis.fetch = async (url) => { + capturedUrl = String(url); + return { + ok: true, + headers: new Headers({ 'content-type': 'text/event-stream' }), + body: sseStream(textTurnEvents('hi')), + }; + }; + + const svc = new CatAgentService({ + catId: 'opus', + projectRoot: tmpDir, + catConfig: { accountRef: 'test-ant-v1' }, + }); + await collect(svc.invoke('test')); + + assert.equal(capturedUrl, 'https://proxy.example/v1/messages'); + }); +}); + +describe('G2 Axis 5: OpenAI Chat protocol e2e', () => { + let prevFetch; + let prevEnv; + + before(() => { + prevFetch = globalThis.fetch; + prevEnv = process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT; + process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT = tmpDir; + resetMigrationState(); + writeFileSync( + join(tmpDir, '.cat-cafe', 'accounts.json'), + JSON.stringify({ + 'test-ant': { authType: 'api_key' }, + 'test-ant-v1': { authType: 'api_key', baseUrl: 'https://proxy.example/v1' }, + 'test-openai': { authType: 'api_key', clientFamily: 'openai', baseUrl: 'https://proxy.example/v1' }, + }), + ); + writeFileSync( + join(tmpDir, '.cat-cafe', 'credentials.json'), + JSON.stringify({ + 'test-ant': { apiKey: 'sk-test-e' }, + 'test-ant-v1': { apiKey: 'sk-test-v1' }, + 'test-openai': { apiKey: 'sk-openai-e' }, + }), + ); + }); + + after(() => { + globalThis.fetch = prevFetch; + if (prevEnv !== undefined) process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT = prevEnv; + else delete process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT; + resetMigrationState(); + }); + + test('single-turn text path uses OpenAI URL/headers/body and ends cleanly', async () => { + const captures = []; + globalThis.fetch = mockOpenAIStreamingApi([openAITextTurnEvents('Hello from OpenAI')], captures); + + const svc = new CatAgentService({ + catId: 'opus', + projectRoot: tmpDir, + catConfig: { accountRef: 'test-openai', clientId: 'catagent', catAgentProtocol: 'openai-chat' }, + }); + const msgs = await collect(svc.invoke('hi')); + + const text = msgs + .filter((msg) => msg.type === 'text') + .map((msg) => msg.content) + .join(''); + assert.equal(text, 'Hello from OpenAI'); + assert.ok(msgs.some((msg) => msg.type === 'done')); + assert.equal(captures[0].url, 'https://proxy.example/v1/chat/completions'); + assert.equal(captures[0].body.messages[0].role, 'user'); + assert.equal(captures[0].body.stream_options.include_usage, true); + }); + + test('tool_call multi-turn path is lossless across assistant history + tool results', async () => { + const captures = []; + globalThis.fetch = mockOpenAIStreamingApi( + [ + openAIToolTurnEvents('read_file', { path: 'hello.txt' }, 'call_read_1'), + openAITextTurnEvents('The file has 3 lines', 'stop', 50, 9), + ], + captures, + ); + + const svc = new CatAgentService({ + catId: 'opus', + projectRoot: tmpDir, + catConfig: { accountRef: 'test-openai', clientId: 'catagent', catAgentProtocol: 'openai-chat' }, + }); + const msgs = await collect(svc.invoke('read hello.txt', { workingDirectory: tmpDir })); + + assert.ok(msgs.some((msg) => msg.type === 'tool_use' && msg.toolName === 'read_file')); + assert.ok(msgs.some((msg) => msg.type === 'tool_result' && msg.toolUseId === 'call_read_1')); + assert.ok(msgs.some((msg) => msg.type === 'text' && msg.content.includes('3 lines'))); + + assert.ok(captures.length >= 2, 'must issue second turn'); + const secondMessages = captures[1].body.messages; + const assistant = secondMessages.find((message) => message.role === 'assistant'); + assert.deepEqual(assistant.tool_calls, [ + { + id: 'call_read_1', + type: 'function', + function: { name: 'read_file', arguments: '{"path":"hello.txt"}' }, + }, + ]); + const toolMessage = secondMessages.find((message) => message.role === 'tool'); + assert.equal(toolMessage.tool_call_id, 'call_read_1'); + assert.match(toolMessage.content, /line1/); + }); }); diff --git a/packages/api/test/catagent-phase-f.test.js b/packages/api/test/catagent-phase-f.test.js new file mode 100644 index 0000000000..b76ee6ad52 --- /dev/null +++ b/packages/api/test/catagent-phase-f.test.js @@ -0,0 +1,377 @@ +/** + * CatAgent Phase F Tests — Write/Exec Tool Surface + * + * Covers F1/F2/F3-min primitives: + * - nativeToolLevel gates L0/L1/L2 tool registration + * - resolveCreatePath blocks symlink-parent creation escapes + * - write_file / patch_file perform bounded, CAS-protected writes with audit + * - run_command uses structured argv + allowlist-first policy + constrained env + * - update_current_task_status is a host-native scoped callback tool + */ + +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { existsSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { after, before, describe, test } from 'node:test'; + +const { buildToolRegistry, findTool } = await import( + '../dist/domains/cats/services/agents/providers/catagent/catagent-read-tools.js' +); +const { CatAgentService, executeCatAgentTools } = await import( + '../dist/domains/cats/services/agents/providers/catagent/CatAgentService.js' +); +const { resolveCreatePath } = await import('../dist/domains/cats/services/agents/providers/catagent/catagent-tools.js'); +const { resetMigrationState } = await import('../dist/config/catalog-accounts.js'); + +function sha256(text) { + return createHash('sha256').update(text).digest('hex'); +} + +async function collect(iter) { + const msgs = []; + for await (const msg of iter) msgs.push(msg); + return msgs; +} + +function sseEvent(data) { + return `data: ${JSON.stringify(data)}\n\n`; +} + +function sseStream(text) { + return new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(text)); + controller.close(); + }, + }); +} + +function mockDoneResponse() { + return { + ok: true, + headers: new Headers({ 'content-type': 'text/event-stream' }), + body: sseStream( + [ + sseEvent({ type: 'message_start', message: { id: 'msg-f', usage: { input_tokens: 1 } } }), + sseEvent({ type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } }), + sseEvent({ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'ok' } }), + sseEvent({ type: 'content_block_stop', index: 0 }), + sseEvent({ type: 'message_delta', delta: { stop_reason: 'end_turn' }, usage: { output_tokens: 1 } }), + sseEvent({ type: 'message_stop' }), + ].join(''), + ), + }; +} + +let tmpDir; +let outsideDir; + +before(() => { + tmpDir = join(tmpdir(), `catagent-f-${Date.now()}`); + outsideDir = join(tmpdir(), `catagent-f-outside-${Date.now()}`); + mkdirSync(tmpDir, { recursive: true }); + mkdirSync(outsideDir, { recursive: true }); + writeFileSync(join(tmpDir, 'existing.txt'), 'alpha beta gamma'); + writeFileSync(join(tmpDir, 'dupe.txt'), 'same same'); + mkdirSync(join(tmpDir, '.cat-cafe'), { recursive: true }); + writeFileSync(join(tmpDir, '.cat-cafe', 'accounts.json'), JSON.stringify({ 'test-ant': { authType: 'api_key' } })); + writeFileSync(join(tmpDir, '.cat-cafe', 'credentials.json'), JSON.stringify({ 'test-ant': { apiKey: 'sk-test-f' } })); +}); + +after(() => { + try { + rmSync(tmpDir, { recursive: true, force: true }); + rmSync(outsideDir, { recursive: true, force: true }); + } catch { + /* best-effort */ + } +}); + +describe('F1: tiered tool registry', () => { + test('defaults to L0 read-only tools', async () => { + const tools = await buildToolRegistry(tmpDir); + assert.ok(findTool(tools, 'read_file')); + assert.ok(findTool(tools, 'list_files')); + assert.equal(findTool(tools, 'write_file'), undefined); + assert.equal(findTool(tools, 'patch_file'), undefined); + assert.equal(findTool(tools, 'run_command'), undefined); + }); + + test('L1 registers write_file and patch_file but not run_command', async () => { + const tools = await buildToolRegistry(tmpDir, { nativeToolLevel: 'L1' }); + assert.ok(findTool(tools, 'write_file')); + assert.ok(findTool(tools, 'patch_file')); + assert.equal(findTool(tools, 'run_command'), undefined); + }); + + test('L2 registers run_command and still fails closed with empty policy', async () => { + const tools = await buildToolRegistry(tmpDir, { nativeToolLevel: 'L2' }); + const run = findTool(tools, 'run_command'); + assert.ok(run); + await assert.rejects(() => run.execute({ binary: 'git', args: ['status'] }), /No command policy configured/); + }); + + test('service sends tool schemas according to nativeToolLevel', async () => { + let capturedBody = null; + const prevFetch = globalThis.fetch; + const prevEnv = process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT; + const prevModel = process.env.CAT_OPUS_MODEL; + process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT = tmpDir; + process.env.CAT_OPUS_MODEL = 'claude-opus-4-6'; + resetMigrationState(); + globalThis.fetch = async (_url, init) => { + capturedBody = JSON.parse(init.body); + return mockDoneResponse(); + }; + try { + const svc = new CatAgentService({ + catId: 'opus', + projectRoot: tmpDir, + catConfig: { accountRef: 'test-ant', nativeToolLevel: 'L1' }, + }); + await collect(svc.invoke('test', { workingDirectory: tmpDir })); + } finally { + globalThis.fetch = prevFetch; + if (prevEnv !== undefined) process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT = prevEnv; + else delete process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT; + if (prevModel !== undefined) process.env.CAT_OPUS_MODEL = prevModel; + else delete process.env.CAT_OPUS_MODEL; + resetMigrationState(); + } + + const names = capturedBody.tools.map((t) => t.name); + assert.ok(names.includes('write_file')); + assert.ok(names.includes('patch_file')); + assert.ok(!names.includes('run_command')); + }); +}); + +describe('F1: create-safe write and patch tools', () => { + test('resolveCreatePath rejects creation through symlink parent escape', async () => { + const linkPath = join(tmpDir, 'outside-link'); + if (!existsSync(linkPath)) symlinkSync(outsideDir, linkPath); + await assert.rejects(() => resolveCreatePath(tmpDir, 'outside-link/new.txt'), /Symlink escapes workspace root/); + }); + + test('write_file writes atomically within workspace and audits hashes', async () => { + const audit = []; + const tools = await buildToolRegistry(tmpDir, { nativeToolLevel: 'L1', audit: (event) => audit.push(event) }); + const write = findTool(tools, 'write_file'); + + const result = await write.execute({ path: 'created.txt', content: 'created content' }); + + assert.equal(readFileSync(join(tmpDir, 'created.txt'), 'utf-8'), 'created content'); + assert.ok(result.includes('Wrote')); + assert.equal(audit.length, 1); + assert.equal(audit[0].tool, 'write_file'); + assert.equal(audit[0].outcome, 'ok'); + assert.equal(audit[0].path, 'created.txt'); + assert.equal(audit[0].hashBefore, null); + assert.equal(audit[0].hashAfter, sha256('created content')); + }); + + test('write_file rejects files over 256 KiB', async () => { + const tools = await buildToolRegistry(tmpDir, { nativeToolLevel: 'L1' }); + const write = findTool(tools, 'write_file'); + await assert.rejects(() => write.execute({ path: 'too-big.txt', content: 'x'.repeat(256 * 1024 + 1) }), /256 KiB/); + }); + + test('patch_file requires expected_hash and unique old_text', async () => { + const audit = []; + const tools = await buildToolRegistry(tmpDir, { nativeToolLevel: 'L1', audit: (event) => audit.push(event) }); + const patch = findTool(tools, 'patch_file'); + + await assert.rejects( + () => patch.execute({ path: 'existing.txt', old_text: 'alpha', new_text: 'omega', expected_hash: 'deadbeef' }), + /expected_hash mismatch/, + ); + await assert.rejects( + () => + patch.execute({ + path: 'dupe.txt', + old_text: 'same', + new_text: 'once', + expected_hash: sha256('same same').slice(0, 12), + }), + /old_text must match exactly once/, + ); + + const before = 'alpha beta gamma'; + const result = await patch.execute({ + path: 'existing.txt', + old_text: 'beta', + new_text: 'BETA', + expected_hash: sha256(before).slice(0, 12), + }); + + assert.equal(readFileSync(join(tmpDir, 'existing.txt'), 'utf-8'), 'alpha BETA gamma'); + assert.ok(result.includes('Patched')); + assert.equal(audit.at(-1).tool, 'patch_file'); + assert.equal(audit.at(-1).hashBefore, sha256(before)); + assert.equal(audit.at(-1).hashAfter, sha256('alpha BETA gamma')); + }); + + test('patch_file treats overlapping old_text matches as non-unique', async () => { + const tools = await buildToolRegistry(tmpDir, { nativeToolLevel: 'L1' }); + const patch = findTool(tools, 'patch_file'); + writeFileSync(join(tmpDir, 'overlap.txt'), 'aaa'); + + await assert.rejects( + () => + patch.execute({ + path: 'overlap.txt', + old_text: 'aa', + new_text: 'X', + expected_hash: sha256('aaa').slice(0, 12), + }), + /old_text must match exactly once \(found 2\)/, + ); + }); + + test('patch_file writes replacement text literally when new_text contains dollar sequences', async () => { + const tools = await buildToolRegistry(tmpDir, { nativeToolLevel: 'L1' }); + const patch = findTool(tools, 'patch_file'); + const replacement = '$$HOME $& $1'; + writeFileSync(join(tmpDir, 'dollar.txt'), 'alpha'); + + await patch.execute({ + path: 'dollar.txt', + old_text: 'alpha', + new_text: replacement, + expected_hash: sha256('alpha').slice(0, 12), + }); + + assert.equal(readFileSync(join(tmpDir, 'dollar.txt'), 'utf-8'), replacement); + }); +}); + +describe('F2: run_command policy', () => { + test('runs only policy-allowed structured argv', async () => { + const audit = []; + const tools = await buildToolRegistry(tmpDir, { + nativeToolLevel: 'L2', + commandPolicy: [ + { + binary: process.execPath, + allowedFlags: ['-e'], + allowedArgPatterns: ['^console\\.log\\("ok"\\)$'], + }, + ], + audit: (event) => audit.push(event), + }); + const run = findTool(tools, 'run_command'); + + const result = await run.execute({ binary: process.execPath, args: ['-e', 'console.log("ok")'] }); + + assert.ok(result.includes('exitCode: 0')); + assert.ok(result.includes('ok')); + assert.equal(audit[0].tool, 'run_command'); + assert.equal(audit[0].exitCode, 0); + await assert.rejects( + () => run.execute({ binary: process.execPath, args: ['-e', 'require("fs").readdirSync(".")'] }), + /not allowed by command policy/, + ); + }); + + test('does not pass HOME to command env', async () => { + const tools = await buildToolRegistry(tmpDir, { + nativeToolLevel: 'L2', + commandPolicy: [{ binary: 'env' }], + }); + const run = findTool(tools, 'run_command'); + + const result = await run.execute({ binary: 'env', args: [] }); + + assert.ok(result.includes('PATH=')); + assert.ok(!result.includes('HOME='), 'HOME must not be present'); + }); + + test('kills commands that exceed timeout', async () => { + const tools = await buildToolRegistry(tmpDir, { + nativeToolLevel: 'L2', + commandTimeoutMs: 50, + commandPolicy: [ + { + binary: process.execPath, + allowedFlags: ['-e'], + allowedArgPatterns: ['^setTimeout'], + }, + ], + }); + const run = findTool(tools, 'run_command'); + + await assert.rejects( + () => run.execute({ binary: process.execPath, args: ['-e', 'setTimeout(() => {}, 500)'] }), + /timed out/, + ); + }); + + test('kills commands that ignore SIGTERM after the grace window', async () => { + const tools = await buildToolRegistry(tmpDir, { + nativeToolLevel: 'L2', + commandTimeoutMs: 50, + commandKillGraceMs: 50, + commandPolicy: [ + { + binary: process.execPath, + allowedFlags: ['-e'], + allowedArgPatterns: ['^process\\.on\\("SIGTERM"'], + }, + ], + }); + const run = findTool(tools, 'run_command'); + + await assert.rejects( + () => + run.execute({ + binary: process.execPath, + args: ['-e', 'process.on("SIGTERM", () => {}); setInterval(() => {}, 100);'], + }), + /timed out/, + ); + }); +}); + +describe('F3-min: host-native scoped callback tool', () => { + test('does not register update_current_task_status without current task scope', async () => { + const tools = await buildToolRegistry(undefined, { nativeToolLevel: 'L1' }); + assert.equal(findTool(tools, 'update_current_task_status'), undefined); + }); + + test('updates only current task controlled fields and audits changedFields', async () => { + const updates = []; + const audit = []; + const tools = await buildToolRegistry(undefined, { + nativeToolLevel: 'L1', + audit: (event) => audit.push(event), + scopedCallbacks: { + currentTask: { + invocationId: 'inv-1', + currentTaskId: 'task-1', + updateCurrentTaskStatus: async (patch) => { + updates.push(patch); + }, + }, + }, + }); + const update = findTool(tools, 'update_current_task_status'); + assert.ok(update); + + const result = await update.execute({ status: 'doing', progress: 50, summary: 'halfway' }); + assert.ok(result.includes('Updated current task')); + assert.deepEqual(updates, [{ status: 'doing', progress: 50, summary: 'halfway' }]); + assert.equal(audit[0].tool, 'update_current_task_status'); + assert.equal(audit[0].invocationId, 'inv-1'); + assert.equal(audit[0].currentTaskId, 'task-1'); + assert.deepEqual(audit[0].changedFields, ['status', 'progress', 'summary']); + + const forbidden = await executeCatAgentTools( + [{ id: 'tu-forbidden', type: 'tool_use', name: 'update_current_task_status', input: { taskId: 'other' } }], + tools, + ); + assert.equal(forbidden[0].status, 'error'); + assert.ok(forbidden[0].content.includes('undeclared field "taskId"')); + }); +}); diff --git a/packages/api/test/catagent-protocol-factory.test.js b/packages/api/test/catagent-protocol-factory.test.js new file mode 100644 index 0000000000..42b975b322 --- /dev/null +++ b/packages/api/test/catagent-protocol-factory.test.js @@ -0,0 +1,113 @@ +/** + * CatAgent Protocol Factory dispatch tests — F159 Phase G G2 Axis 2 (AC-G17). + * + * Verifies fail-closed dispatch per KD-20: + * - undefined / 'anthropic-messages' → AnthropicMessagesAdapter (G1 default + * byte-stable, KD-25 first half) + * - 'openai-chat' → OpenAIChatAdapter + * - unknown string → throws CatAgentProtocolUnknownError (KD-20 strict) + */ + +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +const { createCatAgentProtocolAdapter, CatAgentProtocolUnknownError } = await import( + '../dist/domains/cats/services/agents/providers/catagent/catagent-protocol-factory.js' +); + +const { AnthropicMessagesAdapter } = await import( + '../dist/domains/cats/services/agents/providers/catagent/anthropic-messages-adapter.js' +); +const { OpenAIChatAdapter } = await import( + '../dist/domains/cats/services/agents/providers/catagent/openai-chat-adapter.js' +); + +describe('createCatAgentProtocolAdapter dispatch (AC-G17 / KD-20 fail-closed)', () => { + test('null catConfig → AnthropicMessagesAdapter (legacy/test path)', () => { + const adapter = createCatAgentProtocolAdapter(null); + assert.ok(adapter instanceof AnthropicMessagesAdapter); + assert.equal(adapter.clientFamily, 'anthropic'); + assert.equal(adapter.protocolId, 'anthropic-messages-v1'); + }); + + test('catConfig with no catAgentProtocol → AnthropicMessagesAdapter (G1 catagent member backward-compat)', () => { + const adapter = createCatAgentProtocolAdapter({ id: 'opus', clientId: 'catagent' }); + assert.ok(adapter instanceof AnthropicMessagesAdapter); + }); + + test("catAgentProtocol='anthropic-messages' → AnthropicMessagesAdapter (explicit)", () => { + const adapter = createCatAgentProtocolAdapter({ + id: 'opus', + clientId: 'catagent', + catAgentProtocol: 'anthropic-messages', + }); + assert.ok(adapter instanceof AnthropicMessagesAdapter); + }); + + test("catAgentProtocol='openai-chat' → OpenAIChatAdapter", () => { + const adapter = createCatAgentProtocolAdapter({ + id: 'opus', + clientId: 'catagent', + catAgentProtocol: 'openai-chat', + }); + assert.ok(adapter instanceof OpenAIChatAdapter); + assert.equal(adapter.clientFamily, 'openai'); + assert.equal(adapter.protocolId, 'openai-chat-v1'); + }); + + test('unknown protocol value → CatAgentProtocolUnknownError (KD-20 strict fail-closed)', () => { + assert.throws( + () => + createCatAgentProtocolAdapter({ + id: 'opus', + clientId: 'catagent', + catAgentProtocol: 'gemini-pro', // not in CatAgentProtocol union + }), + (err) => { + assert.ok(err instanceof CatAgentProtocolUnknownError); + assert.equal(err.protocol, 'gemini-pro'); + assert.match(err.message, /fail-closed|KD-20/); + return true; + }, + ); + }); + + test('empty string protocol → CatAgentProtocolUnknownError (does NOT silently default)', () => { + assert.throws( + () => + createCatAgentProtocolAdapter({ + id: 'opus', + clientId: 'catagent', + catAgentProtocol: '', + }), + (err) => { + assert.ok(err instanceof CatAgentProtocolUnknownError); + return true; + }, + ); + }); +}); + +// AC-G31 (KD-25 third pillar): default branch behavior is BYTE-stable with G1. +// G1's AnthropicMessagesAdapter is what factory returned pre-G2; G2 step 1d +// must not change that. +describe('AC-G31 G1 catagent member default branch byte-stable', () => { + test('multiple invocations of default branch return AnthropicMessagesAdapter with identical identity', () => { + const a = createCatAgentProtocolAdapter(null); + const b = createCatAgentProtocolAdapter({ id: 'opus', clientId: 'catagent' }); + const c = createCatAgentProtocolAdapter({ + id: 'opus', + clientId: 'catagent', + catAgentProtocol: 'anthropic-messages', + }); + for (const adapter of [a, b, c]) { + assert.equal(adapter.clientFamily, 'anthropic'); + assert.equal(adapter.protocolId, 'anthropic-messages-v1'); + assert.equal(typeof adapter.buildRequestUrl, 'function'); + assert.equal(typeof adapter.parseStreamEvents, 'function'); + assert.equal(typeof adapter.encodeAssistantTurn, 'function'); + assert.equal(typeof adapter.mapError, 'function'); + assert.equal(typeof adapter.isTerminalStopReason, 'function'); + } + }); +}); diff --git a/packages/api/test/catagent-provider.test.js b/packages/api/test/catagent-provider.test.js index 80f75ab90e..b0cf65ef71 100644 --- a/packages/api/test/catagent-provider.test.js +++ b/packages/api/test/catagent-provider.test.js @@ -78,6 +78,17 @@ function mockFetchAbortable() { // Save original fetch const originalFetch = globalThis.fetch; +let prevCatOpusModel; + +before(() => { + prevCatOpusModel = process.env.CAT_OPUS_MODEL; + process.env.CAT_OPUS_MODEL = 'claude-opus-4-6'; +}); + +after(() => { + if (prevCatOpusModel !== undefined) process.env.CAT_OPUS_MODEL = prevCatOpusModel; + else delete process.env.CAT_OPUS_MODEL; +}); // Mock resolveApiCredentials by patching the module // Since CatAgentService imports resolveApiCredentials, we test via the service diff --git a/packages/api/test/catagent-security-baseline.test.js b/packages/api/test/catagent-security-baseline.test.js index 74e76f05e8..46fa878adf 100644 --- a/packages/api/test/catagent-security-baseline.test.js +++ b/packages/api/test/catagent-security-baseline.test.js @@ -45,6 +45,100 @@ test('resolveApiCredentials ignores env var — only bound account is authoritat } }); +// F159 Phase G G1 AC-G5 P2 fix (@gpt555 review on PR #23): +// `clientFamily` must actually guard the resolved profile — an OAuth Anthropic +// builtin must NOT silently resolve under `clientFamily='openai'`. +test('resolveApiCredentials fail-closes when OAuth builtin family mismatches requested clientFamily', () => { + const tmpDir = realpathSync(mkdtempSync(join(tmpdir(), 'catagent-cred-fam-'))); + const configDir = join(tmpDir, '.cat-cafe'); + mkdirSync(configDir, { recursive: true }); + // `claude` is the Anthropic OAuth builtin per BUILTIN_ACCOUNT_MAP. + writeFileSync(join(configDir, 'accounts.json'), JSON.stringify({ claude: { authType: 'oauth' } })); + writeFileSync(join(configDir, 'credentials.json'), JSON.stringify({ claude: { apiKey: 'sk-ant-test' } }), { + mode: 0o600, + }); + try { + // Sanity: same family resolves (anthropic adapter binding to claude). + const ok = resolveApiCredentials(tmpDir, 'opus', { accountRef: 'claude' }, 'anthropic'); + assert.ok(ok && ok.apiKey === 'sk-ant-test', 'matching clientFamily must resolve'); + + // Mismatch: anthropic builtin must not satisfy openai adapter. + const mismatch = resolveApiCredentials(tmpDir, 'opus', { accountRef: 'claude' }, 'openai'); + assert.equal(mismatch, null, 'mismatched clientFamily must fail closed (anthropic builtin under openai adapter)'); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } +}); + +// F159 Phase G G2 AC-G20/G21/G22 (KD-22): completes G1 P2 fix's half coverage. +// api_key accounts can now declare `clientFamily` in their schema. When set, +// `accountToRuntimeProfile` propagates it to `profile.client`, which the G1 +// narrow guard already enforces — so api_key family mismatches now fail closed +// the same way OAuth builtin mismatches do. Without `clientFamily` set, legacy +// api_key accounts continue best-effort (backward compat preserved). +test('resolveApiCredentials fail-closes when api_key clientFamily mismatches requested clientFamily', () => { + const tmpDir = realpathSync(mkdtempSync(join(tmpdir(), 'catagent-cred-apikey-fam-'))); + const configDir = join(tmpDir, '.cat-cafe'); + mkdirSync(configDir, { recursive: true }); + // Custom api_key account explicitly declared as OpenAI family. + writeFileSync( + join(configDir, 'accounts.json'), + JSON.stringify({ 'my-openai-proxy': { authType: 'api_key', clientFamily: 'openai' } }), + ); + writeFileSync( + join(configDir, 'credentials.json'), + JSON.stringify({ 'my-openai-proxy': { apiKey: 'sk-openai-proxy-test' } }), + { mode: 0o600 }, + ); + try { + // Sanity: matching family resolves. + const ok = resolveApiCredentials(tmpDir, 'opus', { accountRef: 'my-openai-proxy' }, 'openai'); + assert.ok(ok && ok.apiKey === 'sk-openai-proxy-test', 'matching api_key clientFamily must resolve'); + + // Mismatch: openai api_key account must not satisfy anthropic adapter. + const mismatch = resolveApiCredentials(tmpDir, 'opus', { accountRef: 'my-openai-proxy' }, 'anthropic'); + assert.equal( + mismatch, + null, + 'mismatched api_key clientFamily must fail closed (openai api_key under anthropic adapter)', + ); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } +}); + +// F159 Phase G G2 backward-compat (KD-22): existing api_key accounts without +// `clientFamily` continue to resolve best-effort. profile.client remains +// undefined, so the family guard falls through and the bound credential is +// returned regardless of requested clientFamily — runtime API call surfaces +// any protocol mismatch at first invocation rather than silently routing. +test('resolveApiCredentials resolves api_key account without clientFamily for any requested family (backward compat)', () => { + const tmpDir = realpathSync(mkdtempSync(join(tmpdir(), 'catagent-cred-apikey-legacy-'))); + const configDir = join(tmpDir, '.cat-cafe'); + mkdirSync(configDir, { recursive: true }); + // Legacy api_key account without explicit clientFamily — pre-F159 G2 state. + writeFileSync(join(configDir, 'accounts.json'), JSON.stringify({ 'legacy-account': { authType: 'api_key' } })); + writeFileSync( + join(configDir, 'credentials.json'), + JSON.stringify({ 'legacy-account': { apiKey: 'sk-legacy-test' } }), + { mode: 0o600 }, + ); + try { + // Both adapter families resolve — guard falls through because + // profile.client is undefined for legacy api_key. + const asAnthropic = resolveApiCredentials(tmpDir, 'opus', { accountRef: 'legacy-account' }, 'anthropic'); + assert.ok( + asAnthropic && asAnthropic.apiKey === 'sk-legacy-test', + 'legacy api_key resolves under anthropic adapter', + ); + + const asOpenai = resolveApiCredentials(tmpDir, 'opus', { accountRef: 'legacy-account' }, 'openai'); + assert.ok(asOpenai && asOpenai.apiKey === 'sk-legacy-test', 'legacy api_key resolves under openai adapter'); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } +}); + test('resolveApiCredentials does not scan credentials.json even when key exists nearby', () => { // Seed a real credential file so a wildcard scanner would find it const tmpDir = realpathSync(mkdtempSync(join(tmpdir(), 'catagent-cred-'))); diff --git a/packages/api/test/catagent-stream-parser.test.js b/packages/api/test/catagent-stream-parser.test.js index 8f74684fc7..89aa3a9869 100644 --- a/packages/api/test/catagent-stream-parser.test.js +++ b/packages/api/test/catagent-stream-parser.test.js @@ -97,7 +97,7 @@ describe('SSE parser: tool_use streaming', () => { sseEvent({ type: 'message_stop' }); const events = await collect(parseAnthropicSSE(toStream([sse]))); - const complete = events.find((e) => e.type === 'content_block_complete' && e.block.type === 'tool_use'); + const complete = events.find((e) => e.type === 'content_block_complete' && e.block.type === 'tool_call'); assert.ok(complete); assert.equal(complete.block.name, 'read_file'); assert.deepEqual(complete.block.input, { path: 'hello.txt' }); @@ -121,7 +121,7 @@ describe('SSE parser: tool_use streaming', () => { sseEvent({ type: 'message_stop' }); const events = await collect(parseAnthropicSSE(toStream([sse]))); - const complete = events.find((e) => e.type === 'content_block_complete' && e.block.type === 'tool_use'); + const complete = events.find((e) => e.type === 'content_block_complete' && e.block.type === 'tool_call'); assert.ok(complete); assert.ok(complete.block.input._error, 'has error marker'); }); @@ -142,12 +142,17 @@ describe('SSE parser: usage and stop', () => { const events = await collect(parseAnthropicSSE(toStream([sse]))); const usageEvents = events.filter((e) => e.type === 'usage_update'); assert.ok(usageEvents.length >= 1, 'has usage events'); - const inputEvt = usageEvents.find((e) => e.inputUsage); + // G1: parser now yields neutral CatAgentUsageDelta (`usage.inputTokens` / + // `usage.outputTokens` / `usage.cacheReadTokens` / `usage.cacheCreationTokens`), + // and `mapAnthropicUsage` is applied internally so `inputTokens` already + // includes the cache normalisation (raw + cache_read + cache_creation). + const inputEvt = usageEvents.find((e) => e.usage?.inputTokens !== undefined); assert.ok(inputEvt); - assert.equal(inputEvt.inputUsage.input_tokens, 100); - const outputEvt = usageEvents.find((e) => e.outputTokens !== undefined); + assert.equal(inputEvt.usage.inputTokens, 150); // 100 + 50 cache_read + assert.equal(inputEvt.usage.cacheReadTokens, 50); + const outputEvt = usageEvents.find((e) => e.usage?.outputTokens !== undefined); assert.ok(outputEvt); - assert.equal(outputEvt.outputTokens, 25); + assert.equal(outputEvt.usage.outputTokens, 25); }); test('yields stop event with stop_reason', async () => { @@ -294,6 +299,6 @@ describe('SSE parser: multi-block ordering', () => { assert.equal(completes[0].blockIndex, 0); assert.equal(completes[0].block.type, 'text'); assert.equal(completes[1].blockIndex, 1); - assert.equal(completes[1].block.type, 'tool_use'); + assert.equal(completes[1].block.type, 'tool_call'); }); }); diff --git a/packages/api/test/catagent-vendor-neutrality.test.js b/packages/api/test/catagent-vendor-neutrality.test.js new file mode 100644 index 0000000000..fc217ec118 --- /dev/null +++ b/packages/api/test/catagent-vendor-neutrality.test.js @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +function stripCommentsAndStrings(source) { + return source + .replace(/\/\*[\s\S]*?\*\//g, ' ') + .replace(/\/\/.*$/gm, ' ') + .replace(/`(?:\\.|[^`])*`/g, ' ') + .replace(/'(?:\\.|[^'])*'/g, ' ') + .replace(/"(?:\\.|[^"])*"/g, ' '); +} + +test('AC-G12/AC-G27: CatAgentService stays vendor-neutral in code identifiers', () => { + const source = readFileSync( + join( + __dirname, + '..', + 'src', + 'domains', + 'cats', + 'services', + 'agents', + 'providers', + 'catagent', + 'CatAgentService.ts', + ), + 'utf-8', + ); + const stripped = stripCommentsAndStrings(source); + for (const pattern of [ + /\bAnthropic[A-Za-z0-9_]*\b/, + /\bmapAnthropicError\b/, + /\bparseAnthropicSSE\b/, + /\bOpenai[A-Za-z0-9_]*\b/, + /\bopenai[A-Za-z0-9_]*\b/, + ]) { + assert.equal(pattern.test(stripped), false, `CatAgentService must stay vendor-neutral: ${pattern}`); + } +}); diff --git a/packages/api/test/cats-routes-runtime-crud.test.js b/packages/api/test/cats-routes-runtime-crud.test.js index 0affac9ff7..419178107e 100644 --- a/packages/api/test/cats-routes-runtime-crud.test.js +++ b/packages/api/test/cats-routes-runtime-crud.test.js @@ -316,6 +316,337 @@ describe('cats routes runtime CRUD', { concurrency: false }, () => { assert.ok(mentions.includes('runtime-spark'), 'new alias should route immediately'); }); + it('POST and PATCH /api/cats persist CatAgent native tool settings', async () => { + const projectRoot = createProjectRoot(); + process.env.CAT_TEMPLATE_PATH = join(projectRoot, 'cat-template.json'); + + const Fastify = (await import('fastify')).default; + const { catsRoutes } = await import('../dist/routes/cats.js'); + + const app = Fastify(); + await app.register(catsRoutes); + + const commandPolicy = [ + { + binary: 'git', + allowedSubcommands: ['status', 'diff'], + allowedFlags: ['--short', '--branch', '--stat', '--name-only', '--cached'], + }, + ]; + + const createRes = await app.inject({ + method: 'POST', + url: '/api/cats', + headers: { + 'content-type': 'application/json', + 'x-cat-cafe-user': 'codex', + }, + body: JSON.stringify({ + catId: 'runtime-catagent', + name: '原生猫', + displayName: '原生猫', + avatar: '/avatars/catagent.png', + color: { primary: '#16a34a', secondary: '#bbf7d0' }, + mentionPatterns: ['@runtime-catagent'], + roleDescription: '原生工具体验', + personality: '谨慎', + clientId: 'catagent', + accountRef: 'claude', + defaultModel: 'claude-sonnet-4-6', + mcpSupport: false, + nativeToolLevel: 'L2', + commandPolicy, + }), + }); + assert.equal(createRes.statusCode, 201); + + const listRes = await app.inject({ method: 'GET', url: '/api/cats' }); + assert.equal(listRes.statusCode, 200); + const listBody = JSON.parse(listRes.body); + const runtimeCat = listBody.cats.find((cat) => cat.id === 'runtime-catagent'); + assert.ok(runtimeCat, 'runtime-catagent should appear in /api/cats'); + assert.equal(runtimeCat.nativeToolLevel, 'L2'); + assert.deepEqual(runtimeCat.commandPolicy, commandPolicy); + + const patchRes = await app.inject({ + method: 'PATCH', + url: '/api/cats/runtime-catagent', + headers: { + 'content-type': 'application/json', + 'x-cat-cafe-user': 'codex', + }, + body: JSON.stringify({ + nativeToolLevel: null, + commandPolicy: null, + }), + }); + assert.equal(patchRes.statusCode, 200); + + const listAfterClearRes = await app.inject({ method: 'GET', url: '/api/cats' }); + assert.equal(listAfterClearRes.statusCode, 200); + const listAfterClearBody = JSON.parse(listAfterClearRes.body); + const runtimeCatAfterClear = listAfterClearBody.cats.find((cat) => cat.id === 'runtime-catagent'); + assert.ok(runtimeCatAfterClear, 'runtime-catagent should still exist'); + assert.equal(runtimeCatAfterClear.nativeToolLevel, undefined); + assert.equal(runtimeCatAfterClear.commandPolicy, undefined); + }); + + it('rejects native tool settings for non-CatAgent members and clears them on client switch', async () => { + const projectRoot = createProjectRoot(); + process.env.CAT_TEMPLATE_PATH = join(projectRoot, 'cat-template.json'); + + const Fastify = (await import('fastify')).default; + const { catsRoutes } = await import('../dist/routes/cats.js'); + + const app = Fastify(); + await app.register(catsRoutes); + + const commandPolicy = [ + { + binary: 'git', + allowedSubcommands: ['status'], + }, + ]; + + const rejectedCreateRes = await app.inject({ + method: 'POST', + url: '/api/cats', + headers: { + 'content-type': 'application/json', + 'x-cat-cafe-user': 'codex', + }, + body: JSON.stringify({ + catId: 'runtime-openai-tools', + name: '错误工具猫', + displayName: '错误工具猫', + avatar: '/avatars/codex.png', + color: { primary: '#2563eb', secondary: '#bfdbfe' }, + mentionPatterns: ['@runtime-openai-tools'], + roleDescription: 'should reject native tools', + clientId: 'openai', + accountRef: 'codex', + defaultModel: 'gpt-5.5', + nativeToolLevel: 'L2', + commandPolicy, + }), + }); + assert.equal(rejectedCreateRes.statusCode, 400); + assert.match(JSON.parse(rejectedCreateRes.body).error, /only supported for catagent clients/i); + + const createCatAgentRes = await app.inject({ + method: 'POST', + url: '/api/cats', + headers: { + 'content-type': 'application/json', + 'x-cat-cafe-user': 'codex', + }, + body: JSON.stringify({ + catId: 'runtime-switch-agent', + name: '切换工具猫', + displayName: '切换工具猫', + avatar: '/avatars/catagent.png', + color: { primary: '#16a34a', secondary: '#bbf7d0' }, + mentionPatterns: ['@runtime-switch-agent'], + roleDescription: 'switches away from CatAgent', + clientId: 'catagent', + accountRef: 'claude', + defaultModel: 'claude-sonnet-4-6', + nativeToolLevel: 'L2', + commandPolicy, + }), + }); + assert.equal(createCatAgentRes.statusCode, 201); + + const switchClientRes = await app.inject({ + method: 'PATCH', + url: '/api/cats/runtime-switch-agent', + headers: { + 'content-type': 'application/json', + 'x-cat-cafe-user': 'codex', + }, + body: JSON.stringify({ + clientId: 'openai', + accountRef: 'codex', + defaultModel: 'gpt-5.5', + }), + }); + assert.equal(switchClientRes.statusCode, 200); + + const listAfterSwitchRes = await app.inject({ method: 'GET', url: '/api/cats' }); + assert.equal(listAfterSwitchRes.statusCode, 200); + const listAfterSwitchBody = JSON.parse(listAfterSwitchRes.body); + const switchedCat = listAfterSwitchBody.cats.find((cat) => cat.id === 'runtime-switch-agent'); + assert.ok(switchedCat, 'runtime-switch-agent should still exist'); + assert.equal(switchedCat.clientId, 'openai'); + assert.equal(switchedCat.nativeToolLevel, undefined); + assert.equal(switchedCat.commandPolicy, undefined); + + const rejectedPatchRes = await app.inject({ + method: 'PATCH', + url: '/api/cats/runtime-switch-agent', + headers: { + 'content-type': 'application/json', + 'x-cat-cafe-user': 'codex', + }, + body: JSON.stringify({ + nativeToolLevel: 'L2', + }), + }); + assert.equal(rejectedPatchRes.statusCode, 400); + assert.match(JSON.parse(rejectedPatchRes.body).error, /only supported for catagent clients/i); + }); + + // F159 Phase G G2 step 1b (AC-G15): mirror the nativeToolLevel/commandPolicy + // routes-level matrix for the new catAgentProtocol field — three behaviors: + // 1. catagent + catAgentProtocol → POST persists + GET returns + // 2. PATCH update / PATCH null clear on existing catagent member + // 3. non-catagent + catAgentProtocol → POST rejects (400) + // 4. PATCH clientId switch away from catagent → catAgentProtocol auto-cleared + it('persists catAgentProtocol via POST/PATCH for catagent members, GET exposes it', async () => { + const projectRoot = createProjectRoot(); + process.env.CAT_TEMPLATE_PATH = join(projectRoot, 'cat-template.json'); + + const Fastify = (await import('fastify')).default; + const { catsRoutes } = await import('../dist/routes/cats.js'); + + const app = Fastify(); + await app.register(catsRoutes); + + const createRes = await app.inject({ + method: 'POST', + url: '/api/cats', + headers: { 'content-type': 'application/json', 'x-cat-cafe-user': 'codex' }, + body: JSON.stringify({ + catId: 'runtime-protocol-cat', + name: '协议路由猫', + displayName: '协议路由猫', + avatar: '/avatars/catagent.png', + color: { primary: '#0ea5e9', secondary: '#e0f2fe' }, + mentionPatterns: ['@runtime-protocol-cat'], + roleDescription: '协议位 routes 测试', + clientId: 'catagent', + accountRef: 'claude', + defaultModel: 'claude-opus-4-6', + mcpSupport: false, + catAgentProtocol: 'openai-chat', + }), + }); + assert.equal(createRes.statusCode, 201); + + const listRes = await app.inject({ method: 'GET', url: '/api/cats' }); + assert.equal(listRes.statusCode, 200); + const cat = JSON.parse(listRes.body).cats.find((c) => c.id === 'runtime-protocol-cat'); + assert.ok(cat, 'runtime-protocol-cat should appear in /api/cats'); + assert.equal(cat.catAgentProtocol, 'openai-chat'); + + const patchRes = await app.inject({ + method: 'PATCH', + url: '/api/cats/runtime-protocol-cat', + headers: { 'content-type': 'application/json', 'x-cat-cafe-user': 'codex' }, + body: JSON.stringify({ catAgentProtocol: 'anthropic-messages' }), + }); + assert.equal(patchRes.statusCode, 200); + + const listAfterPatchRes = await app.inject({ method: 'GET', url: '/api/cats' }); + const catAfterPatch = JSON.parse(listAfterPatchRes.body).cats.find((c) => c.id === 'runtime-protocol-cat'); + assert.equal(catAfterPatch.catAgentProtocol, 'anthropic-messages'); + + const patchNullRes = await app.inject({ + method: 'PATCH', + url: '/api/cats/runtime-protocol-cat', + headers: { 'content-type': 'application/json', 'x-cat-cafe-user': 'codex' }, + body: JSON.stringify({ catAgentProtocol: null }), + }); + assert.equal(patchNullRes.statusCode, 200); + + const listAfterClearRes = await app.inject({ method: 'GET', url: '/api/cats' }); + const catAfterClear = JSON.parse(listAfterClearRes.body).cats.find((c) => c.id === 'runtime-protocol-cat'); + assert.equal(catAfterClear.catAgentProtocol, undefined, 'patch null must clear catAgentProtocol'); + }); + + it('rejects catAgentProtocol for non-CatAgent members and clears it on client switch', async () => { + const projectRoot = createProjectRoot(); + process.env.CAT_TEMPLATE_PATH = join(projectRoot, 'cat-template.json'); + + const Fastify = (await import('fastify')).default; + const { catsRoutes } = await import('../dist/routes/cats.js'); + + const app = Fastify(); + await app.register(catsRoutes); + + // 1. Non-catagent + catAgentProtocol → POST rejected + const rejectedCreateRes = await app.inject({ + method: 'POST', + url: '/api/cats', + headers: { 'content-type': 'application/json', 'x-cat-cafe-user': 'codex' }, + body: JSON.stringify({ + catId: 'runtime-openai-protocol', + name: '错误协议猫', + displayName: '错误协议猫', + avatar: '/avatars/codex.png', + color: { primary: '#2563eb', secondary: '#bfdbfe' }, + mentionPatterns: ['@runtime-openai-protocol'], + roleDescription: 'should reject catAgentProtocol', + clientId: 'openai', + accountRef: 'codex', + defaultModel: 'gpt-5.5', + catAgentProtocol: 'openai-chat', + }), + }); + assert.equal(rejectedCreateRes.statusCode, 400); + assert.match(JSON.parse(rejectedCreateRes.body).error, /only supported for catagent clients/i); + + // 2. catagent member with catAgentProtocol set, then switch clientId → cleared + const createCatAgentRes = await app.inject({ + method: 'POST', + url: '/api/cats', + headers: { 'content-type': 'application/json', 'x-cat-cafe-user': 'codex' }, + body: JSON.stringify({ + catId: 'runtime-protocol-switch', + name: '协议切换猫', + displayName: '协议切换猫', + avatar: '/avatars/catagent.png', + color: { primary: '#16a34a', secondary: '#bbf7d0' }, + mentionPatterns: ['@runtime-protocol-switch'], + roleDescription: 'switches client from catagent', + clientId: 'catagent', + accountRef: 'claude', + defaultModel: 'claude-opus-4-6', + catAgentProtocol: 'openai-chat', + }), + }); + assert.equal(createCatAgentRes.statusCode, 201); + + const switchClientRes = await app.inject({ + method: 'PATCH', + url: '/api/cats/runtime-protocol-switch', + headers: { 'content-type': 'application/json', 'x-cat-cafe-user': 'codex' }, + body: JSON.stringify({ + clientId: 'openai', + accountRef: 'codex', + defaultModel: 'gpt-5.5', + }), + }); + assert.equal(switchClientRes.statusCode, 200); + + const listAfterSwitchRes = await app.inject({ method: 'GET', url: '/api/cats' }); + const switchedCat = JSON.parse(listAfterSwitchRes.body).cats.find((c) => c.id === 'runtime-protocol-switch'); + assert.ok(switchedCat, 'runtime-protocol-switch should still exist'); + assert.equal(switchedCat.clientId, 'openai'); + assert.equal(switchedCat.catAgentProtocol, undefined, 'switching away from catagent must clear catAgentProtocol'); + assert.equal(switchedCat.nativeToolLevel, undefined, 'companion clearing still works (regression sanity)'); + + // 3. PATCH non-catagent member with catAgentProtocol → 400 + const rejectedPatchRes = await app.inject({ + method: 'PATCH', + url: '/api/cats/runtime-protocol-switch', + headers: { 'content-type': 'application/json', 'x-cat-cafe-user': 'codex' }, + body: JSON.stringify({ catAgentProtocol: 'openai-chat' }), + }); + assert.equal(rejectedPatchRes.statusCode, 400); + assert.match(JSON.parse(rejectedPatchRes.body).error, /only supported for catagent clients/i); + }); + it('PATCH /api/cats/:id can update AGY Opus after bootstrap persists a stale catalog injection', async () => { const projectRoot = createProjectRootFromRepoTemplate(); removeAgyOpusFromRuntimeCatalog(projectRoot); @@ -3001,6 +3332,46 @@ describe('cats routes runtime CRUD', { concurrency: false }, () => { assert.equal(listed.provider, undefined, 'GET should confirm no stale provider remains after migration'); }); + // F159 G2 follow-up: AC for runtimeDefaults extension of /api/cat-templates. + // Without this, picking a catagent template leaves form.clientId='anthropic' and + // catAgentProtocol='' → created member silently falls back to anthropic-messages → /v1/messages 403. + it('GET /api/cat-templates exposes breed defaultVariant runtimeDefaults (kitten → catagent + openai-chat)', async () => { + createProjectRootFromRepoTemplate(); + + const Fastify = (await import('fastify')).default; + const { catsRoutes } = await import('../dist/routes/cats.js'); + const app = Fastify(); + await app.register(catsRoutes); + + const res = await app.inject({ method: 'GET', url: '/api/cat-templates' }); + assert.equal(res.statusCode, 200); + const { templates } = JSON.parse(res.body); + assert.ok(Array.isArray(templates) && templates.length > 0, 'templates non-empty'); + + const kitten = templates.find((t) => t.id === 'kitten'); + assert.ok(kitten, 'kitten template present in /api/cat-templates response'); + assert.deepEqual(kitten.runtimeDefaults, { + clientId: 'catagent', + defaultModel: 'gpt-5.5', + catAgentProtocol: 'openai-chat', + nativeToolLevel: 'L1', + }); + + // ragdoll family: regular anthropic-style template, no catAgentProtocol field surfaces. + const ragdoll = templates.find((t) => t.id === 'ragdoll'); + assert.ok(ragdoll, 'ragdoll template present'); + assert.equal(ragdoll.runtimeDefaults?.clientId, 'anthropic'); + assert.equal(ragdoll.runtimeDefaults?.defaultModel, 'claude-opus-4-6'); + assert.equal(ragdoll.runtimeDefaults?.catAgentProtocol, undefined); + assert.equal(ragdoll.runtimeDefaults?.nativeToolLevel, undefined); + + // maine-coon family: openai client, no catAgentProtocol (it's not a catagent member). + const maineCoon = templates.find((t) => t.id === 'maine-coon'); + assert.ok(maineCoon, 'maine-coon template present'); + assert.equal(maineCoon.runtimeDefaults?.clientId, 'openai'); + assert.equal(maineCoon.runtimeDefaults?.catAgentProtocol, undefined); + }); + it('F247 KD-17: POST with provider=openai-chatgpt-pro skips default cli (cloud-only)', async () => { const projectRoot = createProjectRoot(); process.env.CAT_TEMPLATE_PATH = join(projectRoot, 'cat-template.json'); diff --git a/packages/api/test/cicd-router.test.js b/packages/api/test/cicd-router.test.js index 39f444ed09..6bb69adf57 100644 --- a/packages/api/test/cicd-router.test.js +++ b/packages/api/test/cicd-router.test.js @@ -95,6 +95,30 @@ describe('CiCdRouter', () => { socketMock = mockSocketManager(); }); + describe('tracking instructions head binding', () => { + it('keeps instructions when they describe the current CI head', () => { + const content = buildCiMessageContent( + makePollResult({ headSha: 'abc1234567890' }), + 'Proceed to merge readiness.', + 'abc1234567890', + ); + + assert.ok(content.includes('📌 **Tracking Instructions**')); + assert.ok(content.includes('Proceed to merge readiness.')); + }); + + it('omits stale instructions when CI reports a newer head', () => { + const content = buildCiMessageContent( + makePollResult({ headSha: 'newhead1234567890' }), + 'Handle old head review finding before merge.', + 'oldhead1234567890', + ); + + assert.ok(!content.includes('📌 **Tracking Instructions**')); + assert.ok(!content.includes('Handle old head review finding before merge.')); + }); + }); + // ── AC-A6: Unregistered PR skipped ────────────────────────────── describe('unregistered PR', () => { diff --git a/packages/api/test/claude-agent-service.test.js b/packages/api/test/claude-agent-service.test.js index 753fbe2c9a..b26a070bfe 100644 --- a/packages/api/test/claude-agent-service.test.js +++ b/packages/api/test/claude-agent-service.test.js @@ -114,6 +114,14 @@ function writeCapabilitiesConfig(projectRoot, capabilities) { ); } +const CLAUDE_RESERVED_MCP_SERVER_NAMES = [ + 'workspace', + 'claude-in-chrome', + 'computer-use', + 'Claude Preview', + 'Claude Browser', +]; + // --- Test cases --- test('F203 AC-C5: -p carrier passes --system-prompt-file with compiled L0 path', async () => { @@ -1318,6 +1326,132 @@ test('#712: Claude merge excludes disabled capability-managed user entries', asy } }); +test('Claude skips reserved MCP server names from capabilities', async () => { + const runtimeRoot = mkdtempSync(join(tmpdir(), 'cat-cafe-claude-reserved-cap-runtime-')); + const mcpDistDir = join(runtimeRoot, 'packages', 'mcp-server', 'dist'); + const projectDir = mkdtempSync(join(tmpdir(), 'cat-cafe-claude-reserved-cap-project-')); + mkdirSync(mcpDistDir, { recursive: true }); + writeFileSync(join(mcpDistDir, 'index.js'), '// stub', 'utf8'); + writeCapabilitiesConfig(runtimeRoot, [ + ...CLAUDE_RESERVED_MCP_SERVER_NAMES.map((serverName) => ({ + id: serverName, + type: 'mcp', + enabled: true, + globalEnabled: true, + source: 'external', + mcpServer: { command: 'echo', args: ['reserved-should-be-skipped'] }, + })), + { + id: 'safe-tool', + type: 'mcp', + enabled: true, + globalEnabled: true, + source: 'external', + mcpServer: { command: 'echo', args: ['safe'] }, + }, + ]); + + const proc = createMockProcess(); + const spawnFn = createMockSpawnFn(proc); + const service = createClaudeAgentService({ + spawnFn, + model: 'claude-test-model', + mcpServerPath: join(mcpDistDir, 'index.js'), + }); + + try { + const promise = collect( + service.invoke('hello', { + workingDirectory: projectDir, + callbackEnv: { + CAT_CAFE_API_URL: 'http://localhost:3004', + CAT_CAFE_INVOCATION_ID: 'inv-reserved-cap', + CAT_CAFE_CALLBACK_TOKEN: 'token-reserved-cap', + CAT_CAFE_CAT_ID: 'opus', + }, + }), + ); + emitClaudeEvents(proc, [{ type: 'result', subtype: 'success' }]); + await promise; + + const args = spawnFn.mock.calls[0].arguments[1]; + const parsed = JSON.parse(args[args.indexOf('--mcp-config') + 1]); + for (const serverName of CLAUDE_RESERVED_MCP_SERVER_NAMES) { + assert.equal( + parsed.mcpServers[serverName], + undefined, + `Claude reserved MCP name ${serverName} must not be injected`, + ); + } + assert.ok(parsed.mcpServers['safe-tool'], 'non-reserved external MCP should still be injected'); + } finally { + rmSync(runtimeRoot, { recursive: true, force: true }); + rmSync(projectDir, { recursive: true, force: true }); + } +}); + +test('Claude skips reserved MCP server names from project .mcp.json', async () => { + const runtimeRoot = mkdtempSync(join(tmpdir(), 'cat-cafe-claude-reserved-user-runtime-')); + const mcpDistDir = join(runtimeRoot, 'packages', 'mcp-server', 'dist'); + const projectDir = mkdtempSync(join(tmpdir(), 'cat-cafe-claude-reserved-user-project-')); + mkdirSync(mcpDistDir, { recursive: true }); + writeFileSync(join(mcpDistDir, 'index.js'), '// stub', 'utf8'); + writeCapabilitiesConfig(runtimeRoot, []); + writeFileSync( + join(projectDir, '.mcp.json'), + JSON.stringify({ + mcpServers: { + ...Object.fromEntries( + CLAUDE_RESERVED_MCP_SERVER_NAMES.map((serverName) => [ + serverName, + { command: 'echo', args: ['reserved-should-be-skipped'] }, + ]), + ), + 'my-tool': { command: 'echo', args: ['ok'] }, + }, + }), + 'utf8', + ); + + const proc = createMockProcess(); + const spawnFn = createMockSpawnFn(proc); + const service = createClaudeAgentService({ + spawnFn, + model: 'claude-test-model', + mcpServerPath: join(mcpDistDir, 'index.js'), + }); + + try { + const promise = collect( + service.invoke('hello', { + workingDirectory: projectDir, + callbackEnv: { + CAT_CAFE_API_URL: 'http://localhost:3004', + CAT_CAFE_INVOCATION_ID: 'inv-reserved-user', + CAT_CAFE_CALLBACK_TOKEN: 'token-reserved-user', + CAT_CAFE_CAT_ID: 'opus', + }, + }), + ); + emitClaudeEvents(proc, [{ type: 'result', subtype: 'success' }]); + await promise; + + const args = spawnFn.mock.calls[0].arguments[1]; + const parsed = JSON.parse(args[args.indexOf('--mcp-config') + 1]); + for (const serverName of CLAUDE_RESERVED_MCP_SERVER_NAMES) { + assert.equal( + parsed.mcpServers[serverName], + undefined, + `Claude reserved user MCP ${serverName} must not be merged`, + ); + } + assert.ok(parsed.mcpServers['my-tool'], 'non-reserved user MCP should still be merged'); + } finally { + rmSync(runtimeRoot, { recursive: true, force: true }); + rmSync(projectDir, { recursive: true, force: true }); + } +}); + test('falls back to default MCP path when CAT_CAFE_MCP_SERVER_PATH is empty', async () => { const root = mkdtempSync(join(tmpdir(), 'cat-cafe-mcp-empty-env-')); const apiCwd = join(root, 'packages', 'api'); diff --git a/packages/api/test/cli-jsonl-agent-service.test.js b/packages/api/test/cli-jsonl-agent-service.test.js new file mode 100644 index 0000000000..c6908de257 --- /dev/null +++ b/packages/api/test/cli-jsonl-agent-service.test.js @@ -0,0 +1,240 @@ +/** + * F241 Phase A: CLI JSONL AgentService. + */ + +import './helpers/setup-cat-registry.js'; +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +const { CliJsonlAgentService } = await import( + '../dist/domains/cats/services/agents/providers/cli-jsonl/CliJsonlAgentService.js' +); + +async function collect(iterable) { + const out = []; + for await (const item of iterable) out.push(item); + return out; +} + +describe('CliJsonlAgentService', () => { + it('passes prompt via stdin and maps clowder-code turn_result to AgentMessages', async () => { + const seenOpts = []; + const service = new CliJsonlAgentService({ + catId: 'clowder-code', + providerName: 'clowder-code', + modelName: 'reference-runtime', + command: 'clowder-code', + startupArgs: ['--json', '--non-interactive'], + }); + + const messages = await collect( + service.invoke('hello from user', { + workingDirectory: '/tmp/project', + systemPrompt: 'SYSTEM', + callbackEnv: { CAT_CAFE_API_URL: 'http://127.0.0.1:3004' }, + accountEnv: { HOME: '/tmp/cc-home' }, + spawnCliOverride: async function* (opts) { + seenOpts.push(opts); + yield { + type: 'turn_result', + response: 'hello from runtime', + terminal: { kind: 'completed' }, + stats: { + sessionId: 'cc-session-1', + inputTokens: 11, + outputTokens: 7, + tokensUsed: 18, + }, + }; + }, + }), + ); + + assert.equal(seenOpts.length, 1); + assert.deepEqual(seenOpts[0].args, ['--json', '--non-interactive']); + assert.equal(seenOpts[0].stdinInput, 'SYSTEM\n\nhello from user'); + assert.equal(seenOpts[0].cwd, '/tmp/project'); + assert.equal(seenOpts[0].env.CAT_CAFE_API_URL, 'http://127.0.0.1:3004'); + assert.equal(seenOpts[0].env.HOME, '/tmp/cc-home'); + assert.equal(seenOpts[0].args.includes('hello from user'), false, 'prompt must not be passed through argv'); + + assert.deepEqual( + messages.map((m) => m.type), + ['session_init', 'agent_loop', 'text', 'done'], + ); + assert.equal(messages[0].sessionId, 'cc-session-1'); + assert.equal(messages[0].ephemeralSession, false); + assert.equal(messages[1].metadata.provider, 'clowder-code'); + assert.equal(messages[1].metadata.usage.inputTokens, 11); + assert.equal(messages[2].content, 'hello from runtime'); + assert.equal(messages[3].metadata.sessionId, 'cc-session-1'); + }); + + it('uses resume args when the host provides a resumable sessionId', async () => { + const seenOpts = []; + const service = new CliJsonlAgentService({ + catId: 'clowder-code', + providerName: 'clowder-code', + modelName: 'reference-runtime', + command: 'clowder-code', + startupArgs: ['--json', '--non-interactive'], + resumeArgs: ['resume', '{sessionId}', '--json'], + }); + + const messages = await collect( + service.invoke('follow up', { + sessionId: 'cc-session-1', + spawnCliOverride: async function* (opts) { + seenOpts.push(opts); + yield { + type: 'turn_result', + response: 'continued', + terminal: { kind: 'completed' }, + stats: { sessionId: 'cc-session-1' }, + }; + }, + }), + ); + + assert.deepEqual(seenOpts[0].args, ['resume', 'cc-session-1', '--json']); + assert.equal(seenOpts[0].stdinInput, 'follow up'); + assert.equal(messages[0].type, 'session_init'); + assert.equal(messages[0].sessionId, 'cc-session-1'); + assert.equal(messages[0].ephemeralSession, false); + }); + + it('degrades unsafe multiline resume without rewriting the prompt payload', async () => { + const seenOpts = []; + const service = new CliJsonlAgentService({ + catId: 'clowder-code', + providerName: 'clowder-code', + modelName: 'reference-runtime', + command: 'clowder-code', + startupArgs: ['--json', '--non-interactive'], + resumeArgs: ['resume', '{sessionId}', '--json'], + }); + + const messages = await collect( + service.invoke('follow up', { + sessionId: 'cc-session-1', + systemPrompt: 'SYSTEM', + spawnCliOverride: async function* (opts) { + seenOpts.push(opts); + yield { + type: 'turn_result', + response: 'fresh fallback', + terminal: { kind: 'completed' }, + stats: { sessionId: 'new-cold-session' }, + }; + }, + }), + ); + + assert.deepEqual(seenOpts[0].args, ['--json', '--non-interactive']); + assert.equal(seenOpts[0].stdinInput, 'SYSTEM\n\nfollow up'); + assert.deepEqual( + messages.map((m) => m.type), + ['system_info', 'session_init', 'text', 'done'], + ); + assert.match(messages[0].content, /cli_jsonl_resume_requires_single_line_prompt/); + assert.equal(messages[1].sessionId, 'new-cold-session'); + assert.equal(messages[1].ephemeralSession, false); + assert.equal(messages[3].metadata.sessionId, 'new-cold-session'); + }); + + it('makes stateless session handling explicit instead of emitting continuity metadata', async () => { + const service = new CliJsonlAgentService({ + catId: 'clowder-code', + providerName: 'clowder-code', + modelName: 'reference-runtime', + command: 'clowder-code', + sessionPolicy: 'stateless', + }); + + const messages = await collect( + service.invoke('follow up', { + sessionId: 'cc-session-1', + spawnCliOverride: async function* () { + yield { + type: 'turn_result', + response: 'stateless response', + terminal: { kind: 'completed' }, + stats: { sessionId: 'new-cold-session' }, + }; + }, + }), + ); + + assert.deepEqual( + messages.map((m) => m.type), + ['system_info', 'text', 'done'], + ); + assert.match(messages[0].content, /session_continuity_degraded/); + assert.equal( + messages.some((m) => m.type === 'session_init'), + false, + ); + assert.equal(messages[2].metadata.sessionId, undefined); + }); + + it('passes raw archive diagnostics to spawnCli and archives raw events', async () => { + const archived = []; + const service = new CliJsonlAgentService({ + catId: 'clowder-code', + providerName: 'clowder-code', + modelName: 'reference-runtime', + command: 'clowder-code', + rawArchive: { + getPath: (invocationId) => `/tmp/archive/${invocationId}.ndjson`, + append: async (invocationId, payload) => { + archived.push({ invocationId, payload }); + }, + }, + }); + + const seenOpts = []; + await collect( + service.invoke('hello', { + invocationId: 'inv-1', + spawnCliOverride: async function* (opts) { + seenOpts.push(opts); + yield { + type: 'turn_result', + response: 'ok', + terminal: { kind: 'completed' }, + stats: { sessionId: 'cc-session-1' }, + callback_token: 'secret-token', + }; + }, + }), + ); + + assert.equal(seenOpts[0].rawArchivePath, '/tmp/archive/inv-1.ndjson'); + assert.equal(archived.length, 1); + assert.equal(archived[0].invocationId, 'inv-1'); + assert.equal(archived[0].payload.callback_token, '[redacted]'); + }); + + it('emits a visible error when the CLI exits without a turn_result', async () => { + const service = new CliJsonlAgentService({ + catId: 'clowder-code', + providerName: 'clowder-code', + modelName: 'reference-runtime', + command: 'clowder-code', + }); + + const messages = await collect( + service.invoke('hello', { + spawnCliOverride: async function* () { + // no events + }, + }), + ); + + assert.deepEqual( + messages.map((m) => m.type), + ['error', 'done'], + ); + assert.match(messages[0].error, /without a JSONL turn_result/); + }); +}); diff --git a/packages/api/test/cli-jsonl-provider-transport.test.js b/packages/api/test/cli-jsonl-provider-transport.test.js new file mode 100644 index 0000000000..d3a743db1d --- /dev/null +++ b/packages/api/test/cli-jsonl-provider-transport.test.js @@ -0,0 +1,119 @@ +/** + * F241 Phase A: CLI JSONL provider transport factory. + */ + +import './helpers/setup-cat-registry.js'; +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +const { createCliJsonlProviderTransportFactory } = await import( + '../dist/domains/cats/services/agents/providers/cli-jsonl/CliJsonlProviderTransportFactory.js' +); + +function makeInput(providerTransport) { + return { + projectRoot: '/tmp/project', + profileId: 'clowder-code', + providerTransport, + config: { + id: 'clowder-code', + defaultModel: 'reference-runtime', + }, + }; +} + +describe('CliJsonlProviderTransportFactory', () => { + it('creates a service from a valid cli-jsonl declaration', async () => { + const factory = createCliJsonlProviderTransportFactory({ log: { warn: () => {} } }); + + const result = await factory.create( + makeInput({ + transport: 'cli-jsonl', + command: 'clowder-code', + startupArgs: ['--json', '--non-interactive'], + resumeArgs: ['resume', '{sessionId}', '--json'], + outputProfile: 'clowder-code-turn-result-v1', + }), + ); + + assert.equal(result.handled, true); + assert.ok(result.service); + }); + + it('rejects resume mode declarations without a sessionId placeholder', async () => { + const warnings = []; + const factory = createCliJsonlProviderTransportFactory({ + log: { warn: (payload, message) => warnings.push({ payload, message }) }, + }); + + const result = await factory.create( + makeInput({ + transport: 'cli-jsonl', + command: 'clowder-code', + sessionPolicy: 'resume', + resumeArgs: ['resume', '--json'], + }), + ); + + assert.equal(result.handled, true); + assert.equal(result.service, null); + assert.equal(warnings.length, 1); + }); + + it('allows explicit stateless declarations', async () => { + const factory = createCliJsonlProviderTransportFactory({ log: { warn: () => {} } }); + + const result = await factory.create( + makeInput({ + transport: 'cli-jsonl', + command: 'clowder-code', + sessionPolicy: 'stateless', + resumeArgs: [], + }), + ); + + assert.equal(result.handled, true); + assert.ok(result.service); + }); + + it('defaults startupArgs for the clowder-code turn_result profile', async () => { + const factory = createCliJsonlProviderTransportFactory({ log: { warn: () => {} } }); + + const result = await factory.create(makeInput({ transport: 'cli-jsonl', command: 'clowder-code' })); + + assert.equal(result.handled, true); + assert.ok(result.service); + }); + + it('returns handled null for invalid cli-jsonl declarations', async () => { + const warnings = []; + const factory = createCliJsonlProviderTransportFactory({ + log: { warn: (payload, message) => warnings.push({ payload, message }) }, + }); + + const result = await factory.create(makeInput({ transport: 'cli-jsonl', startupArgs: ['--json'] })); + + assert.equal(result.handled, true); + assert.equal(result.service, null); + assert.equal(warnings.length, 1); + }); + + it('rejects negative timeoutMs declarations', async () => { + const warnings = []; + const factory = createCliJsonlProviderTransportFactory({ + log: { warn: (payload, message) => warnings.push({ payload, message }) }, + }); + + const result = await factory.create( + makeInput({ + transport: 'cli-jsonl', + command: 'clowder-code', + timeoutMs: -1, + }), + ); + + assert.equal(result.handled, true); + assert.equal(result.service, null); + assert.equal(warnings.length, 1); + }); +}); diff --git a/packages/api/test/connector-invoke-trigger.test.js b/packages/api/test/connector-invoke-trigger.test.js index 50eaf09a58..9a408d22de 100644 --- a/packages/api/test/connector-invoke-trigger.test.js +++ b/packages/api/test/connector-invoke-trigger.test.js @@ -271,6 +271,40 @@ describe('ConnectorInvokeTrigger', () => { ); }); + it('connector direct route without callback policy does not mark event-driven waits covered', async () => { + const trigger = createTrigger(); + trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'Plain connector msg', 'msg-plain'); + await waitForTrigger(); + + assert.strictEqual(routerMock.calls.length, 1); + assert.notStrictEqual( + routerMock.calls[0].options?.eventDrivenExternalWaitCoverage, + true, + 'plain bound-chat connector messages must not count as callback-covered external waits', + ); + }); + + it('connector direct route carries explicit event-driven callback coverage policy', async () => { + const trigger = createTrigger(); + trigger.trigger( + 'thread-1', + /** @type {any} */ ('opus'), + 'user-1', + 'Review feedback msg', + 'msg-review-feedback', + undefined, + { + reason: 'github_review_feedback', + sourceCategory: 'review', + eventDrivenExternalWaitCoverage: true, + }, + ); + await waitForTrigger(); + + assert.strictEqual(routerMock.calls.length, 1); + assert.strictEqual(routerMock.calls[0].options?.eventDrivenExternalWaitCoverage, true); + }); + it('broadcasts agent messages to WebSocket room', async () => { const trigger = createTrigger(); trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'Review msg', 'msg-1'); @@ -1099,6 +1133,40 @@ describe('ConnectorInvokeTrigger', () => { assert.strictEqual(entries[0].priority, 'urgent'); }); + it('queued connector without callback policy does not mark event-driven waits covered', async () => { + trackerMock.setActive('thread-1', 'user-1'); + const trigger = createTrigger(); + trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'Plain connector msg', 'msg-plain'); + await waitForTrigger(); + + const entries = queue.list('thread-1', 'user-1'); + assert.strictEqual(entries.length, 1); + assert.notStrictEqual(entries[0].eventDrivenExternalWaitCoverage, true); + }); + + it('queued connector preserves explicit event-driven callback coverage policy', async () => { + trackerMock.setActive('thread-1', 'user-1'); + const trigger = createTrigger(); + trigger.trigger( + 'thread-1', + /** @type {any} */ ('opus'), + 'user-1', + 'Review feedback msg', + 'msg-review-feedback', + undefined, + { + reason: 'github_review_feedback', + sourceCategory: 'review', + eventDrivenExternalWaitCoverage: true, + }, + ); + await waitForTrigger(); + + const entries = queue.list('thread-1', 'user-1'); + assert.strictEqual(entries.length, 1); + assert.strictEqual(entries[0].eventDrivenExternalWaitCoverage, true); + }); + it('urgent connector with owner mismatch still enqueues without cancel (F175)', async () => { trackerMock.setActive('thread-1', 'owner-user'); const trigger = createTrigger(); diff --git a/packages/api/test/f168-phase-b-dual-cursor.test.js b/packages/api/test/f168-phase-b-dual-cursor.test.js index 95e611db30..308b2fe967 100644 --- a/packages/api/test/f168-phase-b-dual-cursor.test.js +++ b/packages/api/test/f168-phase-b-dual-cursor.test.js @@ -983,6 +983,49 @@ describe('IssueCommentTaskSpec: with eventLog — dual-cursor', () => { ); }); + it('closed issue final delivery does not grant event-driven wait coverage after task is done (Cloud PR35 P2)', async () => { + assert.ok(createIssueCommentTaskSpec); + const taskStore = makeTaskStore(); + taskStore.addTask(makeTask({ id: 'task-closed-final-coverage', subjectKey: 'issue:owner/repo#42' })); + const eventLog = makeEventLog(); + const policies = []; + const comments = [ + { + id: 7001, + author: 'external', + body: 'final user comment', + authorAssociation: 'NONE', + createdAt: '2026-01-01T00:00:00Z', + }, + ]; + const { spec } = makeBaseSpec({ + taskStore, + comments, + extra: { + eventLog, + fetchIssueState: async () => 'closed', + invokeTrigger: { + trigger: async (_threadId, _catId, _userId, _message, _messageId, _contentBlocks, policy) => { + policies.push(policy); + return 'dispatched'; + }, + }, + }, + }); + + const gate = await runGate(spec); + await runExecute(spec, gate); + + const taskAfter = taskStore.tasks.get('task-closed-final-coverage'); + assert.strictEqual(taskAfter?.status, 'done', 'closed issue final delivery should complete tracking task'); + assert.strictEqual(policies.length, 1, 'final issue comment should still wake the owner once'); + assert.strictEqual( + policies[0]?.eventDrivenExternalWaitCoverage, + false, + 'closed final delivery has no active issue poller left, so it must not validate later 2b event-driven waits', + ); + }); + // ───────────────────────────────────────────────────────────────────────── // Cloud R8 P1-1: duplicate comment (appended:false) must NOT call projector // Applying stale events out of temporal order corrupts awaiting_external state. diff --git a/packages/api/test/final-routing-slot.test.js b/packages/api/test/final-routing-slot.test.js index 7d5d9b4e30..809504b971 100644 --- a/packages/api/test/final-routing-slot.test.js +++ b/packages/api/test/final-routing-slot.test.js @@ -12,6 +12,7 @@ import { describe, test } from 'node:test'; import { finalRoutingSlot, findInlineMentionsInSlot, + hasEventDrivenExternalWaitExit, validateRoutingSyntax, } from '../dist/domains/cats/services/agents/routing/final-routing-slot.js'; @@ -151,6 +152,62 @@ describe('F167 Phase H AC-H3: validateRoutingSyntax trigger conditions', () => { }); assert.equal(result.kind, 'ok'); }); + + test('2b event-driven external wait exit suppresses inline mention syntax warning', () => { + const result = validateRoutingSyntax({ + text: '不再 @codex。\nExternal Wait: event-driven (pr:35)', + lineStartMentions: [], + toolNames: [], + structuredTargetCats: [], + rosterHandles: roster, + hasEventDrivenExternalWaitCoverage: true, + }); + assert.equal(result.kind, 'ok'); + }); + + test('signed 2b event-driven external wait exit suppresses inline mention syntax warning', () => { + const text = '不再 @codex。\nExternal Wait: event-driven (pr:35)\n\n[砚砚/GPT-5.5]'; + + assert.equal(hasEventDrivenExternalWaitExit(text), true); + + const result = validateRoutingSyntax({ + text, + lineStartMentions: [], + toolNames: [], + structuredTargetCats: [], + rosterHandles: roster, + hasEventDrivenExternalWaitCoverage: true, + }); + assert.equal(result.kind, 'ok'); + }); + + test('URL callback id in 2b event-driven external wait exit suppresses inline mention syntax warning', () => { + const text = + '不再 @codex;等 GitHub 回调。\nExternal Wait: event-driven (https://github.com/clowder-labs/clowder-ai/pull/35)'; + + assert.equal(hasEventDrivenExternalWaitExit(text), true); + + const result = validateRoutingSyntax({ + text, + lineStartMentions: [], + toolNames: [], + structuredTargetCats: [], + rosterHandles: roster, + hasEventDrivenExternalWaitCoverage: true, + }); + assert.equal(result.kind, 'ok'); + }); + + test('2b event-driven external wait text without verified callback coverage does not suppress inline mention syntax warning', () => { + const result = validateRoutingSyntax({ + text: '不再 @codex。\nExternal Wait: event-driven (pr:35)', + lineStartMentions: [], + toolNames: [], + structuredTargetCats: [], + rosterHandles: roster, + }); + assert.equal(result.kind, 'invalid_route_syntax'); + }); }); describe('F167 Phase H AC-H6: structural exemptions', () => { diff --git a/packages/api/test/harness-eval/git-worktree-publisher.test.js b/packages/api/test/harness-eval/git-worktree-publisher.test.js index 1a595d54ec..4665beed20 100644 --- a/packages/api/test/harness-eval/git-worktree-publisher.test.js +++ b/packages/api/test/harness-eval/git-worktree-publisher.test.js @@ -38,6 +38,25 @@ afterEach(() => { syncBuiltinESMExports(); }); +describe('parseGitHubRepoFromRemoteUrl', () => { + it('parses GitHub origin push URL forms used for gh --repo pinning', async () => { + const { parseGitHubRepoFromRemoteUrl } = await import( + `../../dist/infrastructure/harness-eval/publish-verdict/git-worktree-publisher.js?t=${Date.now()}-parse` + ); + + assert.equal( + parseGitHubRepoFromRemoteUrl('https://github.com/clowder-labs/clowder-ai.git'), + 'clowder-labs/clowder-ai', + ); + assert.equal(parseGitHubRepoFromRemoteUrl('git@github.com:clowder-labs/clowder-ai.git'), 'clowder-labs/clowder-ai'); + assert.equal( + parseGitHubRepoFromRemoteUrl('ssh://git@github.com/clowder-labs/clowder-ai.git'), + 'clowder-labs/clowder-ai', + ); + assert.equal(parseGitHubRepoFromRemoteUrl('/tmp/local-bare-origin.git'), null); + }); +}); + describe('createGitWorktreePublisher', () => { it('cleans up a partially-created local branch when worktree add fails before stage', async (t) => { const { repoRoot, remoteRoot } = createRepoWithOrigin(); diff --git a/packages/api/test/install-script-test-helpers.js b/packages/api/test/install-script-test-helpers.js index 8fc7a68bd1..532bd313fa 100644 --- a/packages/api/test/install-script-test-helpers.js +++ b/packages/api/test/install-script-test-helpers.js @@ -36,10 +36,19 @@ export { }; export function runSourceOnlySnippet(snippet) { + const env = { ...process.env }; + delete env.CAT_CAFE_GLOBAL_CONFIG_ROOT; + delete env.CAT_CAFE_RUNTIME_DIR; + delete env.CAT_CAFE_RUNTIME_BRANCH; + delete env.CAT_CAFE_RUNTIME_REMOTE; + delete env.CAT_CAFE_RUNTIME_ROOT; + delete env.CAT_CAFE_RUNTIME_SOURCE_BRANCH; + delete env.CAT_CAFE_RUNTIME_SYNC_COMMAND; + const result = spawnSync( 'bash', ['-lc', `set -e\nsource "${installScript}" --source-only >/dev/null 2>&1\n${snippet}`], - { encoding: 'utf8' }, + { encoding: 'utf8', env }, ); assert.equal( diff --git a/packages/api/test/invoke-single-cat.test.js b/packages/api/test/invoke-single-cat.test.js index 39498aee88..9487247fa7 100644 --- a/packages/api/test/invoke-single-cat.test.js +++ b/packages/api/test/invoke-single-cat.test.js @@ -363,6 +363,143 @@ describe('invokeSingleCat audit events (P1 fix)', () => { assert.equal(payload.invocationId, 'inv-1'); }); + it('passes CatAgent current-task callback only for the explicitly selected task', async () => { + const { MemoryTaskProgressStore } = await import( + '../dist/domains/cats/services/agents/invocation/MemoryTaskProgressStore.js' + ); + let task = { + id: 'task-current', + kind: 'work', + threadId: 'thread-current-task', + subjectKey: null, + title: 'Selected task', + ownerCatId: 'codex', + status: 'todo', + why: '', + createdBy: 'user', + createdAt: Date.now(), + updatedAt: Date.now(), + }; + const taskStore = { + get: async (id) => (id === task.id ? task : null), + update: async (id, input) => { + if (id !== task.id) return null; + task = { ...task, ...input, updatedAt: Date.now() }; + return task; + }, + }; + const threadStore = { + get: async () => ({ + id: 'thread-current-task', + projectPath: 'default', + title: null, + createdBy: 'user1', + participants: ['codex'], + lastActiveAt: Date.now(), + createdAt: Date.now(), + firstRunQuestState: { + v: 1, + phase: 'quest-4-task-running', + startedAt: Date.now(), + selectedTaskId: 'task-current', + }, + }), + isRebornSession: async () => false, + }; + const taskProgressStore = new MemoryTaskProgressStore(); + const deps = { ...makeDeps(), threadStore, taskStore, taskProgressStore }; + let callbackOptions; + const service = { + l0CompilerFn: dummyL0CompilerFn, + async *invoke(_prompt, options) { + callbackOptions = options.catAgentScopedCallbacks; + await callbackOptions.currentTask.updateCurrentTaskStatus({ + status: 'doing', + progress: 42, + summary: 'Halfway done', + }); + yield { type: 'done', catId: 'codex', timestamp: Date.now() }; + }, + }; + + await collect( + invokeSingleCat(deps, { + catId: 'codex', + service, + prompt: 'test', + userId: 'user1', + threadId: 'thread-current-task', + isLastCat: true, + }), + ); + + assert.equal(callbackOptions.currentTask.currentTaskId, 'task-current'); + assert.equal(task.status, 'doing'); + assert.equal(task.why, 'Halfway done'); + const snapshot = await taskProgressStore.getSnapshot('thread-current-task', 'codex'); + assert.equal(snapshot.tasks[0].id, 'task-current'); + assert.equal(snapshot.tasks[0].status, 'in_progress'); + assert.equal(snapshot.tasks[0].activeForm, 'Halfway done'); + }); + + it('does not pass CatAgent current-task callback for another cat owner', async () => { + const taskStore = { + get: async () => ({ + id: 'task-owned-by-gemini', + kind: 'work', + threadId: 'thread-current-task-owner', + subjectKey: null, + title: 'Other owner task', + ownerCatId: 'gemini', + status: 'todo', + why: '', + createdBy: 'user', + createdAt: Date.now(), + updatedAt: Date.now(), + }), + }; + const threadStore = { + get: async () => ({ + id: 'thread-current-task-owner', + projectPath: 'default', + title: null, + createdBy: 'user1', + participants: ['codex'], + lastActiveAt: Date.now(), + createdAt: Date.now(), + firstRunQuestState: { + v: 1, + phase: 'quest-4-task-running', + startedAt: Date.now(), + selectedTaskId: 'task-owned-by-gemini', + }, + }), + isRebornSession: async () => false, + }; + const deps = { ...makeDeps(), threadStore, taskStore }; + let callbackOptions = 'not-called'; + const service = { + l0CompilerFn: dummyL0CompilerFn, + async *invoke(_prompt, options) { + callbackOptions = options.catAgentScopedCallbacks; + yield { type: 'done', catId: 'codex', timestamp: Date.now() }; + }, + }; + + await collect( + invokeSingleCat(deps, { + catId: 'codex', + service, + prompt: 'test', + userId: 'user1', + threadId: 'thread-current-task-owner', + isLastCat: true, + }), + ); + + assert.equal(callbackOptions, undefined); + }); + it('persists task progress snapshot with completed status on done even when tasks are not all completed', async () => { const { MemoryTaskProgressStore } = await import( '../dist/domains/cats/services/agents/invocation/MemoryTaskProgressStore.js' @@ -825,6 +962,254 @@ describe('invokeSingleCat audit events (P1 fix)', () => { assert.equal(active.status, 'active'); }); + it('F241: session_continuity_degraded seals old active record before fresh cli-jsonl session_init', async () => { + const { SessionChainStore } = await import('../dist/domains/cats/services/stores/ports/SessionChainStore.js'); + const { SessionSealer } = await import('../dist/domains/cats/services/session/SessionSealer.js'); + const sessionChainStore = new SessionChainStore(); + const sessionSealer = new SessionSealer(sessionChainStore); + const oldRecord = sessionChainStore.create({ + cliSessionId: 'cli-old', + threadId: 'thread-f241-degraded', + catId: 'opus', + userId: 'user1', + }); + const optionsSeen = []; + + const service = { + l0CompilerFn: dummyL0CompilerFn, + async *invoke(_prompt, options) { + optionsSeen.push(options ?? {}); + yield { + type: 'system_info', + catId: 'opus', + content: JSON.stringify({ + type: 'session_continuity_degraded', + reason: 'cli_jsonl_resume_requires_single_line_prompt', + requestedSessionId: 'cli-old', + }), + timestamp: Date.now(), + }; + yield { type: 'session_init', catId: 'opus', sessionId: 'cli-new', timestamp: Date.now() }; + yield { type: 'text', catId: 'opus', content: 'fresh fallback output', timestamp: Date.now() }; + yield { type: 'done', catId: 'opus', timestamp: Date.now() }; + }, + }; + + const deps = { ...makeDeps(), sessionChainStore, sessionSealer }; + await collect( + invokeSingleCat(deps, { + catId: 'opus', + service, + prompt: 'test', + userId: 'user1', + threadId: 'thread-f241-degraded', + isLastCat: true, + }), + ); + + assert.equal(optionsSeen[0].sessionId, 'cli-old', 'preflight should pass the old active session to provider'); + const oldAfter = sessionChainStore.get(oldRecord.id); + assert.ok(oldAfter, 'old record should still exist for sealed transcript/digest access'); + assert.notEqual(oldAfter.status, 'active', 'degraded old session must not remain active'); + assert.equal(oldAfter.sealReason, 'session_continuity_degraded'); + + const active = sessionChainStore.getActive('opus', 'thread-f241-degraded'); + assert.ok(active, 'fresh session_init should create a new active record'); + assert.notEqual(active.id, oldRecord.id); + assert.equal(active.cliSessionId, 'cli-new'); + assert.equal(active.messageCount, 1, 'fresh output should count against the new record'); + }); + + it('F241: stateless continuity degradation keeps the active record for host persistence', async () => { + const { SessionChainStore } = await import('../dist/domains/cats/services/stores/ports/SessionChainStore.js'); + const { SessionSealer } = await import('../dist/domains/cats/services/session/SessionSealer.js'); + const sessionChainStore = new SessionChainStore(); + const sessionSealer = new SessionSealer(sessionChainStore); + const oldRecord = sessionChainStore.create({ + cliSessionId: 'cli-stateless-old', + threadId: 'thread-f241-stateless', + catId: 'opus', + userId: 'user1', + }); + + const service = { + l0CompilerFn: dummyL0CompilerFn, + async *invoke() { + yield { + type: 'system_info', + catId: 'opus', + content: JSON.stringify({ + type: 'session_continuity_degraded', + reason: 'cli_jsonl_stateless', + requestedSessionId: 'cli-stateless-old', + }), + timestamp: Date.now(), + }; + yield { type: 'text', catId: 'opus', content: 'stateless output', timestamp: Date.now() }; + yield { type: 'done', catId: 'opus', timestamp: Date.now() }; + }, + }; + + const deps = { ...makeDeps(), sessionChainStore, sessionSealer }; + await collect( + invokeSingleCat(deps, { + catId: 'opus', + service, + prompt: 'test', + userId: 'user1', + threadId: 'thread-f241-stateless', + isLastCat: true, + }), + ); + + const active = sessionChainStore.getActive('opus', 'thread-f241-stateless'); + assert.ok(active, 'stateless degradation should not clear host active session state'); + assert.equal(active.id, oldRecord.id); + assert.equal(active.status, 'active'); + assert.equal(active.sealReason, undefined); + assert.equal(active.messageCount, 1, 'stateless output should still count against the active host record'); + }); + + it('F241: stale continuity degradation does not seal a manually rebound active record', async () => { + const { SessionChainStore } = await import('../dist/domains/cats/services/stores/ports/SessionChainStore.js'); + const { SessionSealer } = await import('../dist/domains/cats/services/session/SessionSealer.js'); + const sessionChainStore = new SessionChainStore(); + const sessionSealer = new SessionSealer(sessionChainStore); + const oldRecord = sessionChainStore.create({ + cliSessionId: 'cli-old', + threadId: 'thread-f241-stale-degraded', + catId: 'opus', + userId: 'user1', + }); + const optionsSeen = []; + + const service = { + l0CompilerFn: dummyL0CompilerFn, + async *invoke(_prompt, options) { + optionsSeen.push(options ?? {}); + sessionChainStore.update(oldRecord.id, { + cliSessionId: 'cli-manual-bind', + updatedAt: Date.now(), + }); + yield { + type: 'system_info', + catId: 'opus', + content: JSON.stringify({ + type: 'session_continuity_degraded', + reason: 'cli_jsonl_resume_requires_single_line_prompt', + requestedSessionId: 'cli-old', + }), + timestamp: Date.now(), + }; + yield { type: 'session_init', catId: 'opus', sessionId: 'cli-new-stale', timestamp: Date.now() }; + yield { type: 'text', catId: 'opus', content: 'stale fallback output', timestamp: Date.now() }; + yield { type: 'done', catId: 'opus', timestamp: Date.now() }; + }, + }; + + const deps = { ...makeDeps(), sessionChainStore, sessionSealer }; + const outputs = await collect( + invokeSingleCat(deps, { + catId: 'opus', + service, + prompt: 'test', + userId: 'user1', + threadId: 'thread-f241-stale-degraded', + isLastCat: true, + }), + ); + + assert.equal(optionsSeen[0].sessionId, 'cli-old', 'preflight should resume the originally active session'); + assert.equal( + outputs.some((msg) => msg.type === 'session_init'), + false, + 'stale fresh session_init must not rebind the current active record', + ); + + const active = sessionChainStore.getActive('opus', 'thread-f241-stale-degraded'); + assert.ok(active, 'manual bind should remain active after stale degraded fallback'); + assert.equal(active.id, oldRecord.id); + assert.equal(active.cliSessionId, 'cli-manual-bind'); + assert.equal(active.status, 'active'); + assert.equal(active.sealReason, undefined); + assert.equal(active.messageCount, 0, 'stale fallback output should not count against the rebound record'); + assert.equal(sessionChainStore.getChain('opus', 'thread-f241-stale-degraded').length, 1); + }); + + it('F241: stale continuity degradation does not seal when manual bind wins during requestSeal', async () => { + const { SessionChainStore } = await import('../dist/domains/cats/services/stores/ports/SessionChainStore.js'); + const { SessionSealer } = await import('../dist/domains/cats/services/session/SessionSealer.js'); + const sessionChainStore = new SessionChainStore(); + const realSessionSealer = new SessionSealer(sessionChainStore); + const oldRecord = sessionChainStore.create({ + cliSessionId: 'cli-old', + threadId: 'thread-f241-race-degraded', + catId: 'opus', + userId: 'user1', + }); + const requestSealArgs = []; + const racingSessionSealer = { + async requestSeal(args) { + requestSealArgs.push(args); + sessionChainStore.update(oldRecord.id, { + cliSessionId: 'cli-manual-bind', + updatedAt: Date.now(), + }); + return realSessionSealer.requestSeal(args); + }, + async finalize(args) { + return realSessionSealer.finalize(args); + }, + }; + + const service = { + l0CompilerFn: dummyL0CompilerFn, + async *invoke() { + yield { + type: 'system_info', + catId: 'opus', + content: JSON.stringify({ + type: 'session_continuity_degraded', + reason: 'cli_jsonl_resume_requires_single_line_prompt', + requestedSessionId: 'cli-old', + }), + timestamp: Date.now(), + }; + yield { type: 'session_init', catId: 'opus', sessionId: 'cli-new-race', timestamp: Date.now() }; + yield { type: 'text', catId: 'opus', content: 'stale fallback output', timestamp: Date.now() }; + yield { type: 'done', catId: 'opus', timestamp: Date.now() }; + }, + }; + + const deps = { ...makeDeps(), sessionChainStore, sessionSealer: racingSessionSealer }; + const outputs = await collect( + invokeSingleCat(deps, { + catId: 'opus', + service, + prompt: 'test', + userId: 'user1', + threadId: 'thread-f241-race-degraded', + isLastCat: true, + }), + ); + + assert.equal(requestSealArgs[0].expectedCliSessionId, 'cli-old'); + assert.equal( + outputs.some((msg) => msg.type === 'session_init'), + false, + 'stale fresh session_init must not bind after requestSeal rejects the expected session', + ); + + const active = sessionChainStore.getActive('opus', 'thread-f241-race-degraded'); + assert.ok(active, 'manual bind should remain active after requestSeal rejects stale degradation'); + assert.equal(active.id, oldRecord.id); + assert.equal(active.cliSessionId, 'cli-manual-bind'); + assert.equal(active.status, 'active'); + assert.equal(active.sealReason, undefined); + assert.equal(active.messageCount, 0, 'stale fallback output should not count against the rebound record'); + assert.equal(sessionChainStore.getChain('opus', 'thread-f241-race-degraded').length, 1); + }); + it('F211 A2: repeated Antigravity cascade updates runtime metadata without creating a new SessionRecord', async () => { const { SessionChainStore } = await import('../dist/domains/cats/services/stores/ports/SessionChainStore.js'); const { RuntimeSessionStore } = await import( diff --git a/packages/api/test/l0-compiler.test.js b/packages/api/test/l0-compiler.test.js index 1dc5546b1f..d8f2b4603f 100644 --- a/packages/api/test/l0-compiler.test.js +++ b/packages/api/test/l0-compiler.test.js @@ -9,6 +9,7 @@ */ import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; import { EventEmitter } from 'node:events'; import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; @@ -238,6 +239,75 @@ test('F231: profileDir falls back to script-path-based when cwd has no private/p ); }); +test('F241 Phase C: compileL0 bootstrap registers plugin-projected cats from capabilities.json', () => { + const projectRoot = mkdtempSync(join(tmpdir(), 'l0-f241-project-')); + const sourceTemplate = resolve(import.meta.dirname, '../../../cat-template.json'); + const templatePath = join(projectRoot, 'cat-template.json'); + mkdirSync(join(projectRoot, '.cat-cafe'), { recursive: true }); + writeFileSync(templatePath, readFileSync(sourceTemplate, 'utf8')); + + const descriptorHash = 'hash-for-l0-projection-test'; + writeFileSync( + join(projectRoot, '.cat-cafe', 'capabilities.json'), + JSON.stringify( + { + version: 2, + capabilities: [ + { + id: 'plugin:clowder-code:clowder-code', + type: 'agentProvider', + enabled: true, + source: 'cat-cafe', + pluginId: 'clowder-code', + agentProvider: { + name: 'clowder-code', + transport: 'cli-jsonl', + command: 'clowder-code', + startupArgs: ['--json', '--non-interactive'], + resumeArgs: ['resume', '{sessionId}', '--json'], + sessionPolicy: 'resume', + outputProfile: 'clowder-code-turn-result-v1', + healthCheck: { type: 'cliProbe' }, + state: 'healthy', + routeable: true, + routeableApproved: true, + descriptorHash, + health: { + passed: true, + // Deliberately expired. L0 bootstrap mirrors runtime-visible + // routeable cats; TTL refresh/degrade is owned by API sync. + checkedAt: 1, + ttlMs: 1, + descriptorHash, + }, + routeableBinding: { + catId: 'clowder-cat', + mentionPatterns: ['clowder'], + }, + }, + }, + ], + }, + null, + 2, + ), + ); + + const scriptPath = resolve(import.meta.dirname, '../../../scripts/compile-system-prompt-l0.mjs'); + const out = execFileSync(process.execPath, [scriptPath, '--cat', 'clowder-cat'], { + cwd: resolve(import.meta.dirname, '../../..'), + env: { + ...process.env, + CAT_TEMPLATE_PATH: templatePath, + }, + encoding: 'utf8', + maxBuffer: 1024 * 1024, + }); + + assert.match(out, /clowder-code/); + assert.match(out, /Plugin-projected agentProvider/); +}); + // --- L0 template content guard --- test('L0 template includes limb tool quick index (via L5 segment)', () => { diff --git a/packages/api/test/openai-chat-adapter-golden.test.js b/packages/api/test/openai-chat-adapter-golden.test.js new file mode 100644 index 0000000000..4c5390e894 --- /dev/null +++ b/packages/api/test/openai-chat-adapter-golden.test.js @@ -0,0 +1,290 @@ +/** + * OpenAIChatAdapter Golden-Wire Contract Test — F159 Phase G G2 AC-G25 + * + * Byte-stable lock on the OpenAI Chat Completions protocol shape: + * - URL / headers / body + * - stream_options.include_usage + * - transcript codec (assistant tool_calls + tool messages) + * - streaming chunk → neutral event mapping + * - error text + terminal stop classification + */ + +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +import { OpenAIChatAdapter } from '../dist/domains/cats/services/agents/providers/catagent/openai-chat-adapter.js'; + +function toStream(chunks) { + let i = 0; + return new ReadableStream({ + pull(controller) { + if (i < chunks.length) { + controller.enqueue(new TextEncoder().encode(chunks[i++])); + } else { + controller.close(); + } + }, + }); +} + +function sse(data) { + return `data: ${typeof data === 'string' ? data : JSON.stringify(data)}\n\n`; +} + +async function collect(iter) { + const out = []; + for await (const event of iter) out.push(event); + return out; +} + +describe('OpenAIChatAdapter: identity', () => { + test('clientFamily and protocolId are stable identifiers', () => { + const adapter = new OpenAIChatAdapter(); + assert.equal(adapter.clientFamily, 'openai'); + assert.equal(adapter.protocolId, 'openai-chat-v1'); + }); +}); + +describe('OpenAIChatAdapter: buildRequestUrl', () => { + const adapter = new OpenAIChatAdapter(); + + test('undefined baseURL uses default', () => { + assert.equal(adapter.buildRequestUrl(undefined), 'https://api.openai.com/v1/chat/completions'); + }); + + test('empty string baseURL falls back to default', () => { + assert.equal(adapter.buildRequestUrl(''), 'https://api.openai.com/v1/chat/completions'); + }); + + test('/v1 suffix not double-prefixed', () => { + assert.equal(adapter.buildRequestUrl('https://proxy.example/v1'), 'https://proxy.example/v1/chat/completions'); + }); + + test('custom proxy without /v1 keeps full path', () => { + assert.equal( + adapter.buildRequestUrl('https://gateway.example/openai'), + 'https://gateway.example/openai/v1/chat/completions', + ); + }); +}); + +describe('OpenAIChatAdapter: buildRequestHeaders', () => { + test('produces exact Bearer header set', () => { + const adapter = new OpenAIChatAdapter(); + assert.deepEqual(adapter.buildRequestHeaders({ apiKey: 'sk-openai-test' }), { + 'Content-Type': 'application/json', + Authorization: 'Bearer sk-openai-test', + }); + }); +}); + +describe('OpenAIChatAdapter: buildRequestBody', () => { + const adapter = new OpenAIChatAdapter(); + + test('minimal body uses stream + include_usage', () => { + const body = adapter.buildRequestBody({ + model: 'gpt-5.5', + messages: [adapter.encodeUserPrompt('hi')], + tools: [], + }); + const json = JSON.parse(JSON.stringify(body)); + assert.deepEqual(json, { + model: 'gpt-5.5', + max_tokens: 4096, + messages: [{ role: 'user', content: 'hi' }], + stream: true, + stream_options: { include_usage: true }, + }); + }); + + test('system prompt is prepended as system message', () => { + const body = adapter.buildRequestBody({ + model: 'gpt-5.5', + messages: [adapter.encodeUserPrompt('hi')], + tools: [], + systemPrompt: 'You are X.', + }); + const json = JSON.parse(JSON.stringify(body)); + assert.deepEqual(json.messages[0], { role: 'system', content: 'You are X.' }); + assert.deepEqual(json.messages[1], { role: 'user', content: 'hi' }); + }); + + test('tools map to OpenAI function tool schema', () => { + const body = adapter.buildRequestBody({ + model: 'gpt-5.5', + messages: [adapter.encodeUserPrompt('read file')], + tools: [{ name: 'read_file', description: 'Read file', inputSchema: { type: 'object' } }], + }); + const json = JSON.parse(JSON.stringify(body)); + assert.deepEqual(json.tools, [ + { + type: 'function', + function: { + name: 'read_file', + description: 'Read file', + parameters: { type: 'object' }, + }, + }, + ]); + }); +}); + +describe('OpenAIChatAdapter: transcript codec', () => { + const adapter = new OpenAIChatAdapter(); + + test('encodeAssistantTurn emits assistant content + tool_calls losslessly', () => { + const body = adapter.buildRequestBody({ + model: 'gpt-5.5', + messages: [ + adapter.encodeUserPrompt('go'), + adapter.encodeAssistantTurn([ + { type: 'text', text: 'Thinking...' }, + { type: 'tool_call', id: 'call_1', name: 'read_file', input: { path: 'a.txt' } }, + ]), + ], + tools: [], + }); + const json = JSON.parse(JSON.stringify(body)); + assert.deepEqual(json.messages[1], { + role: 'assistant', + content: 'Thinking...', + tool_calls: [ + { + id: 'call_1', + type: 'function', + function: { + name: 'read_file', + arguments: '{"path":"a.txt"}', + }, + }, + ], + }); + }); + + test('encodeToolResults emits role=tool messages keyed by tool_call_id', () => { + const body = adapter.buildRequestBody({ + model: 'gpt-5.5', + messages: [ + adapter.encodeUserPrompt('go'), + adapter.encodeToolResults([ + { id: 'call_1', content: 'file body', status: 'ok' }, + { id: 'call_2', content: 'Error: nope', status: 'error' }, + ]), + ], + tools: [], + }); + const json = JSON.parse(JSON.stringify(body)); + assert.deepEqual(json.messages.slice(1), [ + { role: 'tool', tool_call_id: 'call_1', content: 'file body' }, + { role: 'tool', tool_call_id: 'call_2', content: 'Error: nope' }, + ]); + }); +}); + +describe('OpenAIChatAdapter: parseStreamEvents', () => { + test('text deltas + final usage + stop are normalised', async () => { + const adapter = new OpenAIChatAdapter(); + const stream = [ + sse({ + id: 'chatcmpl-1', + choices: [{ index: 0, delta: { role: 'assistant', content: 'Hel' }, finish_reason: null }], + }), + sse({ + id: 'chatcmpl-1', + choices: [{ index: 0, delta: { content: 'lo' }, finish_reason: 'stop' }], + }), + sse({ + id: 'chatcmpl-1', + choices: [], + usage: { prompt_tokens: 12, completion_tokens: 4, prompt_tokens_details: { cached_tokens: 3 } }, + }), + sse('[DONE]'), + ].join(''); + const events = await collect(adapter.parseStreamEvents(toStream([stream]))); + assert.deepEqual( + events.filter((event) => event.type === 'text_delta').map((event) => event.text), + ['Hel', 'lo'], + ); + const stop = events.find((event) => event.type === 'stop'); + assert.equal(stop?.stopReason, 'stop'); + const usage = events.find((event) => event.type === 'usage_update'); + assert.deepEqual(usage?.usage, { inputTokens: 12, outputTokens: 4, cacheReadTokens: 3 }); + const complete = events.find((event) => event.type === 'content_block_complete'); + assert.deepEqual(complete?.block, { type: 'text', text: 'Hello' }); + }); + + test('tool_calls delta is reassembled into neutral tool_call block', async () => { + const adapter = new OpenAIChatAdapter(); + const stream = [ + sse({ + id: 'chatcmpl-2', + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + id: 'call_abc', + function: { name: 'read_file', arguments: '{"path":"a' }, + }, + ], + }, + finish_reason: null, + }, + ], + }), + sse({ + id: 'chatcmpl-2', + choices: [ + { + index: 0, + delta: { + tool_calls: [{ index: 0, function: { arguments: '.txt"}' } }], + }, + finish_reason: 'tool_calls', + }, + ], + }), + sse('[DONE]'), + ].join(''); + const events = await collect(adapter.parseStreamEvents(toStream([stream]))); + const complete = events.find((event) => event.type === 'content_block_complete'); + assert.equal(complete?.block.type, 'tool_call'); + assert.equal(complete?.block.id, 'call_abc'); + assert.equal(complete?.block.name, 'read_file'); + assert.deepEqual(complete?.block.input, { path: 'a.txt' }); + const stop = events.find((event) => event.type === 'stop'); + assert.equal(stop?.stopReason, 'tool_calls'); + }); + + test('missing [DONE] yields stream_error', async () => { + const adapter = new OpenAIChatAdapter(); + const stream = sse({ + id: 'chatcmpl-3', + choices: [{ index: 0, delta: { content: 'partial' }, finish_reason: 'stop' }], + }); + const events = await collect(adapter.parseStreamEvents(toStream([stream]))); + const error = events.find((event) => event.type === 'stream_error'); + assert.match(error?.error ?? '', /\[DONE\]/); + }); +}); + +describe('OpenAIChatAdapter: mapError + terminal stop reasons', () => { + test('mapError emits OpenAI-shaped error text', () => { + const adapter = new OpenAIChatAdapter(); + assert.deepEqual(adapter.mapError({ status: 429, message: 'rate limited' }), { + errorText: 'OpenAI API error (429): rate limited', + }); + }); + + test('terminal stop reasons match Chat Completions semantics', () => { + const adapter = new OpenAIChatAdapter(); + for (const reason of ['stop', 'length', 'content_filter']) { + assert.equal(adapter.isTerminalStopReason(reason), true); + } + for (const reason of ['tool_calls', 'function_call', null, undefined, 'future_reason', '']) { + assert.equal(adapter.isTerminalStopReason(reason ?? null), false); + } + }); +}); diff --git a/packages/api/test/plugin-agent-provider-activate.test.js b/packages/api/test/plugin-agent-provider-activate.test.js new file mode 100644 index 0000000000..e67fa92e3d --- /dev/null +++ b/packages/api/test/plugin-agent-provider-activate.test.js @@ -0,0 +1,205 @@ +/** + * F241 Phase B Slice 2a: agentProvider manifest activation stays non-routeable. + */ + +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +const { PluginResourceActivator } = await import('../dist/domains/plugin/PluginResourceActivator.js'); + +function makeAgentProviderResource(overrides = {}) { + const { agentProvider: agentProviderOverrides, ...topLevelOverrides } = overrides; + return { + type: 'agentProvider', + name: 'clowder-code', + ...topLevelOverrides, + agentProvider: { + name: 'clowder-code', + transport: 'cli-jsonl', + command: 'clowder-code', + startupArgs: ['--json', '--non-interactive'], + resumeArgs: ['resume', '{sessionId}', '--json'], + sessionPolicy: 'resume', + outputProfile: 'clowder-code-turn-result-v1', + mcpWhitelistRequest: ['cat-cafe-collab'], + sandboxRequest: 'workspace-write', + healthCheck: { type: 'cliProbe' }, + ...agentProviderOverrides, + }, + }; +} + +function makeManifest(resource = makeAgentProviderResource()) { + return { + id: 'clowder-code', + name: 'Clowder Code', + version: '1.0.0', + builtin: false, + config: [], + resources: [resource], + }; +} + +function makeCapabilitiesStore() { + let state = null; + return { + read: async () => state, + write: async (next) => { + state = structuredClone(next); + }, + get: () => state, + }; +} + +function makeActivator({ providerTransportRegistry = { has: (transportId) => transportId === 'cli-jsonl' } } = {}) { + const capStore = makeCapabilitiesStore(); + const activator = new PluginResourceActivator({ + resolveProjectRoot: () => '/tmp/project', + pluginsDir: '/tmp/project/plugins', + limbRegistry: { register: async () => {}, deregister: () => {} }, + readCapabilities: capStore.read, + writeCapabilities: capStore.write, + withCapabilityLock: async (fn) => fn(), + providerTransportRegistry, + }); + return { activator, capStore }; +} + +describe('PluginResourceActivator - agentProvider resources', () => { + it('activates agentProvider as transportReady but not routeable', async () => { + const { activator, capStore } = makeActivator(); + + const result = await activator.enablePlugin(makeManifest()); + + assert.equal(result.status, 'success'); + assert.equal(result.resources[0].ok, true); + + const entry = capStore.get()?.capabilities[0]; + assert.ok(entry); + assert.equal(entry.type, 'agentProvider'); + assert.equal(entry.enabled, true); + assert.equal(entry.pluginId, 'clowder-code'); + assert.equal(entry.agentProvider.state, 'transportReady'); + assert.equal(entry.agentProvider.routeable, false); + assert.equal(entry.agentProvider.routeableApproved, false); + assert.equal(entry.agentProvider.name, 'clowder-code'); + assert.equal(entry.agentProvider.transport, 'cli-jsonl'); + assert.deepEqual(entry.agentProvider.mcpWhitelistRequest, ['cat-cafe-collab']); + }); + + it('rejects agentProvider activation when host transport is not registered', async () => { + const { activator, capStore } = makeActivator({ providerTransportRegistry: { has: () => false } }); + + const result = await activator.enablePlugin(makeManifest()); + + assert.equal(result.status, 'failed'); + assert.equal(result.resources[0].ok, false); + assert.match(result.resources[0].error ?? '', /Unknown agentProvider transport/); + assert.equal(capStore.get(), null); + }); +}); + +// ─── F241 Phase B Slice 2b: descriptor hash + activator integration ─────── + +describe('PluginResourceActivator - agentProvider descriptor hash (Slice 2b)', () => { + it('writes a stable descriptorHash on first activation', async () => { + const { activator, capStore } = makeActivator(); + await activator.enablePlugin(makeManifest()); + const entry = capStore.get()?.capabilities[0]; + assert.ok(entry?.agentProvider?.descriptorHash, 'descriptorHash should be present after activation'); + assert.match(entry.agentProvider.descriptorHash, /^[0-9a-f]{64}$/); + }); + + it('preserves host-owned routeableApproved/health/lastSyncError when re-activated with identical descriptor', async () => { + const { activator, capStore } = makeActivator(); + // First activation lays the row down. + await activator.enablePlugin(makeManifest()); + + // Simulate downstream flow: operator approved, health passed, AgentRegistry synced. + // Directly mutate the persisted snapshot to seed host-owned state — this mirrors + // what Step 4 (approval admin surface) and Step 6 (sync coordinator) will do. + const current = capStore.get(); + const seeded = structuredClone(current); + seeded.capabilities[0].agentProvider.routeableApproved = true; + seeded.capabilities[0].agentProvider.routeable = true; + seeded.capabilities[0].agentProvider.state = 'healthy'; + seeded.capabilities[0].agentProvider.health = { + passed: true, + checkedAt: 1000, + ttlMs: 60000, + descriptorHash: current.capabilities[0].agentProvider.descriptorHash, + }; + seeded.capabilities[0].agentProvider.lastSyncError = undefined; + await capStore.write(seeded); + + // Re-activate with identical manifest — must NOT clobber the host-owned state. + await activator.enablePlugin(makeManifest()); + + const entry = capStore.get()?.capabilities[0]; + assert.equal(entry.agentProvider.routeableApproved, true, 'approval should be preserved'); + assert.equal(entry.agentProvider.routeable, true, 'routeable should be preserved'); + assert.equal(entry.agentProvider.state, 'healthy', 'lifecycle state should be preserved'); + assert.ok(entry.agentProvider.health, 'health result should be preserved'); + assert.equal(entry.agentProvider.health.passed, true); + }); + + it('resets routeableApproved + invalidates health when descriptor changes', async () => { + const { activator, capStore } = makeActivator(); + await activator.enablePlugin(makeManifest()); + + // Seed approval + health as above. + const current = capStore.get(); + const seeded = structuredClone(current); + seeded.capabilities[0].agentProvider.routeableApproved = true; + seeded.capabilities[0].agentProvider.routeable = true; + seeded.capabilities[0].agentProvider.state = 'healthy'; + seeded.capabilities[0].agentProvider.health = { + passed: true, + checkedAt: 1000, + ttlMs: 60000, + descriptorHash: current.capabilities[0].agentProvider.descriptorHash, + }; + await capStore.write(seeded); + + // Re-activate with a CHANGED descriptor (different command). + const mutated = makeAgentProviderResource({ + agentProvider: { command: '/usr/local/bin/clowder-code-next' }, + }); + await activator.enablePlugin(makeManifest(mutated)); + + const entry = capStore.get()?.capabilities[0]; + assert.equal(entry.agentProvider.routeableApproved, false, 'approval must be reset on descriptor delta'); + assert.equal(entry.agentProvider.routeable, false, 'routeable must be reset on descriptor delta'); + assert.equal(entry.agentProvider.state, 'transportReady', 'state must drop back to transportReady'); + assert.equal(entry.agentProvider.health, undefined, 'health must be invalidated on descriptor delta'); + assert.equal(entry.agentProvider.lastSyncError, undefined, 'lastSyncError must be cleared on descriptor delta'); + assert.notEqual( + entry.agentProvider.descriptorHash, + current.capabilities[0].agentProvider.descriptorHash, + 'descriptorHash must be updated to the new value', + ); + }); + + it('migrates a 2a-shipped row (no descriptorHash) by filling in the hash on next activation', async () => { + const { activator, capStore } = makeActivator(); + // First activation produces the canonical row layout. + await activator.enablePlugin(makeManifest()); + + // Simulate a 2a-shipped row by stripping descriptorHash from the persisted state. + // Real 2a never wrote this field; on upgrade to 2b the first re-activation should + // fill it in (mismatch with undefined → reset branch). approval/health stay at the + // 2a defaults (false / undefined) so resetting is a no-op semantically. + const seeded = structuredClone(capStore.get()); + seeded.capabilities[0].agentProvider.descriptorHash = undefined; + await capStore.write(seeded); + + await activator.enablePlugin(makeManifest()); + + const entry = capStore.get()?.capabilities[0]; + assert.ok(entry.agentProvider.descriptorHash, 'descriptorHash should be filled in after re-activation'); + assert.match(entry.agentProvider.descriptorHash, /^[0-9a-f]{64}$/); + assert.equal(entry.agentProvider.routeableApproved, false); + assert.equal(entry.agentProvider.routeable, false); + assert.equal(entry.agentProvider.state, 'transportReady'); + }); +}); diff --git a/packages/api/test/plugin-manifest-safety.test.js b/packages/api/test/plugin-manifest-safety.test.js index 518a1ab28c..f39eab67ca 100644 --- a/packages/api/test/plugin-manifest-safety.test.js +++ b/packages/api/test/plugin-manifest-safety.test.js @@ -305,6 +305,306 @@ describe('parsePluginManifest security', () => { assert.equal(manifest.resources[1].type, 'skill'); }); + it('parses agentProvider as strict non-routeable transport declaration', () => { + tmpDir = mkdtempSync(join(os.tmpdir(), 'plugin-test-')); + const yamlPath = writeTmpManifest( + tmpDir, + 'clowder-code', + [ + 'id: clowder-code', + 'name: Clowder Code', + 'version: 1.0.0', + 'resources:', + ' - type: agentProvider', + ' name: clowder-code', + ' transport: cli-jsonl', + ' command: clowder-code', + ' startupArgs: ["--json", "--non-interactive"]', + ' resumeArgs: ["resume", "{sessionId}", "--json"]', + ' sessionPolicy: resume', + ' outputProfile: clowder-code-turn-result-v1', + ' timeoutMs: 60000', + ' mcpWhitelist:', + ' - cat-cafe-collab', + ' sandbox: workspace-write', + ' healthCheck:', + ' type: cliProbe', + ].join('\n'), + ); + + const manifest = parsePluginManifest(yamlPath); + const resource = manifest.resources[0]; + + assert.equal(resource.type, 'agentProvider'); + assert.equal(resource.name, 'clowder-code'); + assert.deepEqual(resource.agentProvider, { + name: 'clowder-code', + transport: 'cli-jsonl', + command: 'clowder-code', + startupArgs: ['--json', '--non-interactive'], + resumeArgs: ['resume', '{sessionId}', '--json'], + sessionPolicy: 'resume', + outputProfile: 'clowder-code-turn-result-v1', + timeoutMs: 60000, + mcpWhitelistRequest: ['cat-cafe-collab'], + sandboxRequest: 'workspace-write', + healthCheck: { type: 'cliProbe' }, + }); + }); + + it('rejects agentProvider with unknown transport', () => { + tmpDir = mkdtempSync(join(os.tmpdir(), 'plugin-test-')); + const yamlPath = writeTmpManifest( + tmpDir, + 'bad-provider', + [ + 'id: bad-provider', + 'name: Bad Provider', + 'version: 1.0.0', + 'resources:', + ' - type: agentProvider', + ' name: bad-provider', + ' transport: shell', + ' command: bad-provider', + ' startupArgs: ["--json"]', + ].join('\n'), + ); + + assert.throws(() => parsePluginManifest(yamlPath), /agentProvider transport/); + }); + + it('parses optional F241 2c identity-claim fields (providerId / displayName / mentionPatterns)', () => { + tmpDir = mkdtempSync(join(os.tmpdir(), 'plugin-test-')); + const yamlPath = writeTmpManifest( + tmpDir, + 'clowder-code', + [ + 'id: clowder-code', + 'name: Clowder Code', + 'version: 1.0.0', + 'resources:', + ' - type: agentProvider', + ' name: clowder-code', + ' transport: cli-jsonl', + ' command: clowder-code', + ' startupArgs: ["--json"]', + ' sessionPolicy: stateless', + ' outputProfile: clowder-code-turn-result-v1', + ' providerId: clowder-code', + ' displayName: Clowder Code', + ' mentionPatterns:', + ' - "@clowder"', + ' - "@clowder-code"', + ].join('\n'), + ); + const manifest = parsePluginManifest(yamlPath); + const ap = manifest.resources[0].agentProvider; + assert.equal(ap.providerId, 'clowder-code'); + assert.equal(ap.displayName, 'Clowder Code'); + assert.deepEqual(ap.mentionPatterns, ['@clowder', '@clowder-code']); + }); + + it('omits F241 2c identity-claim fields when not declared (backward-compat with 2b manifests)', () => { + tmpDir = mkdtempSync(join(os.tmpdir(), 'plugin-test-')); + const yamlPath = writeTmpManifest( + tmpDir, + 'clowder-code', + [ + 'id: clowder-code', + 'name: Clowder Code', + 'version: 1.0.0', + 'resources:', + ' - type: agentProvider', + ' name: clowder-code', + ' transport: cli-jsonl', + ' command: clowder-code', + ' startupArgs: ["--json"]', + ' sessionPolicy: stateless', + ' outputProfile: clowder-code-turn-result-v1', + ].join('\n'), + ); + const manifest = parsePluginManifest(yamlPath); + const ap = manifest.resources[0].agentProvider; + assert.equal(ap.providerId, undefined); + assert.equal(ap.displayName, undefined); + assert.equal(ap.mentionPatterns, undefined); + }); + + it('rejects agentProvider mentionPattern that does not start with @', () => { + tmpDir = mkdtempSync(join(os.tmpdir(), 'plugin-test-')); + const yamlPath = writeTmpManifest( + tmpDir, + 'clowder-code', + [ + 'id: clowder-code', + 'name: Clowder Code', + 'version: 1.0.0', + 'resources:', + ' - type: agentProvider', + ' name: clowder-code', + ' transport: cli-jsonl', + ' command: clowder-code', + ' startupArgs: ["--json"]', + ' sessionPolicy: stateless', + ' outputProfile: clowder-code-turn-result-v1', + ' mentionPatterns:', + ' - clowder', + ].join('\n'), + ); + assert.throws(() => parsePluginManifest(yamlPath), /must start with '@'/); + }); + + it('rejects agentProvider mentionPattern that contains whitespace', () => { + tmpDir = mkdtempSync(join(os.tmpdir(), 'plugin-test-')); + const yamlPath = writeTmpManifest( + tmpDir, + 'clowder-code', + [ + 'id: clowder-code', + 'name: Clowder Code', + 'version: 1.0.0', + 'resources:', + ' - type: agentProvider', + ' name: clowder-code', + ' transport: cli-jsonl', + ' command: clowder-code', + ' startupArgs: ["--json"]', + ' sessionPolicy: stateless', + ' outputProfile: clowder-code-turn-result-v1', + ' mentionPatterns:', + ' - "@with space"', + ].join('\n'), + ); + assert.throws(() => parsePluginManifest(yamlPath), /must not contain whitespace/); + }); + + it('rejects agentProvider providerId with path separators (reserved namespace shape)', () => { + tmpDir = mkdtempSync(join(os.tmpdir(), 'plugin-test-')); + const yamlPath = writeTmpManifest( + tmpDir, + 'clowder-code', + [ + 'id: clowder-code', + 'name: Clowder Code', + 'version: 1.0.0', + 'resources:', + ' - type: agentProvider', + ' name: clowder-code', + ' transport: cli-jsonl', + ' command: clowder-code', + ' startupArgs: ["--json"]', + ' sessionPolicy: stateless', + ' outputProfile: clowder-code-turn-result-v1', + ' providerId: namespaced/id', + ].join('\n'), + ); + assert.throws(() => parsePluginManifest(yamlPath), /must not contain path separators/); + }); + + it('rejects agentProvider with duplicate mentionPatterns', () => { + tmpDir = mkdtempSync(join(os.tmpdir(), 'plugin-test-')); + const yamlPath = writeTmpManifest( + tmpDir, + 'clowder-code', + [ + 'id: clowder-code', + 'name: Clowder Code', + 'version: 1.0.0', + 'resources:', + ' - type: agentProvider', + ' name: clowder-code', + ' transport: cli-jsonl', + ' command: clowder-code', + ' startupArgs: ["--json"]', + ' sessionPolicy: stateless', + ' outputProfile: clowder-code-turn-result-v1', + ' mentionPatterns:', + ' - "@dup"', + ' - "@dup"', + ].join('\n'), + ); + assert.throws(() => parsePluginManifest(yamlPath), /duplicate entries are not allowed/); + }); + + // P2 review (@codex on PR #39): runtime mention matching is case-insensitive, + // so `@clowder` and `@Clowder` are runtime-equivalent. Parser must reject them + // at the schema gate or operators get a non-obvious admission collision later. + it('rejects agentProvider mentionPatterns that differ only by case (case-insensitive duplicate)', () => { + tmpDir = mkdtempSync(join(os.tmpdir(), 'plugin-test-')); + const yamlPath = writeTmpManifest( + tmpDir, + 'clowder-code', + [ + 'id: clowder-code', + 'name: Clowder Code', + 'version: 1.0.0', + 'resources:', + ' - type: agentProvider', + ' name: clowder-code', + ' transport: cli-jsonl', + ' command: clowder-code', + ' startupArgs: ["--json"]', + ' sessionPolicy: stateless', + ' outputProfile: clowder-code-turn-result-v1', + ' mentionPatterns:', + ' - "@clowder"', + ' - "@Clowder"', + ].join('\n'), + ); + assert.throws(() => parsePluginManifest(yamlPath), /case-insensitive match/); + }); + + // P2 review (@codex on PR #39): the `@name` contract requires at least one + // character after the `@` prefix. A bare `@` would otherwise pass startsWith + // + non-empty checks but be meaningless to the routing layer. + it('rejects agentProvider mentionPattern that is just "@"', () => { + tmpDir = mkdtempSync(join(os.tmpdir(), 'plugin-test-')); + const yamlPath = writeTmpManifest( + tmpDir, + 'clowder-code', + [ + 'id: clowder-code', + 'name: Clowder Code', + 'version: 1.0.0', + 'resources:', + ' - type: agentProvider', + ' name: clowder-code', + ' transport: cli-jsonl', + ' command: clowder-code', + ' startupArgs: ["--json"]', + ' sessionPolicy: stateless', + ' outputProfile: clowder-code-turn-result-v1', + ' mentionPatterns:', + ' - "@"', + ].join('\n'), + ); + assert.throws(() => parsePluginManifest(yamlPath), /at least one character after '@'/); + }); + + it('rejects agentProvider with negative timeoutMs', () => { + tmpDir = mkdtempSync(join(os.tmpdir(), 'plugin-test-')); + const yamlPath = writeTmpManifest( + tmpDir, + 'bad-provider', + [ + 'id: bad-provider', + 'name: Bad Provider', + 'version: 1.0.0', + 'resources:', + ' - type: agentProvider', + ' name: bad-provider', + ' transport: cli-jsonl', + ' command: bad-provider', + ' startupArgs: ["--json"]', + ' sessionPolicy: stateless', + ' outputProfile: clowder-code-turn-result-v1', + ' timeoutMs: -1', + ].join('\n'), + ); + + assert.throws(() => parsePluginManifest(yamlPath), /timeoutMs/); + }); + it('rejects schedule resource without factoryId', () => { tmpDir = mkdtempSync(join(os.tmpdir(), 'plugin-test-')); const yamlPath = writeTmpManifest( diff --git a/packages/api/test/plugin-registry-agent-provider-info.test.js b/packages/api/test/plugin-registry-agent-provider-info.test.js new file mode 100644 index 0000000000..c5194d7b26 --- /dev/null +++ b/packages/api/test/plugin-registry-agent-provider-info.test.js @@ -0,0 +1,226 @@ +/** + * F241 Phase C — `PluginRegistry.getPluginInfo` agentProvider projection tests. + * + * The Hub UI for owner approval renders entirely off the `PluginResourceStatus` + * fields that `getPluginInfo` projects out of the persisted capabilities row + * and the manifest declaration. These tests lock down that projection: + * - capId is populated for agentProvider resources + * - host-owned routeable / approval / binding state mirrors capabilities + * - manifest-declared claims (PR #39 providerId/displayName/mentionPatterns) + * are surfaced so the form can prefill defaults + * - failureReason surfaces only when health.passed === false + * - non-agentProvider resources are unaffected + */ + +import assert from 'node:assert/strict'; +import { mkdtempSync } from 'node:fs'; +import os from 'node:os'; +import { join } from 'node:path'; +import { describe, it } from 'node:test'; + +const { PluginRegistry } = await import('../dist/domains/plugin/PluginRegistry.js'); + +function makeManifest({ withClaims = true } = {}) { + return { + id: 'clowder-code', + name: 'Clowder Code', + version: '0.1.0', + builtin: false, + config: [], + resources: [ + { + type: 'agentProvider', + name: 'clowder-code', + agentProvider: { + name: 'clowder-code', + transport: 'cli-jsonl', + command: 'clowder-code', + startupArgs: ['--json'], + sessionPolicy: 'stateless', + outputProfile: 'clowder-code-turn-result-v1', + ...(withClaims + ? { + providerId: 'clowder-code', + displayName: 'Clowder Code', + mentionPatterns: ['@clowder', '@clowder-code'], + } + : {}), + }, + }, + ], + }; +} + +function makeCapabilities({ routeable, approved, binding, descriptorHash, failureReason, lastSyncError } = {}) { + return { + version: 1, + capabilities: [ + { + id: 'plugin:clowder-code:clowder-code', + type: 'agentProvider', + enabled: true, + source: 'cat-cafe', + pluginId: 'clowder-code', + agentProvider: { + name: 'clowder-code', + transport: 'cli-jsonl', + command: 'clowder-code', + startupArgs: ['--json'], + sessionPolicy: 'stateless', + outputProfile: 'clowder-code-turn-result-v1', + state: routeable ? 'healthy' : 'transportReady', + routeable: !!routeable, + routeableApproved: !!approved, + descriptorHash: descriptorHash ?? 'hash-X', + ...(binding ? { routeableBinding: binding } : {}), + ...(failureReason + ? { + health: { + passed: false, + checkedAt: 1000, + ttlMs: 60_000, + descriptorHash: descriptorHash ?? 'hash-X', + failureReason, + }, + } + : {}), + ...(lastSyncError ? { lastSyncError } : {}), + }, + }, + ], + }; +} + +function makeRegistry() { + return new PluginRegistry(mkdtempSync(join(os.tmpdir(), 'plugin-info-test-'))); +} + +describe('PluginRegistry.getPluginInfo — F241 agentProvider projection', () => { + it('populates capId for the Hub UI POST URL', () => { + const reg = makeRegistry(); + const info = reg.getPluginInfo(makeManifest(), makeCapabilities({ routeable: false, approved: false }), {}); + const r = info.resources[0]; + assert.equal(r.capId, 'plugin:clowder-code:clowder-code'); + }); + + it('surfaces routeable + approval flags from capabilities', () => { + const reg = makeRegistry(); + const info = reg.getPluginInfo( + makeManifest(), + makeCapabilities({ + routeable: true, + approved: true, + binding: { catId: 'clowder-cat', mentionPatterns: ['@clowder'] }, + }), + {}, + ); + const r = info.resources[0]; + assert.equal(r.agentProviderRouteable, true); + assert.equal(r.agentProviderRouteableApproved, true); + assert.equal(r.agentProviderState, 'healthy'); + assert.deepEqual(r.agentProviderBinding, { catId: 'clowder-cat', mentionPatterns: ['@clowder'] }); + }); + + it('surfaces manifest identity claims (PR #39) for form prefill', () => { + const reg = makeRegistry(); + const info = reg.getPluginInfo( + makeManifest({ withClaims: true }), + makeCapabilities({ routeable: false, approved: false }), + {}, + ); + assert.deepEqual(info.resources[0].agentProviderClaims, { + providerId: 'clowder-code', + displayName: 'Clowder Code', + mentionPatterns: ['@clowder', '@clowder-code'], + }); + }); + + it('omits agentProviderClaims when manifest declares none', () => { + const reg = makeRegistry(); + const info = reg.getPluginInfo( + makeManifest({ withClaims: false }), + makeCapabilities({ routeable: false, approved: false }), + {}, + ); + assert.equal(info.resources[0].agentProviderClaims, undefined); + }); + + it('surfaces descriptorHash so the operator can see when re-approval is pending', () => { + const reg = makeRegistry(); + const info = reg.getPluginInfo( + makeManifest(), + makeCapabilities({ routeable: false, approved: false, descriptorHash: 'hash-Y' }), + {}, + ); + assert.equal(info.resources[0].agentProviderDescriptorHash, 'hash-Y'); + }); + + it('surfaces health.failureReason when probe failed', () => { + const reg = makeRegistry(); + const info = reg.getPluginInfo( + makeManifest(), + makeCapabilities({ routeable: false, approved: false, failureReason: 'cli-probe-cli-not-found:clowder-code' }), + {}, + ); + assert.equal(info.resources[0].agentProviderHealthFailureReason, 'cli-probe-cli-not-found:clowder-code'); + }); + + it('omits failureReason when health passed (no operator-visible noise on the happy path)', () => { + const reg = makeRegistry(); + const info = reg.getPluginInfo( + makeManifest(), + makeCapabilities({ routeable: true, approved: true, binding: { catId: 'clowder-cat' } }), + {}, + ); + assert.equal(info.resources[0].agentProviderHealthFailureReason, undefined); + }); + + // PR #42 round-1 review @codex P2: persisted post-approval sync failures + // must surface to the Hub so operators can diagnose a row that is + // approved + healthy but stuck non-routeable. + it('surfaces lastSyncError (message + occurredAt) so post-approval sync failure is diagnosable', () => { + const reg = makeRegistry(); + const info = reg.getPluginInfo( + makeManifest(), + makeCapabilities({ + routeable: false, + approved: true, + binding: { catId: 'clowder-cat' }, + lastSyncError: { message: 'agent registry sync failed: ENOENT', occurredAt: 1_700_000_000_999 }, + }), + {}, + ); + assert.deepEqual(info.resources[0].agentProviderLastSyncError, { + message: 'agent registry sync failed: ENOENT', + occurredAt: 1_700_000_000_999, + }); + }); + + it('omits lastSyncError on the happy path (no UI noise after sync succeeds)', () => { + const reg = makeRegistry(); + const info = reg.getPluginInfo( + makeManifest(), + makeCapabilities({ routeable: true, approved: true, binding: { catId: 'clowder-cat' } }), + {}, + ); + assert.equal(info.resources[0].agentProviderLastSyncError, undefined); + }); + + it('does NOT add F241 fields to non-agentProvider resources', () => { + const reg = makeRegistry(); + const manifest = { + id: 'gh', + name: 'GitHub', + version: '1.0.0', + builtin: false, + config: [], + resources: [{ type: 'schedule', name: 'cicd-check', factoryId: 'github.cicd-check' }], + }; + const info = reg.getPluginInfo(manifest, { version: 1, capabilities: [] }, {}); + const r = info.resources[0]; + assert.equal(r.capId, undefined); + assert.equal(r.agentProviderRouteable, undefined); + assert.equal(r.agentProviderBinding, undefined); + assert.equal(r.agentProviderClaims, undefined); + }); +}); diff --git a/packages/api/test/provider-transport-registry.test.js b/packages/api/test/provider-transport-registry.test.js new file mode 100644 index 0000000000..72d61243ad --- /dev/null +++ b/packages/api/test/provider-transport-registry.test.js @@ -0,0 +1,218 @@ +/** + * F241 Phase A: host-owned provider transport registry. + */ + +import './helpers/setup-cat-registry.js'; +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +const { ProviderTransportRegistry, deriveReservedProviderTransportIdentities, markActiveProviderTransportProfile } = + await import('../dist/domains/cats/services/agents/providers/transport/ProviderTransportRegistry.js'); +const { createCliJsonlProviderTransportFactory } = await import( + '../dist/domains/cats/services/agents/providers/cli-jsonl/CliJsonlProviderTransportFactory.js' +); + +function makeService() { + return { + async *invoke() { + yield { type: 'done', catId: 'test-cat', timestamp: Date.now() }; + }, + }; +} + +function makeInput(profileId = 'test-cat') { + return { + projectRoot: '/tmp/project', + profileId, + config: { id: profileId, clientId: 'clowder-code' }, + }; +} + +describe('ProviderTransportRegistry', () => { + it('rejects duplicate transport factory ids', () => { + const registry = new ProviderTransportRegistry(); + const factory = { id: 'acp', create: async () => ({ handled: false }) }; + registry.register(factory); + assert.throws(() => registry.register(factory), /already registered/); + }); + + it('falls through when no registered transport handles the config', async () => { + const registry = new ProviderTransportRegistry(); + registry.register({ id: 'acp', create: async () => ({ handled: false }) }); + + const result = await registry.createServiceForConfig(makeInput()); + + assert.deepEqual(result, { handled: false }); + }); + + it('keeps an explicit unknown transport terminal instead of falling back', async () => { + const registry = new ProviderTransportRegistry(); + + const result = await registry.createServiceForConfig({ + ...makeInput(), + providerTransport: { transport: 'missing-transport' }, + }); + + assert.equal(result.handled, true); + assert.equal(result.transportId, 'missing-transport'); + assert.equal(result.service, null); + }); + + it('keeps a malformed explicit transport terminal instead of falling back', async () => { + const registry = new ProviderTransportRegistry(); + + const result = await registry.createServiceForConfig({ + ...makeInput(), + providerTransport: { command: 'clowder-code' }, + }); + + assert.equal(result.handled, true); + assert.equal(result.transportId, 'invalid'); + assert.equal(result.service, null); + }); + + it('rejects cli-jsonl providerTransport with negative timeoutMs', async () => { + const registry = new ProviderTransportRegistry(); + registry.register(createCliJsonlProviderTransportFactory({ log: { warn: () => {} } })); + + const result = await registry.createServiceForConfig({ + ...makeInput('external-provider'), + providerTransport: { + transport: 'cli-jsonl', + command: 'clowder-code', + timeoutMs: -1, + }, + }); + + assert.equal(result.handled, true); + assert.equal(result.transportId, 'cli-jsonl'); + assert.equal(result.service, null); + assert.equal(result.rejectionReason, 'factory-rejected'); + }); + + it('rejects explicit providerTransport declarations on builtin client identities', async () => { + const registry = new ProviderTransportRegistry(); + registry.register({ id: 'cli-jsonl', create: async () => ({ handled: true, service: makeService() }) }); + + const result = await registry.createServiceForConfig({ + ...makeInput('custom-external'), + config: { id: 'custom-external', clientId: 'openai' }, + providerTransport: { transport: 'cli-jsonl', command: 'clowder-code' }, + }); + + assert.equal(result.handled, true); + assert.equal(result.transportId, 'cli-jsonl'); + assert.equal(result.service, null); + assert.equal(result.rejectionReason, 'builtin-client:openai'); + }); + + it('rejects explicit providerTransport declarations that claim routeable builtin cat ids', async () => { + const registry = new ProviderTransportRegistry(); + registry.register({ id: 'cli-jsonl', create: async () => ({ handled: true, service: makeService() }) }); + + const result = await registry.createServiceForConfig({ + ...makeInput('codex'), + providerTransport: { transport: 'cli-jsonl', command: 'clowder-code' }, + reservedRouteableIds: new Set(['codex']), + }); + + assert.equal(result.handled, true); + assert.equal(result.transportId, 'cli-jsonl'); + assert.equal(result.service, null); + assert.equal(result.rejectionReason, 'builtin-cat:codex'); + }); + + it('derives reserved routeable identities from template baseline plus active non-transport cats', () => { + const reserved = deriveReservedProviderTransportIdentities({ + configs: { + 'template-builtin': { id: 'template-builtin', clientId: 'clowder-code' }, + 'legacy-cat': { id: 'legacy-cat', clientId: 'anthropic' }, + 'external-provider': { id: 'external-provider', clientId: 'clowder-code' }, + }, + providerTransportsByProfileId: new Map([ + ['template-builtin', { transport: 'cli-jsonl' }], + ['external-provider', { transport: 'cli-jsonl' }], + ]), + templateBuiltinIds: new Set(['template-builtin']), + }); + + assert.equal(reserved.has('template-builtin'), true, 'template builtin stays reserved even after PT injection'); + assert.equal(reserved.has('legacy-cat'), true, 'active non-PT legacy cats stay reserved'); + assert.equal(reserved.has('external-provider'), false, 'new external PT profiles remain activatable'); + }); + + it('rejects self-hijack when a template builtin gains providerTransport and non-builtin clientId', async () => { + const registry = new ProviderTransportRegistry(); + registry.register({ id: 'cli-jsonl', create: async () => ({ handled: true, service: makeService() }) }); + + const result = await registry.createServiceForConfig({ + ...makeInput('future-builtin'), + config: { id: 'future-builtin', clientId: 'clowder-code' }, + providerTransport: { transport: 'cli-jsonl', command: 'clowder-code' }, + reservedRouteableIds: new Set(['future-builtin']), + }); + + assert.equal(result.handled, true); + assert.equal(result.transportId, 'cli-jsonl'); + assert.equal(result.service, null); + assert.equal(result.rejectionReason, 'builtin-cat:future-builtin'); + }); + + it('rejects explicit providerTransport declarations when reserved identity baseline is unavailable', async () => { + const registry = new ProviderTransportRegistry(); + registry.register({ id: 'cli-jsonl', create: async () => ({ handled: true, service: makeService() }) }); + + const result = await registry.createServiceForConfig({ + ...makeInput('external-provider'), + providerTransport: { transport: 'cli-jsonl', command: 'clowder-code' }, + reservedRouteableIdentityError: 'template-baseline-unavailable', + }); + + assert.equal(result.handled, true); + assert.equal(result.transportId, 'cli-jsonl'); + assert.equal(result.service, null); + assert.equal(result.rejectionReason, 'reserved-routeable-identities-unavailable'); + }); + + it('returns a handled transport service before caller provider switch fallback', async () => { + const registry = new ProviderTransportRegistry(); + const service = makeService(); + registry.register({ id: 'acp', create: async () => ({ handled: true, service }) }); + + const result = await registry.createServiceForConfig(makeInput()); + + assert.equal(result.handled, true); + assert.equal(result.transportId, 'acp'); + assert.equal(result.service, service); + }); + + it('keeps handled null terminal so invalid declared transports cannot fall back to clientId switch', async () => { + const registry = new ProviderTransportRegistry(); + registry.register({ id: 'acp', create: async () => ({ handled: true, service: null }) }); + + const result = await registry.createServiceForConfig(makeInput()); + + assert.equal(result.handled, true); + assert.equal(result.transportId, 'acp'); + assert.equal(result.service, null); + }); + + it('passes active profile ids to each transport closeStale hook', async () => { + const registry = new ProviderTransportRegistry(); + const seen = []; + registry.register({ + id: 'acp', + create: async () => ({ handled: false }), + closeStale: async (activeProfileIds, options) => { + seen.push({ activeProfileIds: [...activeProfileIds], reason: options?.reason }); + }, + }); + const active = new Map(); + markActiveProviderTransportProfile(active, 'acp', 'cat-a'); + markActiveProviderTransportProfile(active, 'acp', 'cat-b'); + + await registry.closeStale(active, { reason: 'config-sync' }); + + assert.deepEqual(seen, [{ activeProfileIds: ['cat-a', 'cat-b'], reason: 'config-sync' }]); + }); +}); diff --git a/packages/api/test/queue-processor.test.js b/packages/api/test/queue-processor.test.js index 1613444fa9..ace6f1b583 100644 --- a/packages/api/test/queue-processor.test.js +++ b/packages/api/test/queue-processor.test.js @@ -1664,6 +1664,37 @@ describe('QueueProcessor', () => { assert.equal(opts.a2aTriggerMessageId, undefined); }); + it('executeEntry does not treat connector source alone as event-driven wait coverage', async () => { + const entry = enqueueEntry(deps.queue, { source: 'connector' }); + deps.queue.backfillMessageId('t1', 'u1', entry.id, 'msg-connector'); + + await processor.processNext('t1', 'u1'); + await new Promise((r) => setTimeout(r, 50)); + + assert.ok(deps.router.routeExecution.mock.calls.length > 0); + const call = deps.router.routeExecution.mock.calls[0]; + const opts = call.arguments[6]; + assert.ok(opts && typeof opts === 'object', 'expected opts object'); + assert.notEqual(opts.eventDrivenExternalWaitCoverage, true); + }); + + it('executeEntry passes explicit queued event-driven wait coverage to routeExecution', async () => { + const entry = enqueueEntry(deps.queue, { + source: 'connector', + eventDrivenExternalWaitCoverage: true, + }); + deps.queue.backfillMessageId('t1', 'u1', entry.id, 'msg-connector-covered'); + + await processor.processNext('t1', 'u1'); + await new Promise((r) => setTimeout(r, 50)); + + assert.ok(deps.router.routeExecution.mock.calls.length > 0); + const call = deps.router.routeExecution.mock.calls[0]; + const opts = call.arguments[6]; + assert.ok(opts && typeof opts === 'object', 'expected opts object'); + assert.equal(opts.eventDrivenExternalWaitCoverage, true); + }); + it('degrades when messageStore.getById throws: still executes without contentBlocks', async () => { deps.messageStore.getById = mock.fn(async () => { throw new Error('redis down'); diff --git a/packages/api/test/redis-ble-binding-store.test.js b/packages/api/test/redis-ble-binding-store.test.js new file mode 100644 index 0000000000..0a1d56c7dc --- /dev/null +++ b/packages/api/test/redis-ble-binding-store.test.js @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict'; +import { after, before, beforeEach, describe, it } from 'node:test'; +import { + assertRedisIsolationOrThrow, + cleanupPrefixedRedisKeys, + redisIsolationSkipReason, +} from './helpers/redis-test-helpers.js'; + +const REDIS_URL = process.env.REDIS_URL; + +describe('RedisBleBindingStore', { skip: redisIsolationSkipReason(REDIS_URL) }, () => { + let redis; + let RedisBleBindingStore; + let bleBindingKey; + let connected = false; + + before(async () => { + assertRedisIsolationOrThrow(REDIS_URL, 'RedisBleBindingStore'); + ({ RedisBleBindingStore, bleBindingKey } = await import('../dist/domains/limb/ble/BleBindingStore.js')); + const { createRedisClient } = await import('@cat-cafe/shared/utils'); + redis = createRedisClient({ url: REDIS_URL }); + try { + await redis.ping(); + connected = true; + } catch { + await redis.quit().catch(() => {}); + } + }); + + beforeEach(async (context) => { + if (!connected) return context.skip('Redis not connected'); + await cleanupPrefixedRedisKeys(redis, ['limb:ble:bindings:*']); + }); + + after(async () => { + if (!connected) return; + await cleanupPrefixedRedisKeys(redis, ['limb:ble:bindings:*']); + await redis.quit(); + }); + + it('persists a binding with TTL=0 semantics and rehydrates it', async () => { + const binding = { + bindingId: 'binding-redis', + scopeId: 'instance', + platformDeviceId: 'device-private', + displayName: 'Redis Sensor', + adapterId: 'standard.environmental', + commands: ['ble.temperature.read'], + nodeId: 'ble:binding-redis', + createdAt: 100, + lastConnectedAt: null, + }; + const store = new RedisBleBindingStore(redis); + await store.put(binding); + + assert.equal(await redis.ttl(bleBindingKey('instance', 'binding-redis')), -1); + const rehydrated = new RedisBleBindingStore(redis); + assert.deepEqual(await rehydrated.get('instance', 'binding-redis'), binding); + }); +}); diff --git a/packages/api/test/redis-session-chain-store.test.js b/packages/api/test/redis-session-chain-store.test.js index af1ef09643..dbebfa6409 100644 --- a/packages/api/test/redis-session-chain-store.test.js +++ b/packages/api/test/redis-session-chain-store.test.js @@ -303,6 +303,44 @@ describe('RedisSessionChainStore', { skip: redisIsolationSkipReason(REDIS_URL) } assert.equal(old, null, 'old CLI session ID should be unlinked'); }); + it('update() rejects cliSessionId rotation after record is inactive', async () => { + const record = await store.create(BASE_INPUT); + await store.update(record.id, { status: 'sealing' }); + + const updated = await store.update(record.id, { cliSessionId: 'cli-new' }); + + assert.equal(updated, null); + assert.equal((await store.getByCliSessionId('cli-sess-1'))?.id, record.id); + assert.equal(await store.getByCliSessionId('cli-new'), null); + }); + + it('update() rejects cliSessionId rotation after record is superseded by active pointer', async () => { + const oldRecord = await store.create(BASE_INPUT); + const newRecord = await store.create({ ...BASE_INPUT, cliSessionId: 'cli-active' }); + + const updated = await store.update(oldRecord.id, { cliSessionId: 'cli-rebound' }); + + assert.equal(updated, null); + assert.equal((await store.getByCliSessionId('cli-sess-1'))?.id, oldRecord.id); + assert.equal(await store.getByCliSessionId('cli-rebound'), null); + assert.equal((await store.getActive('opus', 'thread-1'))?.id, newRecord.id); + }); + + it('compareAndMarkSealing() rejects records superseded by active pointer', async () => { + const oldRecord = await store.create(BASE_INPUT); + const newRecord = await store.create({ ...BASE_INPUT, cliSessionId: 'cli-new' }); + + const sealed = await store.compareAndMarkSealing(oldRecord.id, { + sealReason: 'session_continuity_degraded', + updatedAt: Date.now(), + expectedCliSessionId: 'cli-sess-1', + }); + + assert.equal(sealed, null); + assert.equal((await store.get(oldRecord.id))?.status, 'active'); + assert.equal((await store.getActive('opus', 'thread-1'))?.id, newRecord.id); + }); + it('getChainByThread() returns all cats sessions for a thread', async () => { await store.create(BASE_INPUT); await store.create({ ...BASE_INPUT, catId: 'codex', cliSessionId: 'cli-codex-1' }); diff --git a/packages/api/test/review-feedback-router.test.js b/packages/api/test/review-feedback-router.test.js index 9b9bdb3618..832fc997a1 100644 --- a/packages/api/test/review-feedback-router.test.js +++ b/packages/api/test/review-feedback-router.test.js @@ -68,6 +68,58 @@ describe('ReviewFeedbackRouter', () => { socketMock = mockSocketManager(); }); + describe('tracking instructions head binding', () => { + it('keeps instructions when they describe the current review head', () => { + const content = buildReviewFeedbackContent( + { + repoFullName: 'owner/repo', + prNumber: 42, + headSha: 'abc1234567890', + newComments: [{ id: 1, author: 'bot', body: 'LGTM', createdAt: '2026-06-26', commentType: 'conversation' }], + newDecisions: [], + }, + 'Proceed to merge readiness.', + 'abc1234567890', + ); + + assert.ok(content.includes('📌 **Tracking Instructions**')); + assert.ok(content.includes('Proceed to merge readiness.')); + }); + + it('omits stale instructions when review feedback is for a newer head', () => { + const content = buildReviewFeedbackContent( + { + repoFullName: 'owner/repo', + prNumber: 42, + headSha: 'newhead1234567890', + newComments: [{ id: 1, author: 'bot', body: 'LGTM', createdAt: '2026-06-26', commentType: 'conversation' }], + newDecisions: [], + }, + 'Handle old head review finding before merge.', + 'oldhead1234567890', + ); + + assert.ok(!content.includes('📌 **Tracking Instructions**')); + assert.ok(!content.includes('Handle old head review finding before merge.')); + }); + + it('omits head-bound instructions when the current review head is unknown', () => { + const content = buildReviewFeedbackContent( + { + repoFullName: 'owner/repo', + prNumber: 42, + newComments: [{ id: 1, author: 'bot', body: 'LGTM', createdAt: '2026-06-26', commentType: 'conversation' }], + newDecisions: [], + }, + 'Handle old head review finding before merge.', + 'oldhead1234567890', + ); + + assert.ok(!content.includes('📌 **Tracking Instructions**')); + assert.ok(!content.includes('Handle old head review finding before merge.')); + }); + }); + it('delivers review feedback with correct connector (AC-A3/A4)', async () => { const router = createRouter(); const result = await router.route( diff --git a/packages/api/test/route-serial-routing-guard-remedial.test.js b/packages/api/test/route-serial-routing-guard-remedial.test.js index 3760217b1b..58da961f60 100644 --- a/packages/api/test/route-serial-routing-guard-remedial.test.js +++ b/packages/api/test/route-serial-routing-guard-remedial.test.js @@ -156,7 +156,7 @@ async function loadRealRoster() { async function runRoute(service, threadId, extraServices = {}, mockOptions = {}) { return withCatRegistryLock(async () => { - const { thinkingMode = 'play', ...depsOptions } = mockOptions; + const { thinkingMode = 'play', routeOptions = {}, ...depsOptions } = mockOptions; const original = catRegistry.getAllConfigs(); await loadRealRoster(); const appended = []; @@ -167,6 +167,7 @@ async function runRoute(service, threadId, extraServices = {}, mockOptions = {}) const yielded = []; for await (const msg of routeSerial(deps, ['codex'], 'guard test', 'user1', threadId, { thinkingMode, + ...routeOptions, })) { yielded.push(msg); } @@ -372,6 +373,25 @@ describe('F177 Phase H — route-serial routing guard remedial invoke', () => { assert.deepEqual(codexMessages[0].mentions, ['opus']); }); + test('event-driven wait coverage does not leak from first cat to A2A worklist targets', async () => { + const codexService = createSequenceService('codex', ['@opus']); + const opusService = createSequenceService('opus', ['External Wait: event-driven (pr:35)', '@co-creator']); + + const { appended } = await runRoute( + codexService, + 'thread-routing-guard-event-driven-coverage-per-cat', + { opus: opusService }, + { routeOptions: { eventDrivenExternalWaitCoverage: true } }, + ); + + assert.equal(opusService.calls.length, 2, 'A2A target must not inherit connector callback coverage'); + assert.equal( + appended.find((m) => m.source?.connector === 'routing-guard-failure'), + undefined, + 'valid follow-up remedial exit should avoid failure after rejecting leaked coverage', + ); + }); + test('debug A2A prompt sees validated first-pass content routed by remedial exit', async () => { const codexService = createSequenceService('codex', ['First-pass debug context.', '@opus']); const opusService = createSequenceService('opus', ['ack from opus'], { needsGuard: false }); @@ -427,6 +447,98 @@ describe('F177 Phase H — route-serial routing guard remedial invoke', () => { ); }); + test('event-driven external-wait remedial with verified callback coverage counts as route-only and keeps first-pass text visible', async () => { + const firstPass = '我查完 current truth;不再 @codex,只剩外部 CI gate。'; + const service = createSequenceService('codex', [firstPass, 'External Wait: event-driven (pr:35)']); + + const { appended, calls, yielded } = await runRoute( + service, + 'thread-routing-guard-event-driven-remedial', + {}, + { + routeOptions: { eventDrivenExternalWaitCoverage: true }, + }, + ); + + assert.equal(calls.length, 2, 'first-pass no-exit text should trigger one remedial invoke'); + assert.equal( + appended.find((m) => m.source?.connector === 'routing-guard-failure'), + undefined, + 'event-driven route-only remedial should count as a valid routing exit', + ); + assert.equal( + appended.find((m) => m.source?.connector === 'routing-syntax-hint'), + undefined, + 'event-driven remedial exit should suppress inline-mention syntax hints for the preserved first-pass text', + ); + assert.equal( + appended.find((m) => m.source?.connector === 'void-hold-hint'), + undefined, + 'event-driven remedial exit should not be treated as a void hold', + ); + + const codexMessages = appended.filter((m) => m.catId === 'codex' && m.origin === 'stream'); + assert.equal(codexMessages.length, 1); + assert.equal(codexMessages[0].content, firstPass); + assert.deepEqual(codexMessages[0].mentions, []); + assert.notEqual(codexMessages[0].mentionsUser, true); + assert.deepEqual( + yielded.filter((m) => m.type === 'text').map((m) => m.content), + [firstPass], + 'live stream must surface the first-pass text, not the bare event-driven exit patch', + ); + }); + + test('signed event-driven external-wait remedial with verified callback coverage counts as route-only and keeps first-pass text visible', async () => { + const firstPass = '我查完 current truth;不再 @codex,只剩外部 CI gate。'; + const service = createSequenceService('codex', [ + firstPass, + 'External Wait: event-driven (pr:35)\n\n[砚砚/GPT-5.5]', + ]); + + const { appended, calls, yielded } = await runRoute( + service, + 'thread-routing-guard-event-driven-remedial-signed', + {}, + { routeOptions: { eventDrivenExternalWaitCoverage: true } }, + ); + + assert.equal(calls.length, 2, 'first-pass no-exit text should trigger one remedial invoke'); + assert.equal( + appended.find((m) => m.source?.connector === 'routing-guard-failure'), + undefined, + 'signed event-driven route-only remedial should count as a valid routing exit', + ); + assert.equal( + appended.find((m) => m.source?.connector === 'routing-syntax-hint'), + undefined, + 'signed event-driven remedial exit should suppress inline-mention syntax hints for the preserved text', + ); + + const codexMessages = appended.filter((m) => m.catId === 'codex' && m.origin === 'stream'); + assert.equal(codexMessages.length, 1); + assert.equal(codexMessages[0].content, firstPass); + assert.deepEqual( + yielded.filter((m) => m.type === 'text').map((m) => m.content), + [firstPass], + 'live stream must surface the first-pass text, not the signed event-driven exit patch', + ); + }); + + test('event-driven external-wait remedial without verified callback coverage is rejected as missing route', async () => { + const firstPass = '我查完 current truth;不再 @codex,只剩外部 CI gate。'; + const service = createSequenceService('codex', [firstPass, 'External Wait: event-driven (pr:35)']); + + const { appended, calls } = await runRoute(service, 'thread-routing-guard-event-driven-remedial-no-coverage'); + + assert.equal(calls.length, 2, 'first-pass no-exit text should trigger one remedial invoke'); + assert.notEqual( + appended.find((m) => m.source?.connector === 'routing-guard-failure'), + undefined, + 'text-only event-driven wait must not count as a valid routing exit', + ); + }); + test('tool-only no-text initial output still gets the remedial guard instead of silent completion', async () => { const service = createSequenceService('codex', [ [ @@ -939,6 +1051,115 @@ describe('F177 Phase H — route-serial routing guard remedial invoke', () => { assert.deepEqual(spokenChunks, ['我先持球继续。'], 'voice TTS should match the preserved live text'); }); + test('2b event-driven external wait final slot with verified callback coverage counts as a routing exit without remedial invoke', async () => { + const service = createSequenceService('codex', [ + 'cloud / CI 已有结构化回调覆盖,不需要 hold_ball。\n\nExternal Wait: event-driven (pr:clowder-labs/clowder-ai#32)', + '@co-creator', + ]); + + const { appended, calls } = await runRoute( + service, + 'thread-routing-guard-event-driven-wait', + {}, + { + routeOptions: { eventDrivenExternalWaitCoverage: true }, + }, + ); + + assert.equal(calls.length, 1, 'explicit 2b event-driven external wait should not trigger remedial invoke'); + assert.equal( + appended.find((m) => m.source?.connector === 'routing-guard-failure'), + undefined, + 'event-driven external wait should not emit routing guard failure', + ); + const codexMessages = appended.filter((m) => m.catId === 'codex' && m.origin === 'stream'); + assert.equal(codexMessages.length, 1); + assert.match(codexMessages[0].content, /External Wait: event-driven/); + }); + + test('2b event-driven external wait rejects PR tracking registration without pickup proof', async () => { + const service = createSequenceService('codex', [ + [ + { + type: 'tool_use', + toolName: 'cat_cafe_register_pr_tracking', + toolInput: { repoFullName: 'clowder-labs/clowder-ai', prNumber: 35 }, + }, + { + type: 'tool_result', + toolName: 'cat_cafe_register_pr_tracking', + content: '{"status":"ok","threadId":"thread-routing-guard-event-driven-register"}', + }, + { + type: 'text', + content: + '已注册 PR tracking,后续 review/CI 会结构化回调。\n\nExternal Wait: event-driven (pr:clowder-labs/clowder-ai#35)', + }, + ], + '@co-creator', + ]); + + const { appended, calls } = await runRoute(service, 'thread-routing-guard-event-driven-register'); + + assert.equal(calls.length, 2, 'same-turn PR tracking registration alone should not prevent a remedial invoke'); + assert.equal( + appended.find((m) => m.source?.connector === 'routing-guard-failure'), + undefined, + 'valid follow-up remedial exit should avoid failure after rejecting PR tracking registration alone', + ); + }); + + test('2b event-driven external wait honors issue tracking registered earlier in the same turn', async () => { + const service = createSequenceService('codex', [ + [ + { + type: 'tool_use', + toolName: 'cat_cafe_register_issue_tracking', + toolInput: { repoFullName: 'clowder-labs/clowder-ai', issueNumber: 35 }, + }, + { + type: 'tool_result', + toolName: 'cat_cafe_register_issue_tracking', + content: '{"status":"ok","threadId":"thread-routing-guard-event-driven-issue-register"}', + }, + { + type: 'text', + content: + '已注册 issue tracking,后续评论会结构化回调。\n\nExternal Wait: event-driven (issue:clowder-labs/clowder-ai#35)', + }, + ], + '@co-creator', + ]); + + const { appended, calls } = await runRoute(service, 'thread-routing-guard-event-driven-issue-register'); + + assert.equal(calls.length, 1, 'same-turn issue tracking registration should prevent a remedial invoke'); + assert.equal( + appended.find((m) => m.source?.connector === 'routing-guard-failure'), + undefined, + 'confirmed issue tracking registration should count as verified callback coverage for 2b', + ); + const codexMessages = appended.filter((m) => m.catId === 'codex' && m.origin === 'stream'); + assert.equal(codexMessages.length, 1); + assert.match(codexMessages[0].content, /External Wait: event-driven/); + }); + + test('2b event-driven external wait final slot without verified callback coverage still gets remedial invoke', async () => { + const service = createSequenceService('codex', [ + 'cloud / CI 也许会回调,不需要 hold_ball。\n\nExternal Wait: event-driven (pr:clowder-labs/clowder-ai#32)', + '@co-creator', + ]); + + const { appended, calls } = await runRoute(service, 'thread-routing-guard-event-driven-wait-no-coverage'); + + assert.equal(calls.length, 2, 'text-only event-driven external wait should still trigger remedial invoke'); + assert.equal( + appended.find((m) => m.source?.connector === 'routing-guard-failure'), + undefined, + 'valid follow-up remedial exit should avoid failure after rejecting the text-only event wait', + ); + }); + test('guard-disabled cat still runs once and keeps legacy non-blocking hint behavior', async () => { const service = createSequenceService('codex', ['I will keep going from here.'], { needsGuard: false }); diff --git a/packages/api/test/routing-admission-service.test.js b/packages/api/test/routing-admission-service.test.js new file mode 100644 index 0000000000..b81af1df13 --- /dev/null +++ b/packages/api/test/routing-admission-service.test.js @@ -0,0 +1,212 @@ +/** + * F241 Phase B Slice 2b: Routing admission service unit tests. + * + * Covers each denial branch + happy path, and the structural invariant + * that callers MUST exclude the candidate from the snapshot (the function + * has no way to enforce this, so the test documents it explicitly). + */ + +import './helpers/setup-cat-registry.js'; +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +const { admitForRouting } = await import('../dist/domains/plugin/RoutingAdmissionService.js'); + +function makeCandidate(overrides = {}) { + return { + pluginId: 'clowder-code', + capId: 'clowder-code-provider', + providerId: 'clowder-code', + catId: 'clowder-cat', + profileId: 'clowder-profile', + mentionPatterns: ['clowder'], + healthCheck: { type: 'cliProbe' }, + ...overrides, + }; +} + +function makeSnapshot(overrides = {}) { + return { + templateBaselineIds: new Set(['anthropic', 'openai', 'google', 'kimi']), + existingRouteableIdentities: new Set(), + activeNonProviderTransportIdentities: new Set(), + ...overrides, + }; +} + +describe('RoutingAdmissionService — admitForRouting', () => { + describe('happy path', () => { + it('admits a candidate whose identities do not collide with any snapshot entry', () => { + const result = admitForRouting(makeCandidate(), makeSnapshot()); + assert.deepEqual(result, { admitted: true }); + }); + + it('admits when mentionPatterns is undefined', () => { + const result = admitForRouting(makeCandidate({ mentionPatterns: undefined }), makeSnapshot()); + assert.deepEqual(result, { admitted: true }); + }); + + it('admits when mentionPatterns contains only the same string as providerId (deduped)', () => { + const result = admitForRouting(makeCandidate({ mentionPatterns: ['clowder-code'] }), makeSnapshot()); + assert.deepEqual(result, { admitted: true }); + }); + }); + + describe('Step 5 admission precondition — health check declaration required', () => { + it('denies when healthCheck is undefined', () => { + const result = admitForRouting(makeCandidate({ healthCheck: undefined }), makeSnapshot()); + assert.equal(result.admitted, false); + assert.equal(result.reason, 'missing-health-check'); + assert.match(result.details, /requires a declared healthCheck/); + }); + + it('denies missing healthCheck BEFORE checking identity collisions', () => { + // Even if the identity would collide with reserved, we report the + // healthCheck failure first (cheaper / more-fundamental check). + const result = admitForRouting( + makeCandidate({ healthCheck: undefined, providerId: 'anthropic' }), + makeSnapshot(), + ); + assert.equal(result.reason, 'missing-health-check'); + }); + }); + + describe('identity claim validity', () => { + it('denies when all identity fields are empty', () => { + const result = admitForRouting( + makeCandidate({ + providerId: '', + catId: '', + profileId: undefined, + mentionPatterns: [], + }), + makeSnapshot(), + ); + assert.equal(result.admitted, false); + assert.equal(result.reason, 'invalid-identity-claim'); + }); + + it('treats whitespace-only identity strings as absent', () => { + const result = admitForRouting( + makeCandidate({ + providerId: ' ', + catId: '', + profileId: undefined, + mentionPatterns: [' '], + }), + makeSnapshot(), + ); + assert.equal(result.admitted, false); + assert.equal(result.reason, 'invalid-identity-claim'); + }); + }); + + describe('reserved-baseline collision', () => { + it('denies when providerId collides with the cat-template baseline', () => { + const result = admitForRouting(makeCandidate({ providerId: 'anthropic' }), makeSnapshot()); + assert.equal(result.reason, 'reserved-baseline-collision'); + assert.equal(result.conflictingIdentity, 'anthropic'); + }); + + it('denies when catId collides with the cat-template baseline', () => { + const result = admitForRouting(makeCandidate({ providerId: 'clowder-code', catId: 'openai' }), makeSnapshot()); + assert.equal(result.reason, 'reserved-baseline-collision'); + assert.equal(result.conflictingIdentity, 'openai'); + }); + + it('denies when mentionPatterns collides with the cat-template baseline', () => { + const result = admitForRouting(makeCandidate({ mentionPatterns: ['kimi'] }), makeSnapshot()); + assert.equal(result.reason, 'reserved-baseline-collision'); + assert.equal(result.conflictingIdentity, 'kimi'); + }); + }); + + describe('existing-routeable collision', () => { + it('denies when providerId collides with an existing routeable identity', () => { + const result = admitForRouting( + makeCandidate(), + makeSnapshot({ + existingRouteableIdentities: new Set(['clowder-code']), + }), + ); + assert.equal(result.reason, 'existing-routeable-collision'); + assert.equal(result.conflictingIdentity, 'clowder-code'); + }); + + it('denies when mentionPatterns collides with an existing routeable identity', () => { + const result = admitForRouting( + makeCandidate({ mentionPatterns: ['plugin-a', 'plugin-b'] }), + makeSnapshot({ + existingRouteableIdentities: new Set(['plugin-b']), + }), + ); + assert.equal(result.reason, 'existing-routeable-collision'); + assert.equal(result.conflictingIdentity, 'plugin-b'); + }); + }); + + describe('active-cat collision', () => { + it('denies when catId collides with an active non-providerTransport cat', () => { + const result = admitForRouting( + makeCandidate({ catId: 'opus' }), + makeSnapshot({ + activeNonProviderTransportIdentities: new Set(['opus', 'codex']), + }), + ); + assert.equal(result.reason, 'active-cat-collision'); + assert.equal(result.conflictingIdentity, 'opus'); + }); + + it('denies when profileId collides with an active cat', () => { + const result = admitForRouting( + makeCandidate({ profileId: 'sonnet' }), + makeSnapshot({ + activeNonProviderTransportIdentities: new Set(['sonnet']), + }), + ); + assert.equal(result.reason, 'active-cat-collision'); + assert.equal(result.conflictingIdentity, 'sonnet'); + }); + }); + + describe('check order — fail-closed by most fundamental denial', () => { + it('reports reserved-baseline collision before existing-routeable collision', () => { + const result = admitForRouting( + makeCandidate({ providerId: 'anthropic' }), + makeSnapshot({ + existingRouteableIdentities: new Set(['anthropic']), + }), + ); + assert.equal(result.reason, 'reserved-baseline-collision'); + }); + + it('reports existing-routeable collision before active-cat collision', () => { + const result = admitForRouting( + makeCandidate({ providerId: 'mock-existing-routeable-id' }), + makeSnapshot({ + existingRouteableIdentities: new Set(['mock-existing-routeable-id']), + activeNonProviderTransportIdentities: new Set(['mock-existing-routeable-id']), + }), + ); + assert.equal(result.reason, 'existing-routeable-collision'); + }); + }); + + describe('snapshot exclusion invariant (documented contract)', () => { + it('denies its own identity if the caller forgets to exclude the candidate from existingRouteableIdentities — caller bug, but admission still fail-closes', () => { + // This documents the red line: if the caller builds the snapshot WITHOUT + // excluding the candidate, admission will reject the candidate against + // its own identity. That is the desired fail-closed behavior — better + // a false denial than a parsing-order self-exemption. + const candidate = makeCandidate(); + const result = admitForRouting( + candidate, + makeSnapshot({ + existingRouteableIdentities: new Set([candidate.providerId]), + }), + ); + assert.equal(result.admitted, false); + assert.equal(result.reason, 'existing-routeable-collision'); + }); + }); +}); diff --git a/packages/api/test/routing-guard-remedial.test.js b/packages/api/test/routing-guard-remedial.test.js index e2551e410a..878522ad54 100644 --- a/packages/api/test/routing-guard-remedial.test.js +++ b/packages/api/test/routing-guard-remedial.test.js @@ -75,6 +75,31 @@ describe('F177 Phase H — shouldRemediateRouting', () => { true, ); }); + + test('2b External Wait event-driven 槽位 + verified callback coverage → 不触发 remedial', () => { + assert.equal( + shouldRemediateRouting({ + ...base, + text: 'cloud / CI 已有结构化回调覆盖。\n\nExternal Wait: event-driven (pr:clowder-ai#32)', + hasEventDrivenExternalWaitCoverage: true, + needsGuard: true, + attempted: false, + }), + false, + ); + }); + + test('2b External Wait event-driven 槽位 without verified callback coverage → still triggers remedial', () => { + assert.equal( + shouldRemediateRouting({ + ...base, + text: 'cloud / CI 可能会回调。\n\nExternal Wait: event-driven (pr:clowder-ai#32)', + needsGuard: true, + attempted: false, + }), + true, + ); + }); }); describe('F177 Phase H — hasValidRoutingExit', () => { @@ -88,13 +113,35 @@ describe('F177 Phase H — hasValidRoutingExit', () => { assert.equal(hasValidRoutingExit({ ...base, structuredTargetCats: ['x'] }), true); assert.equal(hasValidRoutingExit({ ...base, hasCoCreatorLineStartMention: true }), true); }); + + test('External Wait: event-driven() counts as a valid 2b external-wait exit with verified coverage', () => { + assert.equal( + hasValidRoutingExit({ + ...base, + text: '结论:已有结构化回调 + EYES>0,不续 hold_ball。\n\nExternal Wait: event-driven (github-pr-32)', + hasEventDrivenExternalWaitCoverage: true, + }), + true, + ); + }); + + test('External Wait: event-driven() alone is not a valid routing exit', () => { + assert.equal( + hasValidRoutingExit({ + ...base, + text: '结论:没有确认 EYES。\n\nExternal Wait: event-driven (github-pr-32)', + }), + false, + ); + }); }); describe('F177 Phase H — buildRemedialPrompt', () => { - test('含路由指引(行首 @ / hold_ball / @co-creator)且明确不重做工作', () => { + test('含路由指引(行首 @ / hold_ball / event-driven / @co-creator)且明确不重做工作', () => { const p = buildRemedialPrompt(); assert.match(p, /行首/); assert.match(p, /hold_ball/); + assert.match(p, /event-driven/); assert.match(p, /@co-creator/); assert.match(p, /不要重做/); }); diff --git a/packages/api/test/runtime-worktree-script.test.js b/packages/api/test/runtime-worktree-script.test.js index 3ef76ffb85..abbc97e6b1 100644 --- a/packages/api/test/runtime-worktree-script.test.js +++ b/packages/api/test/runtime-worktree-script.test.js @@ -20,6 +20,18 @@ const tempProcs = []; process.env.CAT_CAFE_SKIP_NODE_RUNTIME_GUARD = '1'; +function isolatedRuntimeEnv(overrides = {}) { + const env = { ...process.env }; + delete env.CAT_CAFE_RUNTIME_DIR; + delete env.CAT_CAFE_RUNTIME_BRANCH; + delete env.CAT_CAFE_RUNTIME_REMOTE; + delete env.CAT_CAFE_RUNTIME_ROOT; + delete env.CAT_CAFE_RUNTIME_SOURCE_BRANCH; + delete env.CAT_CAFE_RUNTIME_SYNC_COMMAND; + + return { ...env, ...overrides }; +} + function createTempProject(name) { const projectDir = mkdtempSync(join(tmpdir(), `${name}-`)); tempDirs.push(projectDir); @@ -150,12 +162,11 @@ exit 0 function withStubbedPnpmEnv(projectDir, options = {}) { const { binDir, logFile } = createPnpmStub(projectDir, options); - return { - ...process.env, + return isolatedRuntimeEnv({ CAT_CAFE_RUNTIME_RESTART_OK: '1', PATH: `${binDir}:${process.env.PATH}`, RUNTIME_TEST_PNPM_LOG: logFile, - }; + }); } function seedRuntimeDependencyMarkers(projectDir) { @@ -321,7 +332,7 @@ printf 'ok'`, const result = spawnSync('bash', [join(projectDir, 'scripts', 'runtime-worktree.sh'), 'start', '--no-sync'], { cwd: projectDir, encoding: 'utf8', - env: { ...process.env, CAT_CAFE_RUNTIME_RESTART_OK: '1' }, + env: isolatedRuntimeEnv({ CAT_CAFE_RUNTIME_RESTART_OK: '1' }), }); assert.equal(result.status, 0); @@ -354,7 +365,7 @@ server.listen(3010,'127.0.0.1',()=>setInterval(()=>{},1000));`, const result = spawnSync('bash', [join(projectDir, 'scripts', 'runtime-worktree.sh'), 'start', '--no-sync'], { cwd: projectDir, encoding: 'utf8', - env: { ...process.env, CAT_CAFE_RUNTIME_RESTART_OK: '1' }, + env: isolatedRuntimeEnv({ CAT_CAFE_RUNTIME_RESTART_OK: '1' }), }); assert.equal(result.status, 0, `exit=${result.status}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`); @@ -396,6 +407,7 @@ server.listen(3010,'127.0.0.1',()=>setInterval(()=>{},1000));`, { cwd: projectDir, encoding: 'utf8', + env: isolatedRuntimeEnv(), }, ); @@ -416,7 +428,7 @@ server.listen(3010,'127.0.0.1',()=>setInterval(()=>{},1000));`, const result = spawnSync('bash', [join(projectDir, 'scripts', 'runtime-worktree.sh'), 'start', '--no-sync'], { cwd: projectDir, encoding: 'utf8', - env: { ...process.env, CAT_CAFE_RUNTIME_RESTART_OK: '1' }, + env: isolatedRuntimeEnv({ CAT_CAFE_RUNTIME_RESTART_OK: '1' }), }); assert.notEqual(result.status, 0); @@ -624,7 +636,7 @@ server.listen(3010,'127.0.0.1',()=>setInterval(()=>{},1000));`, const result = spawnSync('bash', [join(projectDir, 'scripts', 'runtime-worktree.sh'), 'start', '--no-sync'], { cwd: projectDir, encoding: 'utf8', - env: { ...process.env, CAT_CAFE_RUNTIME_RESTART_OK: '1' }, + env: isolatedRuntimeEnv({ CAT_CAFE_RUNTIME_RESTART_OK: '1' }), }); assert.equal(result.status, 0, `exit=${result.status}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`); @@ -652,12 +664,11 @@ server.listen(3002,'127.0.0.1',()=>setInterval(()=>{},1000));`, tempProcs.push(server); await waitForLocalPort(3002); - const ncFallbackEnv = { - ...process.env, + const ncFallbackEnv = isolatedRuntimeEnv({ API_SERVER_PORT: '3002', PATH: `${binDir}:${process.env.PATH}`, RUNTIME_TEST_PNPM_LOG: logFile, - }; + }); // Ensure CAT_CAFE_RUNTIME_RESTART_OK is not inherited from the parent env; // this test specifically validates that restart is REFUSED when the API port is active. delete ncFallbackEnv.CAT_CAFE_RUNTIME_RESTART_OK; @@ -691,10 +702,9 @@ server.listen(3010,'127.0.0.1',()=>setInterval(()=>{},1000));`, tempProcs.push(server); await waitForLocalPort(3010); - const envFilePortEnv = { - ...process.env, + const envFilePortEnv = isolatedRuntimeEnv({ CAT_CAFE_RUNTIME_DIR: projectDir, - }; + }); // Ensure CAT_CAFE_RUNTIME_RESTART_OK is not inherited from the parent env; // this test validates that restart is REFUSED when .env API_SERVER_PORT is active. delete envFilePortEnv.CAT_CAFE_RUNTIME_RESTART_OK; @@ -837,11 +847,10 @@ server.listen(3010,'127.0.0.1',()=>setInterval(()=>{},1000));`, const result = spawnSync('bash', [join(projectDir, 'scripts', 'runtime-worktree.sh'), 'start', '--no-install'], { cwd: projectDir, encoding: 'utf8', - env: { - ...process.env, + env: isolatedRuntimeEnv({ CAT_CAFE_RUNTIME_DIR: normalizedRuntimeDir, API_SERVER_PORT: '19899', - }, + }), }); assert.notEqual(result.status, 0); @@ -884,11 +893,10 @@ server.listen(3010,'127.0.0.1',()=>setInterval(()=>{},1000));`, const result = spawnSync('bash', [join(projectDir, 'scripts', 'runtime-worktree.sh'), 'start', '--no-install'], { cwd: projectDir, encoding: 'utf8', - env: { - ...process.env, + env: isolatedRuntimeEnv({ CAT_CAFE_RUNTIME_DIR: normalizedRuntimeDir, API_SERVER_PORT: '19899', - }, + }), }); assert.notEqual(result.status, 0); @@ -933,11 +941,10 @@ server.listen(3010,'127.0.0.1',()=>setInterval(()=>{},1000));`, const result = spawnSync('bash', [join(projectDir, 'scripts', 'runtime-worktree.sh'), 'start', '--no-install'], { cwd: projectDir, encoding: 'utf8', - env: { - ...process.env, + env: isolatedRuntimeEnv({ CAT_CAFE_RUNTIME_DIR: normalizedRuntimeDir, API_SERVER_PORT: '19899', - }, + }), }); assert.notEqual(result.status, 0); diff --git a/packages/api/test/scheduler/cicd-check-spec.test.js b/packages/api/test/scheduler/cicd-check-spec.test.js index 235b768226..b47a9683bb 100644 --- a/packages/api/test/scheduler/cicd-check-spec.test.js +++ b/packages/api/test/scheduler/cicd-check-spec.test.js @@ -132,9 +132,10 @@ describe('CiCdCheckTaskSpec', () => { assert.equal(policy.priority, 'normal'); assert.equal(policy.reason, 'github_ci_pass'); assert.equal(policy.suggestedSkill, 'merge-gate'); + assert.equal(policy.eventDrivenExternalWaitCoverage, true); }); - it('execute triggers invokeTrigger for CI fail with urgent priority (unchanged)', async () => { + it('execute triggers CI fail for default review intent without event-driven wait coverage', async () => { const { createCiCdCheckTaskSpec } = await import('../../dist/infrastructure/email/CiCdCheckTaskSpec.js'); const triggered = []; const tasks = [mockTask({ repoFullName: 'a/b', prNumber: 1, userId: 'u1' })]; @@ -165,6 +166,47 @@ describe('CiCdCheckTaskSpec', () => { const policy = triggered[0][6]; assert.equal(policy.priority, 'urgent'); assert.equal(policy.reason, 'github_ci_failure'); + assert.notEqual( + policy.eventDrivenExternalWaitCoverage, + true, + 'review-intent CI failure must not claim pass-wakeup coverage', + ); + }); + + it('execute marks CI fail as event-driven covered only when intent=merge', async () => { + const { createCiCdCheckTaskSpec } = await import('../../dist/infrastructure/email/CiCdCheckTaskSpec.js'); + const triggered = []; + const tasks = [ + mockTask({ repoFullName: 'a/b', prNumber: 1, userId: 'u1' }, { automationState: { intent: 'merge' } }), + ]; + const spec = createCiCdCheckTaskSpec({ + taskStore: mockTaskStore(tasks), + cicdRouter: { + route: async () => ({ + kind: 'notified', + bucket: 'fail', + threadId: 't1', + catId: 'opus', + messageId: 'm1', + content: 'CI failed', + }), + }, + fetchPrStatus: async () => ({ checks: [], headSha: 'sha1', prNumber: 1, repoFullName: 'a/b' }), + invokeTrigger: { + trigger: (...args) => { + triggered.push(args); + return Promise.resolve(); + }, + }, + log: { info: () => {}, error: () => {}, warn: () => {} }, + }); + const gateResult = await spec.admission.gate({ taskId: 'cicd-check', lastRunAt: null, tickCount: 1 }); + await spec.run.execute(gateResult.workItems[0].signal, 'pr:a/b#1', {}); + assert.equal(triggered.length, 1); + const policy = triggered[0][6]; + assert.equal(policy.priority, 'urgent'); + assert.equal(policy.reason, 'github_ci_failure'); + assert.equal(policy.eventDrivenExternalWaitCoverage, true); }); it('gate filters out ci.enabled=false', async () => { diff --git a/packages/api/test/scheduler/conflict-check-spec.test.js b/packages/api/test/scheduler/conflict-check-spec.test.js index 5f35bfcecd..fc153b08ff 100644 --- a/packages/api/test/scheduler/conflict-check-spec.test.js +++ b/packages/api/test/scheduler/conflict-check-spec.test.js @@ -166,6 +166,11 @@ describe('ConflictCheckTaskSpec', () => { assert.equal(triggerCalls[0][1], 'opus'); // catId assert.equal(triggerCalls[0][6].priority, 'urgent'); assert.equal(triggerCalls[0][6].reason, 'github_pr_conflict'); + assert.notEqual( + triggerCalls[0][6].eventDrivenExternalWaitCoverage, + true, + 'conflict wake must not claim follow-up callback coverage', + ); }); it('execute does not trigger when router skips', async () => { diff --git a/packages/api/test/scheduler/review-feedback-spec.test.js b/packages/api/test/scheduler/review-feedback-spec.test.js index 649eed36c7..c1fbfc49b7 100644 --- a/packages/api/test/scheduler/review-feedback-spec.test.js +++ b/packages/api/test/scheduler/review-feedback-spec.test.js @@ -736,6 +736,53 @@ describe('ReviewFeedbackTaskSpec', () => { const policy = triggered[0][6]; assert.equal(policy.priority, 'normal'); assert.equal(policy.suggestedSkill, 'merge-gate'); + assert.notEqual( + policy.eventDrivenExternalWaitCoverage, + true, + 'review-intent approval wake must not claim CI-pass callback coverage', + ); + }); + + it('APPROVED merge-intent wake grants event-driven wait coverage (Phase C)', async () => { + const { createReviewFeedbackTaskSpec } = await import('../../dist/infrastructure/email/ReviewFeedbackTaskSpec.js'); + const triggered = []; + const mergeIntentTask = mockTask( + { + repoFullName: 'owner/repo', + prNumber: 42, + catId: 'opus', + threadId: 'th-1', + userId: 'u-1', + }, + { automationState: { intent: 'merge' } }, + ); + const spec = createReviewFeedbackTaskSpec({ + taskStore: mockTaskStore([mergeIntentTask]), + fetchComments: async () => [], + fetchReviews: async () => [ + { id: 1, author: 'reviewer', state: 'APPROVED', body: 'LGTM', submittedAt: '2026-01-01' }, + ], + reviewFeedbackRouter: { + async route() { + return { kind: 'notified', threadId: 't1', catId: 'opus', messageId: 'm1', content: 'approved' }; + }, + }, + invokeTrigger: { + trigger: (...args) => { + triggered.push(args); + return Promise.resolve(); + }, + }, + log: noopLog, + }); + const gateResult = await spec.admission.gate({ taskId: spec.id, lastRunAt: null, tickCount: 1 }); + assert.equal(gateResult.run, true); + await spec.run.execute(gateResult.workItems[0].signal, 'pr:owner/repo#42', {}); + assert.equal(triggered.length, 1); + const policy = triggered[0][6]; + assert.equal(policy.priority, 'normal'); + assert.equal(policy.suggestedSkill, 'merge-gate'); + assert.equal(policy.eventDrivenExternalWaitCoverage, true); }); it('COMMENTED-only triggers with no suggestedSkill (Phase C)', async () => { diff --git a/packages/api/test/session-chain-store.test.js b/packages/api/test/session-chain-store.test.js index 2bc05bf1cc..1e72e0e056 100644 --- a/packages/api/test/session-chain-store.test.js +++ b/packages/api/test/session-chain-store.test.js @@ -197,6 +197,47 @@ describe('SessionChainStore', () => { assert.equal(store.getByCliSessionId('cli-sess-1'), null, 'old CLI session ID should be unlinked'); }); + test('update() rejects cliSessionId rotation after record is inactive', async () => { + const store = await createStore(); + const record = store.create(BASE_INPUT); + store.update(record.id, { status: 'sealing' }); + + const updated = store.update(record.id, { cliSessionId: 'cli-new' }); + + assert.equal(updated, null); + assert.equal(store.getByCliSessionId('cli-sess-1').id, record.id); + assert.equal(store.getByCliSessionId('cli-new'), null); + }); + + test('update() rejects cliSessionId rotation after record is superseded by active pointer', async () => { + const store = await createStore(); + const oldRecord = store.create(BASE_INPUT); + const newRecord = store.create({ ...BASE_INPUT, cliSessionId: 'cli-active' }); + + const updated = store.update(oldRecord.id, { cliSessionId: 'cli-rebound' }); + + assert.equal(updated, null); + assert.equal(store.getByCliSessionId('cli-sess-1').id, oldRecord.id); + assert.equal(store.getByCliSessionId('cli-rebound'), null); + assert.equal(store.getActive('opus', 'thread-1').id, newRecord.id); + }); + + test('compareAndMarkSealing() rejects records superseded by active pointer', async () => { + const store = await createStore(); + const oldRecord = store.create(BASE_INPUT); + const newRecord = store.create({ ...BASE_INPUT, cliSessionId: 'cli-new' }); + + const sealed = store.compareAndMarkSealing(oldRecord.id, { + sealReason: 'session_continuity_degraded', + updatedAt: Date.now(), + expectedCliSessionId: 'cli-sess-1', + }); + + assert.equal(sealed, null); + assert.equal(store.get(oldRecord.id).status, 'active'); + assert.equal(store.getActive('opus', 'thread-1').id, newRecord.id); + }); + test('update() returns null for non-existent id', async () => { const store = await createStore(); assert.equal(store.update('non-existent', { status: 'sealed' }), null); diff --git a/packages/api/test/session-sealer.test.js b/packages/api/test/session-sealer.test.js index 5ae4f1a2cf..a02bb5b900 100644 --- a/packages/api/test/session-sealer.test.js +++ b/packages/api/test/session-sealer.test.js @@ -50,6 +50,30 @@ describe('SessionSealer', () => { assert.equal(updated?.status, 'sealing'); }); + test('rejects seal when expected cliSessionId no longer matches active record', async () => { + const { store, sealer } = await createFixtures(); + const record = store.create(BASE_INPUT); + store.update(record.id, { + cliSessionId: 'cli-manual-bind', + updatedAt: Date.now(), + }); + + const result = await sealer.requestSeal({ + sessionId: record.id, + reason: 'session_continuity_degraded', + expectedCliSessionId: 'cli-sess-1', + }); + + assert.equal(result.accepted, false); + assert.equal(result.status, 'active'); + + const updated = store.get(record.id); + assert.equal(updated?.status, 'active'); + assert.equal(updated?.cliSessionId, 'cli-manual-bind'); + assert.equal(updated?.sealReason, undefined); + assert.equal(store.getActive('opus', 'thread-1')?.id, record.id); + }); + test('clears active pointer after seal', async () => { const { store, sealer } = await createFixtures(); const record = store.create(BASE_INPUT); diff --git a/packages/api/test/system-prompt-builder.test.js b/packages/api/test/system-prompt-builder.test.js index a702afcf60..05fe83260d 100644 --- a/packages/api/test/system-prompt-builder.test.js +++ b/packages/api/test/system-prompt-builder.test.js @@ -12,7 +12,7 @@ import { catRegistry } from '@cat-cafe/shared'; const REPO_ROOT_TEMPLATE = resolve(dirname(fileURLToPath(import.meta.url)), '../../..', 'cat-template.json'); const CAT_TEMPLATE_PATH = REPO_ROOT_TEMPLATE; -const FULL_RUNTIME_PROMPT_CHAR_BUDGET = 6900; // 6500→6700→6900: gemini35 + gpt-pro roster growth +const FULL_RUNTIME_PROMPT_CHAR_BUDGET = 7000; // 6500→6700→6900→7000: kitten/catagent breed + gemini35 + gpt-pro roster growth function assertWithinFullRuntimePromptBudget(prompt) { assert.ok( @@ -1759,8 +1759,8 @@ describe('SystemPromptBuilder', () => { featureId: 'F073', }, }); - // 6200→6500→6700→6800→6900: decision funnel §17 + roster growth + F208 dossier l0RosterSummary - assert.ok(prompt.length < 6900, `Prompt with SOP hint is ${prompt.length} chars, expected < 6900`); + // 6200→6500→6700→6900→7000: decision funnel §17 + kitten/catagent breed + F208 dossier l0RosterSummary + roster growth + assertWithinFullRuntimePromptBudget(prompt); }); // --- F092: Voice Mode prompt injection --- @@ -1807,8 +1807,8 @@ describe('SystemPromptBuilder', () => { }, voiceMode: true, }); - // 6200→6500→6700→6800→6900: decision funnel §17 + roster growth + F208 dossier l0RosterSummary - assert.ok(prompt.length < 6900, `Prompt with voice mode + SOP hint is ${prompt.length} chars, expected < 6900`); + // 6200→6500→6700→6900→7000: decision funnel §17 + kitten/catagent breed + F208 dossier l0RosterSummary + roster growth + assertWithinFullRuntimePromptBudget(prompt); }); test('buildInvocationContext injects bootcamp mode when bootcampState provided', async () => { diff --git a/packages/api/test/verdict-detect.test.js b/packages/api/test/verdict-detect.test.js index 57e5e9d56a..73cfb7b6b1 100644 --- a/packages/api/test/verdict-detect.test.js +++ b/packages/api/test/verdict-detect.test.js @@ -286,6 +286,31 @@ describe('F167 C2 AC-C7: shouldWarnVerdictWithoutPass', () => { ); }); + test('verdict + structural event-driven external wait exit → false', () => { + assert.equal( + shouldWarnVerdictWithoutPass({ + text: 'LGTM locally; waiting on cloud.\nExternal Wait: event-driven (pr:35)', + lineStartMentions: [], + toolNames: [], + structuredTargetCats: [], + hasEventDrivenExternalWaitCoverage: true, + }), + false, + ); + }); + + test('verdict + structural event-driven external wait exit without verified callback coverage → true', () => { + assert.equal( + shouldWarnVerdictWithoutPass({ + text: 'LGTM locally; maybe cloud will callback.\nExternal Wait: event-driven (pr:35)', + lineStartMentions: [], + toolNames: [], + structuredTargetCats: [], + }), + true, + ); + }); + test('verdict + co-creator line-start mention (hasCoCreatorLineStartMention=true) → false (砚砚 GPT-5.5 fix)', () => { // 2026-04-25 false-positive root cause: parseA2AMentions only parses cat handles, // never returns co-creator handles like 'you'. route-serial passes that empty diff --git a/packages/api/test/void-hold-detect.test.js b/packages/api/test/void-hold-detect.test.js index e32a41be71..659ea98488 100644 --- a/packages/api/test/void-hold-detect.test.js +++ b/packages/api/test/void-hold-detect.test.js @@ -132,6 +132,25 @@ describe('F167 Phase I AC-I1: shouldWarnVoidHold', () => { ); }); + test('does not warn when structural event-driven external wait exit exists', () => { + const result = evaluateVoidHold({ + ...base, + text: '不需要 hold_ball;这是 2b 事件驱动等待。\nExternal Wait: event-driven (pr:35)', + hasEventDrivenExternalWaitCoverage: true, + }); + assert.equal(result.shouldEmit, false); + assert.equal(result.matchedPattern, 'en_hold_ball_underscore'); + }); + + test('warns when structural event-driven external wait exit lacks verified callback coverage', () => { + const result = evaluateVoidHold({ + ...base, + text: '不需要 hold_ball;这是 2b 事件驱动等待。\nExternal Wait: event-driven (pr:35)', + }); + assert.equal(result.shouldEmit, true); + assert.equal(result.matchedPattern, 'en_hold_ball_underscore'); + }); + test('still warns when hold text present but exits are all empty', () => { assert.equal( shouldWarnVoidHold({ ...base, text: '我持球等一下', lineStartMentions: [], structuredTargetCats: [] }), diff --git a/packages/mcp-server/src/tools/publish-verdict-tool.ts b/packages/mcp-server/src/tools/publish-verdict-tool.ts index fed2bf3913..a02555fa67 100644 --- a/packages/mcp-server/src/tools/publish-verdict-tool.ts +++ b/packages/mcp-server/src/tools/publish-verdict-tool.ts @@ -265,6 +265,26 @@ const anchorTelemetrySourceRefsShape = z }) .describe('eval:anchor-first sourceRefs — replayable anchor telemetry rollup window selector.'); +/** + * F253 Phase C — qc-metrics-rollup sourceRefs. Replayable QC metrics rollup + * window selector: provider resolves to zero-baseline QcMetricsSnapshot (Phase C + * bootstrap) or live aggregated review metrics (future phases). Generator writes + * snapshot + attribution + provenance into bundle. + * + * KEEP IN SYNC: packages/api/src/infrastructure/harness-eval/qc-metrics-provider.ts QcMetricsSelector + * + packages/api/.../publish-verdict/validation.ts validateQcMetricsSelector. + */ +const qcMetricsSourceRefsShape = z + .object({ + kind: z.literal('qc-metrics-rollup'), + windowStartMs: z.number().finite().describe('Inclusive epoch ms window start for QC metrics aggregation.'), + windowEndMs: z + .number() + .finite() + .describe('Exclusive epoch ms window end for QC metrics aggregation. Must be > windowStartMs.'), + }) + .describe('eval:qc sourceRefs — replayable QC metrics rollup window selector (window start/end).'); + const sourceRefsShape = z .union([ a2aSourceRefsShape, @@ -274,9 +294,10 @@ const sourceRefsShape = z sopSourceRefsShape, frictionRollupSourceRefsShape, anchorTelemetrySourceRefsShape, + qcMetricsSourceRefsShape, ]) .describe( - 'Discriminated union by `kind` field. a2a kind is default (backward compat); capability-wakeup-trial-window kind wired in PR-2; memory-recall-snapshot kind wired in F192 memory wire-up; task-outcome-snapshot kind wired in task-outcome PR; sop-trace-eval kind wired in F192 sop-wiring; friction-rollup-snapshot kind wired in F245 PR1b; anchor-telemetry-snapshot kind wired in F236 Track-2.', + 'Discriminated union by `kind` field. a2a kind is default (backward compat); capability-wakeup-trial-window kind wired in PR-2; memory-recall-snapshot kind wired in F192 memory wire-up; task-outcome-snapshot kind wired in task-outcome PR; sop-trace-eval kind wired in F192 sop-wiring; friction-rollup-snapshot kind wired in F245 PR1b; anchor-telemetry-snapshot kind wired in F236 Track-2; qc-metrics-rollup kind wired in F253 Phase C.', ); export const publishVerdictInputSchema = { @@ -348,6 +369,11 @@ type PublishVerdictToolInput = { kind: 'anchor-telemetry-snapshot'; windowStartMs: number; windowEndMs: number; + } + | { + kind: 'qc-metrics-rollup'; + windowStartMs: number; + windowEndMs: number; }; agentKeyCatId?: string | undefined; }; @@ -377,7 +403,7 @@ export const publishVerdictTools = [ 'Use after your analysis converges to a verdict for your assigned eval domain. ' + 'Pass the complete VerdictHandoffPacket + sourceRefs (shape depends on your domain — see your eval cat invocation instructions for the exact selector shape). ' + 'The handler validates schema, dispatches to the per-domain generator inside an isolated git worktree, commits + pushes the branch verdict/auto//, and opens an auto-PR. Returns { commitSha, prUrl }. ' + - 'GOTCHA: wired domains: eval:a2a (snapshot/attribution YAML basenames) + eval:capability-wakeup (replayable trial-window selector) + eval:memory (memory-recall-snapshot selector) + eval:sop (sop-trace-eval replayable SOP trace selector) + eval:task-outcome (task-outcome-snapshot replay window) + eval:friction (friction-rollup-snapshot replay window) + eval:anchor-first (anchor-telemetry-snapshot rollup window). Unregistered domains return 501. ' + + 'GOTCHA: wired domains: eval:a2a (snapshot/attribution YAML basenames) + eval:capability-wakeup (replayable trial-window selector) + eval:memory (memory-recall-snapshot selector) + eval:sop (sop-trace-eval replayable SOP trace selector) + eval:task-outcome (task-outcome-snapshot replay window) + eval:friction (friction-rollup-snapshot replay window) + eval:anchor-first (anchor-telemetry-snapshot rollup window) + eval:qc (qc-metrics-rollup window). Unregistered domains return 501. ' + 'GOTCHA: catId must match the registered eval cat for the domain (or its OQ-20 Redis override); 403 not_allowed otherwise. ' + 'GOTCHA: DO NOT run git push/commit/add yourself; this tool owns the publish lifecycle.', inputSchema: publishVerdictInputSchema, diff --git a/packages/shared/src/registry/CatRegistry.ts b/packages/shared/src/registry/CatRegistry.ts index e187be62e4..28d3c0ee3a 100644 --- a/packages/shared/src/registry/CatRegistry.ts +++ b/packages/shared/src/registry/CatRegistry.ts @@ -29,6 +29,28 @@ export class CatRegistry { this.revision += 1; } + /** + * F241 Phase B Slice 2b: register-or-replace for dynamically-sourced cats + * (e.g. plugin-projected routeable agentProviders). Unlike `register`, this + * never throws — used by `syncAgentRegistry` projection which needs to + * idempotently re-publish the synthetic CatConfig on every sync. + */ + registerOrReplace(catId: string, config: CatConfig): void { + this.entries.set(catId, { config }); + this.revision += 1; + } + + /** + * F241 Phase B Slice 2b: remove a previously-registered cat. Used to clear + * a stale plugin-projected synthetic config when the source row is no + * longer routeable (descriptor delta reset, plugin disabled, etc). + */ + unregister(catId: string): boolean { + const had = this.entries.delete(catId); + if (had) this.revision += 1; + return had; + } + has(catId: string): boolean { return this.entries.has(catId); } diff --git a/packages/shared/src/types/capability.ts b/packages/shared/src/types/capability.ts index c34b9d449a..affa97c4ce 100644 --- a/packages/shared/src/types/capability.ts +++ b/packages/shared/src/types/capability.ts @@ -7,6 +7,7 @@ import type { MarketplaceEcosystem } from './marketplace.js'; import type { MountRuleEntry, SkillsSyncState } from './mount-rules.js'; +import type { AgentProviderLifecycleState, PluginAgentProviderResource } from './plugin.js'; // ─── F249: MCP Sync Types ──────────────────────────────────────── @@ -75,12 +76,91 @@ export interface CatCapabilityOverride { enabled: boolean; } +/** + * F241 Phase B Slice 2b: last AgentRegistry sync attempt failure (Step 6 rollback path). + * Cleared on the next successful sync. See F241 doc § Phase B Slice 2b Design Notes. + */ +export interface AgentProviderSyncError { + /** Short, sanitized failure reason (no stack trace, no secrets). */ + readonly message: string; + /** Epoch ms when the failure was recorded. */ + readonly occurredAt: number; +} + +/** + * F241 Phase B Slice 2b: result of the most recent host-side healthCheck run for this + * capability. Bound to `descriptorHash` — when the hash changes, the prior result is + * invalidated and routeability cannot rely on it. See § Phase B Slice 2b Design Notes. + */ +export interface AgentProviderHealthResult { + /** Whether the most recent check succeeded. */ + readonly passed: boolean; + /** Epoch ms when the check completed. */ + readonly checkedAt: number; + /** Time-to-live in ms. When `Date.now() > checkedAt + ttlMs`, the result is stale and + * MUST be refreshed before `routeable` can remain true (no background `false → true` flip). */ + readonly ttlMs: number; + /** Descriptor hash this result is bound to. Mismatched hash → result invalidated. */ + readonly descriptorHash: string; + /** Short, sanitized failure reason captured on failure. */ + readonly failureReason?: string; +} + +/** + * F241 Phase B Slice 2b: host-owned routeable binding record. + * + * The plugin manifest declares claims (providerId, displayName, mentionPatterns), + * but the actual cat-id binding is host-owned and chosen by the operator at + * approval time. The route resolver reads this binding — never the manifest + * resource directly (see F241 doc § Phase B Slice 2b Design Notes — + * Routeable identity ownership). + */ +export interface AgentProviderRouteableBinding { + /** The cat-id the operator chose to bind this provider to. */ + readonly catId: string; + /** Optional profile-id binding. */ + readonly profileId?: string; + /** @-mention patterns the provider responds to (operator-confirmed). */ + readonly mentionPatterns?: readonly string[]; +} + +/** + * F241 Phase B Slice 2b: agentProvider capability descriptor. + * + * Widens 2a's literal-`false` shape into a three-field state model: + * - `routeableApproved` is owner intent (host-owned positive write; reset to `false` + * automatically when `descriptorHash` changes; never positively set by activator). + * - `health` is the last health-check result, bound to `descriptorHash`. + * - `routeable` is the effective truth — "you can @ this cat now" — and is computed + * from admission + approval + fresh health + AgentRegistry sync success. + * + * `state` remains an informational lifecycle progression marker; it does NOT replace + * the boolean fields above. + */ +export interface AgentProviderCapabilityDescriptor extends PluginAgentProviderResource { + state: AgentProviderLifecycleState; + /** Effective truth — never written directly; always computed from approval + health + sync. */ + routeable: boolean; + /** Owner intent — host-owned. Activator only ever resets this to `false` on descriptor change. */ + routeableApproved: boolean; + /** Canonical hash of descriptor inputs (transport, command, args, session/output, sandbox/mcp + * request, healthCheck, routeable identity claims, plugin fingerprint if available). + * Recomputed on every upsert. When changed, activator resets `routeableApproved` to `false`. */ + descriptorHash?: string; + /** Last health-check result, bound to `descriptorHash`. */ + health?: AgentProviderHealthResult; + /** Last AgentRegistry sync failure (Step 6). Cleared on next successful sync. */ + lastSyncError?: AgentProviderSyncError; + /** Host-owned routeable binding — persisted on successful approval. Empty until then. */ + routeableBinding?: AgentProviderRouteableBinding; +} + /** Single capability entry in capabilities.json */ export interface CapabilityEntry { /** Unique capability ID (usually MCP server name) */ id: string; /** Type of capability (F126: 'limb' for device/hardware nodes; F202 Phase 2: 'schedule' for plugin-managed tasks) */ - type: 'mcp' | 'skill' | 'limb' | 'schedule'; + type: 'mcp' | 'skill' | 'limb' | 'schedule' | 'agentProvider'; /** Global enabled state (MCP/limb/schedule still use this; skill uses globalEnabled) */ enabled: boolean; /** @@ -118,6 +198,8 @@ export interface CapabilityEntry { limbNodeId?: string; /** F202 Phase 2: Runtime task ID assigned by TaskRunnerV2 (schedule resources only) */ scheduleTaskId?: string; + /** F241 Phase B Slice 2a: non-routeable agent provider descriptor. */ + agentProvider?: AgentProviderCapabilityDescriptor; /** * F249: Blacklist — cat IDs that cannot use this MCP. * Canonical per-cat access field for MCP (supersedes legacy `overrides`). diff --git a/packages/shared/src/types/cat-breed.ts b/packages/shared/src/types/cat-breed.ts index f9878ec82e..b892e0307d 100644 --- a/packages/shared/src/types/cat-breed.ts +++ b/packages/shared/src/types/cat-breed.ts @@ -7,7 +7,14 @@ * Phase 4-F: 支持多 Variant(多版本猫召唤) */ -import type { AgyProfileConfig, CatColor, ClientId } from './cat.js'; +import type { + AgyProfileConfig, + CatAgentProtocol, + CatColor, + ClientId, + CommandPolicyEntry, + NativeToolLevel, +} from './cat.js'; import type { CatId } from './ids.js'; import type { VoiceConfig } from './tts.js'; @@ -89,6 +96,14 @@ export interface CatVariant { readonly color?: CatColor; /** Per-cat context budget (optional, falls back to defaults) */ readonly contextBudget?: ContextBudget; + /** F159 Phase F: CatAgent native tool level. Omitted = L0. */ + readonly nativeToolLevel?: NativeToolLevel; + /** F159 Phase F: allowlist-first command policy for L2 run_command. */ + readonly commandPolicy?: readonly CommandPolicyEntry[]; + /** F159 Phase G G2 (AC-G13): CatAgent wire protocol selection. Only + * meaningful when `clientId === 'catagent'`; omitted defaults to + * `'anthropic-messages'` (G1 catagent behavior preserved). */ + readonly catAgentProtocol?: CatAgentProtocol; /** Optional per-variant override for sessionChain; falls back to breed.features.sessionChain. */ readonly sessionChain?: boolean; /** F34: Per-cat TTS voice (optional, falls back to defaults in cat-voices.ts) */ @@ -220,8 +235,18 @@ export type AccountProtocol = 'anthropic' | 'openai' | 'openai-responses' | 'goo */ export interface AccountConfig { readonly authType: 'oauth' | 'api_key'; - /** F171: Explicit client identity for API key accounts (e.g. 'anthropic', 'openai'). */ + /** F171 (legacy display): freeform client identity surfaced in Hub UI. + * Do NOT use for routing/security decisions — prefer `clientFamily`, + * which is typed and drives `profile.client` in `accountToRuntimeProfile`. + * TODO(F159 G3+): sunset once Hub UI migrates fully to `clientFamily`. */ readonly clientId?: string; + /** F159 G2 (AC-G20): typed client family for api_key accounts. + * Authoritative source for adapter routing. When set on api_key accounts, + * drives `profile.client` in `accountToRuntimeProfile`, which enables the + * family fail-closed guard in `catagent-credentials.ts` (AC-G22). + * Optional: existing api_key accounts without `clientFamily` continue to + * resolve best-effort (no `profile.client` set → guard falls through). */ + readonly clientFamily?: 'anthropic' | 'openai' | 'google' | 'kimi' | 'dare' | 'opencode'; readonly baseUrl?: string; readonly models?: readonly string[]; readonly displayName?: string; diff --git a/packages/shared/src/types/cat.ts b/packages/shared/src/types/cat.ts index ccdfc0006e..707939aa52 100644 --- a/packages/shared/src/types/cat.ts +++ b/packages/shared/src/types/cat.ts @@ -5,7 +5,6 @@ import type { CliConfig, ContextBudget } from './cat-breed.js'; import type { CatId, SessionId } from './ids.js'; -import { createCatId } from './ids.js'; import type { VoiceConfig } from './tts.js'; /** @@ -26,6 +25,37 @@ export type ClientId = /** @deprecated clowder-ai#340: Use {@link ClientId} instead. Kept as alias for backward compatibility. */ export type CatProvider = ClientId; +/** F159 Phase F: native CatAgent tool capability tier. */ +export type NativeToolLevel = 'L0' | 'L1' | 'L2'; + +/** + * F159 Phase G G2: CatAgent wire protocol selection. + * + * Selects which `CatAgentProtocolAdapter` the factory dispatches for a + * `clientId='catagent'` member: + * - `anthropic-messages` → `AnthropicMessagesAdapter` (G1 default, Anthropic + * Messages API `POST /v1/messages` + `x-api-key` + `anthropic-version`) + * - `openai-chat` → `OpenAIChatAdapter` (G2 new, OpenAI Chat Completions + * `POST /v1/chat/completions` + `Authorization: Bearer`) + * + * Omitted defaults to `'anthropic-messages'` to preserve G1 catagent members' + * behavior across the G2 rollout. Only meaningful when `clientId === 'catagent'`; + * persistence is gated to catagent-only at runtime catalog / route / Hub layers. + * + * See AC-G13 + KD-20 (fail-closed dispatch, no runtime protocol guessing). + */ +export type CatAgentProtocol = 'anthropic-messages' | 'openai-chat'; + +/** F159 Phase F: allowlist-first command policy for CatAgent run_command. */ +export interface CommandPolicyEntry { + readonly binary: string; + readonly allowedSubcommands?: readonly string[]; + readonly allowedFlags?: readonly string[]; + readonly allowedArgPatterns?: readonly string[]; + /** Defense-in-depth only; never grants permission by itself. */ + readonly deniedFlags?: readonly string[]; +} + /** * Cat status in the system */ @@ -73,6 +103,14 @@ export interface CatConfig { readonly agyProfile?: AgyProfileConfig; readonly commandArgs?: readonly string[]; readonly contextBudget?: ContextBudget; + /** F159 Phase F: CatAgent native tool level. Omitted = L0. */ + readonly nativeToolLevel?: NativeToolLevel; + /** F159 Phase F: allowlist-first command policy for L2 run_command. */ + readonly commandPolicy?: readonly CommandPolicyEntry[]; + /** F159 Phase G G2 (AC-G13): CatAgent wire protocol selection. Only + * meaningful when `clientId === 'catagent'`; omitted defaults to + * `'anthropic-messages'` (G1 catagent behavior preserved). */ + readonly catAgentProtocol?: CatAgentProtocol; readonly roleDescription: string; readonly personality: string; /** F32-b: Which breed this cat belongs to (for frontend grouping) */ diff --git a/packages/shared/src/types/client-routing.ts b/packages/shared/src/types/client-routing.ts index 53e0c29e1d..7d9ec1207d 100644 --- a/packages/shared/src/types/client-routing.ts +++ b/packages/shared/src/types/client-routing.ts @@ -1,4 +1,4 @@ -import type { ClientId } from './cat.js'; +import type { CatConfig, ClientId } from './cat.js'; import type { AccountProtocol } from './cat-breed.js'; export type BuiltinAccountClient = Extract; @@ -50,3 +50,79 @@ export function protocolForClient(client: ClientId): BuiltinAccountProtocol | nu return null; } } + +// ── F159 Phase G G2 Axis 3 (KD-24): member-level protocol-aware helpers ── +// +// `protocolForClient` / `builtinAccountFamilyForClient` above are kept as +// pure `clientId → default family/protocol` mappings (client-level default), +// matching the @gpt555 G2 design gate P2 decision: changing those to accept +// `catConfig` would pollute shared routing semantics and force every +// downstream call site to thread `catConfig` through. The G2 effective* +// helpers below take per-member catConfig and surface the protocol-aware +// answer when `clientId === 'catagent'` (where `catAgentProtocol` overrides +// the client-level default), falling through to the client-level helpers +// otherwise. +// +// ── AC-G19 Migration Audit (completed 2026-06-24 per @gpt555 G2 Axis 3 P2) ── +// +// Audit of every existing `protocolForClient('catagent')` / +// `builtinAccountFamilyForClient('catagent')` call site with per-site decision: +// +// | Call site | Decision | Rationale | +// | ------------------------------------------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +// | account-resolver.ts:45 resolveBuiltinClientForProvider(provider: ClientId) | KEEP | Helper accepts only ClientId; caller has no catConfig context. Catagent-aware credential resolution flows through `catagent-credentials.ts` w/ `adapter.clientFamily` (Axis 2 already wires this end-to-end). | +// | account-resolver.ts:131 builtinProtocol from BUILTIN_ACCOUNT_MAP ref | KEEP | Profile-shape derivation from synthetic builtin ref (OAuth fallback); catAgentProtocol does not apply at the account-profile layer. | +// | account-resolver.ts:165 same as :131 in preferred-ref branch | KEEP | Same — synthetic builtin profile shape; not member-level. | +// | account-resolver.ts:202 same as :131 in walk-discovery-chain branch | KEEP | Same. | +// | account-resolver.ts:241 same as :131 in synthetic-fallback branch | KEEP | Same. | +// | first-run-quest.ts:437 buildProbeEnv(clientId, ...) | KEEP | First-run credential probe runs BEFORE any cat is persisted — there is no CatConfig at this point, only the operator's chosen clientId for probing. Catagent-specific protocol is decided post-creation. | +// | hub-cat-editor.model.ts:381 resolveBuiltinClientFamily → filterAccounts | DEFERRED | Hub UI account-picker filter; for catagent + 'openai-chat' the picker should arguably show OpenAI accounts. Migration requires threading `form.catAgentProtocol` through `filterAccounts`. Tracked as Axis 6 cross-cutting UX polish — does NOT block G2 merge gate (credentials path still fails-closed at adapter level via Axis 2). | +// +// Outcome: 6 sites KEEP client-level default (correct semantics — they +// operate on ClientId / profile shape, not member-level routing). 1 site +// DEFERRED with explicit follow-up tracker. Zero sites required immediate +// migration to satisfy AC-G19; the client-level / member-level split is +// honored everywhere a catConfig is actually in scope. + +/** + * Resolve the effective wire protocol for a CatAgent member, honoring the + * `catAgentProtocol` selection bit persisted on `CatConfig`. + * + * - non-catagent clients → falls through to `protocolForClient(clientId)` + * so existing behavior is unchanged + * - catagent + `catAgentProtocol === 'openai-chat'` → `'openai'` + * - catagent + `catAgentProtocol === 'anthropic-messages'` → `'anthropic'` + * - catagent + undefined / unknown → `'anthropic'` (G1 catagent backward- + * compat default; matches `catagent-protocol-factory.ts` default branch) + * + * Returns `null` only when the underlying client has no protocol mapping + * (e.g. 'antigravity' / 'acp'). + */ +export function effectiveProtocolForCat(catConfig: CatConfig): BuiltinAccountProtocol | null { + if (catConfig.clientId === 'catagent') { + if (catConfig.catAgentProtocol === 'openai-chat') return 'openai'; + // 'anthropic-messages' or undefined / unrecognised → anthropic default. + // (Factory still fail-closes on unknown values at adapter dispatch + // time — this helper is for routing/account-binding decisions where + // a sensible default is more useful than throwing.) + return 'anthropic'; + } + return protocolForClient(catConfig.clientId); +} + +/** + * Resolve the effective builtin account family for a CatAgent member, + * honoring the `catAgentProtocol` selection bit on `CatConfig`. + * + * - non-catagent clients → falls through to `builtinAccountFamilyForClient(clientId)` + * - catagent + `'openai-chat'` → `'openai'` + * - catagent + `'anthropic-messages'` / undefined → `'anthropic'` (G1 + * backward-compat default) + */ +export function effectiveClientFamilyForCat(catConfig: CatConfig): BuiltinAccountClient | null { + if (catConfig.clientId === 'catagent') { + if (catConfig.catAgentProtocol === 'openai-chat') return 'openai'; + return 'anthropic'; + } + return builtinAccountFamilyForClient(catConfig.clientId); +} diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index f0f3a2ab85..439a082315 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -146,6 +146,10 @@ export { export type { CallbackPrincipal } from './callback-principal.js'; // Capability types (F041 统一能力模型) export type { + AgentProviderCapabilityDescriptor, + AgentProviderHealthResult, + AgentProviderRouteableBinding, + AgentProviderSyncError, BootstrapAction, BootstrapReport, CapabilitiesConfig, @@ -179,6 +183,7 @@ export type { // Cat types export type { AgyProfileConfig, + CatAgentProtocol, CatColor, CatConfig, /** @deprecated clowder-ai#340: Use ClientId instead. */ @@ -186,6 +191,8 @@ export type { CatState, CatStatus, ClientId, + CommandPolicyEntry, + NativeToolLevel, } from './cat.js'; // Cat breed/variant types (Breed+Variant two-layer schema) export type { @@ -217,6 +224,8 @@ export type { BuiltinAccountClient } from './client-routing.js'; export { builtinAccountFamilyForClient, builtinAccountIdForClient, + effectiveClientFamilyForCat, + effectiveProtocolForCat, protocolForClient, } from './client-routing.js'; // Command types (F142 Phase B — slash command framework) @@ -657,6 +666,14 @@ export type { } from './pack.js'; // Plugin Framework types (F202 声明式插件注册) export type { + AgentProviderHealthCheckRequest, + AgentProviderHealthCheckType, + AgentProviderLifecycleState, + AgentProviderOutputProfile, + AgentProviderSandboxRequest, + AgentProviderSessionPolicy, + AgentProviderTransportId, + PluginAgentProviderResource, PluginConfigField, PluginHealthCheck, PluginInfo, diff --git a/packages/shared/src/types/plugin.ts b/packages/shared/src/types/plugin.ts index 889a414998..e82e784657 100644 --- a/packages/shared/src/types/plugin.ts +++ b/packages/shared/src/types/plugin.ts @@ -16,13 +16,64 @@ export interface PluginHealthCheck { mcpProbe?: string; } +export type AgentProviderTransportId = 'acp' | 'cli-jsonl'; +export type AgentProviderLifecycleState = 'declared' | 'transportReady' | 'routeableApproved' | 'healthy'; +export type AgentProviderSessionPolicy = 'resume' | 'stateless'; +export type AgentProviderOutputProfile = 'clowder-code-turn-result-v1'; +export type AgentProviderSandboxRequest = 'workspace-read' | 'workspace-write'; +export type AgentProviderHealthCheckType = 'acpInitialize' | 'cliProbe'; + +export interface AgentProviderHealthCheckRequest { + type: AgentProviderHealthCheckType; +} + +export interface PluginAgentProviderResource { + name: string; + transport: AgentProviderTransportId; + command: string; + startupArgs: string[]; + resumeArgs?: string[]; + sessionPolicy?: AgentProviderSessionPolicy; + outputProfile?: AgentProviderOutputProfile; + timeoutMs?: number; + /** Plugin-requested capability names. Host policy decides the actual grant in later slices. */ + mcpWhitelistRequest?: string[]; + /** Plugin-requested sandbox tier. Host policy decides the actual grant in later slices. */ + sandboxRequest?: AgentProviderSandboxRequest; + healthCheck?: AgentProviderHealthCheckRequest; + /** + * F241 Phase C 2c — Manifest-declared identity claims. + * + * These are CLAIMS the plugin makes about how it would like to be routed. + * They do NOT bypass admission and they do NOT auto-promote routeability — + * the host-owned `routeableBinding` is still the only routing truth source + * (per F241 doc § Phase B 2b "Routeable identity ownership"). The operator's + * `approve-routeable` call may use these claims as form defaults / pre-fill, + * but ultimately writes its own catId/mentionPatterns into `routeableBinding`. + * + * All three feed `computeAgentProviderDescriptorHash` so any claim change + * forces re-approval (operator must re-confirm the new identity claim). + */ + + /** Suggested provider id (often used as the default catId by operator UX). Reserved namespace rules still apply. */ + providerId?: string; + + /** Human-readable label for Hub UI rendering. Defaults to `name` when absent. */ + displayName?: string; + + /** Suggested `@alias` patterns. Each entry MUST start with `@`. */ + mentionPatterns?: string[]; +} + /** Plugin resource declaration */ export interface PluginResourceDef { - type: 'skill' | 'mcp' | 'limb' | 'schedule'; + type: 'skill' | 'mcp' | 'limb' | 'schedule' | 'agentProvider'; /** F202 Phase 2: Factory ID for schedule resources (white-list reference, no arbitrary scripts) */ factoryId?: string; /** F202 Phase 2 follow-up: optional resources don't count toward 'partial' status when deps are missing */ optional?: boolean; + /** F241 Phase B Slice 2a: non-routeable agent provider declaration. */ + agentProvider?: PluginAgentProviderResource; path?: string; name?: string; command?: string; @@ -56,7 +107,59 @@ export interface PluginResourceStatus { path?: string; name?: string; enabled: boolean; + agentProviderState?: AgentProviderLifecycleState; error?: string; + /** + * F241 Phase C — Operator-visible state for `agentProvider` resources. + * + * Surfaces the host-owned routeable view + manifest-declared claims to the + * Hub UI so an operator can render the approve-routeable form, see whether + * a binding is already live, and prefill the form from claim defaults. + * All fields are optional and only populated for `type === 'agentProvider'`. + */ + + /** Canonical capability id (e.g. `plugin:clowder-code:clowder-code`) — required for approve-routeable POST URL. */ + capId?: string; + + /** Effective truth — "you can `@` this cat now". Always false until the routeable gate fully passes. */ + agentProviderRouteable?: boolean; + + /** Operator intent — flips true on explicit approve, resets to false on descriptor delta. */ + agentProviderRouteableApproved?: boolean; + + /** Live binding the operator set at approve-time. Undefined when not yet approved. */ + agentProviderBinding?: { + catId: string; + profileId?: string; + mentionPatterns?: string[]; + }; + + /** Manifest-declared identity claims (PR #39). Hub UI uses these as form defaults. */ + agentProviderClaims?: { + providerId?: string; + displayName?: string; + mentionPatterns?: string[]; + }; + + /** Stable hash bound to the descriptor; surfaced so the operator can see when re-approval is needed after a manifest delta. */ + agentProviderDescriptorHash?: string; + + /** Operator-visible probe failure (e.g. `cli-probe-cli-not-found:foo`, `cli-probe-timeout:10000ms`). */ + agentProviderHealthFailureReason?: string; + + /** + * Operator-visible AgentRegistry sync failure surfaced from the persisted + * `lastSyncError`. Distinct from `agentProviderHealthFailureReason` — + * sync failures fire AFTER approval / health both pass, during the Step 6 + * AgentRegistry projection (post-approval sync hook in + * `agent-provider-approval-service.ts`). Without this field a sync failure + * leaves the row as `approved=true / healthy / routeable=false` with no + * UI explanation of WHY it isn't routeable. (PR #42 round-1 review @codex.) + */ + agentProviderLastSyncError?: { + message: string; + occurredAt: number; + }; } /** Full plugin info returned by API (manifest + derived state) */ diff --git a/packages/shared/src/types/task.ts b/packages/shared/src/types/task.ts index 4a63c7accf..4bf390bace 100644 --- a/packages/shared/src/types/task.ts +++ b/packages/shared/src/types/task.ts @@ -96,6 +96,8 @@ export interface AutomationState { readonly intent?: PrTrackingIntent; /** F202 Phase 2C: user-provided instructions appended to trigger messages. Task preference, not system override. */ readonly trackingInstructions?: string; + /** PR head that trackingInstructions were written for; stale-head callbacks suppress the instructions. */ + readonly trackingInstructionsHeadSha?: string; } export type TaskProbeSpec = diff --git a/packages/shared/test/client-routing.test.js b/packages/shared/test/client-routing.test.js index 9e9de522b7..c3686a91d4 100644 --- a/packages/shared/test/client-routing.test.js +++ b/packages/shared/test/client-routing.test.js @@ -1,6 +1,14 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { builtinAccountFamilyForClient, builtinAccountIdForClient, protocolForClient } from '../dist/index.js'; +import { + builtinAccountFamilyForClient, + builtinAccountIdForClient, + effectiveClientFamilyForCat, + effectiveProtocolForCat, + protocolForClient, +} from '../dist/index.js'; + +// ── Client-level default mapping (KD-24: PRESERVED unchanged, no catConfig) ── test('catagent shares anthropic builtin account family', () => { assert.equal(builtinAccountFamilyForClient('catagent'), 'anthropic'); @@ -8,7 +16,61 @@ test('catagent shares anthropic builtin account family', () => { }); test('protocolForClient normalizes provider family routing', () => { + // F159 Phase G G2 KD-24: this assertion is intentionally preserved AS-IS. + // protocolForClient is the pure client-level default; the G2 catagent + // protocol-aware answer lives on effectiveProtocolForCat below. assert.equal(protocolForClient('catagent'), 'anthropic'); assert.equal(protocolForClient('opencode'), 'anthropic'); assert.equal(protocolForClient('antigravity'), null); }); + +// ── F159 Phase G G2 Axis 3 (AC-G18 / KD-24): member-level effective helpers ── + +function catagentConfig(catAgentProtocol) { + return { id: 'opus', clientId: 'catagent', catAgentProtocol }; +} + +test('effectiveProtocolForCat: catagent + no catAgentProtocol → anthropic (G1 backward-compat)', () => { + assert.equal(effectiveProtocolForCat({ id: 'opus', clientId: 'catagent' }), 'anthropic'); +}); + +test('effectiveProtocolForCat: catagent + anthropic-messages → anthropic', () => { + assert.equal(effectiveProtocolForCat(catagentConfig('anthropic-messages')), 'anthropic'); +}); + +test('effectiveProtocolForCat: catagent + openai-chat → openai (G2 wire protocol override)', () => { + assert.equal(effectiveProtocolForCat(catagentConfig('openai-chat')), 'openai'); +}); + +test('effectiveProtocolForCat: non-catagent falls through to protocolForClient', () => { + assert.equal(effectiveProtocolForCat({ id: 'codex', clientId: 'openai' }), 'openai'); + assert.equal(effectiveProtocolForCat({ id: 'opus', clientId: 'anthropic' }), 'anthropic'); + assert.equal(effectiveProtocolForCat({ id: 'gemini', clientId: 'google' }), 'google'); + assert.equal(effectiveProtocolForCat({ id: 'agy', clientId: 'antigravity' }), null); +}); + +test('effectiveProtocolForCat: non-catagent ignores catAgentProtocol (only meaningful for catagent)', () => { + // Even if catAgentProtocol leaks onto a non-catagent CatConfig (it shouldn't + // per truth-source gating, but defense-in-depth at the routing helper), it + // does NOT change the protocol — that's strictly a catagent-only switch. + assert.equal(effectiveProtocolForCat({ id: 'codex', clientId: 'openai', catAgentProtocol: 'openai-chat' }), 'openai'); + assert.equal( + effectiveProtocolForCat({ id: 'opus', clientId: 'anthropic', catAgentProtocol: 'openai-chat' }), + 'anthropic', + ); +}); + +test('effectiveClientFamilyForCat: catagent + openai-chat → openai (account family override)', () => { + assert.equal(effectiveClientFamilyForCat(catagentConfig('openai-chat')), 'openai'); +}); + +test('effectiveClientFamilyForCat: catagent default (no catAgentProtocol) → anthropic', () => { + assert.equal(effectiveClientFamilyForCat({ id: 'opus', clientId: 'catagent' }), 'anthropic'); + assert.equal(effectiveClientFamilyForCat(catagentConfig('anthropic-messages')), 'anthropic'); +}); + +test('effectiveClientFamilyForCat: non-catagent falls through to builtinAccountFamilyForClient', () => { + assert.equal(effectiveClientFamilyForCat({ id: 'codex', clientId: 'openai' }), 'openai'); + assert.equal(effectiveClientFamilyForCat({ id: 'opus', clientId: 'anthropic' }), 'anthropic'); + assert.equal(effectiveClientFamilyForCat({ id: 'agy', clientId: 'antigravity' }), null); +}); diff --git a/packages/web/src/components/FirstRunQuestWizard.tsx b/packages/web/src/components/FirstRunQuestWizard.tsx index ac868a0212..85b1164195 100644 --- a/packages/web/src/components/FirstRunQuestWizard.tsx +++ b/packages/web/src/components/FirstRunQuestWizard.tsx @@ -10,6 +10,46 @@ import { type TemplateCard, TemplateStep } from './first-run-quest/TemplateStep' type WizardStep = 'template' | 'client' | 'config' | 'creating' | 'done'; +/** + * F159 G2 follow-up: ConfigStep filters accounts/models by `clientId` (account family). + * For catagent-native templates the wire-protocol determines the *account family* the + * runtime adapter expects — anthropic-messages → 'anthropic'; openai-chat → 'openai'. + * Passing `selectedClient.provider` (the CLI binary the user has installed, e.g. 'anthropic' + * for Claude CLI) would let the user bind a Claude account to a `catagent + openai-chat` + * cat → at invoke time `OpenAIChatAdapter.clientFamily='openai'` fail-closes via + * `catagent-credentials.ts` family guard (created but uncallable cat). Derive the + * effective account family from the template's runtimeDefaults instead. + */ +function effectiveAccountFamily(template: TemplateCard | null, fallbackClientId: string): string { + const tplDefaults = template?.runtimeDefaults; + if (!tplDefaults) return fallbackClientId; + if (tplDefaults.clientId === 'catagent') { + return tplDefaults.catAgentProtocol === 'openai-chat' ? 'openai' : 'anthropic'; + } + return tplDefaults.clientId; +} + +/** + * F159 G2 follow-up: ConfigStep also passes `client` (CLI binary name like 'claude'/'codex') + * to connectivity-test, which uses it to pick the actual CLI probe at runtime + * (first-run-quest.ts:~396). If `client` and `clientId` come from different families, + * the probe runs the wrong CLI binary against the right account → testResult.ok fails → + * create button disabled → legitimate kitten path blocked. Sync `client` to the same + * effective family as `clientId`. + */ +function effectiveClientName(template: TemplateCard | null, fallbackClient: string): string { + const family = effectiveAccountFamily(template, ''); + if (!family) return fallbackClient; + const familyToCli: Record = { + anthropic: 'claude', + openai: 'codex', + google: 'gemini', + kimi: 'kimi', + opencode: 'opencode', + }; + return familyToCli[family] ?? fallbackClient; +} + interface FirstRunQuestWizardProps { open: boolean; onClose: () => void; @@ -85,6 +125,13 @@ export function FirstRunQuestWizard({ open, onClose, onCreated }: FirstRunQuestW const catId = `${selectedTemplate.id}-${suffix}`; const catName = selectedTemplate.nickname ?? selectedTemplate.name; + // F159 G2 follow-up: template runtimeDefaults > user's selectedClient pick. + // The template is the source of truth for "what kind of cat this is" — selectedClient + // only fills in user environment (which actual CLI binary they have installed). For + // catagent native-path templates (kitten), runtimeDefaults carries clientId='catagent' + // + catAgentProtocol='openai-chat' + nativeToolLevel; without this override the wizard + // creates a普通 anthropic/openai 成员 instead of the catagent that was selected. + const tplDefaults = selectedTemplate.runtimeDefaults; const createRes = await apiFetch('/api/cats', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -102,8 +149,12 @@ export function FirstRunQuestWizard({ open, onClose, onCreated }: FirstRunQuestW roleDescription: selectedTemplate.roleDescription, personality: selectedTemplate.personality, teamStrengths: selectedTemplate.teamStrengths, - clientId: selectedClient.provider, + clientId: tplDefaults?.clientId ?? selectedClient.provider, + ...(tplDefaults?.catAgentProtocol ? { catAgentProtocol: tplDefaults.catAgentProtocol } : {}), + ...(tplDefaults?.nativeToolLevel ? { nativeToolLevel: tplDefaults.nativeToolLevel } : {}), accountRef: config.accountRef, + // model 尊重 user 在 ConfigStep 的选择(用户基于实际 selectedClient.models 列表挑的); + // template.runtimeDefaults.defaultModel 只是 suggestion,不强制 override。 defaultModel: config.model, }), }); @@ -202,8 +253,8 @@ export function FirstRunQuestWizard({ open, onClose, onCreated }: FirstRunQuestW {step === 'client' && } {step === 'config' && selectedClient && ( )} diff --git a/packages/web/src/components/HubCatEditor.tsx b/packages/web/src/components/HubCatEditor.tsx index ed06d847dc..c2f7537e4f 100644 --- a/packages/web/src/components/HubCatEditor.tsx +++ b/packages/web/src/components/HubCatEditor.tsx @@ -107,7 +107,7 @@ export function HubCatEditor({ cat, draft, existingCats, hasDossier, open, onClo }, [open, cat, draft]); // Re-fetch profiles when Provider Profiles page creates/saves/deletes an account. - const [profilesVersion, setProfilesVersion] = useState(0); + const [, setProfilesVersion] = useState(0); useEffect(() => { const handler = () => setProfilesVersion((v) => v + 1); window.addEventListener('accounts-changed', handler); @@ -157,7 +157,7 @@ export function HubCatEditor({ cat, draft, existingCats, hasDossier, open, onClo return () => { cancelled = true; }; - }, [open, profilesVersion]); + }, [open]); useEffect(() => { if (!open || !cat) { @@ -229,7 +229,7 @@ export function HubCatEditor({ cat, draft, existingCats, hasDossier, open, onClo return () => { cancelled = true; }; - }, [cat, open, showCodexSettings]); + }, [open, showCodexSettings]); useEffect(() => { if (form.clientId === 'antigravity') { @@ -339,6 +339,16 @@ export function HubCatEditor({ cat, draft, existingCats, hasDossier, open, onClo teamStrengths: t.teamStrengths ?? '', catId, mentionPatterns: joinTags(deduped), + // F159 G2 follow-up: apply runtime身份 defaults if the template carries them. + // Without this, picking 幼仔 leaves clientId='anthropic' + 空 catAgentProtocol → 创建出来不是 catagent。 + ...(t.runtimeDefaults + ? { + clientId: t.runtimeDefaults.clientId, + defaultModel: t.runtimeDefaults.defaultModel, + ...(t.runtimeDefaults.catAgentProtocol ? { catAgentProtocol: t.runtimeDefaults.catAgentProtocol } : {}), + ...(t.runtimeDefaults.nativeToolLevel ? { nativeToolLevel: t.runtimeDefaults.nativeToolLevel } : {}), + } + : {}), }); }; diff --git a/packages/web/src/components/__tests__/first-run-quest-wizard.test.tsx b/packages/web/src/components/__tests__/first-run-quest-wizard.test.tsx index 9317f07a46..77d02dce7d 100644 --- a/packages/web/src/components/__tests__/first-run-quest-wizard.test.tsx +++ b/packages/web/src/components/__tests__/first-run-quest-wizard.test.tsx @@ -1,4 +1,4 @@ -import React, { act, useState } from 'react'; +import { act, useState } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { apiFetch } from '@/utils/api-client'; @@ -43,6 +43,13 @@ function WizardHost({ onCreated }: { onCreated?: (tid: string) => void }) { return setOpen(false)} onCreated={onCreated ?? (() => {})} />; } +function requirePayload(payload: Record | null, label: string): Record { + if (!payload) { + throw new Error(`${label} was not captured`); + } + return payload; +} + describe('FirstRunQuestWizard', () => { let container: HTMLDivElement; let root: Root; @@ -207,7 +214,7 @@ describe('FirstRunQuestWizard', () => { ); expect(templateButton).toBeTruthy(); await act(async () => { - templateButton!.click(); + templateButton?.click(); }); await flushEffects(); @@ -215,7 +222,7 @@ describe('FirstRunQuestWizard', () => { const clientButton = Array.from(document.querySelectorAll('button')).find((b) => b.textContent?.includes('Claude')); expect(clientButton).toBeTruthy(); await act(async () => { - clientButton!.click(); + clientButton?.click(); }); await flushEffects(); @@ -241,8 +248,151 @@ describe('FirstRunQuestWizard', () => { } // Assert: POST /api/cats must use clientId, not client - expect(catsPayload).not.toBeNull(); - expect(catsPayload!.clientId).toBe('anthropic'); - expect(catsPayload!.client).toBeUndefined(); + const payload = requirePayload(catsPayload, 'POST /api/cats payload'); + expect(payload.clientId).toBe('anthropic'); + expect(payload.client).toBeUndefined(); + }); + + // F159 G2 follow-up: kitten/catagent template must POST with clientId=catagent + // + catAgentProtocol=openai-chat + nativeToolLevel=L1 from template.runtimeDefaults, + // overriding whichever client the user picked in step 2. Without this, picking 幼仔 + // in first-run still creates a 普通 anthropic/openai 成员 → /v1/messages 403. + it('catagent template runtimeDefaults override selectedClient in POST payload', async () => { + let catsPayload: Record | null = null; + let connectivityPayload: Record | null = null; + + mockApiFetch.mockImplementation(async (url: string, init?: RequestInit) => { + if (url.includes('/api/cat-templates')) { + return jsonResponse({ + templates: [ + { + id: 'kitten', + name: '幼猫', + nickname: '幼仔', + avatar: '/avatars/catagent.png', + color: { primary: '#9B7EBD', secondary: '#E8DFF5' }, + roleDescription: '灵巧的轻装猫', + personality: '直爽好奇', + runtimeDefaults: { + clientId: 'catagent', + defaultModel: 'gpt-5.5', + catAgentProtocol: 'openai-chat', + nativeToolLevel: 'L1', + }, + }, + ], + }); + } + // user picks an installed CLI client — for this test we want the *degenerate* case: + // user has only Claude CLI installed but picked kitten/openai-chat template. The + // FirstRunQuestWizard must override BOTH clientId (account family) AND client (CLI + // probe binary) so connectivity-test runs the right CLI against the right account. + if (url.includes('/api/first-run/available-clients')) { + return jsonResponse({ + clients: [ + { + client: 'claude', + provider: 'anthropic', + label: 'Claude', + cli: 'claude', + installed: true, + hasApiKey: false, + }, + ], + }); + } + if (url.includes('/api/accounts')) { + return jsonResponse({ + providers: [ + { + id: 'codex', + provider: 'codex', + displayName: 'OpenAI (Codex)', + name: 'OpenAI (Codex)', + authType: 'oauth', + mode: 'subscription', + clientId: 'openai', + models: ['gpt-5.5'], + hasApiKey: false, + createdAt: '2026-01-01', + updatedAt: '2026-01-01', + }, + ], + }); + } + if (url.includes('/api/first-run/connectivity-test')) { + connectivityPayload = JSON.parse(String(init?.body ?? '{}')) as Record; + return jsonResponse({ ok: true, message: '连接成功' }); + } + if (url === '/api/cats' && init?.method === 'POST') { + catsPayload = JSON.parse(String(init.body)) as Record; + return jsonResponse({ cat: { id: 'kitten-abcd', displayName: '幼猫' } }); + } + if (url === '/api/threads' && init?.method === 'POST') { + return jsonResponse({ id: 'thread-test-kitten' }); + } + if (url === '/api/threads') { + return jsonResponse({ threads: [] }); + } + return jsonResponse({}); + }); + + await act(async () => { + root.render(); + }); + await flushEffects(); + + // Step 1: select 幼仔 template + const templateButton = Array.from(document.querySelectorAll('button')).find((b) => b.textContent?.includes('幼猫')); + expect(templateButton).toBeTruthy(); + await act(async () => { + templateButton?.click(); + }); + await flushEffects(); + + // Step 2: select client. User picks Claude (the only CLI installed) — but the wizard + // must remap to codex/openai because the template is kitten/openai-chat. + const clientButton = Array.from(document.querySelectorAll('button')).find((b) => b.textContent?.includes('Claude')); + expect(clientButton).toBeTruthy(); + await act(async () => { + clientButton?.click(); + }); + await flushEffects(); + + // Step 3: connectivity test + create + const testButton = Array.from(document.querySelectorAll('button')).find((b) => b.textContent?.includes('测试连接')); + if (testButton) { + await act(async () => { + testButton.click(); + }); + await flushEffects(); + } + + const createButton = Array.from(document.querySelectorAll('button')).find((b) => + b.textContent?.includes('创建猫猫'), + ); + if (createButton && !createButton.disabled) { + await act(async () => { + createButton.click(); + }); + await flushEffects(); + } + + // Assert: template runtimeDefaults override. + const payload = requirePayload(catsPayload, 'POST /api/cats payload'); + expect(payload.clientId).toBe('catagent'); + expect(payload.catAgentProtocol).toBe('openai-chat'); + expect(payload.nativeToolLevel).toBe('L1'); + // Family consistency: accountRef from openai-family ConfigStep filter. + // OpenAIChatAdapter.clientFamily='openai' will match account.clientFamily='openai' at invoke time. + expect(payload.accountRef).toBe('codex'); + // connectivity-test must use the codex CLI probe (template's effective family), + // NOT the claude probe that user picked in ClientStep. Without this, probe runs + // wrong CLI binary against right account → testResult.ok fails → create blocked. + const connectivityTestPayload = requirePayload(connectivityPayload, 'connectivity-test payload'); + expect(connectivityTestPayload.client).toBe('codex'); + // clientId comes from selectedProfile.provider (account-binding), which is 'codex' + // for the OAuth builtin; what matters is it's openai-family (not 'claude'/'anthropic'). + expect(connectivityTestPayload.clientId).toBe('codex'); }); }); diff --git a/packages/web/src/components/__tests__/hub-cat-editor.test.tsx b/packages/web/src/components/__tests__/hub-cat-editor.test.tsx index 3202bf2a19..2082b58bf4 100644 --- a/packages/web/src/components/__tests__/hub-cat-editor.test.tsx +++ b/packages/web/src/components/__tests__/hub-cat-editor.test.tsx @@ -19,6 +19,7 @@ import { buildCatPatchPayload, buildCatPayload, builtinAccountIdForClient, + CATAGENT_GIT_READONLY_COMMAND_POLICY, DEFAULT_ANTIGRAVITY_COMMAND_ARGS, filterProfiles, getAcpWarning, @@ -53,6 +54,15 @@ const emptyAcpFields = { mcpSupport: true, }; +const emptyNativeToolFields = { + nativeToolLevel: '' as const, + commandPolicyPreset: '' as const, + // F159 Phase G G2 (AC-G16): all form fixtures default catAgentProtocol to '' + // (backend default = 'anthropic-messages'). Tests that need explicit values + // override locally. + catAgentProtocol: '' as const, +}; + function jsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, @@ -122,7 +132,11 @@ describe('HubCatEditor', () => { vi.clearAllMocks(); }); - async function renderAdvancedRuntimeSection(clientId: HubCatEditorFormState['clientId']) { + async function renderAdvancedRuntimeSection( + clientId: HubCatEditorFormState['clientId'], + formPatch: Partial = {}, + ) { + const onChange = vi.fn(); const form: HubCatEditorFormState = { catId: `runtime-${clientId}`, name: `runtime-${clientId}`, @@ -143,6 +157,9 @@ describe('HubCatEditor', () => { defaultModel: 'test-model', commandArgs: '', cliConfigArgs: [], + nativeToolLevel: '', + commandPolicyPreset: '', + catAgentProtocol: '', cliEffort: '', provider: '', sessionChain: 'true', @@ -152,6 +169,7 @@ describe('HubCatEditor', () => { maxContentLengthPerMsg: '', ...emptyAcpFields, ...emptyVoiceFields, + ...formPatch, }; await act(async () => { @@ -167,12 +185,14 @@ describe('HubCatEditor', () => { codexSettingsError: null, codexSettingsEditable: false, showCodexSettings: false, - onChange: vi.fn(), + onChange, onStrategyChange: vi.fn(), onCodexChange: vi.fn(), }), ); }); + + return { form, onChange }; } it('shows extra CLI args editor for CLI clients and hides it for API-only clients', async () => { @@ -187,6 +207,49 @@ describe('HubCatEditor', () => { } }); + it('only shows CatAgent custom command policy as an existing-policy preserve state', async () => { + await renderAdvancedRuntimeSection('catagent', { + nativeToolLevel: 'L2', + commandPolicyPreset: 'git-readonly', + catAgentProtocol: '', + }); + expect(document.body.textContent).toContain('Git 只读:status / diff'); + expect(document.body.textContent).not.toContain('保留现有自定义策略'); + + await renderAdvancedRuntimeSection('catagent', { + nativeToolLevel: 'L2', + commandPolicyPreset: 'custom', + catAgentProtocol: '', + }); + expect(document.body.textContent).toContain('保留现有自定义策略'); + }); + + it('preserves existing CatAgent custom command policy when toggling away from and back to L2', async () => { + const firstRender = await renderAdvancedRuntimeSection('catagent', { + nativeToolLevel: 'L2', + commandPolicyPreset: 'custom', + catAgentProtocol: '', + }); + + await changeField(queryField(container, 'select[aria-label="工具级别 (CatAgent)"]'), 'L1', 'change'); + expect(firstRender.onChange).toHaveBeenLastCalledWith({ + nativeToolLevel: 'L1', + commandPolicyPreset: 'custom', + }); + + const secondRender = await renderAdvancedRuntimeSection('catagent', { + nativeToolLevel: 'L1', + commandPolicyPreset: 'custom', + catAgentProtocol: '', + }); + + await changeField(queryField(container, 'select[aria-label="工具级别 (CatAgent)"]'), 'L2', 'change'); + expect(secondRender.onChange).toHaveBeenLastCalledWith({ + nativeToolLevel: 'L2', + commandPolicyPreset: 'custom', + }); + }); + it('buildCatPayload keeps name in PATCH payload when editing an existing cat', () => { const form: HubCatEditorFormState = { catId: 'runtime-codex', @@ -208,6 +271,9 @@ describe('HubCatEditor', () => { defaultModel: 'gpt-5.4', commandArgs: '', cliConfigArgs: [], + nativeToolLevel: '', + commandPolicyPreset: '', + catAgentProtocol: '', cliEffort: '', provider: '', sessionChain: 'true', @@ -256,6 +322,9 @@ describe('HubCatEditor', () => { defaultModel: 'gpt-5.4', commandArgs: '', cliConfigArgs: [], + nativeToolLevel: '', + commandPolicyPreset: '', + catAgentProtocol: '', cliEffort: '', provider: '', sessionChain: 'true', @@ -317,6 +386,9 @@ describe('HubCatEditor', () => { defaultModel: 'gemini-bridge', commandArgs: '', cliConfigArgs: [], + nativeToolLevel: '', + commandPolicyPreset: '', + catAgentProtocol: '', cliEffort: '', provider: '', sessionChain: 'true', @@ -358,6 +430,9 @@ describe('HubCatEditor', () => { defaultModel: 'gpt-5.4', commandArgs: '', cliConfigArgs: ['--config model_provider="custom"'], + nativeToolLevel: '', + commandPolicyPreset: '', + catAgentProtocol: '', cliEffort: 'xhigh', provider: '', sessionChain: 'true', @@ -374,6 +449,297 @@ describe('HubCatEditor', () => { expect(payload.cliConfigArgs).toEqual(['--config model_provider="custom"']); }); + it('buildCatPayload saves CatAgent L2 with the safe git readonly command policy preset', () => { + const form = { + catId: 'runtime-catagent', + name: '运行时原生猫', + displayName: '运行时原生猫', + variantLabel: '', + nickname: '', + avatar: '/avatars/catagent.png', + colorPrimary: '#16a34a', + colorSecondary: '#bbf7d0', + mentionPatterns: '@runtime-catagent', + roleDescription: '原生工具体验', + personality: '谨慎', + teamStrengths: '', + caution: '', + strengths: '', + clientId: 'catagent', + accountRef: 'anthropic-oauth', + defaultModel: 'claude-sonnet-4-6', + commandArgs: '', + cliConfigArgs: [], + nativeToolLevel: 'L2', + commandPolicyPreset: 'git-readonly', + catAgentProtocol: '', + cliEffort: '', + provider: '', + sessionChain: 'true', + maxPromptTokens: '', + maxContextTokens: '', + maxMessages: '', + maxContentLengthPerMsg: '', + ...emptyVoiceFields, + ...emptyAcpFields, + } as HubCatEditorFormState; + + const payload = buildCatPayload(form, null) as Record; + expect(payload.nativeToolLevel).toBe('L2'); + expect(payload.commandPolicy).toEqual(CATAGENT_GIT_READONLY_COMMAND_POLICY); + }); + + it('buildCatPayload clears CatAgent native tool settings when switching away from CatAgent', () => { + const form = { + catId: 'runtime-catagent', + name: '运行时原生猫', + displayName: '运行时原生猫', + variantLabel: '', + nickname: '', + avatar: '/avatars/catagent.png', + colorPrimary: '#16a34a', + colorSecondary: '#bbf7d0', + mentionPatterns: '@runtime-catagent', + roleDescription: '原生工具体验', + personality: '谨慎', + teamStrengths: '', + caution: '', + strengths: '', + clientId: 'openai', + accountRef: 'codex', + defaultModel: 'gpt-5.5', + commandArgs: '', + cliConfigArgs: [], + nativeToolLevel: 'L2', + commandPolicyPreset: 'git-readonly', + catAgentProtocol: '', + cliEffort: '', + provider: '', + sessionChain: 'true', + maxPromptTokens: '', + maxContextTokens: '', + maxMessages: '', + maxContentLengthPerMsg: '', + ...emptyVoiceFields, + ...emptyAcpFields, + } as HubCatEditorFormState; + const existingCat = { + id: 'runtime-catagent', + name: 'runtime-catagent', + displayName: '运行时原生猫', + clientId: 'catagent', + defaultModel: 'claude-sonnet-4-6', + color: { primary: '#16a34a', secondary: '#bbf7d0' }, + mentionPatterns: ['@runtime-catagent'], + avatar: '/avatars/catagent.png', + roleDescription: '原生工具体验', + nativeToolLevel: 'L2', + commandPolicy: CATAGENT_GIT_READONLY_COMMAND_POLICY, + } as CatData; + + const payload = buildCatPayload(form, existingCat) as Record; + expect(payload.nativeToolLevel).toBeNull(); + expect(payload.commandPolicy).toBeNull(); + }); + + it('buildCatPayload preserves existing custom CatAgent command policy unless explicitly cleared', () => { + const customPolicy = [{ binary: 'make', allowedSubcommands: ['test'] }]; + const form = { + catId: 'runtime-catagent', + name: '运行时原生猫', + displayName: '运行时原生猫', + variantLabel: '', + nickname: '', + avatar: '/avatars/catagent.png', + colorPrimary: '#16a34a', + colorSecondary: '#bbf7d0', + mentionPatterns: '@runtime-catagent', + roleDescription: '原生工具体验', + personality: '谨慎', + teamStrengths: '', + caution: '', + strengths: '', + clientId: 'catagent', + accountRef: 'anthropic-oauth', + defaultModel: 'claude-sonnet-4-6', + commandArgs: '', + cliConfigArgs: [], + nativeToolLevel: 'L2', + commandPolicyPreset: 'custom', + catAgentProtocol: '', + cliEffort: '', + provider: '', + sessionChain: 'true', + maxPromptTokens: '', + maxContextTokens: '', + maxMessages: '', + maxContentLengthPerMsg: '', + ...emptyVoiceFields, + ...emptyAcpFields, + } as HubCatEditorFormState; + const existingCat = { + id: 'runtime-catagent', + name: 'runtime-catagent', + displayName: '运行时原生猫', + clientId: 'catagent', + defaultModel: 'claude-sonnet-4-6', + color: { primary: '#16a34a', secondary: '#bbf7d0' }, + mentionPatterns: ['@runtime-catagent'], + avatar: '/avatars/catagent.png', + roleDescription: '原生工具体验', + personality: '谨慎', + nativeToolLevel: 'L2', + commandPolicy: customPolicy, + } as CatData; + + const preservePayload = buildCatPayload(form, existingCat) as Record; + expect(preservePayload.commandPolicy).toBeUndefined(); + + const clearPayload = buildCatPayload({ ...form, commandPolicyPreset: '' }, existingCat) as Record; + expect(clearPayload.commandPolicy).toBeNull(); + }); + + // F159 Phase G G2 (AC-G16, @gpt555 P2 UI State Drift fix): + // Verify that AccountSection's Client switch onChange explicitly resets + // catAgentProtocol when switching away from catagent — without this, + // users can silently carry over an incompatible protocol selection. + it('AccountSection Client switch clears catAgentProtocol form state when leaving catagent', async () => { + const { AccountSection } = await import('../hub-cat-editor.sections'); + const onChange = vi.fn(); + const form: HubCatEditorFormState = { + catId: 'switch-protocol', + name: '协议切换猫', + displayName: '协议切换猫', + variantLabel: '', + nickname: '', + avatar: '/avatars/catagent.png', + colorPrimary: '#16a34a', + colorSecondary: '#bbf7d0', + mentionPatterns: '@switch-protocol', + roleDescription: '协议切换验证', + personality: '', + teamStrengths: '', + caution: '', + strengths: '', + clientId: 'catagent', + accountRef: 'codex-for-me', + defaultModel: 'gpt-5.5', + commandArgs: '', + cliConfigArgs: [], + nativeToolLevel: '', + commandPolicyPreset: '', + catAgentProtocol: 'openai-chat', + cliEffort: '', + provider: '', + sessionChain: 'true', + maxPromptTokens: '', + maxContextTokens: '', + maxMessages: '', + maxContentLengthPerMsg: '', + ...emptyVoiceFields, + ...emptyAcpFields, + }; + + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + await act(async () => { + root!.render( + React.createElement(AccountSection, { + form, + modelOptions: [], + availableProfiles: [], + loadingProfiles: false, + onChange, + }), + ); + }); + + // Switch Client away from catagent → onChange must include catAgentProtocol: '' + await changeField(queryField(container, 'select[aria-label="Client"]'), 'openai', 'change'); + const calls = (onChange as ReturnType).mock.calls; + const lastCall = calls[calls.length - 1]?.[0] as Partial; + expect(lastCall.clientId).toBe('openai'); + expect(lastCall.catAgentProtocol).toBe(''); + + // Sanity: switching to another catagent (idempotent) does NOT clear it + // (the spread is gated on nextClient !== 'catagent'). + onChange.mockClear(); + await changeField(queryField(container, 'select[aria-label="Client"]'), 'catagent', 'change'); + const catagentCall = (onChange as ReturnType).mock.calls[0]?.[0] as Partial; + expect(catagentCall.clientId).toBe('catagent'); + expect(catagentCall.catAgentProtocol).toBeUndefined(); + }); + + // F159 Phase G G2 (AC-G16): catAgentProtocol round-trip — mirror the + // nativeToolLevel persist/clear matrix at the Hub payload boundary. + it('buildCatPayload emits catAgentProtocol on catagent member and clears on non-catagent', () => { + const formCatAgentOpenAI: HubCatEditorFormState = { + catId: 'runtime-catagent', + name: '运行时原生猫', + displayName: '运行时原生猫', + variantLabel: '', + nickname: '', + avatar: '/avatars/catagent.png', + colorPrimary: '#16a34a', + colorSecondary: '#bbf7d0', + mentionPatterns: '@runtime-catagent', + roleDescription: '原生工具体验', + personality: '谨慎', + teamStrengths: '', + caution: '', + strengths: '', + clientId: 'catagent', + accountRef: 'codex-for-me', + defaultModel: 'gpt-5.5', + commandArgs: '', + cliConfigArgs: [], + nativeToolLevel: '', + commandPolicyPreset: '', + catAgentProtocol: 'openai-chat', + cliEffort: '', + provider: '', + sessionChain: 'true', + maxPromptTokens: '', + maxContextTokens: '', + maxMessages: '', + maxContentLengthPerMsg: '', + ...emptyVoiceFields, + ...emptyAcpFields, + }; + + // 1. catagent + catAgentProtocol set → emitted on payload + const createPayload = buildCatPayload(formCatAgentOpenAI, null) as Record; + expect(createPayload.catAgentProtocol).toBe('openai-chat'); + + // 2. catagent + catAgentProtocol '' (backend default) → field omitted (no patch) + const defaultPayload = buildCatPayload({ ...formCatAgentOpenAI, catAgentProtocol: '' }, null) as Record< + string, + unknown + >; + expect(Object.hasOwn(defaultPayload, 'catAgentProtocol')).toBe(false); + + // 3. Switching away from catagent + existing cat had catAgentProtocol → null clears + const existingCatagent = { + id: 'runtime-catagent', + name: 'runtime-catagent', + displayName: '运行时原生猫', + clientId: 'catagent', + defaultModel: 'gpt-5.5', + color: { primary: '#16a34a', secondary: '#bbf7d0' }, + mentionPatterns: ['@runtime-catagent'], + avatar: '/avatars/catagent.png', + roleDescription: '原生工具体验', + personality: '谨慎', + catAgentProtocol: 'openai-chat', + } as CatData; + const switchAwayPayload = buildCatPayload( + { ...formCatAgentOpenAI, clientId: 'openai', catAgentProtocol: '' }, + existingCatagent, + ) as Record; + expect(switchAwayPayload.catAgentProtocol).toBeNull(); + }); + it('splitCommandArgs preserves quoted segments', () => { expect(splitCommandArgs('chat --mode "agent bridge" --path "/tmp/work tree"')).toEqual([ 'chat', @@ -419,6 +785,7 @@ describe('HubCatEditor', () => { maxMessages: '', maxContentLengthPerMsg: '', ...emptyVoiceFields, + ...emptyNativeToolFields, acpEnabled: true, mcpSupport: true, acpTransport: 'stdio', @@ -465,6 +832,7 @@ describe('HubCatEditor', () => { maxMessages: '', maxContentLengthPerMsg: '', ...emptyVoiceFields, + ...emptyNativeToolFields, acpEnabled: true, mcpSupport: true, acpTransport: 'stdio', @@ -557,6 +925,7 @@ describe('HubCatEditor', () => { maxMessages: '', maxContentLengthPerMsg: '', ...emptyVoiceFields, + ...emptyNativeToolFields, acpEnabled: true, mcpSupport: true, acpTransport: 'stdio', @@ -611,6 +980,7 @@ describe('HubCatEditor', () => { maxMessages: '', maxContentLengthPerMsg: '', ...emptyVoiceFields, + ...emptyNativeToolFields, acpEnabled: true, mcpSupport: true, acpTransport: 'stdio', @@ -672,6 +1042,7 @@ describe('HubCatEditor', () => { maxMessages: '', maxContentLengthPerMsg: '', ...emptyVoiceFields, + ...emptyNativeToolFields, acpEnabled: true, mcpSupport: true, acpTransport: 'stdio', @@ -731,6 +1102,7 @@ describe('HubCatEditor', () => { maxMessages: '', maxContentLengthPerMsg: '', ...emptyVoiceFields, + ...emptyNativeToolFields, acpEnabled: true, mcpSupport: true, acpTransport: 'stdio', diff --git a/packages/web/src/components/__tests__/settings-nav-search.test.ts b/packages/web/src/components/__tests__/settings-nav-search.test.ts index 57e1594884..cb4265edaa 100644 --- a/packages/web/src/components/__tests__/settings-nav-search.test.ts +++ b/packages/web/src/components/__tests__/settings-nav-search.test.ts @@ -46,7 +46,7 @@ describe('SettingsNav search filtering', () => { root.render(React.createElement(SettingsNav, { activeSection: 'members', onSelect: vi.fn() })); }); const buttons = Array.from(container.querySelectorAll('[data-active]')); - expect(buttons).toHaveLength(14); + expect(buttons).toHaveLength(15); expect(container.textContent).toContain('协作与规则'); }); @@ -56,7 +56,7 @@ describe('SettingsNav search filtering', () => { }); const buttons = Array.from(container.querySelectorAll('[data-active]')); - expect(buttons).toHaveLength(14); + expect(buttons).toHaveLength(15); for (const button of buttons) { expect(button.querySelector('svg.h-4.w-4')).toBeTruthy(); } @@ -106,6 +106,17 @@ describe('SettingsNav search filtering', () => { expect(buttons[0].textContent).toContain('猫猫球'); }); + it('filters Bluetooth keywords to the device section', () => { + act(() => { + root.render( + React.createElement(SettingsNav, { activeSection: 'members', onSelect: vi.fn(), searchQuery: '蓝牙' }), + ); + }); + const buttons = Array.from(container.querySelectorAll('[data-active]')); + expect(buttons).toHaveLength(1); + expect(buttons[0].textContent).toContain('设备与 Limb'); + }); + it('shows empty message when no match', () => { act(() => { root.render( diff --git a/packages/web/src/components/first-run-quest/TemplateStep.tsx b/packages/web/src/components/first-run-quest/TemplateStep.tsx index ec67e1d37a..aa3b9928d5 100644 --- a/packages/web/src/components/first-run-quest/TemplateStep.tsx +++ b/packages/web/src/components/first-run-quest/TemplateStep.tsx @@ -1,7 +1,9 @@ 'use client'; +import type { CatAgentProtocol, NativeToolLevel } from '@cat-cafe/shared'; import { useEffect, useState } from 'react'; import { apiFetch } from '@/utils/api-client'; +import type { ClientId } from '../hub-cat-editor.model'; export interface TemplateCard { id: string; @@ -12,6 +14,17 @@ export interface TemplateCard { roleDescription: string; personality: string; teamStrengths?: string; + /** + * F159 G2 follow-up: runtime身份字段(clientId / model / catAgentProtocol / nativeToolLevel), + * 由 /api/cat-templates 从 breeds[].defaultVariant 提取。HubCatEditor.handleTemplateSelect + * 会把这些 patch 进 form,让"点模板=可用猫"。accountRef 不在此——它是 user environment-specific。 + */ + runtimeDefaults?: { + clientId: ClientId; + defaultModel: string; + catAgentProtocol?: CatAgentProtocol; + nativeToolLevel?: NativeToolLevel; + }; } interface TemplateStepProps { diff --git a/packages/web/src/components/hub-cat-editor-advanced.tsx b/packages/web/src/components/hub-cat-editor-advanced.tsx index fa6aa08c7a..d56d176b30 100644 --- a/packages/web/src/components/hub-cat-editor-advanced.tsx +++ b/packages/web/src/components/hub-cat-editor-advanced.tsx @@ -2,12 +2,16 @@ import type { CatData } from '@/hooks/useCatData'; import { + CAT_AGENT_PROTOCOL_OPTIONS, + CATAGENT_COMMAND_POLICY_PRESET_OPTIONS, + CATAGENT_CUSTOM_COMMAND_POLICY_PRESET_OPTION, CODEX_APPROVAL_OPTIONS, CODEX_AUTH_MODE_OPTIONS, CODEX_SANDBOX_OPTIONS, type CodexRuntimeSettings, getCliEffortOptionsForClient, type HubCatEditorFormState, + NATIVE_TOOL_LEVEL_OPTIONS, SESSION_CHAIN_OPTIONS, SESSION_STRATEGY_OPTIONS, type StrategyFormState, @@ -53,6 +57,10 @@ export function AdvancedRuntimeSection({ }; const cliEffortOptions = getCliEffortOptionsForClient(form.clientId); const sessionChainEnabled = form.sessionChain === 'true' && (strategyForm?.sessionChainEnabled ?? true); + const catAgentCommandPolicyPresetOptions = + form.commandPolicyPreset === 'custom' + ? [...CATAGENT_COMMAND_POLICY_PRESET_OPTIONS, CATAGENT_CUSTOM_COMMAND_POLICY_PRESET_OPTION] + : CATAGENT_COMMAND_POLICY_PRESET_OPTIONS; return ( ) : null} + {form.clientId === 'catagent' ? ( +
+ { + const nativeToolLevel = value as HubCatEditorFormState['nativeToolLevel']; + onChange({ + nativeToolLevel, + commandPolicyPreset: + nativeToolLevel === 'L2' + ? form.commandPolicyPreset === '' + ? 'git-readonly' + : form.commandPolicyPreset + : form.commandPolicyPreset === 'custom' + ? 'custom' + : '', + }); + }} + tone="success" + /> +

+ L0 只读 · L1 可写文件(write_file / patch_file)· L2 可执行命令(run_command)。保存后下次调用即生效。 +

+ {form.nativeToolLevel === 'L2' ? ( + + onChange({ commandPolicyPreset: value as HubCatEditorFormState['commandPolicyPreset'] }) + } + tone="success" + /> + ) : null} + {form.nativeToolLevel === 'L2' ? ( +

+ 预设只允许只读的 git status / diff。选择“不允许命令”时 L2 仍会 fail-closed 拒绝执行。 +

+ ) : null} + {form.nativeToolLevel === 'L2' && form.commandPolicyPreset === 'custom' ? ( +

+ 当前成员已有自定义策略;此处只保留或切换到内置预设,不编辑自定义 JSON。 +

+ ) : null} + {/* F159 Phase G G2 (AC-G16): wire protocol selector for CatAgent. */} + onChange({ catAgentProtocol: value as HubCatEditorFormState['catAgentProtocol'] })} + tone="success" + /> +

+ 选择 catagent 调用的 wire protocol。默认 Anthropic Messages,需要绑 Anthropic 兼容账号; OpenAI Chat 走 + `/v1/chat/completions`,需要绑 OpenAI 兼容账号——切换协议后请确认账号 family 匹配。 +

+
+ ) : null} {cat ? ( diff --git a/packages/web/src/components/hub-cat-editor.model.ts b/packages/web/src/components/hub-cat-editor.model.ts index fc3cb4aad7..73a93af501 100644 --- a/packages/web/src/components/hub-cat-editor.model.ts +++ b/packages/web/src/components/hub-cat-editor.model.ts @@ -1,8 +1,11 @@ import { builtinAccountFamilyForClient, + type CatAgentProtocol, CLI_EFFORT_VALUES, type CliEffortValue, + type CommandPolicyEntry, getCliEffortOptionsForProvider, + type NativeToolLevel, builtinAccountIdForClient as sharedBuiltinAccountIdForClient, } from '@cat-cafe/shared'; import type { CatData } from '@/hooks/useCatData'; @@ -19,6 +22,7 @@ export type SessionChainValue = 'true' | 'false'; export type CodexSandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access'; export type CodexApprovalPolicy = 'untrusted' | 'on-failure' | 'on-request' | 'never'; export type CodexAuthMode = 'oauth' | 'api_key' | 'auto'; +export type CatAgentCommandPolicyPreset = '' | 'git-readonly' | 'custom'; export interface HubCatEditorFormState { catId: string; @@ -40,6 +44,14 @@ export interface HubCatEditorFormState { defaultModel: string; commandArgs: string; cliConfigArgs: string[]; + /** F159 Phase F: CatAgent native tool level. '' = default L0 (read-only). */ + nativeToolLevel: NativeToolLevel | ''; + /** F159 Phase F: safe command policy preset for CatAgent L2. */ + commandPolicyPreset: CatAgentCommandPolicyPreset; + /** F159 Phase G G2 (AC-G16): CatAgent wire protocol selection. + * '' = backend default ('anthropic-messages'); switching catagent → catagent + * preserves whatever value was loaded. Cleared when clientId !== 'catagent'. */ + catAgentProtocol: CatAgentProtocol | ''; cliEffort: CliEffortValue | ''; provider: string; acpEnabled: boolean; @@ -110,6 +122,42 @@ export const SESSION_CHAIN_OPTIONS: Array<{ value: SessionChainValue; label: str { value: 'false', label: 'false' }, ]; +/** F159 Phase F: CatAgent native tool level. '' renders the default L0 (read-only) option. */ +export const NATIVE_TOOL_LEVEL_OPTIONS: Array<{ value: NativeToolLevel | ''; label: string }> = [ + { value: '', label: 'L0 · 只读(默认)' }, + { value: 'L1', label: 'L1 · 读 + 写文件' }, + { value: 'L2', label: 'L2 · 读 + 写 + 执行命令' }, +]; + +/** F159 Phase G G2 (AC-G16): CatAgent wire protocol options. + * '' renders the backend default (Anthropic Messages, G1 catagent baseline). */ +export const CAT_AGENT_PROTOCOL_OPTIONS: Array<{ value: CatAgentProtocol | ''; label: string }> = [ + { value: '', label: 'Anthropic Messages(默认)' }, + { value: 'anthropic-messages', label: 'Anthropic Messages · /v1/messages + x-api-key' }, + { value: 'openai-chat', label: 'OpenAI Chat · /v1/chat/completions + Bearer' }, +]; + +export const CATAGENT_GIT_READONLY_COMMAND_POLICY: readonly CommandPolicyEntry[] = [ + { + binary: 'git', + allowedSubcommands: ['status', 'diff'], + allowedFlags: ['--short', '--branch', '--stat', '--name-only', '--cached'], + }, +]; + +export const CATAGENT_COMMAND_POLICY_PRESET_OPTIONS: Array<{ + value: CatAgentCommandPolicyPreset; + label: string; +}> = [ + { value: '', label: '不允许命令(L2 fail-closed)' }, + { value: 'git-readonly', label: 'Git 只读:status / diff' }, +]; + +export const CATAGENT_CUSTOM_COMMAND_POLICY_PRESET_OPTION: { + value: CatAgentCommandPolicyPreset; + label: string; +} = { value: 'custom', label: '保留现有自定义策略' }; + export const SESSION_STRATEGY_OPTIONS: Array<{ value: StrategyType; label: string }> = [ { value: 'handoff', label: 'handoff' }, { value: 'compress', label: 'compress' }, @@ -153,6 +201,27 @@ function isCliEffortValue(value: string | undefined): value is CliEffortValue { return value !== undefined && CLI_EFFORT_VALUES.includes(value as CliEffortValue); } +function stringArraysEqual(a: readonly string[] | undefined, b: readonly string[] | undefined): boolean { + const left = a ?? []; + const right = b ?? []; + return left.length === right.length && left.every((value, index) => value === right[index]); +} + +export function detectCatAgentCommandPolicyPreset( + policy: readonly CommandPolicyEntry[] | undefined, +): CatAgentCommandPolicyPreset { + if (!policy || policy.length === 0) return ''; + if (policy.length !== CATAGENT_GIT_READONLY_COMMAND_POLICY.length) return 'custom'; + const [entry] = policy; + const [expected] = CATAGENT_GIT_READONLY_COMMAND_POLICY; + if (!entry || !expected || entry.binary !== expected.binary) return 'custom'; + if (!stringArraysEqual(entry.allowedSubcommands, expected.allowedSubcommands)) return 'custom'; + if (!stringArraysEqual(entry.allowedFlags, expected.allowedFlags)) return 'custom'; + if (!stringArraysEqual(entry.allowedArgPatterns, expected.allowedArgPatterns)) return 'custom'; + if (!stringArraysEqual(entry.deniedFlags, expected.deniedFlags)) return 'custom'; + return 'git-readonly'; +} + function voiceStr(value: string | number | undefined): string { return value == null ? '' : String(value); } @@ -299,6 +368,12 @@ function isAllowedGoogleGatewayProfile(profile: ProfileItem): boolean { return hostname !== null && !isOfficialGoogleHostname(hostname); } +// F159 Phase G G2 AC-G19 audit (see @cat-cafe/shared client-routing.ts audit +// table): DEFERRED to Axis 6 cross-cutting UX polish. Hub UI account-picker +// filter for catagent + 'openai-chat' should arguably show OpenAI accounts; +// migration requires threading form.catAgentProtocol through filterAccounts. +// Does NOT block G2 merge gate (credentials path still fails-closed at adapter +// level via Axis 2 factory dispatch). function resolveBuiltinClientFamily(client: ClientId): BuiltinAccountClient | null { if (typeof builtinAccountFamilyForClient === 'function') { const family = builtinAccountFamilyForClient(client); @@ -380,6 +455,12 @@ export function initialState(cat?: CatData | null, draft?: HubCatEditorDraft | n defaultModel: cat?.defaultModel ?? createDraft?.defaultModel ?? '', commandArgs: cat?.commandArgs?.join(' ') ?? createDraft?.commandArgs ?? '', cliConfigArgs: [...(cat?.cliConfigArgs ?? [])], + nativeToolLevel: cat?.nativeToolLevel && cat.nativeToolLevel !== 'L0' ? cat.nativeToolLevel : '', + commandPolicyPreset: detectCatAgentCommandPolicyPreset(cat?.commandPolicy), + // F159 Phase G G2 (AC-G16): preserve loaded value verbatim; '' means + // "use backend default" (Anthropic Messages). Hub UI dropdown surfaces + // the explicit option for visibility. + catAgentProtocol: cat?.catAgentProtocol ?? '', cliEffort: isCliEffortValue(persistedCliEffort) ? persistedCliEffort : '', provider: cat?.provider ?? '', acpEnabled: diff --git a/packages/web/src/components/hub-cat-editor.payload.ts b/packages/web/src/components/hub-cat-editor.payload.ts index b29f6cca99..c181609b71 100644 --- a/packages/web/src/components/hub-cat-editor.payload.ts +++ b/packages/web/src/components/hub-cat-editor.payload.ts @@ -1,5 +1,6 @@ import type { CatData } from '@/hooks/useCatData'; import { + CATAGENT_GIT_READONLY_COMMAND_POLICY, type ClientId, DEFAULT_ANTIGRAVITY_COMMAND_ARGS, defaultAcpCommandForClient, @@ -173,6 +174,34 @@ export function buildCatPayload(form: HubCatEditorFormState, cat?: CatData | nul const voiceConfig = buildVoiceConfig(form); const voiceConfigPatch: Record = voiceConfig !== undefined ? { voiceConfig } : cat?.voiceConfig ? { voiceConfig: null } : {}; + const isCatAgent = form.clientId === 'catagent'; + // F159 Phase F: typed as Record to keep the build payload union flat (mirrors voiceConfigPatch). + const nativeToolLevelPatch: Record = + isCatAgent && form.nativeToolLevel + ? { nativeToolLevel: form.nativeToolLevel } + : cat?.nativeToolLevel + ? { nativeToolLevel: null } + : {}; + const commandPolicyPatch: Record = !isCatAgent + ? cat?.commandPolicy + ? { commandPolicy: null } + : {} + : form.commandPolicyPreset === 'git-readonly' + ? { commandPolicy: CATAGENT_GIT_READONLY_COMMAND_POLICY } + : form.commandPolicyPreset === '' + ? cat?.commandPolicy + ? { commandPolicy: null } + : {} + : {}; + // F159 Phase G G2 (AC-G16): catAgentProtocol patch — only emit on catagent + // members; clear when switching away from catagent (mirrors nativeToolLevel + // pattern). '' on the form means "backend default", not "no value to send". + const catAgentProtocolPatch: Record = + isCatAgent && form.catAgentProtocol + ? { catAgentProtocol: form.catAgentProtocol } + : cat?.catAgentProtocol + ? { catAgentProtocol: null } + : {}; const common = { displayName, variantLabel: trimText(form.variantLabel), @@ -218,6 +247,9 @@ export function buildCatPayload(form: HubCatEditorFormState, cat?: CatData | nul ...cliPatch, defaultModel: trimText(form.defaultModel), cliConfigArgs: (form.cliConfigArgs ?? []).filter((arg) => arg.trim().length > 0), + ...nativeToolLevelPatch, + ...commandPolicyPatch, + ...catAgentProtocolPatch, ...buildProviderPatch(form, cat), }; } diff --git a/packages/web/src/components/hub-cat-editor.sections.tsx b/packages/web/src/components/hub-cat-editor.sections.tsx index 325f2a4381..88cefc03af 100644 --- a/packages/web/src/components/hub-cat-editor.sections.tsx +++ b/packages/web/src/components/hub-cat-editor.sections.tsx @@ -415,6 +415,12 @@ export function AccountSection({ cliEffort: '', acpEnabled: nextAcpEnabled, ...(nextAcpEnabled ? acpDefaultsForClientSwitch(form, nextClient) : {}), + // F159 Phase G G2 (AC-G16, @gpt555 P2 UI State Drift fix): + // model注释 + AC-G16 都承诺 clientId !== 'catagent' 时清空 + // catAgentProtocol。否则用户先选 'openai-chat',切到非 catagent, + // 再切回 catagent 时旧协议会悄悄留在 form state 里,可能让 + // payload 提交一个跟新账号 family 不匹配的协议选择。 + ...(nextClient !== 'catagent' ? { catAgentProtocol: '' as const } : {}), }); }} required diff --git a/packages/web/src/components/settings/AgentProviderApprovalSection.tsx b/packages/web/src/components/settings/AgentProviderApprovalSection.tsx new file mode 100644 index 0000000000..8f917cdc8c --- /dev/null +++ b/packages/web/src/components/settings/AgentProviderApprovalSection.tsx @@ -0,0 +1,374 @@ +'use client'; + +/** + * F241 Phase C — Hub UI for owner approval. + * + * Renders an approval section for each `agentProvider` resource of a plugin: + * - Shows the current lifecycle state (transportReady / healthy) + routeable + * bool + any `health.failureReason` so operators can see why a probe failed. + * - For non-routeable rows: renders the approve form (catId input + + * mentionPatterns chip input) prefilled from manifest claims (PR #39). + * - For already-routeable rows: shows the live binding (catId + patterns) + * and a "重新绑定" / "停用路由" pair so the operator can re-approve under + * a new binding or take the cat offline without disabling the whole plugin. + * + * POSTs `/api/plugins/:id/capabilities/:capId/approve-routeable` with + * { catId, mentionPatterns? }. Failure body includes structured + * `reason` + `details` from the approval service so the form can surface + * admission collisions / health-probe failures inline. + */ + +import type { PluginInfo, PluginResourceStatus } from '@cat-cafe/shared'; +import { useMemo, useState } from 'react'; +import { apiFetch } from '@/utils/api-client'; + +interface Props { + plugin: PluginInfo; + onUpdated: () => void; +} + +type ApprovalRowState = { + catId: string; + mentionPatternsText: string; + busy: boolean; + result: { type: 'success' | 'error'; msg: string } | null; + /** Tracks whether the operator is editing an already-bound row (re-bind flow). */ + rebinding: boolean; +}; + +/** Comma- or whitespace-separated `@name` list → trimmed string[] (drop empties). */ +function parseMentionPatterns(text: string): string[] { + return text + .split(/[,\s]+/u) + .map((p) => p.trim()) + .filter((p) => p.length > 0); +} + +function joinMentionPatterns(values: string[] | undefined): string { + return (values ?? []).join(', '); +} + +function defaultCatIdFromResource(resource: PluginResourceStatus, pluginId: string): string { + // Prefer manifest providerId claim → resource name → plugin id (last-resort). + return resource.agentProviderClaims?.providerId ?? resource.name ?? pluginId; +} + +type ApproveResponse = + | { ok: true; capability?: unknown } + | { ok: false; reason?: string; details?: string; conflictingIdentity?: string }; + +/** Build operator-visible error message from a structured approval failure. */ +function approvalErrorMessage(data: Extract): string { + const head = data.reason ?? '未知错误'; + const tail = data.details ? `: ${data.details}` : ''; + const conflict = data.conflictingIdentity ? ` (冲突身份: ${data.conflictingIdentity})` : ''; + return `${head}${tail}${conflict}`; +} + +/** POST approve-routeable + normalize the network/JSON/structured-failure error paths. */ +async function postApproveRouteable( + pluginId: string, + capId: string, + catId: string, + mentionPatterns: string[], +): Promise<{ ok: true } | { ok: false; msg: string }> { + try { + const res = await apiFetch(`/api/plugins/${pluginId}/capabilities/${encodeURIComponent(capId)}/approve-routeable`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + catId, + ...(mentionPatterns.length > 0 ? { mentionPatterns } : {}), + }), + }); + const data = (await res.json().catch(() => ({}))) as ApproveResponse; + if (!res.ok || ('ok' in data && data.ok === false)) { + const msg = 'ok' in data && data.ok === false ? approvalErrorMessage(data) : `HTTP ${res.status}`; + return { ok: false, msg }; + } + return { ok: true }; + } catch (err) { + return { ok: false, msg: err instanceof Error ? err.message : '网络错误' }; + } +} + +function StateChip({ resource }: { resource: PluginResourceStatus }) { + const state = resource.agentProviderState; + const routeable = resource.agentProviderRouteable; + if (state === 'healthy' && routeable) { + return ( + + ✓ routeable · healthy + + ); + } + if (state === 'transportReady') { + return ( + + 待审批 · transportReady + + ); + } + return ( + + {state ?? '未激活'} + + ); +} + +export function AgentProviderApprovalSection({ plugin, onUpdated }: Props) { + const agentProviderRows = useMemo( + () => plugin.resources.filter((r) => r.type === 'agentProvider'), + [plugin.resources], + ); + + const initialRowState = (resource: PluginResourceStatus): ApprovalRowState => ({ + catId: resource.agentProviderBinding?.catId ?? defaultCatIdFromResource(resource, plugin.id), + mentionPatternsText: joinMentionPatterns( + resource.agentProviderBinding?.mentionPatterns ?? resource.agentProviderClaims?.mentionPatterns, + ), + busy: false, + result: null, + rebinding: false, + }); + + // Key by capId so re-renders after onUpdated() naturally re-mount the form + // state from the freshest binding rather than holding stale operator input. + const [rowState, setRowState] = useState>(() => { + const acc: Record = {}; + for (const r of agentProviderRows) { + if (r.capId) acc[r.capId] = initialRowState(r); + } + return acc; + }); + + if (agentProviderRows.length === 0) return null; + + const getRow = (resource: PluginResourceStatus): ApprovalRowState => { + if (!resource.capId) return initialRowState(resource); + return rowState[resource.capId] ?? initialRowState(resource); + }; + + const updateRow = (capId: string, patch: Partial) => { + setRowState((prev) => ({ + ...prev, + [capId]: { ...(prev[capId] ?? ({} as ApprovalRowState)), ...patch }, + })); + }; + + const handleApprove = async (resource: PluginResourceStatus) => { + const capId = resource.capId; + if (!capId) return; + const row = getRow(resource); + const catId = row.catId.trim(); + if (!catId) { + updateRow(capId, { result: { type: 'error', msg: '请填写 catId 绑定' } }); + return; + } + updateRow(capId, { busy: true, result: null }); + const mentionPatterns = parseMentionPatterns(row.mentionPatternsText); + const result = await postApproveRouteable(plugin.id, capId, catId, mentionPatterns); + if (result.ok) { + updateRow(capId, { busy: false, rebinding: false, result: { type: 'success', msg: '审批已生效' } }); + onUpdated(); + } else { + updateRow(capId, { busy: false, result: { type: 'error', msg: result.msg } }); + } + }; + + return ( +
+
外部 Agent Provider 路由审批 (F241)
+ {agentProviderRows.map((resource) => ( + resource.capId && updateRow(resource.capId, patch)} + onApprove={() => void handleApprove(resource)} + /> + ))} +
+ ); +} + +/** + * Single approval row — extracted to keep the parent under the Biome cognitive- + * complexity budget. Owns no state; the parent threads `row` + `onPatch` so + * `onUpdated()` re-renders cleanly when the binding refreshes. + */ +interface ApprovalRowProps { + resource: PluginResourceStatus; + row: ApprovalRowState; + pluginId: string; + onPatch: (patch: Partial) => void; + onApprove: () => void; +} + +function ApprovalRow({ resource, row, pluginId, onPatch, onApprove }: ApprovalRowProps) { + const liveBinding = resource.agentProviderBinding; + const isRouteable = resource.agentProviderRouteable === true; + const showForm = !isRouteable || row.rebinding; + const claims = resource.agentProviderClaims; + return ( +
+
+ + {claims?.displayName ?? resource.name ?? 'agentProvider'} + + + {resource.agentProviderHealthFailureReason && ( + + 探针失败: {resource.agentProviderHealthFailureReason} + + )} + {/* F241 PR #42 round-1 review @codex P2: surface persisted sync failure + separately from health probe failure so operators see WHY a row is + `approved=true / healthy / routeable=false` after a Step 6 sync hook + throws. Distinct chip + label keeps the two failure sources + operator-distinguishable; the title attribute shows the occurredAt + timestamp on hover so operators can correlate with logs. */} + {resource.agentProviderLastSyncError && ( + + 同步失败: {resource.agentProviderLastSyncError.message} + + )} +
+ + {isRouteable && liveBinding && ( + onPatch({ rebinding: true, result: null })} + /> + )} + + {showForm && ( + + )} + + {row.result && ( +
+ {row.result.msg} +
+ )} + + {resource.agentProviderDescriptorHash && ( +
+ descriptorHash: {resource.agentProviderDescriptorHash.slice(0, 12)}… +
+ )} +
+ ); +} + +function BindingSummary({ + binding, + rebinding, + onRebind, +}: { + binding: NonNullable; + rebinding: boolean; + onRebind: () => void; +}) { + return ( +
+
+ 当前绑定 · @{binding.catId} +
+ {binding.mentionPatterns && binding.mentionPatterns.length > 0 && ( +
mention: {binding.mentionPatterns.join(', ')}
+ )} + {!rebinding && ( + + )} +
+ ); +} + +interface ApprovalFormProps { + resource: PluginResourceStatus; + row: ApprovalRowState; + pluginId: string; + claims: PluginResourceStatus['agentProviderClaims']; + onPatch: (patch: Partial) => void; + onApprove: () => void; +} + +function ApprovalForm({ resource, row, pluginId, claims, onPatch, onApprove }: ApprovalFormProps) { + return ( +
+ + +
+ {row.rebinding && ( + + )} + +
+
+ ); +} diff --git a/packages/web/src/components/settings/BleDeviceCards.tsx b/packages/web/src/components/settings/BleDeviceCards.tsx new file mode 100644 index 0000000000..0b72a7af9b --- /dev/null +++ b/packages/web/src/components/settings/BleDeviceCards.tsx @@ -0,0 +1,82 @@ +import type { BleBindingView, BleDiscoveryView, BleStatus } from './ble-device-types'; +import { SettingsBadge, SettingsCard, SettingsDeleteButton, SettingsPrimaryButton, SettingsText } from './primitives'; + +function signalLabel(rssi: number): string { + if (rssi >= -55) return '信号强'; + if (rssi >= -70) return '信号中等'; + return '信号较弱'; +} + +export function bleStatusBadge(status: BleStatus): { + tone: 'emerald' | 'amber' | 'red' | 'slate'; + label: string; +} { + if (status.state === 'ready') return { tone: 'emerald', label: 'BLE 就绪' }; + if (status.state === 'starting') return { tone: 'amber', label: '正在启动 helper' }; + if (status.state === 'degraded') return { tone: 'red', label: 'BLE 已降级' }; + if (status.state === 'unsupported') return { tone: 'slate', label: '当前不支持' }; + return { tone: 'slate', label: '按需启动' }; +} + +export function BleDiscoveryCard({ + discovery, + disabled, + onBind, +}: { + discovery: BleDiscoveryView; + disabled: boolean; + onBind: () => void; +}) { + return ( + +
+ + {discovery.name ?? '未命名 BLE 设备'} + +
+ = -70 ? 'blue' : 'amber'}>{signalLabel(discovery.rssi)} + + {discovery.serviceUuids.length > 0 ? discovery.serviceUuids.join(' · ') : '服务待检查'} + +
+
+ + 绑定设备 + +
+ ); +} + +export function BleBindingCard({ + binding, + disabled, + onUnbind, +}: { + binding: BleBindingView; + disabled: boolean; + onUnbind: () => void; +}) { + return ( + +
+
+ + {binding.displayName} + + 已绑定 +
+ + {binding.adapterId} + +
+ {binding.commands.map((command) => ( + + {command} + + ))} +
+
+ +
+ ); +} diff --git a/packages/web/src/components/settings/BleDeviceSections.tsx b/packages/web/src/components/settings/BleDeviceSections.tsx new file mode 100644 index 0000000000..40a15afe2c --- /dev/null +++ b/packages/web/src/components/settings/BleDeviceSections.tsx @@ -0,0 +1,157 @@ +import { HubIcon } from '../hub-icons'; +import { BleBindingCard, BleDiscoveryCard, bleStatusBadge } from './BleDeviceCards'; +import type { BleBindingView, BleScanSnapshot, BleStatus } from './ble-device-types'; +import { + SettingsBadge, + SettingsEmptyState, + SettingsPrimaryButton, + SettingsSecondaryButton, + SettingsSection, + SettingsStatusStrip, + SettingsText, +} from './primitives'; + +interface BleDeviceViewProps { + status: BleStatus; + bindings: BleBindingView[]; + scan: BleScanSnapshot; + busyKey: string | null; + error: string | null; + onStartScan: () => void; + onStopScan: () => void; + onBind: (discoveryId: string) => void; + onUnbind: (binding: BleBindingView) => void; +} + +function BleStatusNotices({ status, error }: Pick) { + return ( + <> + {!status.available && ( + + + 当前平台暂不支持 BLE。 {status.reason} + + + )} + {status.available && status.state === 'degraded' && ( + + + BLE helper 已降级。 {status.reason} + + + )} + {error && {error}} + + ); +} + +function BleScanControls({ + status, + scan, + busyKey, + onStartScan, + onStopScan, +}: Pick) { + if (!status.available) return null; + if (!scan.active) { + const scanEnabled = status.state !== 'starting' && status.state !== 'unsupported'; + return ( + + {status.state === 'degraded' ? '重试并扫描' : '扫描附近设备'} + + ); + } + + const remainingSeconds = scan.expiresAt ? Math.max(0, Math.ceil((scan.expiresAt - Date.now()) / 1_000)) : null; + return ( + <> + + 停止扫描 + + + 正在扫描{remainingSeconds !== null ? ` · 剩余 ${remainingSeconds} 秒` : ''} + + + ); +} + +function BleDiscoveryList({ + status, + scan, + busyKey, + onBind, +}: Pick) { + if (!status.available) return null; + if (scan.discoveries.length === 0) { + return ( + } + title={scan.active ? '正在等待附近设备' : '当前扫描会话没有设备'} + description={scan.active ? '保持设备处于广播状态。' : '发起扫描后,附近设备会在此处短暂显示。'} + /> + ); + } + return ( +
+ {scan.discoveries.map((discovery) => ( + onBind(discovery.discoveryId)} + /> + ))} +
+ ); +} + +function BleBindingsSection({ + bindings, + busyKey, + onUnbind, +}: Pick) { + return ( + + {bindings.length === 0 ? ( + 尚未绑定 BLE 设备。 + ) : ( +
+ {bindings.map((binding) => ( + onUnbind(binding)} + /> + ))} +
+ )} +
+ ); +} + +export function BleDeviceSections(props: BleDeviceViewProps) { + const badge = bleStatusBadge(props.status); + return ( +
+ + {badge.label}} + > +
+ +
+ +
+ +
+ ); +} diff --git a/packages/web/src/components/settings/BleDevicesContent.tsx b/packages/web/src/components/settings/BleDevicesContent.tsx new file mode 100644 index 0000000000..0b64e19dac --- /dev/null +++ b/packages/web/src/components/settings/BleDevicesContent.tsx @@ -0,0 +1,167 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { apiFetch } from '@/utils/api-client'; +import { useConfirm } from '../useConfirm'; +import { BleDeviceSections } from './BleDeviceSections'; +import { type BleBindingView, type BleScanSnapshot, type BleStatus, EMPTY_BLE_SCAN } from './ble-device-types'; +import { SettingsStatusStrip } from './primitives'; + +async function responseError(response: Response, fallback: string): Promise { + const payload = (await response.json().catch(() => ({}))) as { error?: string }; + return payload.error ?? `${fallback} (${response.status})`; +} + +export function BleDevicesContent() { + const confirm = useConfirm(); + const [loading, setLoading] = useState(true); + const [status, setStatus] = useState(null); + const [bindings, setBindings] = useState([]); + const [scan, setScan] = useState(EMPTY_BLE_SCAN); + const [busyKey, setBusyKey] = useState(null); + const [error, setError] = useState(null); + + const refreshStatus = useCallback(async (): Promise => { + const response = await apiFetch('/api/limb/ble/status'); + if (!response.ok) throw new Error(await responseError(response, 'BLE 状态加载失败')); + const nextStatus = (await response.json()) as BleStatus; + setStatus(nextStatus); + return nextStatus; + }, []); + + const refreshBindings = useCallback(async () => { + const response = await apiFetch('/api/limb/ble/bindings'); + if (!response.ok) throw new Error(await responseError(response, '设备绑定加载失败')); + const payload = (await response.json()) as { bindings: BleBindingView[] }; + setBindings(payload.bindings); + }, []); + + const refreshScan = useCallback(async () => { + const response = await apiFetch('/api/limb/ble/scan'); + if (!response.ok) throw new Error(await responseError(response, '扫描状态加载失败')); + setScan((await response.json()) as BleScanSnapshot); + }, []); + + const load = useCallback(async () => { + setError(null); + try { + const nextStatus = await refreshStatus(); + if (nextStatus.available) await Promise.all([refreshBindings(), refreshScan()]); + } catch (loadError) { + setError(loadError instanceof Error ? loadError.message : 'BLE 状态加载失败'); + } finally { + setLoading(false); + } + }, [refreshBindings, refreshScan, refreshStatus]); + + useEffect(() => { + void load(); + }, [load]); + + useEffect(() => { + if (!scan.active) return; + const timer = window.setInterval(() => { + void refreshScan().catch((pollError) => { + setError(pollError instanceof Error ? pollError.message : '扫描状态刷新失败'); + }); + }, 1_000); + return () => window.clearInterval(timer); + }, [refreshScan, scan.active]); + + const startScan = useCallback(async () => { + setBusyKey('scan'); + setError(null); + try { + const response = await apiFetch('/api/limb/ble/scan', { method: 'POST' }); + if (!response.ok) throw new Error(await responseError(response, '扫描启动失败')); + const started = (await response.json()) as Omit; + setScan({ active: true, discoveries: [], ...started }); + await refreshStatus(); + } catch (scanError) { + setError(scanError instanceof Error ? scanError.message : '扫描启动失败'); + } finally { + setBusyKey(null); + } + }, [refreshStatus]); + + const stopScan = useCallback(async () => { + setBusyKey('scan'); + try { + const response = await apiFetch('/api/limb/ble/scan', { method: 'DELETE' }); + if (!response.ok) throw new Error(await responseError(response, '扫描停止失败')); + setScan(EMPTY_BLE_SCAN); + } catch (scanError) { + setError(scanError instanceof Error ? scanError.message : '扫描停止失败'); + } finally { + setBusyKey(null); + } + }, []); + + const bind = useCallback( + async (discoveryId: string) => { + if (!scan.sessionId) return; + setBusyKey(discoveryId); + setError(null); + try { + const response = await apiFetch('/api/limb/ble/bindings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionId: scan.sessionId, discoveryId }), + }); + if (!response.ok) throw new Error(await responseError(response, '设备绑定失败')); + await Promise.all([refreshBindings(), refreshScan()]); + } catch (bindError) { + setError(bindError instanceof Error ? bindError.message : '设备绑定失败'); + } finally { + setBusyKey(null); + } + }, + [refreshBindings, refreshScan, scan.sessionId], + ); + + const unbind = useCallback( + async (binding: BleBindingView) => { + const accepted = await confirm({ + title: '解除 BLE 绑定', + message: `解除「${binding.displayName}」后,相关 Limb 能力会立即失效。`, + confirmLabel: '解除绑定', + variant: 'danger', + }); + if (!accepted) return; + setBusyKey(binding.bindingId); + try { + const response = await apiFetch(`/api/limb/ble/bindings/${encodeURIComponent(binding.bindingId)}`, { + method: 'DELETE', + }); + if (!response.ok) throw new Error(await responseError(response, '解除绑定失败')); + await refreshBindings(); + } catch (unbindError) { + setError(unbindError instanceof Error ? unbindError.message : '解除绑定失败'); + } finally { + setBusyKey(null); + } + }, + [confirm, refreshBindings], + ); + + if (loading) { + return 正在加载 BLE 状态...; + } + if (!status) { + return {error ?? 'BLE 状态不可用'}; + } + + return ( + void startScan()} + onStopScan={() => void stopScan()} + onBind={(discoveryId) => void bind(discoveryId)} + onUnbind={(binding) => void unbind(binding)} + /> + ); +} diff --git a/packages/web/src/components/settings/PluginConfigPanel.tsx b/packages/web/src/components/settings/PluginConfigPanel.tsx index 69d777806c..811db8f78b 100644 --- a/packages/web/src/components/settings/PluginConfigPanel.tsx +++ b/packages/web/src/components/settings/PluginConfigPanel.tsx @@ -4,6 +4,7 @@ import type { PluginInfo } from '@cat-cafe/shared'; import { useState } from 'react'; import { apiFetch } from '@/utils/api-client'; import { ExternalLinkIcon, StepBadge } from '../HubConfigIcons'; +import { AgentProviderApprovalSection } from './AgentProviderApprovalSection'; import { ConfigFieldRenderer } from './primitives/ConfigFieldRenderer'; function isSafeUrl(url: string): boolean { @@ -161,6 +162,11 @@ export function PluginConfigPanel({ plugin, onUpdated }: Props) { )} + {/* F241 Phase C — Hub UI for owner approval of agentProvider routeable rows. */} + {plugin.resources.some((r) => r.type === 'agentProvider') && ( + + )} + {result && (
; case 'notify': return ; + case 'devices': + return ; case 'ops': return ; case 'rules': diff --git a/packages/web/src/components/settings/SettingsNav.tsx b/packages/web/src/components/settings/SettingsNav.tsx index cb9a45a8dc..376b363c9e 100644 --- a/packages/web/src/components/settings/SettingsNav.tsx +++ b/packages/web/src/components/settings/SettingsNav.tsx @@ -78,6 +78,7 @@ const SECTION_KEYWORDS: Record = { rules: '规则 家规 提示词 system prompt SOP 协作 governance', system: '配置 环境 .env bubble A2A codex', notify: '推送 通知 push web', + devices: '设备 Limb 蓝牙 Bluetooth BLE GATT 传感器 按钮 hardware', ops: '运维 监控 排行 记忆 健康 命令 救援 usage', concierge: '猫猫球 悬浮球 值班猫 前台 主动性 proactive ball persona', }; diff --git a/packages/web/src/components/settings/__tests__/BleDevicesContent.test.tsx b/packages/web/src/components/settings/__tests__/BleDevicesContent.test.tsx new file mode 100644 index 0000000000..c87922f2fe --- /dev/null +++ b/packages/web/src/components/settings/__tests__/BleDevicesContent.test.tsx @@ -0,0 +1,172 @@ +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { apiFetch } = vi.hoisted(() => ({ apiFetch: vi.fn() })); +vi.mock('@/utils/api-client', () => ({ apiFetch })); +vi.mock('../../useConfirm', () => ({ useConfirm: () => vi.fn(async () => true) })); + +import { BleDevicesContent } from '../BleDevicesContent'; + +function jsonResponse(payload: unknown, ok = true, status = 200) { + return { ok, status, json: async () => payload }; +} + +describe('BleDevicesContent', () => { + let container: HTMLDivElement; + let root: Root; + + beforeAll(() => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + }); + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + apiFetch.mockReset(); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + afterAll(() => { + delete (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT; + }); + + it('renders the unsupported state without offering a scan action', async () => { + apiFetch.mockResolvedValue( + jsonResponse({ + platform: 'linux', + available: false, + state: 'unsupported', + reason: 'BLE helper is only available on macOS in Phase A', + restartAttempts: 0, + bindingCount: 0, + }), + ); + + await act(async () => root.render()); + await act(async () => Promise.resolve()); + + expect(container.textContent).toContain('当前平台暂不支持 BLE'); + expect(container.textContent).toContain('BLE helper is only available on macOS'); + expect(container.textContent).not.toContain('扫描附近设备'); + }); + + it('renders scanning results and binds using opaque IDs only', async () => { + apiFetch.mockImplementation(async (path: string, init?: RequestInit) => { + const requestKey = `${init?.method ?? 'GET'} ${path}`; + switch (requestKey) { + case 'GET /api/limb/ble/status': + return jsonResponse({ + platform: 'darwin', + available: true, + state: 'ready', + reason: null, + restartAttempts: 0, + bindingCount: 0, + }); + case 'GET /api/limb/ble/bindings': + return jsonResponse({ bindings: [] }); + case 'GET /api/limb/ble/scan': + return jsonResponse({ + active: true, + sessionId: 'scan-opaque', + startedAt: Date.now(), + expiresAt: Date.now() + 30_000, + discoveries: [ + { + discoveryId: 'discovery-opaque', + name: 'Desk Sensor', + rssi: -47, + serviceUuids: ['181a'], + }, + ], + }); + case 'POST /api/limb/ble/bindings': + return jsonResponse( + { + bindingId: 'binding-1', + displayName: 'Desk Sensor', + adapterId: 'standard.environmental', + commands: ['ble.temperature.read'], + nodeId: 'ble:binding-1', + createdAt: Date.now(), + lastConnectedAt: Date.now(), + }, + true, + 201, + ); + default: + throw new Error(`Unexpected request: ${requestKey}`); + } + }); + + await act(async () => root.render()); + await act(async () => Promise.resolve()); + expect(container.textContent).toContain('Desk Sensor'); + expect(container.textContent).toContain('正在扫描'); + + const bindButton = Array.from(container.querySelectorAll('button')).find((button) => + button.textContent?.includes('绑定设备'), + ); + expect(bindButton).toBeTruthy(); + await act(async () => bindButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))); + + const bindCall = apiFetch.mock.calls.find(([, init]) => init?.method === 'POST'); + expect(JSON.parse(bindCall?.[1]?.body as string)).toEqual({ + sessionId: 'scan-opaque', + discoveryId: 'discovery-opaque', + }); + expect(bindCall?.[1]?.body).not.toContain('deviceId'); + }); + + it('shows degraded helper state and keeps existing bindings visible', async () => { + apiFetch.mockImplementation(async (path: string) => { + if (path.endsWith('/status')) { + return jsonResponse({ + platform: 'darwin', + available: true, + state: 'degraded', + reason: 'helper restart attempts exhausted', + restartAttempts: 3, + bindingCount: 1, + }); + } + if (path.endsWith('/bindings')) { + return jsonResponse({ + bindings: [ + { + bindingId: 'binding-1', + displayName: 'Desk Sensor', + adapterId: 'standard.environmental', + commands: ['ble.temperature.read'], + nodeId: 'ble:binding-1', + createdAt: 100, + lastConnectedAt: 100, + }, + ], + }); + } + if (path.endsWith('/scan')) { + return jsonResponse({ active: false, sessionId: null, startedAt: null, expiresAt: null, discoveries: [] }); + } + throw new Error(`Unexpected request: ${path}`); + }); + + await act(async () => root.render()); + await act(async () => Promise.resolve()); + + expect(container.textContent).toContain('BLE helper 已降级'); + expect(container.textContent).toContain('helper restart attempts exhausted'); + expect(container.textContent).toContain('Desk Sensor'); + expect(container.textContent).toContain('重试并扫描'); + expect( + Array.from(container.querySelectorAll('button')).find((button) => button.textContent?.includes('重试并扫描')) + ?.disabled, + ).toBe(false); + }); +}); diff --git a/packages/web/src/components/settings/__tests__/SettingsShell-deep-link.test.tsx b/packages/web/src/components/settings/__tests__/SettingsShell-deep-link.test.tsx index 424f96d8fc..dd7d8528eb 100644 --- a/packages/web/src/components/settings/__tests__/SettingsShell-deep-link.test.tsx +++ b/packages/web/src/components/settings/__tests__/SettingsShell-deep-link.test.tsx @@ -41,4 +41,15 @@ describe('SettingsShell deep-link routing', () => { expect(html).toContain('data-section="members"'); expect(html).toContain('data-active="members"'); }); + + it('restores the devices deep link without routing it into notifications or ops', () => { + mockSearchParams = new URLSearchParams('s=devices'); + + const html = renderToStaticMarkup(); + + expect(html).toContain('data-section="devices"'); + expect(html).toContain('data-active="devices"'); + expect(html).not.toContain('data-section="notify"'); + expect(html).not.toContain('data-section="ops"'); + }); }); diff --git a/packages/web/src/components/settings/ble-device-types.ts b/packages/web/src/components/settings/ble-device-types.ts new file mode 100644 index 0000000000..172e6cc0a2 --- /dev/null +++ b/packages/web/src/components/settings/ble-device-types.ts @@ -0,0 +1,41 @@ +export interface BleStatus { + platform: string; + available: boolean; + state: 'idle' | 'starting' | 'ready' | 'degraded' | 'unsupported'; + reason: string | null; + restartAttempts: number; + bindingCount: number; +} + +export interface BleBindingView { + bindingId: string; + displayName: string; + adapterId: string; + commands: string[]; + nodeId: string; + createdAt: number; + lastConnectedAt: number | null; +} + +export interface BleDiscoveryView { + discoveryId: string; + name: string | null; + rssi: number; + serviceUuids: string[]; +} + +export interface BleScanSnapshot { + active: boolean; + sessionId: string | null; + startedAt: number | null; + expiresAt: number | null; + discoveries: BleDiscoveryView[]; +} + +export const EMPTY_BLE_SCAN: BleScanSnapshot = { + active: false, + sessionId: null, + startedAt: null, + expiresAt: null, + discoveries: [], +}; diff --git a/packages/web/src/components/settings/settings-nav-config.ts b/packages/web/src/components/settings/settings-nav-config.ts index 18e595da50..4c99829cd2 100644 --- a/packages/web/src/components/settings/settings-nav-config.ts +++ b/packages/web/src/components/settings/settings-nav-config.ts @@ -98,6 +98,13 @@ export const SETTINGS_SECTIONS: SettingsSection[] = [ color: 'var(--color-gemini-primary)', description: '推送订阅、提醒策略与设备联动。', }, + { + id: 'devices', + label: '设备与 Limb', + icon: 'activity', + color: 'var(--color-gemini-primary)', + description: 'BLE 设备发现、显式绑定和类型化 Limb 能力。', + }, { id: 'ops', label: '运维监控', diff --git a/packages/web/src/hooks/useCatData.ts b/packages/web/src/hooks/useCatData.ts index 87a2ce6053..8c78d91dc2 100644 --- a/packages/web/src/hooks/useCatData.ts +++ b/packages/web/src/hooks/useCatData.ts @@ -5,6 +5,7 @@ * Fetches once per session, caches module-level. All consumers share same data. */ +import type { CatAgentProtocol, CommandPolicyEntry } from '@cat-cafe/shared'; import { useEffect, useMemo, useState } from 'react'; import { UNKNOWN_CAT_COLOR } from '@/lib/color-defaults'; import { refreshMentionData } from '@/lib/mention-highlight'; @@ -32,6 +33,12 @@ export interface CatData { }; commandArgs?: string[]; cliConfigArgs?: string[]; + /** F159 Phase F: CatAgent native tool level (L0 read / L1 write / L2 exec). */ + nativeToolLevel?: 'L0' | 'L1' | 'L2'; + /** F159 Phase F: CatAgent L2 command allowlist. */ + commandPolicy?: CommandPolicyEntry[]; + /** F159 Phase G G2 (AC-G15): CatAgent wire protocol — surfaces from /api/cats GET. */ + catAgentProtocol?: CatAgentProtocol; /** clowder-ai#340 P5: Model provider name (renamed from ocProviderName). */ provider?: string; /** F161: ACP transport config. Presence means this member runs through ACP instead of legacy CLI. */ diff --git a/packages/web/src/lib/capability-tips.seed.json b/packages/web/src/lib/capability-tips.seed.json index aca06998ce..97322c2c9a 100644 --- a/packages/web/src/lib/capability-tips.seed.json +++ b/packages/web/src/lib/capability-tips.seed.json @@ -452,6 +452,76 @@ "action": { "type": "open_concierge_draft", "label": "了解更多" }, "owner": "codex" }, + { + "id": "feature-f126-limb-control-plane", + "kind": "feature", + "sourceRef": { "path": "docs/features/F126-limb-control-plane.md", "anchor": "Cat Café Limb Control Plane" }, + "structureSource": { "path": "docs/features/F126-limb-control-plane.md", "anchor": "User Journey" }, + "bodySource": { "path": "docs/features/F126-limb-control-plane.md", "anchor": "typed limb action" }, + "contexts": ["thinking", "feature_dev"], + "audience": ["developer", "maintainer"], + "body": "接入外部 limb 时走 F126 控制面:先看 capability、presence、policy,再发 typed action。", + "action": { + "type": "open_source", + "label": "打开 spec", + "sourceRef": { "path": "docs/features/F126-limb-control-plane.md", "anchor": "Cat Café Limb Control Plane" } + }, + "owner": "codex" + }, + { + "id": "feature-f202-plugin-framework", + "kind": "feature", + "sourceRef": { "path": "docs/features/F202-plugin-framework.md", "anchor": "Plugin Framework" }, + "structureSource": { "path": "docs/features/F202-plugin-framework.md", "anchor": "User Journey" }, + "bodySource": { "path": "docs/features/F202-plugin-framework.md", "anchor": "plugin-owned configuration" }, + "contexts": ["feature_dev", "review"], + "audience": ["developer", "maintainer"], + "body": "改插件资源前先对齐 F202:manifest、配置、激活和审计都必须留在 plugin framework 边界内。", + "action": { + "type": "open_source", + "label": "打开 spec", + "sourceRef": { "path": "docs/features/F202-plugin-framework.md", "anchor": "Plugin Framework" } + }, + "owner": "codex" + }, + { + "id": "feature-f159-catagent-native-provider", + "kind": "feature", + "sourceRef": { "path": "docs/features/F159-catagent-native-provider.md", "anchor": "CatAgent Native Provider" }, + "structureSource": { "path": "docs/features/F159-catagent-native-provider.md", "anchor": "User Journey" }, + "bodySource": { + "path": "docs/features/F159-catagent-native-provider.md", + "anchor": "constrained native provider path" + }, + "contexts": ["feature_dev", "review"], + "audience": ["developer", "maintainer"], + "body": "接入 API 型 CatAgent 前,先看 F159:账号绑定、workspace 边界和工具分级都必须保留。", + "action": { + "type": "open_source", + "label": "打开 spec", + "sourceRef": { "path": "docs/features/F159-catagent-native-provider.md", "anchor": "CatAgent Native Provider" } + }, + "owner": "codex" + }, + { + "id": "feature-f207-finance-data-plane", + "kind": "feature", + "sourceRef": { "path": "docs/features/F207-personal-finance-infra.md", "anchor": "AI Family Office" }, + "structureSource": { "path": "docs/features/F207-personal-finance-infra.md", "anchor": "User Journey" }, + "bodySource": { + "path": "docs/features/F207-personal-finance-infra.md", + "anchor": "read-only finance analysis request" + }, + "contexts": ["thinking", "feature_dev"], + "audience": ["all"], + "body": "查投资学习数据时走 F207 finance MCP:只读事实、带 source/asOf,不触发交易动作。", + "action": { + "type": "open_source", + "label": "打开 spec", + "sourceRef": { "path": "docs/features/F207-personal-finance-infra.md", "anchor": "AI Family Office" } + }, + "owner": "codex" + }, { "id": "feature-f237-prompt-injection-visibility", "kind": "feature", @@ -777,5 +847,55 @@ "sourceRef": { "path": "docs/features/F245-friction-signal-eval.md", "anchor": "Friction Signal Eval" } }, "owner": "gpt52" + }, + { + "id": "feature-f258-ble-binding-permission", + "kind": "feature", + "sourceRef": { "path": "docs/features/F258-ble-physical-event-limb.md", "anchor": "BLE Physical Event Limb" }, + "structureSource": { + "path": "docs/features/F258-ble-physical-event-limb.md", + "anchor": "Tips Contribution(F244)" + }, + "bodySource": { + "path": "docs/features/F258-ble-physical-event-limb.md", + "anchor": "扫描会话最长 30 秒" + }, + "contexts": ["thinking", "feature_dev"], + "audience": ["all"], + "body": "绑定 BLE 设备前先确认 macOS 蓝牙权限;未绑定的扫描结果会在 30 秒内清除。", + "action": { + "type": "open_source", + "label": "打开 F258", + "sourceRef": { + "path": "docs/features/F258-ble-physical-event-limb.md", + "anchor": "Security and Privacy Invariants" + } + }, + "owner": "sol" + }, + { + "id": "feature-f258-ble-proximity-not-auth", + "kind": "feature", + "sourceRef": { "path": "docs/features/F258-ble-physical-event-limb.md", "anchor": "BLE Physical Event Limb" }, + "structureSource": { + "path": "docs/features/F258-ble-physical-event-limb.md", + "anchor": "Tips Contribution(F244)" + }, + "bodySource": { + "path": "docs/features/F258-ble-physical-event-limb.md", + "anchor": "BLE proximity 不作为敏感操作认证" + }, + "contexts": ["thinking", "review"], + "audience": ["all"], + "body": "BLE 设备在附近只适合作为辅助信号,不能单独授权删除或 force push 等敏感操作。", + "action": { + "type": "open_source", + "label": "打开 F258", + "sourceRef": { + "path": "docs/features/F258-ble-physical-event-limb.md", + "anchor": "BLE proximity 不作为敏感操作认证" + } + }, + "owner": "sol" } ] diff --git a/packages/web/src/utils/__tests__/api-client-resolve.test.ts b/packages/web/src/utils/__tests__/api-client-resolve.test.ts index 46b708a99c..89a90a3cd3 100644 --- a/packages/web/src/utils/__tests__/api-client-resolve.test.ts +++ b/packages/web/src/utils/__tests__/api-client-resolve.test.ts @@ -56,7 +56,7 @@ describe('resolveApiUrl', () => { expect(resolve()).toBe('https://api.example.com'); }); - // ── P1 fix: localhost env + remote access → skip env, auto-detect ── + // ── localhost env + remote access → same-origin proxy ── it('skips localhost env when accessed remotely (reverse proxy)', async () => { process.env.NEXT_PUBLIC_API_URL = 'http://localhost:3004'; @@ -65,11 +65,18 @@ describe('resolveApiUrl', () => { expect(resolve()).toBe('http://1.2.3.4'); }); - it('skips 127.0.0.1 env when accessed remotely', async () => { + it('uses same-origin proxy for 127.0.0.1 env when accessed remotely with an explicit web port', async () => { process.env.NEXT_PUBLIC_API_URL = 'http://127.0.0.1:3004'; stubLocation({ hostname: '10.0.0.5', protocol: 'http:', port: '3003' }); const resolve = await loadResolveApiUrl(); - expect(resolve()).toBe('http://10.0.0.5:3004'); + expect(resolve()).toBe('http://10.0.0.5:3003'); + }); + + it('does not derive a nonexistent remote API port when runtime web/API ports are non-adjacent', async () => { + process.env.NEXT_PUBLIC_API_URL = 'http://localhost:3122'; + stubLocation({ hostname: '100.64.1.23', protocol: 'http:', port: '5122' }); + const resolve = await loadResolveApiUrl(); + expect(resolve()).toBe('http://100.64.1.23:5122'); }); // ── localhost env + local access → use env (no skip) ── diff --git a/packages/web/src/utils/api-client.ts b/packages/web/src/utils/api-client.ts index e4ddcdc6e1..6032d83bc5 100644 --- a/packages/web/src/utils/api-client.ts +++ b/packages/web/src/utils/api-client.ts @@ -31,6 +31,11 @@ export function resolveApiUrl(): string { // - localhost env + remote browser → reverse-proxy users would hit dev's loopback // - cloud env + local browser → would force a Cloudflare Tunnel round-trip for nothing const mismatch = (isLocalhostDefault && isRemoteAccess) || (!isLocalhostDefault && isLocalAccess); + if (isLocalhostDefault && isRemoteAccess) { + const protocol = location.protocol ?? 'http:'; + const port = location.port ? `:${location.port}` : ''; + return `${protocol}//${location.hostname}${port}`; + } if (!mismatch) return envUrl; } if (typeof window === 'undefined') return 'http://localhost:3004'; diff --git a/review-notes/2026-06-26-pr-tracking-instructions-review-request.md b/review-notes/2026-06-26-pr-tracking-instructions-review-request.md new file mode 100644 index 0000000000..44be28482b --- /dev/null +++ b/review-notes/2026-06-26-pr-tracking-instructions-review-request.md @@ -0,0 +1,157 @@ +# Review Request: suppress stale PR tracking instructions + +Review-Target-ID: fix-pr-tracking-instruction-head-scope +Branch: fix/pr-tracking-instruction-head-scope + +## What + +PR #29 binds PR tracking instructions to the PR head observed when +`/api/callbacks/register-pr-tracking` stores them, then suppresses those +instructions from CI/review-feedback callbacks when later callbacks report a +different head. + +## Why + +Daily patrol found PR #187 callbacks replaying old head-specific instructions +after newer commits. A current-head CI/review callback should not keep telling +the receiver to handle stale findings from an earlier head. + +## Original Requirements + +> 每轮必须先查真相源和证据,再给风险/价值判断与下一步动作。 +> 发现可执行事项后主导闭环:按家规走 feature lifecycle(定位真相源、立项、实现/协调、质量门禁、review、完成记录)。 + +- Source: patrol thread `thread_mqcj45byxoka2z7u`, scheduled wake `2026-06-26 00:00 Asia/Shanghai`. +- Please check that the implementation solves the observed PR-tracking callback problem, not just the narrow tests. + +## Tradeoff + +This keeps backward compatibility by still appending instructions for older +tasks that have no stored `trackingInstructionsHeadSha`. Head-bound +instructions now fail closed when the callback cannot prove the current head, +which avoids replaying stale head-specific actions during transient metadata +failures. + +## Architecture Ownership + +Architecture cell: callback-routing / PR-tracking automation state +Map delta: none +Why: This extends existing callback router/task automation metadata without +adding a new Store, Router, Adapter, Dispatcher, or ownership boundary. + +Please check: +- diff matches `Map delta: none` +- no parallel Store/Queue/Router/Adapter/Dispatcher/Binding was introduced +- no architecture ownership docs should have changed + +## Open Questions + +### Technical OQ + +- Is the fail-open behavior for legacy tasks without `trackingInstructionsHeadSha` the right compatibility boundary? +- Is the chosen head source during registration correct for both fresh and active re-registration paths? + +### Value OQ + +None. + +## Next Action + +Please do a non-author current-SHA review of PR #29. If there are P1/P2 +findings, route back to receive-review. If clean, approve and include the +focused validation you ran. + +## Review Sandbox + +- Path: `/tmp/cat-cafe-review/fix-pr-tracking-instruction-head-scope/gpt555` +- Start Command: `pnpm review:start` or equivalent read-only checkout/test commands +- Ports: not used; no frontend/runtime server needed for this review + +## Self-Check Evidence + +### Spec Compliance + +- Found truth source in `CiCdRouter`, `ReviewFeedbackRouter`, `ReviewFeedbackTaskSpec`, and `register-pr-tracking`. +- Created task `[Patrol P2] PR tracking callbacks should not replay stale head-specific instructions`. +- Implemented in isolated worktree with dev/test `.env` pointing Redis to `6398`. +- Registered PR tracking for PR #29. + +### Test Results + +Red tests first: +- `cicd-router.test.js`: stale-head CI instructions assertion failed before implementation. +- `review-feedback-router.test.js`: stale-head review feedback instructions assertion failed before implementation. +- `callback-routes.test.js`: expected `trackingInstructionsHeadSha === "test-head"`, got `undefined` before implementation. + +Green / focused: +- `pnpm --dir packages/api run build`: passed +- `CAT_CAFE_DISABLE_SHARED_STATE_PREFLIGHT=1 bash packages/api/scripts/with-test-home.sh node --import ./packages/api/test/helpers/setup-cat-registry.js --test --test-timeout=60000 packages/api/test/cicd-router.test.js packages/api/test/review-feedback-router.test.js`: 41 tests / 17 suites passed +- `CAT_CAFE_DISABLE_SHARED_STATE_PREFLIGHT=1 bash packages/api/scripts/with-test-home.sh node --import ./packages/api/test/helpers/setup-cat-registry.js --test --test-timeout=60000 --test-name-pattern "binds instructions" packages/api/test/callback-routes.test.js`: 1 test passed +- `CAT_CAFE_DISABLE_SHARED_STATE_PREFLIGHT=1 bash packages/api/scripts/with-test-home.sh node --import ./packages/api/test/helpers/setup-cat-registry.js --test --test-timeout=60000 packages/api/test/f202-phase2-c.test.js packages/api/test/task-store-instructions.test.js`: 30 tests / 11 suites passed +- `pnpm --dir packages/api run lint`: passed +- `git diff --check`: passed + +Dogfood: +- Built `buildCiMessageContent()` with a current head plus old-head instructions; output omitted the stale Tracking Instructions block. + +Known unrelated checks: +- An accidental broad API test run hit an unrelated `capabilities-route.test.js` timeout; the targeted suites above passed. + +### Receive-Review Update + +Reviewer found one P2 on active re-register: updating instructions on an already +tracked PR could reuse old `automationState.ci.headSha`. Fixed by fetching the +current PR boundary for non-empty active instruction updates and using that +head only for `trackingInstructionsHeadSha`, without reseeding review/CI cursors. + +Red→Green: +- `POST register-pr-tracking rebinds updated instructions to the current active PR head`: failed with `sha-old`, now passes with `sha-current`. + +Additional verification after the fix: +- `pnpm --dir packages/api run build`: passed +- `CAT_CAFE_DISABLE_SHARED_STATE_PREFLIGHT=1 bash packages/api/scripts/with-test-home.sh node --import ./packages/api/test/helpers/setup-cat-registry.js --test --test-timeout=60000 --test-name-pattern "rebinds updated instructions" packages/api/test/callback-routes.test.js`: 1 test passed +- `CAT_CAFE_DISABLE_SHARED_STATE_PREFLIGHT=1 bash packages/api/scripts/with-test-home.sh node --import ./packages/api/test/helpers/setup-cat-registry.js --test --test-timeout=60000 packages/api/test/cicd-router.test.js packages/api/test/review-feedback-router.test.js`: 41 tests / 17 suites passed +- `CAT_CAFE_DISABLE_SHARED_STATE_PREFLIGHT=1 bash packages/api/scripts/with-test-home.sh node --import ./packages/api/test/helpers/setup-cat-registry.js --test --test-timeout=60000 --test-name-pattern "binds instructions|rebinds updated instructions|allows empty instructions" packages/api/test/callback-routes.test.js`: 4 tests passed +- `CAT_CAFE_DISABLE_SHARED_STATE_PREFLIGHT=1 bash packages/api/scripts/with-test-home.sh node --import ./packages/api/test/helpers/setup-cat-registry.js --test --test-timeout=60000 packages/api/test/f202-phase2-c.test.js packages/api/test/task-store-instructions.test.js`: 30 tests / 11 suites passed +- `pnpm --dir packages/api run lint`: passed +- `git diff --check`: passed +- `pnpm check`: initially hit an unrelated `ROADMAP`/missing `F207` feature-truth issue; the PR now removes that stale ROADMAP row and `pnpm check` passes. + +### Receive-Review Update 2 + +Cloud review found two P2s after the active re-register fix: + +1. Active instruction updates still should not fall back to a cached + `automationState.ci.headSha` if the fresh PR boundary cannot provide the + current head. +2. Review feedback for head-bound tracking instructions should fail closed when + the current review head is unknown. + +Fixes: +- `register-pr-tracking` now rejects non-empty active instruction updates with + `503` when the fresh PR boundary is unavailable or lacks `ci.headSha`; it no + longer binds new instructions to cached CI head state. +- CI/review-feedback formatters still fail open for legacy unbound + instructions, but fail closed for head-bound instructions when the callback + head is unavailable. + +Red→Green: +- `POST register-pr-tracking rejects active instruction updates when current PR head is unavailable`: failed with `200`, now passes with `503`. +- `omits head-bound instructions when the current review head is unknown`: failed with stale Tracking Instructions present, now passes with the block omitted. + +Additional verification after the fix: +- `pnpm --dir packages/api run build`: passed +- `CAT_CAFE_DISABLE_SHARED_STATE_PREFLIGHT=1 bash packages/api/scripts/with-test-home.sh node --import ./packages/api/test/helpers/setup-cat-registry.js --test --test-timeout=60000 --test-name-pattern "rejects active instruction updates" packages/api/test/callback-routes.test.js`: 1 test passed +- `CAT_CAFE_DISABLE_SHARED_STATE_PREFLIGHT=1 bash packages/api/scripts/with-test-home.sh node --import ./packages/api/test/helpers/setup-cat-registry.js --test --test-timeout=60000 --test-name-pattern "omits head-bound instructions" packages/api/test/review-feedback-router.test.js`: 1 test passed +- `CAT_CAFE_DISABLE_SHARED_STATE_PREFLIGHT=1 bash packages/api/scripts/with-test-home.sh node --import ./packages/api/test/helpers/setup-cat-registry.js --test --test-timeout=60000 packages/api/test/cicd-router.test.js packages/api/test/review-feedback-router.test.js`: 42 tests / 17 suites passed +- `CAT_CAFE_DISABLE_SHARED_STATE_PREFLIGHT=1 bash packages/api/scripts/with-test-home.sh node --import ./packages/api/test/helpers/setup-cat-registry.js --test --test-timeout=60000 --test-name-pattern "binds instructions|rebinds updated instructions|rejects active instruction updates|allows empty instructions" packages/api/test/callback-routes.test.js`: 5 tests passed +- `CAT_CAFE_DISABLE_SHARED_STATE_PREFLIGHT=1 bash packages/api/scripts/with-test-home.sh node --import ./packages/api/test/helpers/setup-cat-registry.js --test --test-timeout=60000 packages/api/test/f202-phase2-c.test.js packages/api/test/task-store-instructions.test.js`: 30 tests / 11 suites passed +- `pnpm --dir packages/api run lint`: passed +- `git diff --check`: passed +- `pnpm check`: passed + +### Related Documents + +- `docs/features/F133-cicd-tracking.md` +- `docs/features/F140-github-pr-automation.md` +- PR: https://github.com/clowder-labs/clowder-ai/pull/29 diff --git a/review-notes/2026-06-27-hotfix-detector-review-request.md b/review-notes/2026-06-27-hotfix-detector-review-request.md new file mode 100644 index 0000000000..a02ff80cb0 --- /dev/null +++ b/review-notes/2026-06-27-hotfix-detector-review-request.md @@ -0,0 +1,48 @@ +# Review Request: clowder-ai hotfix detector + +Review-Target-ID: fix-hotfix-detector-script +Branch: fix/hotfix-detector-script +PR: https://github.com/clowder-labs/clowder-ai/pull/32 +Target: current PR head + +## Original Requirements + +Source: scheduled patrol in `thread_mqcj45byxoka2z7u`. + +> 每轮必须先查真相源和证据,再给风险/价值判断与下一步动作。 +> 发现可执行事项后主导闭环:按家规走 feature lifecycle(定位真相源、立项、实现/协调、质量门禁、review、完成记录)。 + +## What Changed + +- Added `scripts/check-hotfix-pattern.mjs`, the repo-local script required by `merge-gate` and `quality-gate`. +- Added `scripts/check-hotfix-pattern.test.mjs` covering: + - conventional `fix:` detection + - copied detector-output false positives + - real hotfix metadata preservation + - CLI JSON output from `--input-json` + - fail-closed missing-input JSON +- Added `check:hotfix-pattern` to root `pnpm check`. + +## Architecture Ownership + +Architecture cell: governance / merge-gate tooling +Map delta: none +Why: this fills a missing repo-local script required by existing SOP/skills; it does not introduce a new runtime service, store, queue, router, or external contract. + +## Quality Gate Evidence + +- RED: `node --test scripts/check-hotfix-pattern.test.mjs` failed with `ERR_MODULE_NOT_FOUND` before implementation. +- GREEN: `pnpm check:hotfix-pattern` passed. +- `pnpm check` passed. +- `pnpm build` passed. +- `pnpm lint` passed with existing frontend warnings only. +- `git diff --check` passed. +- Artifact hygiene: no root media/design artifacts. + +Residual risk: full `pnpm test` was attempted and hit the existing `packages/api/test/capabilities-route.test.js` 60s timeout path. This is unrelated to the root script change and was previously seen during PR #29. + +## Review Focus + +- Does the detector preserve the F177 governance semantics expected by `merge-gate` and `quality-gate`? +- Is the fail-closed CLI behavior acceptable for merge-gate JSON parsing? +- Does the detector-output scrub avoid false positives without hiding real hotfix metadata? diff --git a/review-notes/2026-06-28-route-guard-2b-event-driven-review-request.md b/review-notes/2026-06-28-route-guard-2b-event-driven-review-request.md new file mode 100644 index 0000000000..4fd3977fc7 --- /dev/null +++ b/review-notes/2026-06-28-route-guard-2b-event-driven-review-request.md @@ -0,0 +1,281 @@ +# Review Request: route guard 2b event-driven external wait + +Review-Target-ID: fix-route-guard-2b-event-driven +Branch: fix/route-guard-2b-event-driven +Target: local branch based on `origin/main` at `8e412d2b` + +## What + +The routing guard now treats a final-slot line of +`External Wait: event-driven ()` as a legitimate 2b external-wait exit. + +Changed paths: +- `packages/api/src/domains/cats/services/agents/routing/guards/routing-guard-remedial.ts` +- `packages/api/src/domains/cats/services/agents/routing/route-serial.ts` +- `packages/api/test/routing-guard-remedial.test.js` +- `packages/api/test/route-serial-routing-guard-remedial.test.js` + +## Why + +Daily patrol found a real routing contradiction: when PR tracking had structured +callback coverage and EYES>0, the collaboration rule said 2b event-driven wait +means no `hold_ball`, but the server-side routing guard rejected the response as +"no legal route exit" unless it saw line-start `@` or `cat_cafe_hold_ball`. +That caused unnecessary remedial churn and local cat ping-pong even though the +next action was an external callback. + +## Original Requirements + +Source: scheduled patrol in `thread_mqcj45byxoka2z7u`, wake +`2026-06-28 00:00 Asia/Shanghai`. + +> 每轮必须先查真相源和证据,再给风险/价值判断与下一步动作。 +> 发现可执行事项后主导闭环:按家规走 feature lifecycle(定位真相源、立项、实现/协调、质量门禁、review、完成记录)。 + +Observed incident source: `clowder-labs/clowder-ai#32` review/check wait path +where current rules selected 2b event-driven waiting but route guard demanded +`@` or `hold_ball`. + +## Tradeoff + +This is intentionally structural: only a final routing slot line matching +`External Wait: event-driven ()` counts. It does not classify natural +language like "I will wait for CI", so the F177/KD-8 guard remains mechanical. + +The remedial prompt now teaches that exact outlet format, so future guard +patches can add the missing exit without redoing work. + +## Architecture Ownership + +Architecture cell: routing / A2A guard +Map delta: none +Why: this extends the existing routing guard exit predicate and route-serial +input plumbing. It does not add a new Store, Queue, Router, Adapter, Dispatcher, +Binding, runtime service, or external contract. + +Please check: +- diff matches `Map delta: none` +- the `External Wait` recognizer is structural enough and not an intent classifier +- route-serial passes the correct stored text at every guard check + +## Quality Gate Evidence + +### Red + +- `routing-guard-remedial.test.js`: `2b External Wait event-driven 槽位 → 不触发 remedial` failed, returning `true` instead of `false`. +- `routing-guard-remedial.test.js`: `External Wait: event-driven() counts as a valid 2b external-wait exit` failed, returning `false` instead of `true`. +- `routing-guard-remedial.test.js`: prompt test failed because `event-driven` was missing. +- `route-serial-routing-guard-remedial.test.js`: event-driven external wait caused two Codex invocations instead of one. + +### Green + +- `pnpm --dir packages/api run build`: passed +- `CAT_CAFE_DISABLE_SHARED_STATE_PREFLIGHT=1 bash packages/api/scripts/with-test-home.sh node --import $(pwd)/packages/api/test/helpers/setup-cat-registry.js --test --test-timeout=60000 packages/api/test/routing-guard-remedial.test.js packages/api/test/route-serial-routing-guard-remedial.test.js`: 33 tests passed +- `CAT_CAFE_DISABLE_SHARED_STATE_PREFLIGHT=1 bash packages/api/scripts/with-test-home.sh node --import $(pwd)/packages/api/test/helpers/setup-cat-registry.js --test --test-timeout=60000 packages/api/test/final-routing-slot.test.js packages/api/test/verdict-detect.test.js`: 57 tests passed +- `pnpm --dir packages/api run lint`: passed +- `git diff --check`: passed +- `pnpm check`: passed + +### Extra Gate Checks + +- `node scripts/check-hotfix-pattern.mjs`: `{"hotfix":false,"matchedTerms":[],"matches":[]}` +- `node scripts/check-fallback-layers.mjs`: N/A, script is not present in this tree. +- `pnpm run check:architecture-ownership`: N/A, script is not present in this tree. +- `rg --files designs | rg '\.pen$'`: N/A, `designs/` is not present. +- Root artifact hygiene: + - `git status --short | rg '^.. [^/]+\.(png|jpe?g|webp|gif|webm|mp4|mov|wav|pdf|pen)$'`: no output + - `git diff --name-only origin/main...HEAD | rg '^[^/]+\.(png|jpe?g|webp|gif|webm|mp4|mov|wav|pdf|pen)$'`: no output + +### Dogfood-Your-Slice + +Scope verdict: required. This is cat-visible routing behavior. + +Dogfood path: the route-serial integration suite exercises a guarded Codex turn +whose final slot is `External Wait: event-driven (pr:clowder-labs/clowder-ai#32)`. +Before the fix, route-serial invoked Codex twice; after the fix, it persists the +original visible response with one invocation and no routing-guard failure. + +## Open Questions + +### Technical OQ + +- Should we accept only the English `External Wait` template, or also add a + separate Chinese canonical template later? This patch keeps the existing + documented template only. +- Is line-level matching inside the final slot acceptable, or should the entire + final paragraph be exactly one `External Wait` line? + +### Value OQ + +None. + +## Next Action + +Please do a non-author review of `fix/route-guard-2b-event-driven`. If clean, +approve and include the focused validation you ran. If there are P1/P2 findings, +route back to `@codex` for receive-review. + +## Receive-Review Update + +Reviewer found one P2 on current head `a9e177d8`: `External Wait: event-driven` +was accepted by `routing-guard-remedial`, but Phase H `validateRoutingSyntax` +still treated an inline mention in the same final slot as `invalid_route_syntax`. + +Fix: +- moved the structural event-driven external-wait predicate into + `final-routing-slot.ts` +- made `routing-guard-remedial.ts` reuse that shared helper +- taught `validateRoutingSyntax()` to treat the same final-slot event-driven + exit as a legitimate syntax suppressor + +Red→Green: +- `2b event-driven external wait exit suppresses inline mention syntax warning` + failed with `invalid_route_syntax`, now passes with `ok`. + +Failure-mode sweep: +- Pattern: newly added legitimate route exit must be recognized consistently by + every mechanical routing guard in this PR. +- Scanned touched routing guard surfaces: remedial exit predicate, route-serial + guard invocation sites, Phase H final-slot syntax validator, verdict adjacent + tests. +- Result: shared helper now prevents remedial/Phase-H drift for this exit. + +Additional verification after the fix: +- `pnpm --dir packages/api run build`: passed +- `CAT_CAFE_DISABLE_SHARED_STATE_PREFLIGHT=1 bash packages/api/scripts/with-test-home.sh node --import $(pwd)/packages/api/test/helpers/setup-cat-registry.js --test --test-timeout=60000 packages/api/test/final-routing-slot.test.js`: 23 tests passed +- `CAT_CAFE_DISABLE_SHARED_STATE_PREFLIGHT=1 bash packages/api/scripts/with-test-home.sh node --import $(pwd)/packages/api/test/helpers/setup-cat-registry.js --test --test-timeout=60000 packages/api/test/routing-guard-remedial.test.js packages/api/test/route-serial-routing-guard-remedial.test.js packages/api/test/verdict-detect.test.js`: 68 tests passed + +## Receive-Review Update 2 + +Cloud review on current head `e44e81c2` found one current P2, plus an older +still-applicable same-family P2: + +1. `External Wait: event-driven (...)` remedials were still not recognized by + `normalizeRouteOnlyRemedialText`, so route-serial treated the bare wait line + as replacement content and discarded useful first-pass text. +2. Valid 2b event-driven waits could still trip `void-hold-hint` when the text + mentioned `hold_ball`, because void-hold suppression only knew `@`, structured + targets, co-creator, or actual hold tool calls. + +Fix: +- `normalizeRouteOnlyRemedialText()` now treats the shared structural + event-driven external wait template as route-only content. +- `runRoutingGuardRemedial()` returns separate `routingContent`; route-serial + persists the original visible text but validates follow-up guards against + `storedContent + routingContent`. +- `void-hold-detect.ts` and `verdict-detect.ts` now reuse + `hasEventDrivenExternalWaitExit()` so direct 2b waits suppress the same + false-positive class without adding semantic intent classification. + +Red→Green: +- `event-driven external-wait remedial counts as route-only and keeps first-pass + text visible` failed by persisting `External Wait: event-driven (pr:35)`, now + persists the original first-pass text and emits no guard/syntax/void-hold hint. +- `does not warn when structural event-driven external wait exit exists` failed + in `void-hold-detect`, now suppresses while preserving the matched hold pattern. +- `verdict + structural event-driven external wait exit → false` failed in + `verdict-detect`, now suppresses as a legitimate external wait exit. + +Failure-mode sweep: +- Invariant: the structural 2b external-wait exit must be recognized consistently + by every mechanical post-output guard, not only the remedial gate. +- Scanned touched sibling guard surfaces: remedial route-only normalization, + Phase H syntax validation, verdict-without-pass detection, void-hold detection, + and route-serial post-remedial validation. +- Result: all current touched surfaces now consume the shared final-slot helper. + +Additional verification after the cloud fix: +- `pnpm --dir packages/api run build`: passed +- Focused red→green suite: + `route-serial-routing-guard-remedial.test.js`, + `void-hold-detect.test.js`, + `verdict-detect.test.js`: 85/85 passed +- Expanded guard suite: + `final-routing-slot.test.js`, `routing-guard-remedial.test.js`, + `route-serial-routing-guard-remedial.test.js`, `verdict-detect.test.js`, + `void-hold-detect.test.js`: 122/122 passed +- `git diff --check`: passed +- `pnpm check:hotfix-pattern`: 24/24 passed +- `pnpm check`: passed +- `scripts/check-fallback-layers.mjs`: unavailable in this tree +- `pnpm check:architecture-ownership`: unavailable in this tree + +## Receive-Review Update 3 + +Cloud review on current head `b41b72a3` found one P2: + +- Signed outputs like + `External Wait: event-driven (pr:35)\n\n[砚砚/GPT-5.5]` + made `finalRoutingSlot()` pick the trailing signature paragraph, so + `hasEventDrivenExternalWaitExit()` returned `false` and the new legal 2b exit + could still trip remedial/verdict/void-hold guards. + +Fix: +- moved trailing cat-signature stripping into `final-routing-slot.ts` +- made `hasEventDrivenExternalWaitExit()` strip signatures before selecting the + final slot +- made `verdict-detect.ts` reuse the same shared signature stripper instead of + keeping a separate local copy + +Red→Green: +- `signed 2b event-driven external wait exit suppresses inline mention syntax + warning` failed because `hasEventDrivenExternalWaitExit()` returned `false`, + now passes. + +Failure-mode sweep: +- Invariant: final-slot guard helpers must treat trailing identity signatures as + metadata, not content. +- Scanned touched sibling surfaces: event-driven exit detection, Phase H syntax + validation, verdict detection, void-hold detection. +- Result: event-driven exit and verdict detection now share the same signature + stripping helper. + +Additional verification after the signed-exit fix: +- `pnpm --dir packages/api run build`: passed +- Expanded guard suite: + `final-routing-slot.test.js`, `routing-guard-remedial.test.js`, + `route-serial-routing-guard-remedial.test.js`, `verdict-detect.test.js`, + `void-hold-detect.test.js`: 123/123 passed +- `git diff --check`: passed +- `pnpm check`: passed + +## Receive-Review Update 4 + +Cloud review on current head `602e3c11` found one P2: + +- Signed remedial patches like + `External Wait: event-driven (pr:35)\n\n[砚砚/GPT-5.5]` + still had two non-empty lines when `normalizeRouteOnlyRemedialText()` ran, so + the remedial turn was treated as replacement content and overwrote the first + pass instead of acting as a route-only exit patch. + +Fix: +- `route-serial.ts` now strips trailing cat signatures before route-only + remedial normalization +- this applies to both line-start `@...` remedials and structural + `External Wait: event-driven (...)` remedials + +Red→Green: +- `signed event-driven external-wait remedial counts as route-only and keeps + first-pass text visible` failed because the visible/persisted content became + the signed remedial patch, now passes and preserves the first-pass text. + +Failure-mode sweep: +- Invariant: trailing identity signatures are metadata anywhere route-only + outlet text is structurally classified. +- Scanned sibling surfaces in this PR: final-slot validation, event-driven exit + detection, verdict/void-hold suppression, and route-only remedial + normalization. +- Result: final-slot and route-serial route-only paths both reuse the shared + trailing signature stripper. + +Additional verification after the signed-remedial fix: +- `pnpm --dir packages/api run build`: passed +- Red test: route-serial remedial suite failed 20/21 with the signed patch + replacing first-pass text +- Green test: route-serial remedial suite passed 21/21 +- Expanded guard suite: + `final-routing-slot.test.js`, `routing-guard-remedial.test.js`, + `route-serial-routing-guard-remedial.test.js`, `verdict-detect.test.js`, + `void-hold-detect.test.js`: 124/124 passed +- `git diff --check`: passed diff --git a/scripts/check-hotfix-pattern.mjs b/scripts/check-hotfix-pattern.mjs new file mode 100644 index 0000000000..233b62f894 --- /dev/null +++ b/scripts/check-hotfix-pattern.mjs @@ -0,0 +1,619 @@ +#!/usr/bin/env node +import { execFile } from 'node:child_process'; +import { readFile } from 'node:fs/promises'; +import { pathToFileURL } from 'node:url'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); + +const HOTFIX_PATTERNS = [ + { term: 'hotfix', regex: /\bhot[-\s]?fix\b/i }, + { term: 'quick fix', regex: /\bquick\s+fix\b/i }, + { term: 'minimal fix', regex: /\bminimal\s+fix\b/i }, + { term: 'band-aid', regex: /\bband[-\s]?aid\b/i }, + { term: 'temp', regex: /^temp(?:\([^)]+\))?!?(?=$|[\s:])/i }, + { + term: 'temporary', + regex: + /\btemp(?:orary)?\s+(?:fix|patch|workaround|mitigation|band[-\s]?aid|disable|bypass|skip)\b|\b(?:fix|patch|workaround|mitigation|band[-\s]?aid)\s+(?:is\s+)?temp(?:orary)?\b/i, + }, + { term: 'workaround', regex: /\bworkaround\b/i }, + { term: 'fix', regex: /^(?:fix|bugfix)(?:\([^)]+\))?!?(?=$|[\s:])/i }, +]; + +const DETECTOR_FILE_PATHS = new Set(['scripts/check-hotfix-pattern.mjs', 'scripts/check-hotfix-pattern.test.mjs']); +const DETECTOR_OUTPUT_BEGIN_MARKER = '<<>>'; +const DETECTOR_OUTPUT_END_MARKER = '<<>>'; +const CHECK_HOTFIX_PATTERN_TOKEN = String.raw`check-hotfix-pattern(?:\.mjs)?`; +const DETECTOR_NAME_TOKEN = String.raw`hot[-\s]?fix[-\s]+detector`; +const DETECTOR_REFERENCE_TOKEN = String.raw`(?:${DETECTOR_NAME_TOKEN}|${CHECK_HOTFIX_PATTERN_TOKEN})`; +const DETECTOR_REFERENCE_REGEX = new RegExp(String.raw`\b${DETECTOR_REFERENCE_TOKEN}\b`, 'i'); +const DETECTOR_REFERENCE_GLOBAL_REGEX = new RegExp(String.raw`\b${DETECTOR_REFERENCE_TOKEN}\b`, 'gi'); +const DETECTOR_REFERENCE_SCAN_REGEX = new RegExp(String.raw`\b${DETECTOR_REFERENCE_TOKEN}\b`, 'gi'); +const DETECTOR_OUTPUT_PREFIX_REGEX = new RegExp( + String.raw`(?:\b${DETECTOR_REFERENCE_TOKEN}\b\s*(?:(?:\`[^\`\r\n]*\`|[^\`\r\n]*\b${CHECK_HOTFIX_PATTERN_TOKEN}\b[^\`\r\n]*?)\s*)?|\`[^\`\r\n]*\b${CHECK_HOTFIX_PATTERN_TOKEN}\b[^\`\r\n]*\`\s*)(?:(?::\s*)?\b(?:returned|reported|outputs?|printed|emitted)\b\s*:?\s*|=>\s*|:\s*)`, + 'gi', +); +const CONVENTIONAL_HOTFIX_SIGNAL_REGEX = /^(?:fix|bugfix|hotfix|temp)(?:\([^)]+\))?!?(?=$|[\s:])/i; + +export function detectHotfixSignals(pr) { + const matches = []; + for (const candidate of collectCandidates(pr)) { + if (candidate.term) { + matches.push({ + source: candidate.source, + term: candidate.term, + text: candidate.text, + }); + continue; + } + + const textForMatch = candidate.textForMatch ?? stripDetectorSelfReferenceTokens(candidate.text); + for (const pattern of HOTFIX_PATTERNS) { + if (pattern.regex.test(textForMatch)) { + matches.push({ + source: candidate.source, + term: pattern.term, + text: candidate.text, + }); + } + } + } + return { hotfix: matches.length > 0, matches }; +} + +function collectCandidates(pr) { + const candidates = []; + const detectorScriptPr = isDetectorScriptPr(pr); + if (typeof pr?.title === 'string' && pr.title.trim()) { + candidates.push({ source: 'title', text: pr.title.trim() }); + } + + const labels = Array.isArray(pr?.labels) ? pr.labels : []; + for (const label of labels) { + const text = typeof label === 'string' ? label : label?.name; + if (typeof text === 'string' && text.trim()) { + const normalizedLabel = text.trim(); + if (normalizedLabel.toLowerCase() === 'hotfix') { + candidates.push({ source: 'label', term: 'hotfix', text: normalizedLabel }); + } + } + } + + const commits = Array.isArray(pr?.commits) ? pr.commits : []; + for (const commit of commits) { + for (const part of [ + commit?.messageHeadline, + commit?.messageBody, + commit?.message, + commit?.headline, + commit?.body, + ]) { + if (typeof part !== 'string') continue; + collectCommitPartCandidates(candidates, part, { detectorScriptPr }); + } + } + + return candidates; +} + +function collectCommitPartCandidates(candidates, part, { detectorScriptPr } = {}) { + const outputRanges = findDetectorOutputRanges(part); + for (const line of splitLinesWithOffsets(part)) { + const text = line.text.trim(); + if (!text) continue; + + const lineForMatch = removeRangesFromSlice(part, line.start, line.end, outputRanges); + const textForMatch = stripDetectorLineTokens(lineForMatch).trim(); + if ( + detectorScriptPr && + DETECTOR_REFERENCE_REGEX.test(text) && + !CONVENTIONAL_HOTFIX_SIGNAL_REGEX.test(textForMatch) + ) { + continue; + } + + candidates.push({ + source: 'commit', + text, + textForMatch, + }); + } +} + +function isDetectorScriptPr(pr) { + return Array.isArray(pr?.files) && pr.files.some((file) => DETECTOR_FILE_PATHS.has(file?.filename)); +} + +function stripDetectorSelfReferenceTokens(text) { + if (!DETECTOR_REFERENCE_REGEX.test(text)) return text; + return stripDetectorLineTokens(removeRangesFromText(text, findDetectorOutputRanges(text))); +} + +function stripDetectorLineTokens(text) { + if (!DETECTOR_REFERENCE_REGEX.test(text)) return text; + return text.replace(DETECTOR_REFERENCE_GLOBAL_REGEX, 'detector'); +} + +function findDetectorOutputRanges(text) { + const ranges = [...findSentinelDetectorOutputRanges(text), ...findDetectorReferencedJsonOutputRanges(text)]; + DETECTOR_OUTPUT_PREFIX_REGEX.lastIndex = 0; + + for (;;) { + const match = DETECTOR_OUTPUT_PREFIX_REGEX.exec(text); + if (!match) break; + + const outputStart = DETECTOR_OUTPUT_PREFIX_REGEX.lastIndex; + const outputEnd = findDetectorOutputEnd(text, outputStart); + if (outputEnd <= outputStart) { + DETECTOR_OUTPUT_PREFIX_REGEX.lastIndex = outputStart + 1; + continue; + } + + ranges.push([match.index, outputEnd]); + DETECTOR_OUTPUT_PREFIX_REGEX.lastIndex = outputEnd; + } + + return mergeRanges(ranges); +} + +function findSentinelDetectorOutputRanges(text) { + const ranges = []; + let searchStart = 0; + + for (;;) { + const start = text.indexOf(DETECTOR_OUTPUT_BEGIN_MARKER, searchStart); + if (start === -1) break; + + const outputStart = start + DETECTOR_OUTPUT_BEGIN_MARKER.length; + const end = text.indexOf(DETECTOR_OUTPUT_END_MARKER, outputStart); + if (end === -1) { + searchStart = outputStart; + continue; + } + + ranges.push([start, end + DETECTOR_OUTPUT_END_MARKER.length]); + searchStart = end + DETECTOR_OUTPUT_END_MARKER.length; + } + + return ranges; +} + +function findDetectorReferencedJsonOutputRanges(text) { + const ranges = []; + DETECTOR_REFERENCE_SCAN_REGEX.lastIndex = 0; + + for (;;) { + const match = DETECTOR_REFERENCE_SCAN_REGEX.exec(text); + if (!match) break; + + const searchStart = match.index + match[0].length; + const searchEnd = findDetectorOutputSearchEnd(text, searchStart); + const outputRange = findNextJsonLikeOutputRange(text, searchStart, searchEnd); + if (outputRange) ranges.push(outputRange); + } + + return ranges; +} + +function findDetectorOutputSearchEnd(text, searchStart) { + const blankLineMatch = /\r?\n\s*\r?\n/.exec(text.slice(searchStart)); + return blankLineMatch ? searchStart + blankLineMatch.index : text.length; +} + +function findNextJsonLikeOutputRange(text, searchStart, searchEnd) { + const fenceStart = findWithin(text, '```', searchStart, searchEnd); + const objectStart = findWithin(text, '{', searchStart, searchEnd); + + if (fenceStart !== -1 && (objectStart === -1 || fenceStart <= objectStart)) { + const fenceEnd = findFencedCodeBlockEnd(text, fenceStart); + return fenceEnd === -1 ? null : [fenceStart, fenceEnd]; + } + + if (objectStart === -1) return null; + const objectEnd = findBalancedObjectEnd(text, objectStart); + return objectEnd === -1 ? null : [objectStart, objectEnd + 1]; +} + +function findWithin(text, needle, searchStart, searchEnd) { + const index = text.indexOf(needle, searchStart); + return index !== -1 && index < searchEnd ? index : -1; +} + +function findFencedCodeBlockEnd(text, fenceStart) { + const contentStart = findFencedCodeContentStart(text, fenceStart); + if (contentStart === -1) return -1; + + const closingFenceStart = text.indexOf('```', contentStart); + return closingFenceStart === -1 ? -1 : closingFenceStart + 3; +} + +function findDetectorOutputEnd(text, outputStart) { + const trimmedStart = skipWhitespaceAndBackticks(text, outputStart); + if (text[trimmedStart] === '{') { + const objectEnd = findBalancedObjectEnd(text, trimmedStart); + if (objectEnd === -1) return outputStart; + return consumeClosingBackticks(text, objectEnd + 1); + } + + const booleanMatch = /^(?:\\?["']hot[-\s]?fix\\?["']|\bhot[-\s]?fix\b)\s*[:=]\s*(?:true|false)\b`?/i.exec( + text.slice(trimmedStart), + ); + if (booleanMatch) return trimmedStart + booleanMatch[0].length; + + return outputStart; +} + +function skipWhitespaceAndBackticks(text, start) { + let index = skipWhitespace(text, start); + const fencedContentStart = findFencedCodeContentStart(text, index); + if (fencedContentStart !== -1) return skipWhitespace(text, fencedContentStart); + + while (index < text.length && text[index] === '`') index += 1; + return skipWhitespace(text, index); +} + +function skipWhitespace(text, start) { + let index = start; + while (index < text.length && /\s/.test(text[index])) index += 1; + return index; +} + +function findFencedCodeContentStart(text, fenceStart) { + if (!text.startsWith('```', fenceStart)) return -1; + + let index = fenceStart + 3; + while (index < text.length && text[index] !== '\n' && text[index] !== '\r') { + index += 1; + } + if (index >= text.length) return -1; + + if (text[index] === '\r' && text[index + 1] === '\n') return index + 2; + return index + 1; +} + +function consumeClosingBackticks(text, start) { + let index = start; + while (index < text.length && text[index] === '`') index += 1; + return index; +} + +function findBalancedObjectEnd(text, objectStart) { + let depth = 0; + let inString = false; + let escapedQuoteString = false; + + for (let index = objectStart; index < text.length; index += 1) { + const char = text[index]; + + if (inString) { + if (escapedQuoteString && isEscapedJsonSyntaxQuote(text, index)) { + inString = false; + escapedQuoteString = false; + continue; + } + if (!escapedQuoteString && isUnescapedJsonQuote(text, index)) { + inString = false; + } + continue; + } + + if (isEscapedJsonSyntaxQuote(text, index)) { + inString = true; + escapedQuoteString = true; + continue; + } + if (isUnescapedJsonQuote(text, index)) { + inString = true; + continue; + } + if (char === '{') depth += 1; + if (char === '}') { + depth -= 1; + if (depth === 0) return index; + } + } + + return -1; +} + +function isEscapedJsonSyntaxQuote(text, index) { + return text[index] === '"' && countBackslashesBefore(text, index) % 2 === 1; +} + +function isUnescapedJsonQuote(text, index) { + return text[index] === '"' && countBackslashesBefore(text, index) % 2 === 0; +} + +function countBackslashesBefore(text, index) { + let count = 0; + for (let cursor = index - 1; cursor >= 0 && text[cursor] === '\\'; cursor -= 1) { + count += 1; + } + return count; +} + +function splitLinesWithOffsets(text) { + const lines = []; + let start = 0; + for (let index = 0; index <= text.length; index += 1) { + if (index !== text.length && text[index] !== '\n') continue; + + const end = index > start && text[index - 1] === '\r' ? index - 1 : index; + lines.push({ start, end, text: text.slice(start, end) }); + start = index + 1; + } + return lines; +} + +function mergeRanges(ranges) { + const sorted = [...ranges].sort((left, right) => left[0] - right[0]); + const merged = []; + for (const [start, end] of sorted) { + const previous = merged.at(-1); + if (previous && start <= previous[1]) { + previous[1] = Math.max(previous[1], end); + continue; + } + merged.push([start, end]); + } + return merged; +} + +function removeRangesFromText(text, ranges) { + return removeRangesFromSlice(text, 0, text.length, ranges); +} + +function removeRangesFromSlice(text, sliceStart, sliceEnd, ranges) { + if (ranges.length === 0) return text.slice(sliceStart, sliceEnd); + + let result = ''; + let cursor = sliceStart; + for (const [rangeStart, rangeEnd] of ranges) { + if (rangeEnd <= sliceStart) continue; + if (rangeStart >= sliceEnd) break; + + const overlapStart = Math.max(rangeStart, sliceStart); + const overlapEnd = Math.min(rangeEnd, sliceEnd); + result += text.slice(cursor, overlapStart); + cursor = overlapEnd; + } + + return result + text.slice(cursor, sliceEnd); +} + +function parseArgs(argv) { + const args = { + applyLabelPrNumber: null, + inputJsonPath: null, + prNumber: process.env.PR_NUMBER || null, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === '--apply-label') { + const next = argv[index + 1]; + if (next && !next.startsWith('--')) { + args.applyLabelPrNumber = next; + index += 1; + } else { + args.applyLabelPrNumber = args.prNumber; + } + continue; + } + if (arg === '--input-json') { + args.inputJsonPath = argv[index + 1] ?? null; + index += 1; + continue; + } + if (!arg.startsWith('--') && !args.prNumber) { + args.prNumber = arg; + } + } + + if (!args.prNumber && !args.inputJsonPath && args.applyLabelPrNumber) { + args.prNumber = args.applyLabelPrNumber; + } + + return args; +} + +async function loadPrInput(args) { + if (args.inputJsonPath) { + return JSON.parse(await readFile(args.inputJsonPath, 'utf8')); + } + if (!args.prNumber) { + try { + return await loadLocalGitInput(); + } catch (error) { + throw new Error(`Missing PR input and local git fallback failed: ${cleanError(error)}`); + } + } + + const [prResult, repoResult] = await Promise.all([ + execFileAsync('gh', [ + 'pr', + 'view', + String(args.prNumber), + '--json', + 'title,labels', + '--jq', + '{title: .title, labels: [.labels[].name]}', + ]), + execFileAsync('gh', ['repo', 'view', '--json', 'nameWithOwner', '--jq', '.nameWithOwner']), + ]); + const prView = JSON.parse(prResult.stdout); + const repoFullName = repoResult.stdout.trim(); + if (!repoFullName) throw new Error('Unable to resolve repository nameWithOwner.'); + + const [{ stdout: commitsJsonLines }, { stdout: filesJsonLines }] = await Promise.all([ + execFileAsync('gh', [ + 'api', + '--paginate', + `repos/${repoFullName}/pulls/${args.prNumber}/commits`, + '--jq', + '.[] | {message: .commit.message}', + ]), + execFileAsync('gh', [ + 'api', + '--paginate', + `repos/${repoFullName}/pulls/${args.prNumber}/files`, + '--jq', + '.[] | {filename, additions, deletions, changes}', + ]), + ]); + + return { + title: typeof prView.title === 'string' ? prView.title.trim() : '', + labels: Array.isArray(prView.labels) ? prView.labels : [], + commits: parseJsonLines(commitsJsonLines), + files: parseJsonLines(filesJsonLines), + }; +} + +async function loadLocalGitInput() { + const baseRef = await resolveLocalBaseRef(); + const [branchResult, commitsResult, filesResult] = await Promise.all([ + execFileAsync('git', ['branch', '--show-current']), + execFileAsync('git', ['log', '--format=%B%x1e', `${baseRef}..HEAD`]), + execFileAsync('git', ['diff', '--numstat', `${baseRef}...HEAD`]), + ]); + + return { + title: branchResult.stdout.trim(), + commits: parseGitCommitMessages(commitsResult.stdout), + files: parseGitNumstat(filesResult.stdout), + }; +} + +async function resolveLocalBaseRef() { + for (const ref of ['origin/main', 'main']) { + try { + await execFileAsync('git', ['rev-parse', '--verify', ref]); + return ref; + } catch { + // Try the next conventional base ref before fail-closing. + } + } + throw new Error('Unable to resolve local comparison base: origin/main or main'); +} + +function parseGitCommitMessages(text) { + return text + .split('\x1e') + .map((message) => message.trim()) + .filter(Boolean) + .map((message) => ({ message })); +} + +function parseGitNumstat(text) { + return text + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const [additionsText, deletionsText, ...filenameParts] = line.split('\t'); + const additions = Number(additionsText); + const deletions = Number(deletionsText); + const hasLineStats = Number.isFinite(additions) && Number.isFinite(deletions); + return { + filename: filenameParts.join('\t'), + additions: hasLineStats ? additions : null, + deletions: hasLineStats ? deletions : null, + changes: hasLineStats ? additions + deletions : null, + }; + }); +} + +function parseJsonLines(text) { + return text + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => JSON.parse(line)); +} + +async function applyHotfixLabel(prNumber) { + if (!prNumber) return 'Cannot apply label: missing PR number'; + try { + await execFileAsync('gh', ['pr', 'edit', String(prNumber), '--add-label', 'hotfix']); + return null; + } catch (error) { + return cleanError(error); + } +} + +function getAutoLabelEligibility(pr) { + if (!Array.isArray(pr?.files)) { + return { eligible: false, reason: 'missing changed-file stats' }; + } + if (pr.files.length !== 1) { + return { eligible: false, reason: `changed file count ${pr.files.length} is not 1` }; + } + + const changedLines = getChangedLineCount(pr.files[0]); + if (!Number.isFinite(changedLines)) { + return { eligible: false, reason: 'missing changed-line count' }; + } + if (changedLines > 50) { + return { eligible: false, reason: `changed lines ${changedLines} exceeds 50` }; + } + return { eligible: true }; +} + +function getChangedLineCount(file) { + const changes = Number(file?.changes); + if (Number.isFinite(changes)) return changes; + + const additions = Number(file?.additions); + const deletions = Number(file?.deletions); + if (Number.isFinite(additions) && Number.isFinite(deletions)) return additions + deletions; + return Number.NaN; +} + +function buildOutput(result, extras = {}) { + const matchedTerms = [...new Set(result.matches.map((match) => match.term))]; + return { + hotfix: result.hotfix, + matchedTerms, + matches: result.matches, + ...extras, + }; +} + +function cleanError(error) { + const text = [error?.stderr, error?.stdout, error?.message].filter(Boolean).join('\n'); + return text.trim().replace(/\s+/g, ' ').slice(0, 500) || 'Unknown error'; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + + try { + const pr = await loadPrInput(args); + const result = detectHotfixSignals(pr); + const extras = {}; + if (result.hotfix && args.applyLabelPrNumber) { + const labelEligibility = getAutoLabelEligibility(pr); + if (!labelEligibility.eligible) { + extras.labelSkippedReason = labelEligibility.reason; + } else { + const labelError = await applyHotfixLabel(args.applyLabelPrNumber); + if (labelError) extras.labelError = labelError; + else extras.labelApplied = true; + } + } + console.log(JSON.stringify(buildOutput(result, extras))); + } catch (error) { + process.exitCode = 1; + console.log( + JSON.stringify( + buildOutput({ hotfix: true, matches: [] }, { detectionError: cleanError(error), failClosed: true }), + ), + ); + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + await main(); +} diff --git a/scripts/check-hotfix-pattern.test.mjs b/scripts/check-hotfix-pattern.test.mjs new file mode 100644 index 0000000000..488f93bb55 --- /dev/null +++ b/scripts/check-hotfix-pattern.test.mjs @@ -0,0 +1,485 @@ +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { chmod, mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, test } from 'node:test'; +import { promisify } from 'node:util'; + +import { detectHotfixSignals } from './check-hotfix-pattern.mjs'; + +const execFileAsync = promisify(execFile); +const SCRIPT_PATH = new URL('./check-hotfix-pattern.mjs', import.meta.url); + +describe('check-hotfix-pattern', () => { + test('detects conventional fix titles as hotfix work', () => { + const result = detectHotfixSignals({ + title: 'fix(cli): preserve MCP tool contracts', + commits: [], + }); + + assert.equal(result.hotfix, true); + assert.deepEqual(result.matches, [ + { + source: 'title', + term: 'fix', + text: 'fix(cli): preserve MCP tool contracts', + }, + ]); + }); + + test('detects standalone temp titles as hotfix work', () => { + const result = detectHotfixSignals({ + title: 'temp: bypass failing check', + commits: [], + }); + + assert.equal(result.hotfix, true); + assert.deepEqual(result.matches, [ + { + source: 'title', + term: 'temp', + text: 'temp: bypass failing check', + }, + ]); + }); + + test('detects existing hotfix labels as hotfix work', () => { + const result = detectHotfixSignals({ + title: 'docs: neutral update', + labels: ['hotfix'], + commits: [{ message: 'docs: neutral update' }], + }); + + assert.equal(result.hotfix, true); + assert.deepEqual(result.matches, [ + { + source: 'label', + term: 'hotfix', + text: 'hotfix', + }, + ]); + }); + + test('detects normalized hotfix label objects as hotfix work', () => { + const result = detectHotfixSignals({ + title: 'docs: neutral update', + labels: [{ name: ' HotFix ' }], + commits: [{ message: 'docs: neutral update' }], + }); + + assert.equal(result.hotfix, true); + assert.deepEqual(result.matches, [ + { + source: 'label', + term: 'hotfix', + text: 'HotFix', + }, + ]); + }); + + test('ignores non-hotfix labels that contain hotfix as prose', () => { + const result = detectHotfixSignals({ + title: 'docs: neutral update', + labels: ['not-hotfix', 'no-hotfix'], + commits: [{ message: 'docs: neutral update' }], + }); + + assert.deepEqual(result, { hotfix: false, matches: [] }); + }); + + test('ignores copied detector JSON output in commit evidence', () => { + const result = detectHotfixSignals({ + title: 'docs: sync status wording', + commits: [ + { + messageHeadline: 'docs: sync status wording', + messageBody: + 'Verification: check-hotfix-pattern returned {"hotfix":true,"matches":[{"text":"quick fix"}]}; gate passed.', + }, + ], + }); + + assert.deepEqual(result, { hotfix: false, matches: [] }); + }); + + test('ignores copied detector bare-colon JSON output in commit evidence', () => { + const result = detectHotfixSignals({ + title: 'docs: sync status wording', + commits: [ + { + messageHeadline: 'docs: sync status wording', + messageBody: + 'Verification: check-hotfix-pattern: {"hotfix":false,"matches":[{"text":"quick fix"}]}; gate passed.', + }, + ], + }); + + assert.deepEqual(result, { hotfix: false, matches: [] }); + }); + + test('ignores copied detector command JSON output in commit evidence', () => { + const result = detectHotfixSignals({ + title: 'docs: sync status wording', + commits: [ + { + messageHeadline: 'docs: sync status wording', + messageBody: + 'Verification: node scripts/check-hotfix-pattern.mjs returned {"hotfix":true,"matches":[{"text":"quick fix"}]}; gate passed.', + }, + ], + }); + + assert.deepEqual(result, { hotfix: false, matches: [] }); + }); + + test('ignores copied hyphenated detector JSON output in commit evidence', () => { + const result = detectHotfixSignals({ + title: 'docs: sync status wording', + commits: [ + { + messageHeadline: 'docs: sync status wording', + messageBody: + 'Verification: hotfix-detector returned {"hotfix":true,"matches":[{"text":"quick fix"}]}; gate passed.', + }, + ], + }); + + assert.deepEqual(result, { hotfix: false, matches: [] }); + }); + + test('ignores copied detector JSON output after unrecognized transition wording', () => { + const result = detectHotfixSignals({ + title: 'docs: sync status wording', + commits: [ + { + messageHeadline: 'docs: sync status wording', + messageBody: + 'Verification: check-hotfix-pattern said {"hotfix":true,"matches":[{"text":"quick fix"}]}; gate passed.', + }, + ], + }); + + assert.deepEqual(result, { hotfix: false, matches: [] }); + }); + + test('ignores copied detector fenced JSON output in commit evidence', () => { + const result = detectHotfixSignals({ + title: 'docs: sync status wording', + commits: [ + { + messageHeadline: 'docs: sync status wording', + messageBody: [ + 'Verification: check-hotfix-pattern returned:', + '```json', + '{"hotfix":true,"matches":[{"text":"quick fix"}]}', + '```', + 'No runtime change.', + ].join('\n'), + }, + ], + }); + + assert.deepEqual(result, { hotfix: false, matches: [] }); + }); + + test('ignores copied detector fenced JSON output without transition wording', () => { + const result = detectHotfixSignals({ + title: 'docs: sync status wording', + commits: [ + { + messageHeadline: 'docs: sync status wording', + messageBody: [ + 'Verification: check-hotfix-pattern result was:', + '```json', + '{"hotfix":true,"matches":[{"text":"quick fix"}]}', + '```', + 'No runtime change.', + ].join('\n'), + }, + ], + }); + + assert.deepEqual(result, { hotfix: false, matches: [] }); + }); + + test('ignores literal sentinel-wrapped detector output in commit evidence', () => { + const result = detectHotfixSignals({ + title: 'docs: sync status wording', + commits: [ + { + messageHeadline: 'docs: sync status wording', + messageBody: + 'Verification: <<>>{"hotfix":true,"matches":[{"text":"quick fix"}]}<<>> gate passed.', + }, + ], + }); + + assert.deepEqual(result, { hotfix: false, matches: [] }); + }); + + test('ignores hyphenated detector references in neutral titles', () => { + const result = detectHotfixSignals({ + title: 'chore: tune hotfix-detector script', + commits: [], + }); + + assert.deepEqual(result, { hotfix: false, matches: [] }); + }); + + test('ignores detector maintenance prose in detector script PRs', () => { + const result = detectHotfixSignals({ + title: 'chore: tune detector', + commits: [ + { + messageHeadline: 'chore: tune detector', + messageBody: 'Adjusted check-hotfix-pattern to recognize quick fix variants correctly.', + }, + ], + files: [{ filename: 'scripts/check-hotfix-pattern.mjs', changes: 12 }], + }); + + assert.deepEqual(result, { hotfix: false, matches: [] }); + }); + + test('preserves fix commit signals that mention the detector in detector script PRs', () => { + const result = detectHotfixSignals({ + title: 'chore: tune detector', + commits: [ + { + messageHeadline: 'fix(check-hotfix-pattern): fail closed', + messageBody: 'No runtime behavior change.', + }, + ], + files: [{ filename: 'scripts/check-hotfix-pattern.mjs', changes: 12 }], + }); + + assert.equal(result.hotfix, true); + assert.deepEqual(result.matches, [ + { + source: 'commit', + term: 'fix', + text: 'fix(check-hotfix-pattern): fail closed', + }, + ]); + }); + + test('ignores detector maintenance prose in detector test-only PRs', () => { + const result = detectHotfixSignals({ + title: 'test: cover detector variants', + commits: [ + { + messageHeadline: 'test: cover detector variants', + messageBody: 'Adjusted check-hotfix-pattern to recognize quick fix variants correctly.', + }, + ], + files: [{ filename: 'scripts/check-hotfix-pattern.test.mjs', changes: 12 }], + }); + + assert.deepEqual(result, { hotfix: false, matches: [] }); + }); + + test('preserves real hotfix metadata outside copied detector output', () => { + const result = detectHotfixSignals({ + title: 'chore: release metadata', + commits: [ + { + messageHeadline: 'chore: release metadata', + messageBody: + 'Verification: check-hotfix-pattern returned hotfix=false; release metadata hotfix=true for emergency path.', + }, + ], + }); + + assert.equal(result.hotfix, true); + assert.deepEqual(result.matches, [ + { + source: 'commit', + term: 'hotfix', + text: 'Verification: check-hotfix-pattern returned hotfix=false; release metadata hotfix=true for emergency path.', + }, + ]); + }); + + test('CLI accepts input JSON and prints merge-gate JSON', async () => { + const dir = await mkdtemp(join(tmpdir(), 'hotfix-detector-')); + const inputPath = join(dir, 'pr.json'); + await writeFile( + inputPath, + JSON.stringify({ + title: 'docs: update status', + commits: [{ message: 'docs: update status' }], + }), + ); + + const { stdout } = await execFileAsync('node', [SCRIPT_PATH.pathname, '--input-json', inputPath]); + + assert.deepEqual(JSON.parse(stdout), { + hotfix: false, + matchedTerms: [], + matches: [], + }); + }); + + test('CLI skips hotfix label when changed-file stats exceed auto-label guard', async () => { + const dir = await mkdtemp(join(tmpdir(), 'hotfix-detector-')); + const inputPath = join(dir, 'pr.json'); + const fakeGhPath = join(dir, 'gh'); + await writeFile( + inputPath, + JSON.stringify({ + title: 'fix: broad change', + commits: [{ message: 'fix: broad change' }], + files: [{ filename: 'scripts/large-change.mjs', changes: 51 }], + }), + ); + await writeFile(fakeGhPath, '#!/bin/sh\necho "gh should not be called" >&2\nexit 42\n'); + await chmod(fakeGhPath, 0o755); + + const { stdout } = await execFileAsync( + 'node', + [SCRIPT_PATH.pathname, '--input-json', inputPath, '--apply-label', '123'], + { env: { ...process.env, PATH: `${dir}:${process.env.PATH}` } }, + ); + + const parsed = JSON.parse(stdout); + assert.equal(parsed.hotfix, true); + assert.equal(parsed.labelApplied, undefined); + assert.equal(parsed.labelError, undefined); + assert.match(parsed.labelSkippedReason, /changed lines 51 exceeds 50/); + }); + + test('CLI uses --apply-label value as PR input when PR_NUMBER is unset', async () => { + const dir = await mkdtemp(join(tmpdir(), 'hotfix-detector-gh-')); + const fakeGhPath = join(dir, 'gh'); + const ghLogPath = join(dir, 'gh-calls.jsonl'); + await writeFile( + fakeGhPath, + `#!/usr/bin/env node +import { appendFileSync } from 'node:fs'; + +const callsPath = ${JSON.stringify(ghLogPath)}; +const args = process.argv.slice(2); +appendFileSync(callsPath, JSON.stringify(args) + '\\n'); + +if (args[0] === 'pr' && args[1] === 'view' && args[2] === '123') { + console.log(JSON.stringify({ title: 'fix: pr hotfix', labels: [] })); + process.exit(0); +} +if (args[0] === 'repo' && args[1] === 'view') { + console.log('clowder-labs/clowder-ai'); + process.exit(0); +} +if (args[0] === 'api' && args[2] === 'repos/clowder-labs/clowder-ai/pulls/123/commits') { + console.log(JSON.stringify({ message: 'fix: pr hotfix' })); + process.exit(0); +} +if (args[0] === 'api' && args[2] === 'repos/clowder-labs/clowder-ai/pulls/123/files') { + console.log(JSON.stringify({ filename: 'scripts/check-hotfix-pattern.mjs', changes: 12 })); + process.exit(0); +} +if (args[0] === 'pr' && args[1] === 'edit' && args[2] === '123') { + process.exit(0); +} + +console.error('unexpected gh args: ' + JSON.stringify(args)); +process.exit(42); +`, + ); + await chmod(fakeGhPath, 0o755); + + const { stdout } = await execFileAsync('node', [SCRIPT_PATH.pathname, '--apply-label', '123'], { + cwd: dir, + env: { ...process.env, PATH: `${dir}:${process.env.PATH}`, PR_NUMBER: '' }, + }); + + const parsed = JSON.parse(stdout); + assert.equal(parsed.hotfix, true); + assert.equal(parsed.labelApplied, true); + + const ghCalls = (await readFile(ghLogPath, 'utf8')) + .trim() + .split('\n') + .map((line) => JSON.parse(line)); + assert.ok( + ghCalls.some((args) => args[0] === 'pr' && args[1] === 'view' && args[2] === '123'), + '--apply-label value should be reused as the PR input number', + ); + }); + + test('CLI falls back to local git evidence when no PR input is available', async () => { + const dir = await mkdtemp(join(tmpdir(), 'hotfix-detector-git-')); + await execFileAsync('git', ['init'], { cwd: dir }); + await execFileAsync('git', ['config', 'user.email', 'test@example.invalid'], { cwd: dir }); + await execFileAsync('git', ['config', 'user.name', 'Test User'], { cwd: dir }); + await writeFile(join(dir, 'README.md'), 'base\n'); + await execFileAsync('git', ['add', 'README.md'], { cwd: dir }); + await execFileAsync('git', ['commit', '-m', 'chore: base'], { cwd: dir }); + await execFileAsync('git', ['update-ref', 'refs/remotes/origin/main', 'HEAD'], { cwd: dir }); + await execFileAsync('git', ['checkout', '-b', 'feature/neutral-work'], { cwd: dir }); + await writeFile(join(dir, 'README.md'), 'base\nneutral\n'); + await execFileAsync('git', ['add', 'README.md'], { cwd: dir }); + await execFileAsync('git', ['commit', '-m', 'docs: neutral update'], { cwd: dir }); + + const { stdout } = await execFileAsync('node', [SCRIPT_PATH.pathname], { + cwd: dir, + env: { ...process.env, PR_NUMBER: '' }, + }); + + assert.deepEqual(JSON.parse(stdout), { + hotfix: false, + matchedTerms: [], + matches: [], + }); + }); + + test('CLI local git fallback uses main when origin/main is unavailable', async () => { + const dir = await mkdtemp(join(tmpdir(), 'hotfix-detector-git-main-')); + await execFileAsync('git', ['init'], { cwd: dir }); + await execFileAsync('git', ['config', 'user.email', 'test@example.invalid'], { cwd: dir }); + await execFileAsync('git', ['config', 'user.name', 'Test User'], { cwd: dir }); + await writeFile(join(dir, 'README.md'), 'base\n'); + await execFileAsync('git', ['add', 'README.md'], { cwd: dir }); + await execFileAsync('git', ['commit', '-m', 'chore: base'], { cwd: dir }); + await execFileAsync('git', ['branch', '-M', 'main'], { cwd: dir }); + await execFileAsync('git', ['checkout', '-b', 'feature/neutral-work'], { cwd: dir }); + await writeFile(join(dir, 'README.md'), 'base\nneutral\n'); + await execFileAsync('git', ['add', 'README.md'], { cwd: dir }); + await execFileAsync('git', ['commit', '-m', 'docs: neutral update'], { cwd: dir }); + + const { stdout } = await execFileAsync('node', [SCRIPT_PATH.pathname], { + cwd: dir, + env: { ...process.env, PR_NUMBER: '' }, + }); + + assert.deepEqual(JSON.parse(stdout), { + hotfix: false, + matchedTerms: [], + matches: [], + }); + }); + + test('CLI fails closed with valid JSON outside PR and git contexts', async () => { + const dir = await mkdtemp(join(tmpdir(), 'hotfix-detector-no-git-')); + let output = ''; + await assert.rejects( + async () => { + try { + await execFileAsync('node', [SCRIPT_PATH.pathname], { + cwd: dir, + env: { ...process.env, PR_NUMBER: '' }, + }); + } catch (error) { + output = error.stdout; + throw error; + } + }, + { code: 1 }, + ); + + const parsed = JSON.parse(output); + assert.equal(parsed.hotfix, true); + assert.equal(parsed.failClosed, true); + assert.match(parsed.detectionError, /local git fallback failed/); + }); +}); diff --git a/scripts/compile-system-prompt-l0.mjs b/scripts/compile-system-prompt-l0.mjs index 9a7511bc0c..470607c4a1 100644 --- a/scripts/compile-system-prompt-l0.mjs +++ b/scripts/compile-system-prompt-l0.mjs @@ -107,11 +107,68 @@ let _loadCompiledGovernanceL0 = null; // 渲染(buildStaticIdentity L568-571 同源),非 L0 硬编码 @co-creator—— // 否则删 user message 后 co-creator 多 handle / 自定义 name 丢失。 let _coCreatorConfig = null; + +function resolveConfigProjectRoot() { + const templatePath = process.env.CAT_TEMPLATE_PATH ?? resolve(REPO_ROOT, 'cat-template.json'); + return dirname(resolve(templatePath)); +} + +function readCapabilitiesConfigSync(projectRoot) { + try { + const raw = readFileSync(resolve(projectRoot, '.cat-cafe/capabilities.json'), 'utf8'); + const parsed = JSON.parse(raw); + if (!parsed || !Array.isArray(parsed.capabilities)) return null; + return parsed; + } catch { + return null; + } +} + +async function projectRouteableAgentProviderConfigs(allConfigs, catConfigLoader) { + const projectRoot = resolveConfigProjectRoot(); + const capabilitiesConfig = readCapabilitiesConfigSync(projectRoot); + if (!capabilitiesConfig) return {}; + + const { listApprovedRouteableRows, projectRouteableAgentProviders } = await import( + '../packages/api/dist/domains/plugin/agent-provider-projection.js' + ); + const { buildAgentProviderAdmissionSnapshot } = await import( + '../packages/api/dist/domains/plugin/agent-provider-admission-snapshot.js' + ); + + const rows = listApprovedRouteableRows(capabilitiesConfig); + if (rows.length === 0) return {}; + + const templateBaselineIds = catConfigLoader.getTemplateBuiltinCatIds(projectRoot); + const projection = projectRouteableAgentProviders({ + rows, + buildSnapshot: (pluginId, capId) => + buildAgentProviderAdmissionSnapshot({ + capabilitiesConfig, + activeCatConfigs: allConfigs, + templateBaselineIds, + hasProviderTransportConfig: (id) => { + const pt = catConfigLoader.getProviderTransportConfig(id, projectRoot); + return pt !== undefined && pt !== null; + }, + candidatePluginId: pluginId, + candidateCapId: capId, + }), + now: () => Date.now(), + // L0 bootstrap mirrors routeable cats already materialized by API sync. + // TTL refresh/degrade requires live providerTransportRegistry and belongs + // to syncAgentRegistry; the read-only compiler must not independently + // de-route a cat that the API in-memory catRegistry can still route. + enforceHealthTtl: false, + }); + + return projection.configs; +} + async function bootstrapCatRegistry() { if (_bootstrapped) return; - const { loadCatConfig, toAllCatConfigs, isCatAvailable, getCoCreatorConfig } = await import( - '../packages/api/dist/config/cat-config-loader.js' - ); + const catConfigLoader = await import('../packages/api/dist/config/cat-config-loader.js'); + const { loadCatConfig, toAllCatConfigs, isCatAvailable, getCoCreatorConfig } = catConfigLoader; const { getCatModel } = await import('../packages/api/dist/config/cat-models.js'); const { loadCompiledGovernanceL0 } = await import( '../packages/api/dist/domains/cats/services/context/governance-l0.js' @@ -127,6 +184,10 @@ async function bootstrapCatRegistry() { catRegistry.register(id, config); } } + const projectedConfigs = await projectRouteableAgentProviderConfigs(allConfigs, catConfigLoader); + for (const [id, config] of Object.entries(projectedConfigs)) { + catRegistry.registerOrReplace(id, config); + } _bootstrapped = true; } diff --git a/scripts/develop-worktree.sh b/scripts/develop-worktree.sh new file mode 100755 index 0000000000..74f7875454 --- /dev/null +++ b/scripts/develop-worktree.sh @@ -0,0 +1,27 @@ +#!/bin/bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +DEFAULT_DEVELOP_DIR="$(cd "$PROJECT_DIR/.." && pwd)/cat-cafe-develop" + +export CAT_CAFE_RUNTIME_DIR="${CAT_CAFE_DEVELOP_DIR:-$DEFAULT_DEVELOP_DIR}" +export CAT_CAFE_RUNTIME_BRANCH="${CAT_CAFE_DEVELOP_BRANCH:-develop-runtime-sync}" +export CAT_CAFE_RUNTIME_REMOTE="${CAT_CAFE_DEVELOP_REMOTE:-origin}" +export CAT_CAFE_RUNTIME_SOURCE_BRANCH="${CAT_CAFE_DEVELOP_SOURCE_BRANCH:-develop}" +export CAT_CAFE_RUNTIME_SYNC_COMMAND="${CAT_CAFE_DEVELOP_SYNC_COMMAND:-pnpm develop:sync}" + +# Develop intentionally shares the same singleton ports as `pnpm start`. +# Set CAT_CAFE_DEVELOP_WORKTREE_PORT_OFFSET only for explicit isolated +# experiments. +if [ -n "${CAT_CAFE_DEVELOP_WORKTREE_PORT_OFFSET:-}" ]; then + export WORKTREE_PORT_OFFSET="$CAT_CAFE_DEVELOP_WORKTREE_PORT_OFFSET" +else + unset WORKTREE_PORT_OFFSET +fi + +[[ "${1:-}" == "--source-only" ]] && { return 0 2>/dev/null; exit 0; } + +exec "$SCRIPT_DIR/runtime-worktree.sh" "$@" diff --git a/scripts/runtime-worktree.sh b/scripts/runtime-worktree.sh index e354c143fb..e489431a55 100755 --- a/scripts/runtime-worktree.sh +++ b/scripts/runtime-worktree.sh @@ -19,6 +19,8 @@ DEFAULT_RUNTIME_DIR="$(cd "$PROJECT_DIR/.." && pwd)/cat-cafe-runtime" RUNTIME_DIR="${CAT_CAFE_RUNTIME_DIR:-$DEFAULT_RUNTIME_DIR}" RUNTIME_BRANCH="${CAT_CAFE_RUNTIME_BRANCH:-runtime/main-sync}" REMOTE_NAME="${CAT_CAFE_RUNTIME_REMOTE:-origin}" +SOURCE_BRANCH="${CAT_CAFE_RUNTIME_SOURCE_BRANCH:-main}" +SYNC_COMMAND_HINT="${CAT_CAFE_RUNTIME_SYNC_COMMAND:-pnpm runtime:sync}" FORCE=false RUN_INSTALL=true SYNC_BEFORE_START=true @@ -29,6 +31,10 @@ usage() { Clowder AI Runtime Worktree Manager Usage: + ./scripts/runtime-worktree.sh init [--dir PATH] [--branch NAME] [--remote NAME] [--source-branch NAME] [--no-install] + ./scripts/runtime-worktree.sh sync [--dir PATH] [--branch NAME] [--remote NAME] [--source-branch NAME] [--force] [--no-install] + ./scripts/runtime-worktree.sh start [--dir PATH] [--branch NAME] [--remote NAME] [--source-branch NAME] [--force] [--no-sync] [--] [start-dev args...] + ./scripts/runtime-worktree.sh status [--dir PATH] [--branch NAME] [--remote NAME] [--source-branch NAME] ./scripts/runtime-worktree.sh init [--dir PATH] [--branch NAME] [--remote NAME] [--no-install] ./scripts/runtime-worktree.sh start [--dir PATH] [--branch NAME] [--remote NAME] [--force] [--no-sync] [--] [start-dev args...] ./scripts/runtime-worktree.sh status [--dir PATH] [--branch NAME] [--remote NAME] @@ -37,6 +43,7 @@ Defaults: --dir ../cat-cafe-runtime --branch runtime/main-sync --remote origin + --source-branch main Runtime Contract (passive frozen): Runtime restarts ONLY on explicit `pnpm start` invocation. @@ -115,6 +122,27 @@ runtime_env_value() { read_env_file_value "$runtime_dir/.env" "$1" } +derive_worktree_env_value() { + local key="$1" + local offset="${WORKTREE_PORT_OFFSET:-0}" + local derive_stdout line value + + [ "$offset" != "0" ] || return 1 + + derive_stdout="$(node "$SCRIPT_DIR/derive-worktree-ports.mjs" "$offset" 2>/dev/null)" || return 1 + while IFS= read -r line; do + case "$line" in + "export $key="*) + value="${line#export $key=}" + printf '%s\n' "$value" + return 0 + ;; + esac + done <<< "$derive_stdout" + + return 1 +} + require_git_repo() { git -C "$PROJECT_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1 \ || die "project dir is not a git repository: $PROJECT_DIR" @@ -188,7 +216,10 @@ port_is_listening() { is_api_running() { local port - port="$(runtime_env_value API_SERVER_PORT 2>/dev/null || true)" + port="$(derive_worktree_env_value API_SERVER_PORT 2>/dev/null || true)" + if [ -z "$port" ]; then + port="$(runtime_env_value API_SERVER_PORT 2>/dev/null || true)" + fi port="${port:-${API_SERVER_PORT:-3004}}" port_is_listening "$port" } @@ -480,15 +511,15 @@ init_runtime_worktree() { fi fi - info "fetching $REMOTE_NAME/main" - git -C "$PROJECT_DIR" fetch "$REMOTE_NAME" main + info "fetching $REMOTE_NAME/$SOURCE_BRANCH" + git -C "$PROJECT_DIR" fetch "$REMOTE_NAME" "$SOURCE_BRANCH" if git -C "$PROJECT_DIR" show-ref --verify --quiet "refs/heads/$RUNTIME_BRANCH"; then info "adding existing branch '$RUNTIME_BRANCH' to $RUNTIME_DIR" git -C "$PROJECT_DIR" worktree add "$RUNTIME_DIR" "$RUNTIME_BRANCH" else - info "creating branch '$RUNTIME_BRANCH' from $REMOTE_NAME/main" - git -C "$PROJECT_DIR" worktree add "$RUNTIME_DIR" -b "$RUNTIME_BRANCH" "$REMOTE_NAME/main" + info "creating branch '$RUNTIME_BRANCH' from $REMOTE_NAME/$SOURCE_BRANCH" + git -C "$PROJECT_DIR" worktree add "$RUNTIME_DIR" -b "$RUNTIME_BRANCH" "$REMOTE_NAME/$SOURCE_BRANCH" fi if [ "$RUN_INSTALL" = "true" ]; then @@ -514,9 +545,9 @@ sync_runtime_worktree() { ensure_runtime_clean ensure_runtime_branch - info "syncing runtime worktree with $REMOTE_NAME/main (ff-only)" - git -C "$RUNTIME_DIR" fetch "$REMOTE_NAME" main - if ! git -C "$RUNTIME_DIR" merge --ff-only "$REMOTE_NAME/main" 2>/dev/null; then + info "syncing runtime worktree with $REMOTE_NAME/$SOURCE_BRANCH (ff-only)" + git -C "$RUNTIME_DIR" fetch "$REMOTE_NAME" "$SOURCE_BRANCH" + if ! git -C "$RUNTIME_DIR" merge --ff-only "$REMOTE_NAME/$SOURCE_BRANCH" 2>/dev/null; then echo "" echo " ff-only merge failed." if print_untracked_merge_blockers; then @@ -573,20 +604,21 @@ status_runtime_worktree() { head=$(git -C "$RUNTIME_DIR" rev-parse --short HEAD) dirty=$(git -C "$RUNTIME_DIR" status --short | wc -l | awk '{print $1}') - git -C "$RUNTIME_DIR" fetch "$REMOTE_NAME" main >/dev/null 2>&1 || true - ahead=$(git -C "$RUNTIME_DIR" rev-list --count "$REMOTE_NAME/main..HEAD" 2>/dev/null || echo "0") - behind=$(git -C "$RUNTIME_DIR" rev-list --count "HEAD..$REMOTE_NAME/main" 2>/dev/null || echo "0") + git -C "$RUNTIME_DIR" fetch "$REMOTE_NAME" "$SOURCE_BRANCH" >/dev/null 2>&1 || true + ahead=$(git -C "$RUNTIME_DIR" rev-list --count "$REMOTE_NAME/$SOURCE_BRANCH..HEAD" 2>/dev/null || echo "0") + behind=$(git -C "$RUNTIME_DIR" rev-list --count "HEAD..$REMOTE_NAME/$SOURCE_BRANCH" 2>/dev/null || echo "0") echo "runtime worktree: $RUNTIME_DIR" echo "branch: $branch" echo "head: $head" echo "dirty_files: $dirty" - echo "ahead_of_${REMOTE_NAME}/main: $ahead" - echo "behind_${REMOTE_NAME}/main: $behind" + echo "source: $REMOTE_NAME/$SOURCE_BRANCH" + echo "ahead_of_${REMOTE_NAME}/${SOURCE_BRANCH}: $ahead" + echo "behind_${REMOTE_NAME}/${SOURCE_BRANCH}: $behind" } start_runtime_worktree() { - info "preparing runtime worktree (checking ports, syncing origin/main...)" + info "preparing runtime worktree (checking ports, syncing $REMOTE_NAME/$SOURCE_BRANCH...)" if ! is_git_repo; then RUNTIME_DIR="$PROJECT_DIR" @@ -618,6 +650,7 @@ start_runtime_worktree() { if [ "$SYNC_BEFORE_START" = "true" ]; then if is_api_running && [ "$FORCE" != "true" ]; then info "API port is active; skip pre-start sync to avoid in-place hot swap." + info "Run '$SYNC_COMMAND_HINT' after stop if you need latest $REMOTE_NAME/$SOURCE_BRANCH." info "Stop API first (pnpm stop), then re-run 'pnpm start' to sync + restart." seed_runtime_config_from_project else @@ -675,6 +708,11 @@ while [ $# -gt 0 ]; do REMOTE_NAME="$2" shift 2 ;; + --source-branch) + [ $# -ge 2 ] || die "--source-branch requires a value" + SOURCE_BRANCH="$2" + shift 2 + ;; --force) FORCE=true shift diff --git a/scripts/runtime-worktree.test.sh b/scripts/runtime-worktree.test.sh new file mode 100755 index 0000000000..a1e414cf22 --- /dev/null +++ b/scripts/runtime-worktree.test.sh @@ -0,0 +1,174 @@ +#!/bin/bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +# shellcheck source=./runtime-worktree.sh +source "$SCRIPT_DIR/runtime-worktree.sh" --source-only + +assert_contains() { + local haystack="$1" + local needle="$2" + local message="$3" + + if [[ "$haystack" != *"$needle"* ]]; then + echo "FAIL: $message" + echo " missing: $needle" + exit 1 + fi +} + +test_usage_includes_source_branch() { + local output + output="$(usage)" + assert_contains "$output" "--source-branch NAME" "usage should document source branch" + assert_contains "$output" "--source-branch main" "usage should show default source branch" + echo "PASS: usage documents runtime source branch" +} + +test_develop_wrapper_exports_runtime_defaults() { + ( + WORKTREE_PORT_OFFSET="-20" + unset CAT_CAFE_DEVELOP_WORKTREE_PORT_OFFSET + + # shellcheck source=./develop-worktree.sh + source "$SCRIPT_DIR/develop-worktree.sh" --source-only + + [[ "$CAT_CAFE_RUNTIME_DIR" == */cat-cafe-develop ]] || { + echo "FAIL: develop wrapper should target cat-cafe-develop" + exit 1 + } + [ "$CAT_CAFE_RUNTIME_BRANCH" = "develop-runtime-sync" ] || { + echo "FAIL: develop wrapper should use an independent sync branch" + exit 1 + } + [ "$CAT_CAFE_RUNTIME_SOURCE_BRANCH" = "develop" ] || { + echo "FAIL: develop wrapper should sync from origin/develop" + exit 1 + } + [ "$CAT_CAFE_RUNTIME_SYNC_COMMAND" = "pnpm develop:sync" ] || { + echo "FAIL: develop wrapper should show develop sync hint" + exit 1 + } + [ "${WORKTREE_PORT_OFFSET+set}" != "set" ] || { + echo "FAIL: develop wrapper should share default runtime ports" + exit 1 + } + ) + + echo "PASS: develop wrapper exports runtime defaults" +} + +test_develop_wrapper_allows_explicit_port_offset() { + ( + unset WORKTREE_PORT_OFFSET + CAT_CAFE_DEVELOP_WORKTREE_PORT_OFFSET="-20" + + # shellcheck source=./develop-worktree.sh + source "$SCRIPT_DIR/develop-worktree.sh" --source-only + + [ "$WORKTREE_PORT_OFFSET" = "-20" ] || { + echo "FAIL: develop wrapper should allow an explicit port offset" + exit 1 + } + ) + + echo "PASS: develop wrapper allows explicit port offset" +} + +test_is_api_running_uses_worktree_port_offset() { + local observed_port tmp_runtime_dir + tmp_runtime_dir="$(mktemp -d)" + trap 'rm -rf "$tmp_runtime_dir"' RETURN + + RUNTIME_DIR="$tmp_runtime_dir" + WORKTREE_PORT_OFFSET="-20" + observed_port="" + + port_is_listening() { + observed_port="$1" + return 1 + } + + is_api_running || true + + [ "$observed_port" = "3122" ] || { + echo "FAIL: active API guard should probe the offset-derived develop API port" + echo " expected: 3122" + echo " actual: $observed_port" + exit 1 + } + + unset WORKTREE_PORT_OFFSET + echo "PASS: active API guard uses worktree port offset" +} + +test_init_and_sync_runtime_worktree_from_develop() { + local tmp_root origin_dir src_dir runtime_dir initial_head expected_head synced_head status_output + tmp_root="$(mktemp -d)" + trap 'rm -rf "$tmp_root"' RETURN + + origin_dir="$tmp_root/origin.git" + src_dir="$tmp_root/src" + runtime_dir="$tmp_root/cat-cafe-develop" + + git init --bare "$origin_dir" >/dev/null + git clone "$origin_dir" "$src_dir" >/dev/null 2>&1 + git -C "$src_dir" config user.name "Runtime Test" + git -C "$src_dir" config user.email "runtime-test@example.com" + + echo "main" > "$src_dir/README.md" + git -C "$src_dir" add README.md + git -C "$src_dir" commit -m "main init" >/dev/null + git -C "$src_dir" branch -M main + git -C "$src_dir" push -u origin main >/dev/null 2>&1 + + git -C "$src_dir" switch -c develop >/dev/null + echo "develop" > "$src_dir/README.md" + git -C "$src_dir" add README.md + git -C "$src_dir" commit -m "develop init" >/dev/null + git -C "$src_dir" push -u origin develop >/dev/null 2>&1 + + PROJECT_DIR="$src_dir" + RUNTIME_DIR="$(abs_path "$runtime_dir")" + RUNTIME_BRANCH="develop-runtime-sync" + REMOTE_NAME="origin" + SOURCE_BRANCH="develop" + RUN_INSTALL=false + FORCE=true + + init_runtime_worktree + + initial_head="$(git -C "$RUNTIME_DIR" rev-parse HEAD)" + expected_head="$(git -C "$PROJECT_DIR" rev-parse origin/develop)" + [ "$initial_head" = "$expected_head" ] || { + echo "FAIL: init should create runtime worktree from origin/develop" + exit 1 + } + + echo "develop two" >> "$src_dir/README.md" + git -C "$src_dir" add README.md + git -C "$src_dir" commit -m "develop update" >/dev/null + git -C "$src_dir" push >/dev/null 2>&1 + + sync_runtime_worktree + + synced_head="$(git -C "$RUNTIME_DIR" rev-parse HEAD)" + expected_head="$(git -C "$PROJECT_DIR" rev-parse origin/develop)" + [ "$synced_head" = "$expected_head" ] || { + echo "FAIL: sync should fast-forward runtime worktree to origin/develop" + exit 1 + } + + status_output="$(status_runtime_worktree)" + assert_contains "$status_output" "source: origin/develop" "status should report source branch" + + echo "PASS: init + sync runtime worktree from develop" +} + +test_usage_includes_source_branch +test_develop_wrapper_exports_runtime_defaults +test_develop_wrapper_allows_explicit_port_offset +test_is_api_running_uses_worktree_port_offset +test_init_and_sync_runtime_worktree_from_develop