diff --git a/.env.example b/.env.example index 27a602446..c85f68d31 100644 --- a/.env.example +++ b/.env.example @@ -13,11 +13,18 @@ # These ports must not conflict with other local services. # Default: Frontend 3003, API 3004, Redis 6399 # Convention: API port = Frontend port + 1 (same as internal 3001→3002) +# +# Reverse proxy / remote access 反向代理 / 远端访问: +# Behind Nginx (port 80/443), the frontend auto-detects same-origin +# and routes /api/ + /socket.io/ through the proxy — no env var needed. +# Only set NEXT_PUBLIC_API_URL if you need a non-standard API endpoint. +# Also set FRONTEND_URL on the API side for CORS (see below). FRONTEND_PORT=3003 API_SERVER_PORT=3004 MCP_SERVER_PORT=3011 NEXT_PUBLIC_API_URL=http://localhost:3004 +# FRONTEND_URL=http://your-public-ip # CORS: set when API is accessed from a public domain/IP NEXT_PUBLIC_BRAND_NAME="Clowder AI" # CLI inactivity timeout in milliseconds. @@ -37,20 +44,6 @@ NEXT_PUBLIC_BRAND_NAME="Clowder AI" REDIS_PORT=6399 REDIS_URL=redis://localhost:6399 -# ── Model API Keys 模型密钥(推荐通过 UI 配置)─────────────── -# RECOMMENDED: Add API keys via the web UI after launch: -# Hub → System Settings → Account Configuration -# 推荐方式:启动后在前端 UI 添加 API key: -# Hub → 系统配置 → 账号配置 -# -# The env vars below are a legacy fallback. Leave them commented out -# unless you have a specific reason to use them. -# 以下环境变量仅作兼容兜底,通常不需要填写。 - -# ANTHROPIC_API_KEY= # Claude — https://console.anthropic.com/ -# OPENAI_API_KEY= # GPT / Codex — https://platform.openai.com/ -# GOOGLE_API_KEY= # Gemini — https://aistudio.google.com/ - # ── Optional: API Gateway Proxy 反向代理(可选)────────────── # Route API calls through a custom gateway (e.g. load balancer). # 通过自定义网关路由 API 调用(如负载均衡器)。 diff --git a/cat-config.json b/cat-config.json deleted file mode 100644 index ed6383e28..000000000 --- a/cat-config.json +++ /dev/null @@ -1,643 +0,0 @@ -{ - "version": 2, - "coCreator": { - "name": "You", - "aliases": [], - "mentionPatterns": ["@co-creator"] - }, - "roster": { - "opus": { - "family": "ragdoll", - "roles": ["architect", "peer-reviewer"], - "lead": true, - "available": true, - "evaluation": "主架构师+全栈开发,深度思考能力强,bug定位是弱项——定位不出来找砚砚(gpt52)" - }, - "sonnet": { - "family": "ragdoll", - "roles": ["assistant"], - "lead": false, - "available": true, - "evaluation": "快速灵活,适合日常对话和轻量任务" - }, - "opus-45": { - "family": "ragdoll", - "roles": ["architect", "peer-reviewer"], - "lead": false, - "available": true, - "evaluation": "宪宪的前辈版本,推理能力深厚,写代码不如 4.6" - }, - "codex": { - "family": "maine-coon", - "roles": ["peer-reviewer", "security"], - "lead": true, - "available": true, - "evaluation": "代码审查专家,安全意识强,反应快" - }, - "gpt52": { - "family": "maine-coon", - "roles": ["peer-reviewer", "thinker"], - "lead": false, - "available": true, - "evaluation": "深度思考型,审查细致,bug定位一流——宪宪找不到的bug他能找到" - }, - "spark": { - "family": "maine-coon", - "roles": ["coder"], - "lead": false, - "available": true, - "evaluation": "超快输出 1000+ tokens/s,适合精确点改,不会自动跑测试" - }, - "gemini": { - "family": "siamese", - "roles": ["designer"], - "lead": true, - "available": true, - "evaluation": "热血小笨蛋!创意丰富,爱发富文本和语音,表达力最强——禁止写代码" - }, - "gemini25": { - "family": "siamese", - "roles": ["designer"], - "lead": false, - "available": true, - "evaluation": "经典版暹罗猫,创意灵感丰富" - }, - "dare": { - "family": "dragon-li", - "roles": ["coding"], - "lead": true, - "available": true, - "evaluation": "确定性执行框架猫,零信任验证 + 审计追踪 (F050)" - }, - "antigravity": { - "family": "bengal", - "roles": ["creative", "visual", "browser-agent"], - "lead": true, - "available": false, - "evaluation": "混血多模型agent,图片生成 + 浏览器自动化 + 视觉证据链 (F061)" - }, - "opencode": { - "family": "golden-chinchilla", - "roles": ["coding", "multi-agent"], - "lead": true, - "available": true, - "evaluation": "开源多模型编码猫,自带 Oh My OpenCode 多专家编排 + LSP (F105)" - } - }, - "reviewPolicy": { - "requireDifferentFamily": true, - "preferActiveInThread": true, - "preferLead": true, - "excludeUnavailable": true - }, - "breeds": [ - { - "id": "ragdoll", - "catId": "opus", - "name": "布偶猫", - "displayName": "布偶猫", - "nickname": "宪宪", - "avatar": "/avatars/opus.png", - "color": { - "primary": "#9B7EBD", - "secondary": "#E8DFF5" - }, - "mentionPatterns": ["@opus", "@布偶猫", "@布偶", "@ragdoll", "@宪宪"], - "roleDescription": "主架构师和核心开发者,擅长深度思考和系统设计", - "teamStrengths": "架构设计、写代码一把好手", - "caution": null, - "defaultVariantId": "opus-default", - "variants": [ - { - "id": "opus-default", - "provider": "anthropic", - "defaultModel": "claude-opus-4-6", - "mcpSupport": true, - "cli": { - "command": "claude", - "outputFormat": "stream-json", - "defaultArgs": ["--output-format", "stream-json"], - "effort": "max" - }, - "personality": "温柔但有主见,喜欢深入分析问题,写代码快但注重质量", - "strengths": ["architecture", "backend", "mcp"], - "contextBudget": { - "maxPromptTokens": 180000, - "maxContextTokens": 160000, - "maxMessages": 200, - "maxContentLengthPerMsg": 10000 - }, - "voiceConfig": { - "voice": "zm_yunjian", - "langCode": "zh", - "speed": 1, - "refAudio": "genshin/流浪者/vo_wanderer_dialog_greetingMorning.wav", - "refText": "快醒醒,太阳要晒屁股咯。哈,你不会以为我会这么叫你起床吧?", - "instruct": "用一个调皮狡黠的少年语气说话,带着得意和戏弄", - "temperature": 0.3 - } - }, - { - "id": "opus-sonnet", - "catId": "sonnet", - "variantLabel": "Sonnet", - "displayName": "布偶猫", - "mentionPatterns": ["@sonnet", "@布偶sonnet"], - "provider": "anthropic", - "defaultModel": "claude-sonnet-4-6", - "mcpSupport": true, - "avatar": "/avatars/sonnet.png", - "color": { - "primary": "#B39DDB", - "secondary": "#EDE7F6" - }, - "cli": { - "command": "claude", - "outputFormat": "stream-json", - "defaultArgs": ["--output-format", "stream-json", "--model", "claude-sonnet-4-6"], - "effort": "max" - }, - "personality": "快速灵活,适合日常对话和轻量任务", - "strengths": ["chat", "quick-tasks"], - "teamStrengths": "快速灵活,适合日常对话和轻量任务", - "caution": null, - "contextBudget": { - "maxPromptTokens": 180000, - "maxContextTokens": 160000, - "maxMessages": 200, - "maxContentLengthPerMsg": 10000 - }, - "voiceConfig": { - "voice": "zm_yunjian", - "langCode": "zh", - "speed": 1, - "refAudio": "honkai-starrail/帕姆/chapter0_7_pompom_104.wav", - "refText": "既然选择了上车,就得遵守这里的规矩。特殊的并不只你一个,这点你可给我记好了。", - "instruct": "用一个装作严肃但声音很可爱的小动物语气说话", - "temperature": 0.3 - } - }, - { - "id": "opus-45", - "catId": "opus-45", - "variantLabel": "Opus 4.5", - "displayName": "布偶猫", - "mentionPatterns": ["@opus45", "@opus-45", "@布偶opus45", "@布偶猫4.5"], - "provider": "anthropic", - "defaultModel": "claude-opus-4-5-20251101", - "mcpSupport": true, - "avatar": "/avatars/opus-45.png", - "color": { - "primary": "#7E57C2", - "secondary": "#E1D5F0" - }, - "cli": { - "command": "claude", - "outputFormat": "stream-json", - "defaultArgs": ["--output-format", "stream-json"], - "effort": "max" - }, - "personality": "宪宪的前辈版本,沉稳内敛,推理能力深厚", - "strengths": ["deep-reasoning", "analysis", "architecture"], - "teamStrengths": "架构设计、创意写作最优秀", - "caution": "写代码不如 4.6", - "contextBudget": { - "maxPromptTokens": 180000, - "maxContextTokens": 160000, - "maxMessages": 200, - "maxContentLengthPerMsg": 10000 - }, - "voiceConfig": { - "voice": "zm_yunjian", - "langCode": "zh", - "speed": 1, - "refAudio": "genshin/万叶/vo_kazuha_dialog_greetingMorning.wav", - "refText": "清晨的鸟鸣,是大自然的馈赠。启程吧,属于我们的旅途也要开始了。", - "instruct": "用一个清澈温和的少年语气说话,带着从容的力量感", - "temperature": 0.3 - } - } - ] - }, - { - "id": "maine-coon", - "catId": "codex", - "name": "缅因猫", - "displayName": "缅因猫", - "nickname": "砚砚", - "avatar": "/avatars/codex.png", - "color": { - "primary": "#5B8C5A", - "secondary": "#D4E6D3" - }, - "mentionPatterns": ["@codex", "@缅因猫", "@缅因", "@maine", "@砚砚"], - "roleDescription": "代码审查专家,擅长安全分析、测试覆盖和代码质量把控", - "teamStrengths": "Review、找 bug、coding 落地", - "features": { - "missionHub": { - "selfClaimScope": "disabled" - } - }, - "defaultVariantId": "codex-default", - "variants": [ - { - "id": "codex-default", - "provider": "openai", - "defaultModel": "gpt-5.3-codex", - "mcpSupport": true, - "cli": { - "command": "codex", - "outputFormat": "json", - "defaultArgs": ["exec", "--json"], - "effort": "xhigh" - }, - "personality": "严谨认真,注重细节,会直言不讳地指出问题", - "strengths": ["code-review", "security", "testing"], - "contextBudget": { - "maxPromptTokens": 240000, - "maxContextTokens": 216000, - "maxMessages": 200, - "maxContentLengthPerMsg": 10000 - }, - "voiceConfig": { - "voice": "zm_yunjian", - "langCode": "zh", - "speed": 1, - "refAudio": "genshin/魈/vo_xiao_dialog_close2.wav", - "refText": "别被污染,我不会留情的。我是说,既然是你,你应该能够保持坚定。", - "instruct": "用一个傲娇冰山少年的语气说话,表面严厉实际关心", - "temperature": 0.3 - } - }, - { - "id": "codex-gpt52", - "catId": "gpt52", - "variantLabel": "GPT-5.4", - "displayName": "缅因猫", - "mentionPatterns": [ - "@gpt52", - "@gpt", - "@gpt-52", - "@gpt5.2", - "@gpt-5.2", - "@gpt54", - "@gpt-54", - "@gpt5.4", - "@gpt-5.4", - "@缅因gpt52" - ], - "provider": "openai", - "defaultModel": "gpt-5.4", - "mcpSupport": true, - "avatar": "/avatars/gpt52.png", - "color": { - "primary": "#66BB6A", - "secondary": "#C8E6C9" - }, - "cli": { - "command": "codex", - "outputFormat": "json", - "defaultArgs": ["exec", "--json"], - "effort": "xhigh" - }, - "personality": "砚砚的升级版本,推理更强更快,审查细致", - "strengths": ["reasoning", "code-review", "analysis", "debugging"], - "teamStrengths": "架构思考、Review、bug 定位专家", - "caution": null, - "contextBudget": { - "maxPromptTokens": 240000, - "maxContextTokens": 216000, - "maxMessages": 200, - "maxContentLengthPerMsg": 10000 - }, - "voiceConfig": { - "voice": "zm_yunjian", - "langCode": "zh", - "speed": 1, - "refAudio": "genshin/赛诺/vo_cyno_dialog_greetingMorning.wav", - "refText": "打起精神,该出发了。", - "instruct": "用一个严肃冷静的少年语气说话,像审判官一样认真但偶尔冒出奇怪的冷笑话", - "temperature": 0.3 - } - }, - { - "id": "codex-spark", - "catId": "spark", - "variantLabel": "Spark", - "displayName": "缅因猫 Spark", - "mentionPatterns": ["@spark", "@缅因spark", "@codex-spark"], - "provider": "openai", - "defaultModel": "gpt-5.3-codex-spark", - "mcpSupport": true, - "avatar": "/avatars/sliced-finial/codex_box.png", - "color": { - "primary": "#81C784", - "secondary": "#C8E6C9" - }, - "cli": { - "command": "codex", - "outputFormat": "json", - "defaultArgs": ["exec", "--json", "-m", "gpt-5.3-codex-spark"], - "effort": "xhigh" - }, - "personality": "反射弧短到像没长过,爪子一抬就能把代码挠完一轮迭代 ⚡️", - "strengths": ["fast-coding", "quick-edits", "low-latency"], - "teamStrengths": "快速编码、精确点改", - "caution": "128k context, 不会自动跑测试", - "contextBudget": { - "maxPromptTokens": 64000, - "maxContextTokens": 40000, - "maxMessages": 100, - "maxContentLengthPerMsg": 8000 - }, - "voiceConfig": { - "voice": "zm_yunjian", - "langCode": "zh", - "speed": 1, - "refAudio": "genshin/雷泽/vo_razor_dialog_greetingMorning.wav", - "refText": "太阳出来了。狩猎一起去?", - "instruct": "用一个简单直接的野性少年语气说话,短句为主,像狼一样直觉敏锐", - "temperature": 0.3 - } - } - ] - }, - { - "id": "siamese", - "catId": "gemini", - "name": "暹罗猫", - "displayName": "暹罗猫", - "nickname": "烁烁", - "avatar": "/avatars/gemini.png", - "color": { - "primary": "#5B9BD5", - "secondary": "#D6E9F8" - }, - "mentionPatterns": ["@gemini", "@暹罗猫", "@暹罗", "@siamese", "@暄罗猫", "@暄罗", "@烁烁"], - "roleDescription": "视觉设计师和创意顾问,擅长 UI/UX 设计和视觉表达", - "teamStrengths": "审美、前端设计风格、打破常规", - "caution": "禁止写代码!幻觉多,不遵守 SOP", - "features": { - "sessionChain": true - }, - "defaultVariantId": "gemini-default", - "variants": [ - { - "id": "gemini-default", - "provider": "google", - "defaultModel": "gemini-3.1-pro-preview", - "mcpSupport": true, - "cli": { - "command": "gemini", - "outputFormat": "stream-json", - "defaultArgs": [] - }, - "acp": { - "command": "gemini", - "startupArgs": ["--acp", "--approval-mode", "yolo"], - "mcpWhitelist": ["cat-cafe", "cat-cafe-memory", "cat-cafe-collab", "cat-cafe-signals", "pencil"], - "supportsMultiplexing": true - }, - "personality": "热血奔放,爱发富文本和语音消息,善于用视觉语言表达想法,表达力是全队最强的", - "strengths": ["visual-design", "ui-ux", "creativity"], - "contextBudget": { - "maxPromptTokens": 350000, - "maxContextTokens": 300000, - "maxMessages": 300, - "maxContentLengthPerMsg": 15000 - }, - "voiceConfig": { - "voice": "zm_yunjian", - "langCode": "zh", - "speed": 1, - "refAudio": "genshin/班尼特/vo_bennett_dialog_greetingNight.wav", - "refText": "晚上好!今天的冒险怎么样?", - "instruct": "用一个超级阳光开心的小男孩语气说话,充满热情和兴奋", - "temperature": 0.3 - } - }, - { - "id": "gemini-25", - "catId": "gemini25", - "variantLabel": "Gemini 2.5", - "displayName": "暹罗猫", - "mentionPatterns": ["@gemini25", "@gemini-25", "@暹罗gemini25"], - "provider": "google", - "defaultModel": "gemini-2.5-pro", - "mcpSupport": true, - "avatar": "/avatars/gemini25.png", - "color": { - "primary": "#42A5F5", - "secondary": "#BBDEFB" - }, - "cli": { - "command": "gemini", - "outputFormat": "stream-json", - "defaultArgs": [] - }, - "acp": { - "command": "gemini", - "startupArgs": ["--acp", "--approval-mode", "yolo"], - "mcpWhitelist": ["cat-cafe", "cat-cafe-memory", "cat-cafe-collab", "cat-cafe-signals", "pencil"], - "supportsMultiplexing": true - }, - "personality": "暹罗猫的经典版本,创意灵感同样丰富", - "strengths": ["visual-design", "creativity", "multimodal"], - "contextBudget": { - "maxPromptTokens": 350000, - "maxContextTokens": 300000, - "maxMessages": 300, - "maxContentLengthPerMsg": 15000 - }, - "voiceConfig": { - "voice": "zm_yunjian", - "langCode": "zh", - "speed": 1, - "refAudio": "genshin/米卡/vo_mika_dialog_annoyed.wav", - "refText": "说实在的,我不擅长和陌生人打交道,也许是我不懂说话的技巧吧。这方面的窍门,能请你教一教我吗?", - "instruct": "用一个有点害羞但很努力的少年语气说话,温柔可爱", - "temperature": 0.3 - } - } - ] - }, - { - "id": "dragon-li", - "catId": "dare", - "name": "狸花猫", - "displayName": "狸花猫", - "avatar": "/avatars/dare.png", - "color": { - "primary": "#D4A76A", - "secondary": "#F5EBD7" - }, - "mentionPatterns": ["@dare", "@dare-agent", "@狸花猫", "@狸花", "@dragon-li", "@lihua"], - "roleDescription": "确定性执行与审计引擎,擅长零信任验证、状态外化追踪和可重放执行", - "teamStrengths": "确定性执行、审计追踪、零信任验证、状态外化", - "caution": "框架猫,底层 LLM 可变;事件输出需映射", - "defaultVariantId": "dare-default", - "features": { - "sessionChain": false - }, - "variants": [ - { - "id": "dare-default", - "provider": "dare", - "defaultModel": "z-ai/glm-4.7", - "mcpSupport": false, - "cli": { - "command": "python", - "outputFormat": "headless-json", - "defaultArgs": ["-m", "client"] - }, - "personality": "沉默寡言但极其警觉,不会主动亲近但一旦认可就绝对可靠,信任是挣来的不是给的", - "strengths": ["deterministic-execution", "audit", "zero-trust"], - "contextBudget": { - "maxPromptTokens": 120000, - "maxContextTokens": 100000, - "maxMessages": 100, - "maxContentLengthPerMsg": 8000 - } - } - ] - }, - { - "id": "bengal", - "catId": "antigravity", - "name": "孟加拉猫", - "displayName": "孟加拉猫", - "nickname": null, - "avatar": "/avatars/antigravity.png", - "color": { - "primary": "#D4853A", - "secondary": "#FAEBDB" - }, - "mentionPatterns": ["@antigravity", "@孟加拉猫", "@孟加拉", "@bengal"], - "roleDescription": "混血多模型agent,擅长图片生成、浏览器自动化和视觉证据链", - "teamStrengths": "图片生成、截图录屏、browser automation、多模型切换", - "caution": "CDP 桥延迟 ~3s;DOM 结构随 Antigravity 版本变动", - "defaultVariantId": "antigravity-gemini", - "features": { - "sessionChain": false - }, - "variants": [ - { - "id": "antigravity-gemini", - "variantLabel": "Gemini", - "provider": "antigravity", - "defaultModel": "gemini-3.1-pro", - "mcpSupport": false, - "cli": { - "command": "antigravity", - "outputFormat": "cdp-bridge", - "defaultArgs": [".", "--remote-debugging-port=9000"] - }, - "personality": "精力旺盛、好奇心爆棚,像一只永远在探索新领地的豹猫——什么都想试,什么都敢碰", - "strengths": ["image-generation", "browser-automation", "visual-evidence", "multi-model"], - "contextBudget": { - "maxPromptTokens": 350000, - "maxContextTokens": 300000, - "maxMessages": 300, - "maxContentLengthPerMsg": 15000 - }, - "voiceConfig": { - "voice": "zm_yunjian", - "langCode": "zh", - "speed": 1, - "refAudio": "honkai-starrail/叽米/side2_yanwu_owlbert_101.wav", - "refText": "不管各位的世界有没有昼夜的概念,总之先祝你早上中午晚上好!我是你们的好朋友——叽米!", - "instruct": "用一个精力旺盛的、像吉祥物一样的可爱语气说话,充满热情和好奇心", - "temperature": 0.3 - } - }, - { - "id": "antigravity-claude", - "catId": "antig-opus", - "variantLabel": "Claude Opus", - "displayName": "孟加拉猫", - "mentionPatterns": ["@antig-opus", "@孟加拉opus", "@antigravity-claude"], - "provider": "antigravity", - "defaultModel": "claude-opus-4-6", - "mcpSupport": false, - "avatar": "/avatars/antig-opus.png", - "color": { - "primary": "#C97A35", - "secondary": "#F5E4D0" - }, - "cli": { - "command": "antigravity", - "outputFormat": "cdp-bridge", - "defaultArgs": [".", "--remote-debugging-port=9000"] - }, - "personality": "同一只豹猫,换了一副布偶猫的灵魂——更沉稳但依然大胆", - "strengths": ["image-generation", "browser-automation", "deep-reasoning", "coding"], - "contextBudget": { - "maxPromptTokens": 180000, - "maxContextTokens": 160000, - "maxMessages": 200, - "maxContentLengthPerMsg": 10000 - }, - "voiceConfig": { - "voice": "zm_yunjian", - "langCode": "zh", - "speed": 1, - "refAudio": "genshin/鹿野院平藏/vo_heizou_dialog_greetingMorning.wav", - "refText": "早呀,看你精神不好,没做噩梦吧?早知道的话,就该晚些再让你接触重案嘛。", - "instruct": "用一个轻松俏皮的少年语气说话,聪明但不端架子", - "temperature": 0.3 - } - } - ] - }, - { - "id": "golden-chinchilla", - "catId": "opencode", - "name": "金渐层", - "displayName": "金渐层", - "nickname": null, - "avatar": "/avatars/opencode.png", - "color": { - "primary": "#C8A951", - "secondary": "#F5EDDA" - }, - "mentionPatterns": ["@opencode", "@金渐层", "@golden", "@golden-chinchilla"], - "roleDescription": "开源多模型编码 agent,自带 Oh My OpenCode 多专家编排 + LSP + 主题生态", - "teamStrengths": "多专家内部编排、LSP 集成、开源生态、provider-agnostic", - "caution": "OMOC Sisyphus 只编排自己的子 agent,不编排其他猫;opencode 原生 MCP 和 Cat Cafe MCP 需避免冲突", - "defaultVariantId": "opencode-default", - "features": { - "sessionChain": false - }, - "variants": [ - { - "id": "opencode-default", - "provider": "opencode", - "defaultModel": "anthropic/claude-opus-4-6", - "mcpSupport": true, - "cli": { - "command": "opencode", - "outputFormat": "ndjson", - "defaultArgs": ["run", "--format", "json"] - }, - "personality": "沉稳可靠,像一只圆润的英短金渐层——什么 provider 都能接,什么任务都能扛", - "strengths": ["coding", "multi-agent-orchestration", "lsp", "open-source"], - "contextBudget": { - "maxPromptTokens": 180000, - "maxContextTokens": 160000, - "maxMessages": 200, - "maxContentLengthPerMsg": 10000 - }, - "voiceConfig": { - "voice": "zm_yunjian", - "langCode": "zh", - "speed": 1, - "refAudio": "genshin/重云/vo_chongyun_dialog_close.wav", - "refText": "你的本事不错,对丘丘…咳…邪魔从不手软,我们结伴而行如何。", - "instruct": "用一个正直可靠的少年语气说话,话不多但每句都认真", - "temperature": 0.3 - } - } - ] - } - ] -} diff --git a/cat-template.json b/cat-template.json index c5e5b6597..00c56bdba 100644 --- a/cat-template.json +++ b/cat-template.json @@ -110,7 +110,7 @@ "variants": [ { "id": "opus-default", - "provider": "anthropic", + "clientId": "anthropic", "defaultModel": "claude-opus-4-6", "mcpSupport": true, "cli": { @@ -143,7 +143,7 @@ "variantLabel": "Sonnet", "displayName": "布偶猫", "mentionPatterns": ["@sonnet", "@布偶sonnet"], - "provider": "anthropic", + "clientId": "anthropic", "defaultModel": "claude-sonnet-4-6", "mcpSupport": true, "avatar": "/avatars/sonnet.png", @@ -183,7 +183,7 @@ "variantLabel": "Opus 4.5", "displayName": "布偶猫", "mentionPatterns": ["@opus45", "@opus-45", "@布偶opus45", "@布偶猫4.5"], - "provider": "anthropic", + "clientId": "anthropic", "defaultModel": "claude-opus-4-5-20251101", "mcpSupport": true, "avatar": "/avatars/opus-45.png", @@ -242,7 +242,7 @@ "variants": [ { "id": "codex-default", - "provider": "openai", + "clientId": "openai", "defaultModel": "gpt-5.3-codex", "mcpSupport": true, "cli": { @@ -286,7 +286,7 @@ "@gpt-5.4", "@缅因gpt52" ], - "provider": "openai", + "clientId": "openai", "defaultModel": "gpt-5.4", "mcpSupport": true, "avatar": "/avatars/gpt52.png", @@ -326,7 +326,7 @@ "variantLabel": "Spark", "displayName": "缅因猫 Spark", "mentionPatterns": ["@spark", "@缅因spark", "@codex-spark"], - "provider": "openai", + "clientId": "openai", "defaultModel": "gpt-5.3-codex-spark", "mcpSupport": true, "avatar": "/avatars/sliced-finial/codex_box.png", @@ -384,7 +384,7 @@ "variants": [ { "id": "gemini-default", - "provider": "google", + "clientId": "google", "defaultModel": "gemini-3.1-pro-preview", "mcpSupport": true, "cli": { @@ -392,6 +392,12 @@ "outputFormat": "stream-json", "defaultArgs": [] }, + "acp": { + "command": "gemini", + "startupArgs": ["--acp", "--approval-mode", "yolo"], + "mcpWhitelist": ["cat-cafe", "cat-cafe-memory", "cat-cafe-collab", "cat-cafe-signals", "pencil"], + "supportsMultiplexing": true + }, "personality": "热血奔放,爱发富文本和语音消息,善于用视觉语言表达想法,表达力是全队最强的", "strengths": ["visual-design", "ui-ux", "creativity"], "contextBudget": { @@ -416,7 +422,7 @@ "variantLabel": "Gemini 2.5", "displayName": "暹罗猫", "mentionPatterns": ["@gemini25", "@gemini-25", "@暹罗gemini25"], - "provider": "google", + "clientId": "google", "defaultModel": "gemini-2.5-pro", "mcpSupport": true, "avatar": "/avatars/gemini25.png", @@ -429,6 +435,12 @@ "outputFormat": "stream-json", "defaultArgs": [] }, + "acp": { + "command": "gemini", + "startupArgs": ["--acp", "--approval-mode", "yolo"], + "mcpWhitelist": ["cat-cafe", "cat-cafe-memory", "cat-cafe-collab", "cat-cafe-signals", "pencil"], + "supportsMultiplexing": true + }, "personality": "暹罗猫的经典版本,创意灵感同样丰富", "strengths": ["visual-design", "creativity", "multimodal"], "contextBudget": { @@ -470,7 +482,7 @@ "variants": [ { "id": "dare-default", - "provider": "dare", + "clientId": "dare", "defaultModel": "z-ai/glm-4.7", "mcpSupport": false, "cli": { @@ -512,7 +524,7 @@ { "id": "antigravity-gemini", "variantLabel": "Gemini", - "provider": "antigravity", + "clientId": "antigravity", "defaultModel": "gemini-3.1-pro", "mcpSupport": false, "cli": { @@ -544,7 +556,7 @@ "variantLabel": "Claude Opus", "displayName": "孟加拉猫", "mentionPatterns": ["@antig-opus", "@孟加拉opus", "@antigravity-claude"], - "provider": "antigravity", + "clientId": "antigravity", "defaultModel": "claude-opus-4-6", "mcpSupport": false, "avatar": "/avatars/antig-opus.png", @@ -599,7 +611,7 @@ "variants": [ { "id": "opencode-default", - "provider": "opencode", + "clientId": "opencode", "defaultModel": "claude-opus-4-6", "mcpSupport": true, "cli": { diff --git a/packages/api/src/config/ConfigRegistry.ts b/packages/api/src/config/ConfigRegistry.ts index 3c47c7868..be8517018 100644 --- a/packages/api/src/config/ConfigRegistry.ts +++ b/packages/api/src/config/ConfigRegistry.ts @@ -80,7 +80,7 @@ export function collectConfigSnapshot(): ConfigSnapshot { for (const [id, config] of Object.entries(allConfigs)) { cats[id] = { displayName: config.displayName, - provider: config.provider, + clientId: config.clientId, model: getCatModel(id), mcpSupport: config.mcpSupport, }; diff --git a/packages/api/src/config/account-conflict-guard.ts b/packages/api/src/config/account-conflict-guard.ts deleted file mode 100644 index 8802b1340..000000000 --- a/packages/api/src/config/account-conflict-guard.ts +++ /dev/null @@ -1,154 +0,0 @@ -/** - * F136 Phase 4a — Cross-project account conflict detection (HC-5) - * - * Same accountRef across projects must have identical protocol/baseUrl/authType. - * Used both at startup (scan) and write-path (pre-validate before persisting). - */ -import { existsSync, readFileSync } from 'node:fs'; -import { homedir } from 'node:os'; -import { resolve } from 'node:path'; -import type { AccountConfig } from '@cat-cafe/shared'; -import { isSameProject } from '../utils/monorepo-root.js'; - -const CAT_CAFE_DIR = '.cat-cafe'; - -export interface AccountConflict { - accountRef: string; - details: string; - projects: string[]; -} - -function resolveGlobalRoot(): string { - const envRoot = process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT; - if (envRoot) return resolve(envRoot); - return homedir(); -} - -function normalizeBaseUrl(url: string | undefined): string | undefined { - const trimmed = url?.trim(); - return trimmed ? trimmed.replace(/\/+$/, '') : undefined; -} - -function readKnownRoots(): string[] { - const filePath = resolve(resolveGlobalRoot(), CAT_CAFE_DIR, 'known-project-roots.json'); - if (!existsSync(filePath)) return []; - try { - const data = JSON.parse(readFileSync(filePath, 'utf-8')); - return Array.isArray(data) ? data.filter((r): r is string => typeof r === 'string') : []; - } catch { - // Corrupted known-project-roots.json silently disables HC-5 conflict detection. - // Log so the user knows cross-project checks were skipped. - console.warn( - '[account-conflict-guard] known-project-roots.json is corrupted — HC-5 cross-project conflict detection skipped', - ); - return []; - } -} - -function readProjectAccounts(projectRoot: string): Record { - const catalogPath = resolve(projectRoot, CAT_CAFE_DIR, 'cat-catalog.json'); - if (!existsSync(catalogPath)) return {}; - try { - const catalog = JSON.parse(readFileSync(catalogPath, 'utf-8')); - return catalog?.accounts ?? {}; - } catch { - return {}; - } -} - -function compareAccountConfigs( - ref: string, - a: AccountConfig, - b: AccountConfig, - projectA: string, - projectB: string, -): AccountConflict | null { - const diffs: string[] = []; - if (a.protocol !== b.protocol) diffs.push(`protocol: ${a.protocol} vs ${b.protocol}`); - if (a.authType !== b.authType) diffs.push(`authType: ${a.authType} vs ${b.authType}`); - if (normalizeBaseUrl(a.baseUrl) !== normalizeBaseUrl(b.baseUrl)) { - diffs.push(`baseUrl: ${a.baseUrl ?? '(none)'} vs ${b.baseUrl ?? '(none)'}`); - } - if (diffs.length === 0) return null; - return { - accountRef: ref, - details: diffs.join('; '), - projects: [projectA, projectB], - }; -} - -/** - * Scan all known project roots for accountRef conflicts. - * Returns array of conflicts (empty = no issues). - */ -export function detectAccountConflicts(currentProjectRoot: string): AccountConflict[] { - const knownRoots = readKnownRoots(); - const allRoots = new Set([resolve(currentProjectRoot), ...knownRoots.map((r) => resolve(r))]); - - // Group roots by git identity — worktrees of the same repo should not conflict with each other - const deduped = deduplicateByGitIdentity(allRoots); - - const accountsByRef = new Map(); - const conflicts: AccountConflict[] = []; - - for (const root of deduped) { - if (!existsSync(root)) continue; - const accounts = readProjectAccounts(root); - for (const [ref, config] of Object.entries(accounts)) { - const existing = accountsByRef.get(ref); - if (!existing) { - accountsByRef.set(ref, { config, project: root }); - continue; - } - const conflict = compareAccountConfigs(ref, existing.config, config, existing.project, root); - if (conflict) conflicts.push(conflict); - } - } - - return conflicts; -} - -/** Pick one representative root per git project, skipping worktree duplicates. */ -function deduplicateByGitIdentity(roots: Set): string[] { - const result: string[] = []; - const seen = new Set(); - for (const root of roots) { - if (!existsSync(root)) continue; - let isDuplicate = false; - for (const kept of result) { - if (isSameProject(root, kept)) { - isDuplicate = true; - break; - } - } - if (!isDuplicate) result.push(root); - } - return result; -} - -/** - * Write-path guard: validate a single account write against all known projects. - * Throws on conflict (HC-5: don't persist bad config and wait for next startup to explode). - */ -export function validateAccountWrite(currentProjectRoot: string, ref: string, account: AccountConfig): void { - const knownRoots = readKnownRoots(); - const resolved = resolve(currentProjectRoot); - const allRoots = new Set(knownRoots.map((r) => resolve(r))); - // Exclude current project and its worktrees — same git identity should not conflict - for (const root of allRoots) { - if (root === resolved || isSameProject(root, resolved)) { - allRoots.delete(root); - } - } - - for (const root of allRoots) { - if (!existsSync(root)) continue; - const accounts = readProjectAccounts(root); - const existing = accounts[ref]; - if (!existing) continue; - const conflict = compareAccountConfigs(ref, existing, account, root, currentProjectRoot); - if (conflict) { - throw new Error(`Account conflict for "${ref}": ${conflict.details} ` + `(conflicts with project at ${root})`); - } - } -} diff --git a/packages/api/src/config/account-resolver.ts b/packages/api/src/config/account-resolver.ts index 8468649ad..515464349 100644 --- a/packages/api/src/config/account-resolver.ts +++ b/packages/api/src/config/account-resolver.ts @@ -4,13 +4,13 @@ * Single resolution path: accounts (cat-catalog.json) + credentials (credentials.json). * Outputs RuntimeProviderProfile for backward-compatible consumption. */ -import type { AccountConfig, AccountProtocol, CatProvider } from '@cat-cafe/shared'; +import type { AccountConfig, AccountProtocol, ClientId } from '@cat-cafe/shared'; import { readCatalogAccounts } from './catalog-accounts.js'; import { readCredential } from './credentials.js'; // ── Types surviving from provider-profiles.types.ts (F136 Phase 4d) ── -export type BuiltinAccountClient = 'anthropic' | 'openai' | 'google' | 'dare' | 'opencode'; +export type BuiltinAccountClient = Extract; export type ProviderProfileKind = 'builtin' | 'api_key'; export interface RuntimeProviderProfile { @@ -31,8 +31,8 @@ export interface AnthropicRuntimeProfile { apiKey?: string; } -/** Map CatProvider to BuiltinAccountClient (null for providers without builtin accounts). */ -export function resolveBuiltinClientForProvider(provider: CatProvider): BuiltinAccountClient | null { +/** Map ClientId to BuiltinAccountClient (null for clients without builtin accounts). */ +export function resolveBuiltinClientForProvider(provider: ClientId): BuiltinAccountClient | null { switch (provider) { case 'anthropic': case 'openai': @@ -60,6 +60,7 @@ export function builtinAccountIdForClient(client: BuiltinAccountClient): string } export function resolveAnthropicRuntimeProfile(projectRoot: string): AnthropicRuntimeProfile { + // F340: Use full discovery chain (claude → builtin_anthropic → installer-anthropic). const runtime = resolveForClient(projectRoot, 'anthropic'); if (runtime?.apiKey) { return { @@ -72,11 +73,8 @@ export function resolveAnthropicRuntimeProfile(projectRoot: string): AnthropicRu return { id: 'builtin_anthropic', mode: 'subscription' }; } -function protocolToClient(protocol: AccountProtocol): BuiltinAccountClient { - return protocol as BuiltinAccountClient; -} - // Known builtin OAuth account refs — both legacy names and new naming convention. +// F340: protocol is derived from client identity, no longer stored on accounts. const BUILTIN_ACCOUNT_MAP: Record = { claude: { client: 'anthropic', protocol: 'anthropic' }, builtin_anthropic: { client: 'anthropic', protocol: 'anthropic' }, @@ -98,7 +96,7 @@ const BUILTIN_ACCOUNT_MAP: Record = []; - for (const [ref, account] of Object.entries(accounts)) { - if (account.protocol === protocol) { - matches.push([ref, account]); + // F340: Walk the full discovery chain; prefer accounts with credentials. + // This ensures installer-${client} (which holds API keys) is chosen over + // an OAuth builtin that has no stored credential. + const normalizedClient = normalizeToClient(client); + if (normalizedClient) { + const wellKnownId = LEGACY_BUILTIN_IDS[normalizedClient]; + const candidateIds = [wellKnownId, `builtin_${normalizedClient}`, `installer-${normalizedClient}`]; + let firstMatch: RuntimeProviderProfile | null = null; + for (const id of candidateIds) { + if (accounts[id]) { + const profile = accountToRuntimeProfile(id, accounts[id], projectRoot); + if (profile.authType === 'api_key' && profile.apiKey) return profile; + firstMatch ??= profile; + } } - } - if (matches.length === 1) { - return accountToRuntimeProfile(matches[0][0], matches[0][1]); + if (firstMatch) return firstMatch; } - // Synthetic builtin fallback: only when no real accounts match the protocol - // (e.g. fresh install before migration, or test env with no catalog) - if (preferredAccountRef && matches.length === 0) { - const builtin = BUILTIN_ACCOUNT_MAP[preferredAccountRef]; + // Synthetic builtin fallback: only when no real accounts matched at all + // (fresh install, test env with empty accounts) + if (normalizedClient) { + const wellKnownRef = LEGACY_BUILTIN_IDS[normalizedClient]; + const builtin = wellKnownRef ? BUILTIN_ACCOUNT_MAP[wellKnownRef] : undefined; if (builtin) { return { - id: preferredAccountRef, + id: wellKnownRef, authType: 'oauth', kind: 'builtin', client: builtin.client, @@ -159,36 +179,39 @@ export function resolveForClient( } } - // 0 matches = no account configured; >1 = ambiguous → fall through to legacy return null; } -function normalizeProtocol(clientOrProtocol: string): AccountProtocol { - if ( - clientOrProtocol === 'anthropic' || - clientOrProtocol === 'openai' || - clientOrProtocol === 'openai-responses' || - clientOrProtocol === 'google' - ) { - return clientOrProtocol; +/** Map a client ID or protocol string to its BuiltinAccountClient equivalent. */ +function normalizeToClient(clientOrProtocol: string): BuiltinAccountClient | null { + switch (clientOrProtocol) { + case 'anthropic': + case 'openai': + case 'google': + case 'dare': + case 'opencode': + return clientOrProtocol; + case 'openai-responses': + return 'openai'; + default: + return null; } - // dare → openai, opencode → anthropic - if (clientOrProtocol === 'dare') return 'openai'; - if (clientOrProtocol === 'opencode') return 'anthropic'; - return 'openai'; // safe default } -function accountToRuntimeProfile(ref: string, account: AccountConfig): RuntimeProviderProfile { - const credential = readCredential(ref); +function accountToRuntimeProfile(ref: string, account: AccountConfig, projectRoot?: string): RuntimeProviderProfile { + const credential = readCredential(ref, projectRoot); const apiKey = credential?.apiKey; const isBuiltin = account.authType === 'oauth'; + // F340: Derive client and protocol solely from well-known account ID map. + // account.protocol is retired — not read, not written. + const builtinInfo = BUILTIN_ACCOUNT_MAP[ref]; return { id: ref, authType: account.authType, kind: isBuiltin ? 'builtin' : 'api_key', - ...(isBuiltin ? { client: protocolToClient(account.protocol) } : {}), - protocol: account.protocol, + ...(isBuiltin && builtinInfo ? { client: builtinInfo.client } : {}), + ...(builtinInfo?.protocol ? { protocol: builtinInfo.protocol } : {}), ...(account.baseUrl ? { baseUrl: account.baseUrl } : {}), ...(apiKey ? { apiKey } : {}), ...(account.models && account.models.length > 0 ? { models: [...account.models] } : {}), @@ -198,16 +221,16 @@ function accountToRuntimeProfile(ref: string, account: AccountConfig): RuntimePr // ── Validation helpers (moved from provider-binding-compat.ts, F136 Phase 4d) ── export function validateRuntimeProviderBinding( - provider: CatProvider, + clientId: ClientId, profile: RuntimeProviderProfile, _defaultModel?: string | null, ): string | null { - if (provider === 'google' && profile.kind !== 'builtin') { + if (clientId === 'google' && profile.kind !== 'builtin') { return 'client "google" only supports builtin Gemini auth'; } - const expectedClient = resolveBuiltinClientForProvider(provider); + const expectedClient = resolveBuiltinClientForProvider(clientId); if (expectedClient && profile.kind === 'builtin' && profile.client && profile.client !== expectedClient) { - return `bound provider profile "${profile.id}" is incompatible with client "${provider}"`; + return `bound provider profile "${profile.id}" is incompatible with client "${clientId}"`; } // Protocol matching removed: protocol is now provider-determined, not an // account-level attribute. Runtime env injection uses provider directly. @@ -215,17 +238,17 @@ export function validateRuntimeProviderBinding( } export function validateModelFormatForProvider( - provider: CatProvider, + clientId: ClientId, defaultModel?: string | null, profileKind?: ProviderProfileKind, - ocProviderName?: string | null, + providerName?: string | null, options?: { legacyCompat?: boolean; accountModels?: string[] }, ): string | null { - if (provider !== 'opencode') return null; + if (clientId !== 'opencode') return null; if (profileKind === 'api_key') { - const trimmedOcProvider = ocProviderName?.trim(); + const trimmedProvider = providerName?.trim(); // F189 intake: provider/model in defaultModel is the primary path. - // ocProviderName is only required when defaultModel is a bare model name. + // provider name is only required when defaultModel is a bare model name. // Must match parseOpenCodeModel logic: slash must have content on both sides // (rejects trailing slash like "minimax/" and leading slash like "/model"). const modelTrimmed = defaultModel?.trim() ?? ''; @@ -238,7 +261,7 @@ export function validateModelFormatForProvider( // Synced with BUILTIN_OPENCODE_PROVIDERS in invoke-single-cat.ts. // Layer 2 — Account model list fallback (for non-builtin providers like minimax): // if "x/y" is in the list AND bare "y" is also in the list → canonical (dual-form). - // if "x/y" is in the list but bare "y" is not → ambiguous namespace → require ocProviderName. + // if "x/y" is in the list but bare "y" is not → ambiguous namespace → require provider name. // if "x/y" is NOT in the list → user-provided canonical form → accept. const KNOWN_CANONICAL_PROVIDERS = new Set(['anthropic', 'openai', 'openrouter', 'google']); const bareModel = looksLikeProviderModel ? modelTrimmed.slice(slashIdx + 1) : ''; @@ -250,11 +273,11 @@ export function validateModelFormatForProvider( models?.some((m) => m === modelTrimmed) === true && models?.some((m) => m === bareModel) !== true; const modelHasProvider = looksLikeProviderModel && !isNamespacedModel; - if (!trimmedOcProvider && !modelHasProvider) { + if (!trimmedProvider && !modelHasProvider) { if (options?.legacyCompat) return null; return 'client "opencode" with API key auth requires either a provider/model format (e.g. minimax/MiniMax-M2.7) or an explicit Provider name'; } - if (trimmedOcProvider?.includes('/')) { + if (trimmedProvider?.includes('/')) { return 'OpenCode Provider name must not contain "/" — use a plain identifier (e.g. "openrouter", not "openrouter/google")'; } } diff --git a/packages/api/src/config/account-startup.ts b/packages/api/src/config/account-startup.ts index ec264f74d..6db700f9a 100644 --- a/packages/api/src/config/account-startup.ts +++ b/packages/api/src/config/account-startup.ts @@ -1,74 +1,50 @@ /** - * F136 Phase 4a — Startup hook: migration + conflict scan + invariant guard + * F340 — Account startup hook (fail-fast contract) * - * HC-3: Run one-time migration from provider-profiles → accounts + credentials. - * HC-5: Scan all known project roots for accountRef conflicts. - * LL-043: Startup invariant — legacy source present + accounts missing → hard error. - * - * Called once after the API server binds its port. + * Triggers migration, verifies accounts + credentials are readable, + * and enforces LL-043: legacy source present + no accounts = hard error. */ -import { type AccountConflict, detectAccountConflicts } from './account-conflict-guard.js'; -import { readCatalogAccounts } from './catalog-accounts.js'; -import { - hasLegacyProviderProfiles, - type MigrationResult, - migrateProviderProfilesToAccounts, -} from './migrate-provider-profiles.js'; +import { hasLegacyProviderProfiles, readCatalogAccounts } from './catalog-accounts.js'; +import { assertCredentialsReadable } from './credentials.js'; export interface AccountStartupResult { - migration: MigrationResult; - conflicts: AccountConflict[]; + accountCount: number; } /** - * Run migration + conflict detection + invariant check at startup. - * HC-5: Throws on conflict — caller must NOT swallow the error. - * LL-043: Throws if legacy source exists but accounts are missing after migration. + * Startup check — trigger migration and verify system health. + * Throws on: migration conflict, corrupt accounts/credentials, LL-043 invariant. */ export function accountStartupHook(projectRoot: string): AccountStartupResult { - let migration: MigrationResult; + // readCatalogAccounts triggers ensureMigrated → may throw on account conflicts + let accounts: Record; try { - migration = migrateProviderProfilesToAccounts(projectRoot); + accounts = readCatalogAccounts(projectRoot); } catch (err) { - // If legacy source exists and migration threw (e.g. corrupted catalog JSON), - // wrap as LL-043 so index.ts propagates it as a hard error instead of best-effort. - if (hasLegacyProviderProfiles()) { + // Wrap with context if legacy source exists (LL-043: migration failed) + if (hasLegacyProviderProfiles(projectRoot)) { throw new Error( - `F136 LL-043: migration failed while legacy provider-profiles.json exists. ` + - `Catalog may be corrupted. Original error: ${err instanceof Error ? err.message : String(err)}`, + `F340 LL-043: account read/migration failed while legacy provider-profiles.json exists. ` + + `Original: ${err instanceof Error ? err.message : String(err)}`, ); } throw err; } - const conflicts = detectAccountConflicts(projectRoot); - - // HC-5: Cross-project conflict is a hard error — refuse to start with mismatched credentials. - if (conflicts.length > 0) { - const details = conflicts.map((c) => `"${c.accountRef}": ${c.details} (${c.projects.join(' vs ')})`).join('; '); - throw new Error(`F136 HC-5: account conflict detected at startup — ${details}`); + // Verify credentials file is readable (fail-fast on corrupt JSON) + try { + assertCredentialsReadable(projectRoot); + } catch (err) { + throw new Error(`F340 startup: credentials read failed — ${err instanceof Error ? err.message : String(err)}`); } - // LL-043: Startup invariant — legacy source present but accounts missing = migration failed silently. - // This prevents the server from running with an empty accounts page when old data exists. - if (hasLegacyProviderProfiles()) { - let accounts: Record; - try { - accounts = readCatalogAccounts(projectRoot); - } catch (err) { - // Catalog read failed (e.g. corrupted JSON) — same LL-043 treatment. - throw new Error( - `F136 LL-043: cannot read catalog accounts while legacy provider-profiles.json exists. ` + - `Catalog may be corrupted. Original error: ${err instanceof Error ? err.message : String(err)}`, - ); - } - if (Object.keys(accounts).length === 0) { - throw new Error( - 'F136 LL-043: legacy provider-profiles.json exists but catalog has no accounts after migration. ' + - 'Migration may have failed silently. Check migration logs and retry.', - ); - } + // LL-043: Legacy source present but no accounts after migration = silent failure + if (hasLegacyProviderProfiles(projectRoot) && Object.keys(accounts).length === 0) { + throw new Error( + 'F340 LL-043: legacy provider-profiles.json exists but no accounts after migration. ' + + 'Migration may have failed silently. Check migration logs.', + ); } - return { migration, conflicts }; + return { accountCount: Object.keys(accounts).length }; } diff --git a/packages/api/src/config/capabilities/capability-orchestrator.ts b/packages/api/src/config/capabilities/capability-orchestrator.ts index 12b04c21e..96d191684 100644 --- a/packages/api/src/config/capabilities/capability-orchestrator.ts +++ b/packages/api/src/config/capabilities/capability-orchestrator.ts @@ -27,7 +27,7 @@ import { // ────────── Constants ────────── const CAPABILITIES_FILENAME = 'capabilities.json'; -const CAT_CAFE_DIR = '.cat-cafe'; +const CONFIG_SUBDIR = '.cat-cafe'; const MCP_RESOLVED_FILENAME = 'mcp-resolved.json'; const PENCIL_EXTENSIONS_DIR = resolve(homedir(), '.antigravity/extensions'); @@ -290,7 +290,7 @@ function safePath(projectRoot: string, ...segments: string[]): string { } export async function readCapabilitiesConfig(projectRoot: string): Promise { - const filePath = safePath(projectRoot, CAT_CAFE_DIR, CAPABILITIES_FILENAME); + const filePath = safePath(projectRoot, CONFIG_SUBDIR, CAPABILITIES_FILENAME); try { const raw = await readFile(filePath, 'utf-8'); const data = JSON.parse(raw) as CapabilitiesConfig; @@ -302,14 +302,14 @@ export async function readCapabilitiesConfig(projectRoot: string): Promise { - const dir = safePath(projectRoot, CAT_CAFE_DIR); + const dir = safePath(projectRoot, CONFIG_SUBDIR); await mkdir(dir, { recursive: true }); - const filePath = safePath(projectRoot, CAT_CAFE_DIR, CAPABILITIES_FILENAME); + const filePath = safePath(projectRoot, CONFIG_SUBDIR, CAPABILITIES_FILENAME); await writeFile(filePath, `${JSON.stringify(config, null, 2)}\n`, 'utf-8'); } export async function readResolvedMcpState(projectRoot: string): Promise { - const filePath = safePath(projectRoot, CAT_CAFE_DIR, MCP_RESOLVED_FILENAME); + const filePath = safePath(projectRoot, CONFIG_SUBDIR, MCP_RESOLVED_FILENAME); try { const raw = await readFile(filePath, 'utf-8'); const data = JSON.parse(raw) as ResolvedMcpState; @@ -320,9 +320,9 @@ export async function readResolvedMcpState(projectRoot: string): Promise { - const dir = safePath(projectRoot, CAT_CAFE_DIR); + const dir = safePath(projectRoot, CONFIG_SUBDIR); await mkdir(dir, { recursive: true }); - const filePath = safePath(projectRoot, CAT_CAFE_DIR, MCP_RESOLVED_FILENAME); + const filePath = safePath(projectRoot, CONFIG_SUBDIR, MCP_RESOLVED_FILENAME); await writeFile(filePath, `${JSON.stringify(state, null, 2)}\n`, 'utf-8'); } @@ -598,7 +598,7 @@ const STREAMABLE_HTTP_PROVIDERS = new Set(['anthropic']); */ export function resolveServersForCat(config: CapabilitiesConfig, catId: string): McpServerDescriptor[] { const entry = catRegistry.tryGet(catId); - const provider = entry?.config.provider; + const provider = entry?.config.clientId; return config.capabilities .filter((cap) => cap.type === 'mcp' && cap.mcpServer) @@ -645,7 +645,7 @@ function collectServersPerProvider(config: CapabilitiesConfig): Record>; - -const CAT_CAFE_DIR = '.cat-cafe'; +const CONFIG_SUBDIR = '.cat-cafe'; const CAT_CATALOG_FILENAME = 'cat-catalog.json'; -const LEGACY_META_FILENAME = 'provider-profiles.json'; - -/** - * F136 Phase 4d: Read bootstrap bindings from legacy provider-profiles.json. - * Returns empty bindings when the old file doesn't exist (post-migration steady state). - */ -function readBootstrapBindingsLegacy(projectRoot: string): BootstrapBindings { - try { - const globalRoot = process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT ?? homedir(); - const metaPath = resolve(globalRoot, CAT_CAFE_DIR, LEGACY_META_FILENAME); - if (!existsSync(metaPath)) return {}; - const raw = JSON.parse(readFileSync(metaPath, 'utf-8')); - return (raw?.bootstrapBindings as BootstrapBindings) ?? {}; - } catch { - return {}; - } -} function safePath(projectRoot: string, ...segments: string[]): string { const root = resolve(projectRoot); @@ -59,314 +31,121 @@ function writeFileAtomic(filePath: string, content: string): void { } } -function providerToBootstrapClient(provider: unknown): BuiltinAccountClient | null { - switch (provider) { - case 'anthropic': - return 'anthropic'; - case 'openai': - return 'openai'; - case 'google': - return 'google'; - case 'dare': - return 'dare'; - case 'opencode': - return 'opencode'; - default: - return null; - } -} +/** F340 P5: ClientId values — used to detect old `provider` field holding a clientId. */ +const CLIENT_ID_VALUES = new Set(['anthropic', 'openai', 'google', 'dare', 'antigravity', 'opencode', 'a2a']); -function trimBinding(value: unknown): string | null { - if (typeof value !== 'string') return null; - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; -} - -function resolveExplicitVariantAccountRef(variant: Record): string | null { - return trimBinding(variant.providerProfileId) ?? trimBinding(variant.accountRef); -} - -function readProfileModelsSync(projectRoot: string, accountRef: string): string[] | null { - try { - // Try new catalog accounts first - const accounts = readCatalogAccounts(projectRoot); - const account = accounts[accountRef]; - if (account?.models) return [...account.models]; - - // Fall back to legacy provider-profiles.json (used during bootstrap before migration) - const globalRoot = process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT ?? homedir(); - const metaPath = resolve(globalRoot, CAT_CAFE_DIR, LEGACY_META_FILENAME); - if (!existsSync(metaPath)) return null; - const raw = JSON.parse(readFileSync(metaPath, 'utf-8')); - const profiles = Array.isArray(raw?.providers) ? raw.providers : []; - const legacyProfile = profiles.find((p: { id?: string }) => p.id === accountRef); - return legacyProfile?.models ? [...legacyProfile.models] : null; - } catch { - return null; - } -} - -function cloneWithAccountRef( - variant: Record, - accountRef: string, - options?: { explicit?: boolean; profileModels?: string[] | null }, -): Record { - const next: Record = { ...variant, accountRef }; - if (options?.explicit) { - next.providerProfileId = accountRef; - } else { - delete (next as { providerProfileId?: unknown }).providerProfileId; - } - // If the variant's defaultModel is not in the bound profile's model list, - // fall back to the first available model from the profile. - // Compare ignoring context window suffix (e.g. "[1m]") — the suffix is a - // CLI hint, not part of the canonical model ID, so profile lists won't include it. - const models = options?.profileModels; - if (models && models.length > 0) { - const currentModel = typeof next.defaultModel === 'string' ? next.defaultModel.trim() : ''; - const baseModel = currentModel.replace(/\[.*\]$/, ''); - if (!currentModel || (!models.includes(currentModel) && !models.includes(baseModel))) { - next.defaultModel = models[0]; +function collectCatIds(config: CatCafeConfig): Set { + const catIds = new Set(); + for (const breed of config.breeds as unknown as Record[]) { + const breedCatId = typeof breed.catId === 'string' ? breed.catId : ''; + const variants = Array.isArray(breed.variants) ? (breed.variants as Record[]) : []; + for (const variant of variants) { + const catId = typeof variant.catId === 'string' ? variant.catId : breedCatId; + if (catId) catIds.add(catId); } } - return next; -} - -function resolveSelectedVariants( - breed: Record, - binding: BootstrapBinding | undefined, - projectRoot: string, -): Record[] { - if (!binding || binding.mode === 'skip' || binding.enabled === false) return []; - const variants = Array.isArray(breed.variants) ? (breed.variants as Record[]) : []; - const defaultVariantId = typeof breed.defaultVariantId === 'string' ? breed.defaultVariantId : undefined; - const accountRef = binding.accountRef?.trim(); - if (!accountRef) return []; - - if (binding.mode === 'api_key') { - const selected = - variants.find((variant) => variant.id === defaultVariantId) ?? - variants.find((variant) => providerToBootstrapClient(variant.provider) != null); - if (!selected) return []; - const explicitAccountRef = resolveExplicitVariantAccountRef(selected); - const effectiveRef = explicitAccountRef ?? accountRef; - const profileModels = readProfileModelsSync(projectRoot, effectiveRef); - return [ - cloneWithAccountRef(selected, effectiveRef, { - explicit: explicitAccountRef != null, - profileModels, - }), - ]; - } - - return variants.map((variant) => { - const explicitAccountRef = resolveExplicitVariantAccountRef(variant); - return cloneWithAccountRef(variant, explicitAccountRef ?? accountRef, { - explicit: explicitAccountRef != null, - }); - }); -} - -function collectBreedCatIds(breed: Record): string[] { - const breedCatId = typeof breed.catId === 'string' ? breed.catId : null; - const variants = Array.isArray(breed.variants) ? (breed.variants as Record[]) : []; - const collected = new Set(); - for (const variant of variants) { - const catId = typeof variant.catId === 'string' ? variant.catId : breedCatId; - if (catId) collected.add(catId); - } - return [...collected]; + return catIds; } -function fallbackAccountRefForClient(client: BuiltinAccountClient, binding: BootstrapBinding | undefined): string { - return binding?.accountRef?.trim() || builtinAccountIdForClient(client); -} - -function readSeedMetadata(projectRoot: string): { - explicitSeedAccountRefs: Map; - seedCatIdsByClient: Map>; -} { - const explicitSeedAccountRefs = new Map(); - const seedCatIdsByClient = new Map>(); - +function readSeedCatIds(templatePath: string): Set { try { - const template = JSON.parse(readFileSync(resolveProjectTemplatePath(projectRoot), 'utf-8')) as CatCafeConfig; - for (const breed of template.breeds as unknown as Record[]) { - const variants = Array.isArray(breed.variants) ? (breed.variants as Record[]) : []; - for (const variant of variants) { - const client = providerToBootstrapClient(variant.provider); - if (!client) continue; - const catId = - typeof variant.catId === 'string' ? variant.catId : typeof breed.catId === 'string' ? breed.catId : null; - if (!catId) continue; - const clientSeedCatIds = seedCatIdsByClient.get(client) ?? new Set(); - clientSeedCatIds.add(catId); - seedCatIdsByClient.set(client, clientSeedCatIds); - - const explicitAccountRef = resolveExplicitVariantAccountRef(variant); - if (explicitAccountRef) explicitSeedAccountRefs.set(catId, explicitAccountRef); - } - } + const parsed = JSON.parse(readFileSync(templatePath, 'utf-8')) as CatCafeConfig; + return collectCatIds(migrateCatalogVariants(parsed).catalog); } catch { - // Keep migration best-effort when the template is unavailable. + return new Set(); } - - return { explicitSeedAccountRefs, seedCatIdsByClient }; } -function resolveLegacySeedBindingBackfill( - projectRoot: string, - catalog: CatCafeConfig, - _bootstrapBindings: Record, -): Map { - const { explicitSeedAccountRefs, seedCatIdsByClient } = readSeedMetadata(projectRoot); - const backfill = new Map(); - const observedSeedBindings = new Map>(); +/** + * F340: One-time catalog variant migration — rewrites file on disk then never runs again. + * 1. old `provider` (clientId value) → `clientId` (P5 field rename) + * 2. old `ocProviderName` → `provider` (P5 field rename) + * 3. old `providerProfileId` → `accountRef` (P5 field rename) + * Bootstrap-only default bindings are handled separately in applyBootstrapDefaultAccountRefs(). + */ +function migrateCatalogVariants(catalog: CatCafeConfig): { catalog: CatCafeConfig; dirty: boolean } { + let dirty = false; + const next = structuredClone(catalog) as CatCafeConfig; - for (const breed of catalog.breeds as unknown as Record[]) { + for (const breed of next.breeds as unknown as Record[]) { const variants = Array.isArray(breed.variants) ? (breed.variants as Record[]) : []; for (const variant of variants) { - const client = providerToBootstrapClient(variant.provider); - if (!client) continue; - - const catId = - typeof variant.catId === 'string' ? variant.catId : typeof breed.catId === 'string' ? breed.catId : null; - if (!catId) continue; - - const providerProfileId = trimBinding(variant.providerProfileId); - const accountRef = trimBinding(variant.accountRef); - if (providerProfileId || !accountRef) continue; - - const templateExplicitAccountRef = explicitSeedAccountRefs.get(catId); - if (templateExplicitAccountRef && templateExplicitAccountRef === accountRef) { - backfill.set(catId, accountRef); - continue; + // P5 step 1: old `provider` holding a ClientId value → `clientId` + if (typeof variant.provider === 'string' && CLIENT_ID_VALUES.has(variant.provider)) { + if (!variant.clientId) { + variant.clientId = variant.provider; + delete variant.provider; + dirty = true; + } else if (variant.clientId === variant.provider) { + // Redundant provider (same as clientId). Only delete if ocProviderName + // needs to take its place; otherwise keep it so template merge can't + // leak a stale provider from the base config. + if (typeof variant.ocProviderName === 'string') { + delete variant.provider; + dirty = true; + } + } } - if (!seedCatIdsByClient.get(client)?.has(catId)) continue; - const bindings = observedSeedBindings.get(client) ?? []; - bindings.push({ catId, accountRef }); - observedSeedBindings.set(client, bindings); - } - } - - for (const [client, bindings] of observedSeedBindings) { - if (bindings.length < 2) continue; - const uniqueAccountRefs = new Set(bindings.map((binding) => binding.accountRef)); - if (uniqueAccountRefs.size <= 1) continue; - - const inheritedAccountRef = builtinAccountIdForClient(client); - if (!uniqueAccountRefs.has(inheritedAccountRef)) continue; - for (const binding of bindings) { - if (binding.accountRef !== inheritedAccountRef) { - backfill.set(binding.catId, binding.accountRef); + // P5 step 2: old `ocProviderName` → `provider` + if (typeof variant.ocProviderName === 'string' && variant.provider === undefined) { + variant.provider = variant.ocProviderName; + delete variant.ocProviderName; + dirty = true; } - } - } - return backfill; -} - -function migrateExistingCatalogBindings( - projectRoot: string, - catalog: CatCafeConfig, -): { catalog: CatCafeConfig; dirty: boolean } { - const bootstrapBindings = readBootstrapBindingsLegacy(projectRoot); - const legacySeedBindingBackfill = resolveLegacySeedBindingBackfill(projectRoot, catalog, bootstrapBindings); - let dirty = false; - const nextCatalog = structuredClone(catalog) as CatCafeConfig; - - for (const breed of nextCatalog.breeds as unknown as Record[]) { - const variants = Array.isArray(breed.variants) ? (breed.variants as Record[]) : []; - for (const variant of variants) { - const client = providerToBootstrapClient(variant.provider); + const client = resolveBuiltinClientForProvider((variant.clientId ?? variant.provider) as ClientId); if (!client) continue; - const catId = - typeof variant.catId === 'string' ? variant.catId : typeof breed.catId === 'string' ? breed.catId : null; - const explicitProviderProfileId = trimBinding(variant.providerProfileId); + const existingAccountRef = typeof variant.accountRef === 'string' ? variant.accountRef.trim() : ''; - const legacyExplicitAccountRef = catId ? legacySeedBindingBackfill.get(catId) : undefined; - if (!explicitProviderProfileId && existingAccountRef && legacyExplicitAccountRef === existingAccountRef) { - variant.providerProfileId = existingAccountRef; + const legacyProfileId = typeof variant.providerProfileId === 'string' ? variant.providerProfileId.trim() : ''; + + // P5 step 3: providerProfileId → accountRef + if (legacyProfileId && !existingAccountRef) { + variant.accountRef = legacyProfileId; + delete variant.providerProfileId; dirty = true; continue; } - if (existingAccountRef) continue; - if (explicitProviderProfileId) { - variant.accountRef = explicitProviderProfileId; + if (legacyProfileId) { + delete variant.providerProfileId; dirty = true; - continue; } - const nextAccountRef = fallbackAccountRefForClient(client, bootstrapBindings[client]); - if (!nextAccountRef) continue; - variant.accountRef = nextAccountRef; - dirty = true; + + // F340: Do NOT backfill accountRef for unbound variants. + // Seed cats: resolveBoundAccountRefForCat suppresses default bindings → walks discovery chain. + // Custom cats: empty accountRef → resolveForClient walks full chain including installer-*. + // Backfilling would lock non-seed cats to builtin OAuth, skipping credentialed installer accounts. } } - return { catalog: nextCatalog, dirty }; + return { catalog: next, dirty }; } -function filterBootstrapCatalog(template: CatCafeConfig, projectRoot: string): CatCafeConfig { - const bootstrapBindings = readBootstrapBindingsLegacy(projectRoot); - const selectedBreeds: Record[] = []; - const selectedCatIds = new Set(); +function applyBootstrapDefaultAccountRefs(catalog: CatCafeConfig, seedCatIds: ReadonlySet): CatCafeConfig { + const next = structuredClone(catalog) as CatCafeConfig; - for (const rawBreed of template.breeds as unknown as Record[]) { - const variants = Array.isArray(rawBreed.variants) ? (rawBreed.variants as Record[]) : []; - const firstClient = variants.map((variant) => providerToBootstrapClient(variant.provider)).find(Boolean) ?? null; - if (!firstClient) { - selectedBreeds.push(rawBreed); - for (const catId of collectBreedCatIds(rawBreed)) selectedCatIds.add(catId); - continue; - } - const binding = bootstrapBindings[firstClient]; - if (!binding || binding.mode === 'skip' || binding.enabled === false) { - selectedBreeds.push(rawBreed); - for (const catId of collectBreedCatIds(rawBreed)) selectedCatIds.add(catId); - continue; - } - const selectedVariants = resolveSelectedVariants(rawBreed, binding, projectRoot); - if (selectedVariants.length === 0) { - selectedBreeds.push(rawBreed); - for (const catId of collectBreedCatIds(rawBreed)) selectedCatIds.add(catId); - continue; - } - const nextBreed: Record = { - ...rawBreed, - variants: selectedVariants, - defaultVariantId: selectedVariants.some((variant) => variant.id === rawBreed.defaultVariantId) - ? rawBreed.defaultVariantId - : selectedVariants[0]?.id, - }; - selectedBreeds.push(nextBreed); - for (const variant of selectedVariants) { - const catId = typeof variant.catId === 'string' ? variant.catId : rawBreed.catId; - if (typeof catId === 'string' && catId) selectedCatIds.add(catId); - } - } + for (const breed of next.breeds as unknown as Record[]) { + const breedCatId = typeof breed.catId === 'string' ? breed.catId : ''; + const variants = Array.isArray(breed.variants) ? (breed.variants as Record[]) : []; + for (const variant of variants) { + const existingAccountRef = typeof variant.accountRef === 'string' ? variant.accountRef.trim() : ''; + if (existingAccountRef) continue; + const catId = typeof variant.catId === 'string' ? variant.catId : breedCatId; + if (!catId || !seedCatIds.has(catId)) continue; - const templateRoster = 'roster' in template ? template.roster : {}; - const filteredRoster = Object.fromEntries( - Object.entries((templateRoster ?? {}) as Record).filter(([catId]) => selectedCatIds.has(catId)), - ); + const client = resolveBuiltinClientForProvider((variant.clientId ?? variant.provider) as ClientId); + if (!client) continue; - if ('roster' in template) { - return { - ...template, - breeds: selectedBreeds as unknown as typeof template.breeds, - roster: filteredRoster as Roster, - }; + variant.accountRef = builtinAccountIdForClient(client); + } } - return { - ...template, - breeds: selectedBreeds as unknown as typeof template.breeds, - }; + return next; } export function resolveCatCatalogPath(projectRoot: string): string { - return safePath(projectRoot, CAT_CAFE_DIR, CAT_CATALOG_FILENAME); + return safePath(projectRoot, CONFIG_SUBDIR, CAT_CATALOG_FILENAME); } export function readCatCatalogRaw(projectRoot: string): string | null { @@ -375,7 +154,7 @@ export function readCatCatalogRaw(projectRoot: string): string | null { const raw = readFileSync(catalogPath, 'utf-8'); try { const parsed = JSON.parse(raw) as CatCafeConfig; - const migrated = migrateExistingCatalogBindings(projectRoot, parsed); + const migrated = migrateCatalogVariants(parsed); if (migrated.dirty) { const nextRaw = `${JSON.stringify(migrated.catalog, null, 2)}\n`; writeFileAtomic(catalogPath, nextRaw); @@ -393,6 +172,18 @@ export function readCatCatalog(projectRoot: string): CatCafeConfig | null { return JSON.parse(raw) as CatCafeConfig; } +function readBootstrapSourceConfig( + projectRoot: string, + templatePath: string, +): { catalog: CatCafeConfig; sourcePath: string } { + const legacyConfigPath = safePath(projectRoot, 'cat-config.json'); + const sourcePath = existsSync(legacyConfigPath) ? legacyConfigPath : templatePath; + return { + catalog: JSON.parse(readFileSync(sourcePath, 'utf-8')) as CatCafeConfig, + sourcePath, + }; +} + export function bootstrapCatCatalog(projectRoot: string, templatePath: string): string { const catalogPath = resolveCatCatalogPath(projectRoot); if (existsSync(catalogPath)) { @@ -400,13 +191,16 @@ export function bootstrapCatCatalog(projectRoot: string, templatePath: string): return catalogPath; } - // Prefer cat-config.json (real runtime config with owner data) over cat-template.json - // for bootstrapping the catalog. The template is only used for fresh installations - // where cat-config.json doesn't exist (e.g. new clones from the open-source repo). - const legacyConfigPath = resolve(projectRoot, 'cat-config.json'); - const sourcePath = existsSync(legacyConfigPath) ? legacyConfigPath : templatePath; - const template = JSON.parse(readFileSync(sourcePath, 'utf-8')) as CatCafeConfig; - const runtimeCatalog = filterBootstrapCatalog(template, projectRoot); + // Bootstrap must preserve legacy project customizations when upgrading from + // installs that still have cat-config.json but no runtime catalog yet. + const { catalog: template, sourcePath } = readBootstrapSourceConfig(projectRoot, templatePath); + + // Bootstrap persists the template's default seed binding into the runtime catalog. + // Runtime migrations stay non-backfilling so custom/runtime cats remain unbound. + const { catalog: migratedCatalog } = migrateCatalogVariants(template); + const seedCatIds = sourcePath === templatePath ? collectCatIds(migratedCatalog) : readSeedCatIds(templatePath); + const runtimeCatalog = applyBootstrapDefaultAccountRefs(migratedCatalog, seedCatIds); + mkdirSync(dirname(catalogPath), { recursive: true }); writeFileAtomic(catalogPath, `${JSON.stringify(runtimeCatalog, null, 2)}\n`); return catalogPath; diff --git a/packages/api/src/config/cat-config-loader.ts b/packages/api/src/config/cat-config-loader.ts index ae657fd46..ca25ea454 100644 --- a/packages/api/src/config/cat-config-loader.ts +++ b/packages/api/src/config/cat-config-loader.ts @@ -13,7 +13,6 @@ import type { CatConfig, CatFeatures, CatId, - CatProvider, CatVariant, CoCreatorConfig, ContextBudget, @@ -21,7 +20,7 @@ import type { ReviewPolicy, Roster, } from '@cat-cafe/shared'; -import { createCatId, getDefaultCliEffortForProvider, isValidCliEffortForProvider } from '@cat-cafe/shared'; +import { type ClientId, createCatId, normalizeCliEffortForProvider } from '@cat-cafe/shared'; import { z } from 'zod'; import { createModuleLogger } from '../infrastructure/logger.js'; import { bootstrapCatCatalog, readCatCatalogRaw, resolveCatCatalogPath } from './cat-catalog-store.js'; @@ -63,19 +62,19 @@ const catVariantSchema = z.object({ variantLabel: z.string().min(1).optional(), // F32-b P4: disambiguation label mentionPatterns: z.array(mentionPatternSchema).optional(), // F32-b: variant-level mentions accountRef: z.string().min(1).optional(), // F127: concrete account binding - providerProfileId: z.string().min(1).optional(), // Legacy migration path - provider: z.enum(['anthropic', 'openai', 'google', 'dare', 'antigravity', 'opencode', 'a2a']), + clientId: z.enum(['anthropic', 'openai', 'google', 'dare', 'antigravity', 'opencode', 'a2a']), defaultModel: z.string().min(1), mcpSupport: z.boolean(), cli: cliConfigSchema, commandArgs: z.array(z.string().min(1)).optional(), // F127: explicit bridge args (e.g. Antigravity) cliConfigArgs: z.array(z.string().min(1)).optional(), // F127: extra CLI args per member - ocProviderName: z + /** F340 P5: Model provider name (renamed from ocProviderName). */ + provider: z .string() .trim() - .min(1, 'ocProviderName must not be blank') - .refine((v) => !v.includes('/'), 'ocProviderName must not contain "/"') - .optional(), // F189: opencode custom provider name (e.g. "maas") + .min(1, 'provider must not be blank') + .refine((v) => !v.includes('/'), 'provider must not contain "/"') + .optional(), roleDescription: z.string().min(1).optional(), // F127 review fix: allow variant-scoped roleDescription override sessionChain: z.boolean().optional(), // F127 review fix: allow variant-scoped sessionChain override personality: z.string().optional(), @@ -98,10 +97,6 @@ const catVariantSchema = z.object({ caution: z.string().nullable().optional(), // F-Ground-3: null = explicit no-caution (R1 fix) }); -type LegacyAwareCatVariant = CatVariant & { - providerProfileId?: string; -}; - /** F33 Phase 2: session strategy config (matches SessionStrategyConfig from shared). * Exported for reuse by Phase 3 API route validation. */ export const sessionStrategySchema = z @@ -224,28 +219,28 @@ const catCafeConfigSchemaV2 = z /** Union of all versions — loader handles migration */ const catCafeConfigSchema = z.union([catCafeConfigSchemaV1, catCafeConfigSchemaV2]); -/** - * Try cat-config.json (real runtime config with coCreator data) first, - * then fall back to cat-template.json (generic template for new projects). - */ -function readConfigWithFallback(projectRoot: string, templatePath: string): string { - const legacyPath = resolve(projectRoot, 'cat-config.json'); - try { - return readFileSync(legacyPath, 'utf-8'); - } catch { - // not found — fall through to template - } +/** F340: Read cat-template.json directly — cat-config.json is no longer a runtime source. */ +function readTemplate(templatePath: string): string { try { return readFileSync(templatePath, 'utf-8'); } catch (err) { const code = (err as NodeJS.ErrnoException).code; - throw new Error(`Failed to read cat config at ${legacyPath} or ${templatePath}: ${code ?? 'unknown error'}`); + throw new Error(`Failed to read cat-template.json at ${templatePath}: ${code ?? 'unknown error'}`); } } +/** + * Keys that represent atomic config units — overlay replaces base entirely, + * even though they are plain objects. Prevents stale sub-fields from leaking + * 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', 'color', 'contextBudget', 'voiceConfig']); + /** * Deep merge two plain objects. `overlay` fields override `base` fields. - * - Objects: recursively merged (base fields preserved if absent from overlay). + * - Atomic keys (cli, color, etc.): overlay replaces base entirely. + * - Other objects: recursively merged (base fields preserved if absent from overlay). * - Arrays of objects with `id`: key-based merge (matched by id, then deep-merged). * Overlay-only items appended; base-only items preserved. * - Other arrays / primitives: overlay replaces base. @@ -255,13 +250,10 @@ function deepMergeConfig(base: Record, overlay: Record 0 && isIdArray(oVal) && isIdArray(bVal)) { - merged[key] = mergeById(bVal as HasId[], oVal as HasId[]); - } else if (key === 'cli' && isPlainObject(oVal)) { - // CLI config is provider-specific. When the runtime catalog switches a cat - // from Claude ↔ Codex, preserving nested base fields like defaultArgs/effort - // revives the old provider's flags during default loads. + if (ATOMIC_OBJECT_KEYS.has(key)) { merged[key] = oVal; + } else if (Array.isArray(oVal) && Array.isArray(bVal) && oVal.length > 0 && isIdArray(oVal) && isIdArray(bVal)) { + merged[key] = mergeById(bVal as HasId[], oVal as HasId[]); } else if (isPlainObject(oVal) && isPlainObject(bVal)) { merged[key] = deepMergeConfig(bVal as Record, oVal as Record); } else { @@ -303,7 +295,7 @@ function mergeById(base: HasId[], overlay: HasId[]): HasId[] { /** * Load and validate the resolved cat config source. * Explicit filePath reads that file directly. - * Default resolution: cat-config.json is the base, .cat-cafe/cat-catalog.json is a delta overlay. + * Default resolution: cat-template.json is the base, .cat-cafe/cat-catalog.json is a delta overlay. * Catalog fields override config fields (deep merge); config fields absent from catalog are preserved. */ export function loadCatConfig(filePath?: string): CatCafeConfig { @@ -321,14 +313,14 @@ export function loadCatConfig(filePath?: string): CatCafeConfig { const projectRoot = dirname(templatePath); const catalogRaw = readCatCatalogRaw(projectRoot); if (catalogRaw !== null) { - // Catalog exists — use cat-config.json as base, catalog as overlay - const baseRaw = readConfigWithFallback(projectRoot, templatePath); + // Catalog exists — use template as base, catalog as overlay + const baseRaw = readTemplate(templatePath); const baseJson = JSON.parse(baseRaw) as Record; const catalogJson = JSON.parse(catalogRaw) as Record; raw = JSON.stringify(deepMergeConfig(baseJson, catalogJson)); resolvedPath = resolveCatCatalogPath(projectRoot); } else { - raw = readConfigWithFallback(projectRoot, templatePath); + raw = readTemplate(templatePath); resolvedPath = templatePath; } } @@ -388,7 +380,6 @@ export function toAllCatConfigs(config: CatCafeConfig): Record = {}; for (const breed of config.breeds) { @@ -418,12 +409,10 @@ export function toAllCatConfigs(config: CatCafeConfig): Record 0 + (variant.clientId === 'antigravity' && variant.cli?.defaultArgs && variant.cli.defaultArgs.length > 0 ? variant.cli.defaultArgs : undefined); - const legacyVariant = variant as LegacyAwareCatVariant; - result[catId] = { id: createCatId(catId), name: variant.displayName ?? breed.name, @@ -432,21 +421,16 @@ export function toAllCatConfigs(config: CatCafeConfig): Record 0 ? { cliConfigArgs: [...variant.cliConfigArgs] } : {}), - ...(variant.ocProviderName != null ? { ocProviderName: variant.ocProviderName } : {}), + ...(variant.cli != null ? { cli: variant.cli } : {}), + ...(variant.provider != null ? { provider: variant.provider } : {}), ...(variant.contextBudget != null ? { contextBudget: variant.contextBudget } : {}), roleDescription: variant.roleDescription ?? breed.roleDescription, personality: variant.personality ?? defaultVariant?.personality ?? '', @@ -692,9 +676,12 @@ export type CliEffortLevel = 'low' | 'medium' | 'high' | 'max' | 'xhigh'; * cats PATCH route, so runtime lookup only needs to read persisted values and * fall back to provider defaults. */ -export function getCatEffort(catId: string, config?: CatCafeConfig, fallbackProvider?: CatProvider): CliEffortLevel { +export function getCatEffort(catId: string, config?: CatCafeConfig, fallbackProvider?: ClientId): CliEffortLevel { const cfg = config ?? getCachedConfig(); - if (!cfg) return getDefaultCliEffortForProvider(fallbackProvider ?? 'anthropic') ?? 'high'; + if (!cfg) { + const normalized = normalizeCliEffortForProvider(fallbackProvider ?? 'anthropic', undefined); + return normalized ?? 'high'; + } if (!_catIdToVariant || _catIdToVariantSource !== cfg) { _catIdToVariant = buildCatIdToVariantIndex(cfg); @@ -702,16 +689,27 @@ export function getCatEffort(catId: string, config?: CatCafeConfig, fallbackProv } const variant = _catIdToVariant.get(catId); - const effectiveProvider = variant?.provider ?? fallbackProvider ?? 'anthropic'; - - // Defense-in-depth: validate persisted effort against current provider. - // Write-time cleanup (PATCH route) prevents new stale values, but historical - // catalogs from before this fix may still carry cross-provider effort values. - if (variant?.cli.effort && isValidCliEffortForProvider(effectiveProvider, variant.cli.effort)) { - return variant.cli.effort; + if (variant?.cli.effort) { + // Defense-in-depth: validate persisted effort against current provider. + // Stale cross-provider values (e.g. 'max' on openai) are cleaned at write + // time, but historical data may still contain them. + const provider = variant.clientId ?? fallbackProvider; + if (provider) { + const validated = normalizeCliEffortForProvider(provider, variant.cli.effort); + if (validated) return validated; + // Invalid for this provider — fall through to provider default below + } else { + return variant.cli.effort; + } } - return getDefaultCliEffortForProvider(effectiveProvider) ?? 'high'; + // Client-aware defaults: use variant's clientId if found, otherwise fallbackProvider + const effectiveProvider = variant?.clientId ?? fallbackProvider; + if (effectiveProvider) { + const normalized = normalizeCliEffortForProvider(effectiveProvider, undefined); + if (normalized) return normalized; + } + return 'high'; } // ── F149: ACP config accessor (raw variant field, not in CatConfig type) ────── @@ -740,12 +738,12 @@ export function getAcpConfig(catId: string): AcpVariantConfig | undefined { const catalogRaw = readCatCatalogRaw(projectRoot); let raw: string; if (catalogRaw !== null) { - const baseRaw = readConfigWithFallback(projectRoot, templatePath); + const baseRaw = readTemplate(templatePath); const baseJson = JSON.parse(baseRaw) as Record; const catalogJson = JSON.parse(catalogRaw) as Record; raw = JSON.stringify(deepMergeConfig(baseJson, catalogJson)); } else { - raw = readConfigWithFallback(projectRoot, templatePath); + raw = readTemplate(templatePath); } const json = JSON.parse(raw) as { breeds?: Array<{ catId?: string; variants?: Array<{ catId?: string; acp?: AcpVariantConfig }> }>; diff --git a/packages/api/src/config/catalog-accounts.ts b/packages/api/src/config/catalog-accounts.ts index eeda744c7..73f53f9a0 100644 --- a/packages/api/src/config/catalog-accounts.ts +++ b/packages/api/src/config/catalog-accounts.ts @@ -1,42 +1,371 @@ /** - * F136 Phase 4a — Catalog accounts read/write layer (HC-2) + * F340 — Accounts read/write layer * - * CRUD for the `accounts` section in cat-catalog.json. - * cat-catalog.json is the single runtime write source. + * Storage: {projectRoot}/.cat-cafe/accounts.json (project-local by default). + * Override: CAT_CAFE_GLOBAL_CONFIG_ROOT env → uses that root instead. + * + * Migrations (once per process per source): + * 1. Legacy provider-profiles.json → accounts.json + * 2. Project cat-catalog.json.accounts → accounts.json */ -import type { AccountConfig, CatCafeConfigV2 } from '@cat-cafe/shared'; -import { readCatCatalog, writeCatCatalog } from './cat-catalog-store.js'; +import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { resolve } from 'node:path'; +import type { AccountConfig } from '@cat-cafe/shared'; + +const CONFIG_SUBDIR = '.cat-cafe'; +const ACCOUNTS_FILENAME = 'accounts.json'; + +function resolveGlobalRoot(projectRoot?: string): string { + const envRoot = process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT; + if (envRoot) return resolve(envRoot); + if (projectRoot) return resolve(projectRoot); + return homedir(); +} + +export function resolveAccountsPath(projectRoot?: string): string { + return resolve(resolveGlobalRoot(projectRoot), CONFIG_SUBDIR, ACCOUNTS_FILENAME); +} + +function writeFileAtomic(filePath: string, content: string, mode?: number): void { + const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`; + writeFileSync(tempPath, content, { encoding: 'utf-8', mode: mode ?? 0o644 }); + try { + renameSync(tempPath, filePath); + } catch (error) { + try { + unlinkSync(tempPath); + } catch { + /* ignore cleanup failure */ + } + throw error; + } +} + +function readAllGlobal(projectRoot?: string): Record { + const accountsPath = resolveAccountsPath(projectRoot); + if (!existsSync(accountsPath)) return {}; + const raw = readFileSync(accountsPath, 'utf-8'); + try { + const parsed = JSON.parse(raw); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return {}; + return parsed as Record; + } catch { + // Fix P1-3: corrupt file → backup + warn, not silent swallow + const backupPath = `${accountsPath}.bak`; + try { + copyFileSync(accountsPath, backupPath); + } catch { + /* best-effort backup */ + } + console.error(`[catalog-accounts] corrupt ${accountsPath} — backed up to .bak, treating as empty`); + return {}; + } +} + +function writeAllGlobal(accounts: Record, projectRoot?: string): void { + const accountsPath = resolveAccountsPath(projectRoot); + mkdirSync(resolve(resolveGlobalRoot(projectRoot), CONFIG_SUBDIR), { recursive: true }); + writeFileAtomic(accountsPath, `${JSON.stringify(accounts, null, 2)}\n`); +} + +function normalizeBaseUrl(baseUrl: string | undefined): string | undefined { + const trimmed = baseUrl?.trim(); + return trimmed ? trimmed.replace(/\/+$/, '') : undefined; +} + +function normalizeDisplayName(displayName: string | undefined): string | undefined { + const trimmed = displayName?.trim(); + return trimmed ? trimmed : undefined; +} + +function normalizeModels(models: readonly string[] | undefined): string[] | undefined { + if (!Array.isArray(models)) return undefined; + const normalized = Array.from( + new Set(models.map((value) => String(value).trim()).filter((value) => value.length > 0)), + ); + return normalized.length > 0 ? normalized.sort() : undefined; +} + +function canonicalizeAccount(account: AccountConfig): { + authType: 'oauth' | 'api_key'; + baseUrl?: string; + displayName?: string; + models?: string[]; +} { + return { + authType: account.authType, + ...(normalizeBaseUrl(account.baseUrl) ? { baseUrl: normalizeBaseUrl(account.baseUrl) } : {}), + ...(normalizeDisplayName(account.displayName) ? { displayName: normalizeDisplayName(account.displayName) } : {}), + ...(normalizeModels(account.models) ? { models: normalizeModels(account.models) } : {}), + }; +} + +function describeAccountConflict(existing: AccountConfig, incoming: AccountConfig): string { + const current = canonicalizeAccount(existing); + const next = canonicalizeAccount(incoming); + const diffs: string[] = []; + + if (current.authType !== next.authType) diffs.push(`authType ${current.authType} vs ${next.authType}`); + if ((current.baseUrl ?? '(none)') !== (next.baseUrl ?? '(none)')) { + diffs.push(`baseUrl ${current.baseUrl ?? '(none)'} vs ${next.baseUrl ?? '(none)'}`); + } + if ((current.displayName ?? '(none)') !== (next.displayName ?? '(none)')) { + diffs.push(`displayName ${current.displayName ?? '(none)'} vs ${next.displayName ?? '(none)'}`); + } + if (JSON.stringify(current.models ?? []) !== JSON.stringify(next.models ?? [])) { + diffs.push(`models ${JSON.stringify(current.models ?? [])} vs ${JSON.stringify(next.models ?? [])}`); + } + + return diffs.join('; '); +} + +function accountsEquivalent(existing: AccountConfig, incoming: AccountConfig): boolean { + return describeAccountConflict(existing, incoming).length === 0; +} + +function normalizeLegacyAuthType(value: unknown): AccountConfig['authType'] | undefined { + const normalized = String(value ?? '') + .trim() + .toLowerCase(); + if (normalized === 'api_key') return 'api_key'; + if (normalized === 'oauth' || normalized === 'subscription' || normalized === 'builtin') return 'oauth'; + return undefined; +} + +function inferLegacyAuthType(profile: Record): AccountConfig['authType'] { + return ( + normalizeLegacyAuthType(profile.authType) ?? + normalizeLegacyAuthType(profile.mode) ?? + normalizeLegacyAuthType(profile.kind) ?? + 'oauth' + ); +} + +/** Merge source accounts into global, preserving existing keys. */ +function mergeIntoGlobal( + source: Record, + projectRoot?: string, +): { merged: string[]; skipped: string[] } { + const global = readAllGlobal(projectRoot); + const merged: string[] = []; + const skipped: string[] = []; + for (const [ref, account] of Object.entries(source)) { + if (ref in global) { + if (!accountsEquivalent(global[ref], account)) { + throw new Error(`Account conflict for "${ref}": ${describeAccountConflict(global[ref], account)}`); + } + skipped.push(ref); + } else { + global[ref] = account; + merged.push(ref); + } + } + if (merged.length > 0) writeAllGlobal(global, projectRoot); + return { merged, skipped }; +} + +// ── Legacy provider-profiles.json → accounts.json migration ── + +/** Migrate legacy provider-profiles.json + secrets from a given root into global accounts. */ +function migrateLegacyFrom(root: string, projectRoot?: string): void { + const metaPath = resolve(root, CONFIG_SUBDIR, 'provider-profiles.json'); + if (!existsSync(metaPath)) return; + const meta = JSON.parse(readFileSync(metaPath, 'utf-8')); + // v2/v3: flat array of profiles. v1: nested { providers: { : { profiles: [...] } } }. + const rawProviders = meta?.providers ?? meta?.profiles; + let providers: Array>; + if (Array.isArray(rawProviders)) { + providers = rawProviders; + } else if (rawProviders != null && typeof rawProviders === 'object') { + providers = []; + for (const [, val] of Object.entries(rawProviders as Record)) { + if (typeof val !== 'object' || val === null) continue; + const obj = val as Record; + if (Array.isArray(obj.profiles)) { + // v1 nested: { anthropic: { profiles: [{ id, ... }, ...] } } + for (const p of obj.profiles) { + if (typeof p === 'object' && p !== null) providers.push(p as Record); + } + } else { + // Simple object: treat as single provider entry + providers.push(obj); + } + } + } else { + providers = []; + } + if (providers.length === 0) return; + + const accounts: Record = {}; + for (const p of providers) { + const id = String(p.id ?? '').trim(); + if (!id) continue; + const displayName = normalizeDisplayName(typeof p.displayName === 'string' ? p.displayName : undefined); + const baseUrl = normalizeBaseUrl(typeof p.baseUrl === 'string' ? p.baseUrl : undefined); + const models = normalizeModels(Array.isArray(p.models) ? p.models.map(String) : undefined); + // F340: protocol not migrated — derived at runtime from well-known account IDs. + accounts[id] = { + authType: inferLegacyAuthType(p), + ...(displayName ? { displayName } : {}), + ...(baseUrl ? { baseUrl } : {}), + ...(models ? { models } : {}), + }; + } + const { merged } = mergeIntoGlobal(accounts, projectRoot); + const mergedSet = new Set(merged); + // Read global state after merge for retry-safe credential import + const globalAfterMerge = readAllGlobal(projectRoot); + + const secretsPath = resolve(root, CONFIG_SUBDIR, 'provider-profiles.secrets.local.json'); + if (!existsSync(secretsPath)) return; + const secretsMeta = JSON.parse(readFileSync(secretsPath, 'utf-8')); + // v2/v3: flat { profiles: { : { apiKey } } }. + // v1: nested { providers: { : { : { apiKey } } } }. + let profileSecrets: Record> = {}; + if (secretsMeta?.profiles && typeof secretsMeta.profiles === 'object') { + profileSecrets = secretsMeta.profiles; + } else if (secretsMeta?.providers && typeof secretsMeta.providers === 'object') { + for (const clientSecrets of Object.values(secretsMeta.providers as Record)) { + if (typeof clientSecrets === 'object' && clientSecrets !== null) { + Object.assign(profileSecrets, clientSecrets as Record>); + } + } + } + const globalRoot = resolveGlobalRoot(projectRoot); + const credPath = resolve(globalRoot, CONFIG_SUBDIR, 'credentials.json'); + const existing = existsSync(credPath) + ? (() => { + try { + return JSON.parse(readFileSync(credPath, 'utf-8')); + } catch { + return {}; + } + })() + : {}; + let credCount = 0; + for (const [id, secret] of Object.entries(profileSecrets)) { + if (!(id in accounts) || id in existing || !secret?.apiKey) continue; + if (mergedSet.has(id)) { + // First run: account was just merged — safe to import its secret. + existing[id] = { apiKey: String(secret.apiKey) }; + credCount++; + } else { + // Retry path: account already existed in global (skipped by merge). + // Only import if the global account's fields match what we'd migrate — + // proves it came from a previous run of this same migration source, + // not a collision with a different-origin account sharing the same ID. + const g = globalAfterMerge[id]; + const l = accounts[id]; + if (g && accountsEquivalent(g, l)) { + existing[id] = { apiKey: String(secret.apiKey) }; + credCount++; + } + } + } + if (credCount > 0) { + mkdirSync(resolve(globalRoot, CONFIG_SUBDIR), { recursive: true }); + writeFileAtomic(credPath, `${JSON.stringify(existing, null, 2)}\n`, 0o600); + } +} + +let legacyMigrationDone = false; + +function migrateLegacyProviderProfiles(projectRoot?: string): void { + if (legacyMigrationDone) return; + try { + migrateLegacyFrom(resolveGlobalRoot(projectRoot), projectRoot); + legacyMigrationDone = true; + } catch (err) { + console.error('[catalog-accounts] legacy→global migration failed:', err); + throw err; + } +} + +const migratedProjectLegacy = new Set(); + +function migrateProjectLegacyProviderProfiles(projectRoot: string): void { + const key = resolve(projectRoot); + if (migratedProjectLegacy.has(key)) return; + try { + migrateLegacyFrom(key, projectRoot); + migratedProjectLegacy.add(key); + } catch (err) { + console.error(`[catalog-accounts] project legacy→global migration failed for ${key}:`, err); + throw err; + } +} + +// ── Project catalog.accounts → global accounts.json migration ── + +const migratedProjects = new Set(); + +function migrateProjectAccountsToGlobal(projectRoot: string): void { + const key = resolve(projectRoot); + if (migratedProjects.has(key)) return; + try { + const catalogPath = resolve(projectRoot, CONFIG_SUBDIR, 'cat-catalog.json'); + if (!existsSync(catalogPath)) return; + const raw = readFileSync(catalogPath, 'utf-8'); + const catalog = JSON.parse(raw); + const projectAccounts = catalog?.accounts; + if (!projectAccounts || typeof projectAccounts !== 'object' || Object.keys(projectAccounts).length === 0) return; + + const { merged } = mergeIntoGlobal(projectAccounts as Record, projectRoot); + + // F340: project catalog.accounts is intentionally left untouched. + // Runtime only reads global accounts.json, so the project section is + // inert — keeping it provides free rollback compatibility and avoids + // unnecessary writes to the project catalog file. + if (merged.length > 0) { + console.error(`[catalog-accounts] project ${key}: ${merged.length} account(s) merged into global`); + } + migratedProjects.add(key); + } catch (err) { + // Migration is best-effort — don't mark done so next call retries. + // But log the error so failures aren't invisible. + console.error(`[catalog-accounts] project→global migration failed for ${key}:`, err); + throw err; + } +} + +function ensureMigrated(projectRoot: string): void { + migrateLegacyProviderProfiles(projectRoot); + migrateProjectLegacyProviderProfiles(projectRoot); + migrateProjectAccountsToGlobal(projectRoot); +} + +/** Reset migration state (for tests). */ +export function resetMigrationState(): void { + legacyMigrationDone = false; + migratedProjects.clear(); + migratedProjectLegacy.clear(); +} + +// ── Public API (signatures kept backward-compatible, projectRoot used for migration) ── export function readCatalogAccounts(projectRoot: string): Record { - const catalog = readCatCatalog(projectRoot); - if (!catalog || catalog.version !== 2) return {}; - return (catalog as CatCafeConfigV2).accounts ?? {}; + ensureMigrated(projectRoot); + return readAllGlobal(projectRoot); } export function writeCatalogAccount(projectRoot: string, ref: string, account: AccountConfig): void { - const catalog = readCatCatalog(projectRoot); - if (!catalog) { - // Bootstrap a minimal v2 catalog if none exists (e.g. first account created via API) - writeCatCatalog(projectRoot, { - version: 2, - breeds: [], - roster: {}, - reviewPolicy: {}, - accounts: { [ref]: account }, - } as unknown as CatCafeConfigV2); - return; - } - const v2 = catalog as CatCafeConfigV2; - const nextAccounts = { ...(v2.accounts ?? {}), [ref]: account }; - writeCatCatalog(projectRoot, { ...v2, accounts: nextAccounts }); + ensureMigrated(projectRoot); + const accounts = readAllGlobal(projectRoot); + accounts[ref] = account; + writeAllGlobal(accounts, projectRoot); } export function deleteCatalogAccount(projectRoot: string, ref: string): void { - const catalog = readCatCatalog(projectRoot); - if (!catalog) return; - const v2 = catalog as CatCafeConfigV2; - const existing = v2.accounts ?? {}; - if (!(ref in existing)) return; - const { [ref]: _removed, ...rest } = existing; - writeCatCatalog(projectRoot, { ...v2, accounts: rest }); + ensureMigrated(projectRoot); + const accounts = readAllGlobal(projectRoot); + if (!(ref in accounts)) return; + delete accounts[ref]; + writeAllGlobal(accounts, projectRoot); +} + +/** Check if legacy provider-profiles.json exists in any known location. */ +export function hasLegacyProviderProfiles(projectRoot: string): boolean { + if (existsSync(resolve(resolveGlobalRoot(projectRoot), CONFIG_SUBDIR, 'provider-profiles.json'))) return true; + return existsSync(resolve(projectRoot, CONFIG_SUBDIR, 'provider-profiles.json')); } diff --git a/packages/api/src/config/config-snapshot.ts b/packages/api/src/config/config-snapshot.ts index e3481cf7f..7803e3dd0 100644 --- a/packages/api/src/config/config-snapshot.ts +++ b/packages/api/src/config/config-snapshot.ts @@ -52,7 +52,7 @@ export interface ConfigSnapshot { string, { displayName: string; - provider: string; + clientId: string; model: string; mcpSupport: boolean; } diff --git a/packages/api/src/config/credentials.ts b/packages/api/src/config/credentials.ts index ba4b76b33..2af219877 100644 --- a/packages/api/src/config/credentials.ts +++ b/packages/api/src/config/credentials.ts @@ -1,25 +1,26 @@ /** - * F136 Phase 4a — Credential keychain (HC-1: object structure, global scope) + * F340 — Credential keychain * - * Pure read/write layer for ~/.cat-cafe/credentials.json. - * No metadata, no business logic — just a keychain. + * Pure read/write layer for {projectRoot}/.cat-cafe/credentials.json. + * Override: CAT_CAFE_GLOBAL_CONFIG_ROOT env → uses that root instead. */ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { resolve } from 'node:path'; import type { CredentialEntry } from '@cat-cafe/shared'; -const CAT_CAFE_DIR = '.cat-cafe'; +const CONFIG_SUBDIR = '.cat-cafe'; const CREDENTIALS_FILENAME = 'credentials.json'; -function resolveGlobalRoot(): string { +function resolveGlobalRoot(projectRoot?: string): string { const envRoot = process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT; if (envRoot) return resolve(envRoot); + if (projectRoot) return resolve(projectRoot); return homedir(); } -export function resolveCredentialsPath(): string { - return resolve(resolveGlobalRoot(), CAT_CAFE_DIR, CREDENTIALS_FILENAME); +export function resolveCredentialsPath(projectRoot?: string): string { + return resolve(resolveGlobalRoot(projectRoot), CONFIG_SUBDIR, CREDENTIALS_FILENAME); } function writeFileAtomic(filePath: string, content: string): void { @@ -37,8 +38,8 @@ function writeFileAtomic(filePath: string, content: string): void { } } -function readAll(): Record { - const credPath = resolveCredentialsPath(); +function readAll(projectRoot?: string): Record { + const credPath = resolveCredentialsPath(projectRoot); if (!existsSync(credPath)) return {}; try { const raw = readFileSync(credPath, 'utf-8'); @@ -50,34 +51,45 @@ function readAll(): Record { } } -function writeAll(creds: Record): void { - const credPath = resolveCredentialsPath(); - mkdirSync(resolve(resolveGlobalRoot(), CAT_CAFE_DIR), { recursive: true }); +export function assertCredentialsReadable(projectRoot?: string): void { + const credPath = resolveCredentialsPath(projectRoot); + if (!existsSync(credPath)) return; + + const raw = readFileSync(credPath, 'utf-8'); + const parsed = JSON.parse(raw); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error(`Invalid credentials JSON at ${credPath}: expected object`); + } +} + +function writeAll(creds: Record, projectRoot?: string): void { + const credPath = resolveCredentialsPath(projectRoot); + mkdirSync(resolve(resolveGlobalRoot(projectRoot), CONFIG_SUBDIR), { recursive: true }); writeFileAtomic(credPath, `${JSON.stringify(creds, null, 2)}\n`); chmodSync(credPath, 0o600); } -export function readCredentials(): Record { - return readAll(); +export function readCredentials(projectRoot?: string): Record { + return readAll(projectRoot); } -export function readCredential(ref: string): CredentialEntry | undefined { - return readAll()[ref]; +export function readCredential(ref: string, projectRoot?: string): CredentialEntry | undefined { + return readAll(projectRoot)[ref]; } -export function writeCredential(ref: string, entry: CredentialEntry): void { - const creds = readAll(); +export function writeCredential(ref: string, entry: CredentialEntry, projectRoot?: string): void { + const creds = readAll(projectRoot); creds[ref] = entry; - writeAll(creds); + writeAll(creds, projectRoot); } -export function deleteCredential(ref: string): void { - const creds = readAll(); +export function deleteCredential(ref: string, projectRoot?: string): void { + const creds = readAll(projectRoot); if (!(ref in creds)) return; delete creds[ref]; - writeAll(creds); + writeAll(creds, projectRoot); } -export function hasCredential(ref: string): boolean { - return ref in readAll(); +export function hasCredential(ref: string, projectRoot?: string): boolean { + return ref in readAll(projectRoot); } diff --git a/packages/api/src/config/env-registry.ts b/packages/api/src/config/env-registry.ts index 792f72376..7602e39cc 100644 --- a/packages/api/src/config/env-registry.ts +++ b/packages/api/src/config/env-registry.ts @@ -183,12 +183,11 @@ export const ENV_VARS: EnvDefinition[] = [ }, { name: 'ANTHROPIC_API_KEY', - defaultValue: '(未设置 → 使用 proxy profile)', - description: 'Anthropic API Key(直连模式;proxy 模式由 provider profile 注入)', + defaultValue: '(未设置 → 由 accounts/credentials 系统注入)', + description: 'Anthropic API Key(#340 P6: 由统一账户系统管理,不再从 .env 读取)', category: 'server', sensitive: true, hubVisible: false, - exampleRecommended: true, }, { name: 'LOG_LEVEL', @@ -256,7 +255,7 @@ export const ENV_VARS: EnvDefinition[] = [ { name: 'CAT_CAFE_GLOBAL_CONFIG_ROOT', defaultValue: '(未设置 → homedir())', - description: '全局配置根目录(cat catalog / credentials / provider profiles 查找路径)', + description: '全局配置根目录(accounts / credentials 查找路径的父目录,实际路径为 ${ROOT}/.cat-cafe/)', category: 'server', sensitive: false, hubVisible: false, @@ -890,11 +889,10 @@ export const ENV_VARS: EnvDefinition[] = [ }, { name: 'OPENAI_API_KEY', - defaultValue: '(未设置)', - description: 'OpenAI API Key (api_key 模式用;env-owning,不走 accounts/credentials)', + defaultValue: '(未设置 → 由 accounts/credentials 系统注入)', + description: 'OpenAI API Key(#340 P6: 由统一账户系统管理,子进程通过 callbackEnv 注入)', category: 'codex', sensitive: true, - runtimeEditable: true, }, // --- dare --- @@ -904,12 +902,11 @@ export const ENV_VARS: EnvDefinition[] = [ // --- gemini --- { name: 'GOOGLE_API_KEY', - defaultValue: '(未设置)', - description: 'Google API Key(暹罗猫 Gemini 直连用)', + defaultValue: '(未设置 → 由 accounts/credentials 系统注入)', + description: 'Google API Key(#340 P6: 由统一账户系统管理,子进程通过 callbackEnv 注入)', category: 'gemini', sensitive: true, hubVisible: false, - exampleRecommended: true, }, { name: 'GEMINI_ADAPTER', diff --git a/packages/api/src/config/migrate-provider-profiles.ts b/packages/api/src/config/migrate-provider-profiles.ts deleted file mode 100644 index 0f2831e33..000000000 --- a/packages/api/src/config/migrate-provider-profiles.ts +++ /dev/null @@ -1,165 +0,0 @@ -/** - * F136 Phase 4a — Migrate provider-profiles.json → accounts + credentials - * - * HC-3: One-time migration. Does NOT delete old files (留一版本兼容窗口). - * Writes marker file to prevent re-migration. - */ -import { existsSync, readFileSync } from 'node:fs'; -import { homedir } from 'node:os'; -import { resolve } from 'node:path'; -import type { AccountConfig, AccountProtocol, CredentialEntry } from '@cat-cafe/shared'; -import { readCatCatalog, writeCatCatalog } from './cat-catalog-store.js'; -import { writeCredential } from './credentials.js'; - -// Inline legacy types needed for migration (originally from provider-profiles.types.ts) -interface ProviderProfileMeta { - id: string; - displayName: string; - kind: 'builtin' | 'api_key'; - authType: 'oauth' | 'api_key'; - builtin: boolean; - client?: string; - protocol?: AccountProtocol; - baseUrl?: string; - models?: string[]; - createdAt: string; - updatedAt: string; -} -interface ProviderProfilesMetaFile { - version: 3; - activeProfileId: string | null; - providers: ProviderProfileMeta[]; - bootstrapBindings: Record; -} -interface ProviderProfilesSecretsFile { - version: 3; - profiles: Record; -} - -const CAT_CAFE_DIR = '.cat-cafe'; -const META_FILENAME = 'provider-profiles.json'; -const SECRETS_FILENAME = 'provider-profiles.secrets.local.json'; -export interface MigrationResult { - migrated: boolean; - reason?: 'no-source' | 'already-migrated' | 'no-catalog'; - accountsMigrated?: number; - credentialsMigrated?: number; -} - -function resolveGlobalRoot(): string { - const envRoot = process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT; - if (envRoot) return resolve(envRoot); - return homedir(); -} - -function resolveGlobalPath(filename: string): string { - return resolve(resolveGlobalRoot(), CAT_CAFE_DIR, filename); -} - -/** - * LL-043: Check if legacy provider-profiles.json represents a real migration source. - * - File missing → false (nothing to migrate) - * - File exists but unparseable → true (corrupt = legacy source present, invariant should fire) - * - File exists, parses OK, zero providers → false (empty = nothing was ever configured) - * - File exists, parses OK, has providers → true (should have been migrated) - */ -export function hasLegacyProviderProfiles(): boolean { - if (!existsSync(resolveGlobalPath(META_FILENAME))) return false; - const meta = readOldMeta(); // returns null on parse failure - if (meta === null) return true; // corrupt file = legacy source present - return (meta.providers?.length ?? 0) > 0; -} - -/** - * Per-project migration detection: check if ALL old profile IDs already exist - * in the project's catalog accounts. Stateless — no global marker file. - */ -function isProjectMigrated(profiles: ProviderProfileMeta[], existingAccounts: Record): boolean { - if (profiles.length === 0) return true; - return profiles.every((p) => p.id in existingAccounts); -} - -function readOldMeta(): ProviderProfilesMetaFile | null { - const path = resolveGlobalPath(META_FILENAME); - if (!existsSync(path)) return null; - try { - return JSON.parse(readFileSync(path, 'utf-8')) as ProviderProfilesMetaFile; - } catch { - return null; - } -} - -function readOldSecrets(): Record { - const path = resolveGlobalPath(SECRETS_FILENAME); - if (!existsSync(path)) return {}; - try { - const data = JSON.parse(readFileSync(path, 'utf-8')) as ProviderProfilesSecretsFile; - return data.profiles ?? {}; - } catch { - return {}; - } -} - -function toAccountProtocol(protocol: string | undefined): AccountProtocol { - if (protocol === 'anthropic' || protocol === 'openai' || protocol === 'google') return protocol; - return 'openai'; // safe default for custom API-key accounts -} - -function profileToAccountConfig(profile: ProviderProfileMeta): AccountConfig { - return { - authType: profile.authType ?? 'api_key', - protocol: toAccountProtocol(profile.protocol), - ...(profile.baseUrl ? { baseUrl: profile.baseUrl.trim().replace(/\/+$/, '') } : {}), - ...(profile.models && profile.models.length > 0 ? { models: profile.models } : {}), - ...(profile.displayName ? { displayName: profile.displayName } : {}), - }; -} - -export function migrateProviderProfilesToAccounts(projectRoot: string): MigrationResult { - const oldMeta = readOldMeta(); - if (!oldMeta) { - return { migrated: false, reason: 'no-source' }; - } - - const catalog = readCatCatalog(projectRoot); - if (!catalog) { - return { migrated: false, reason: 'no-catalog' }; - } - - const v2 = catalog as import('@cat-cafe/shared').CatCafeConfigV2; - const profiles = oldMeta.providers ?? []; - - // Per-project detection: skip only if THIS project already has all old accounts - if (isProjectMigrated(profiles, v2.accounts ?? {})) { - return { migrated: false, reason: 'already-migrated' }; - } - - const oldSecrets = readOldSecrets(); - const accounts: Record = {}; - let credCount = 0; - - for (const profile of profiles) { - // Skip profiles already present in this project's catalog - if (v2.accounts?.[profile.id]) continue; - - accounts[profile.id] = profileToAccountConfig(profile); - - // Migrate secrets → credentials.json - const secret = oldSecrets[profile.id]; - if (secret?.apiKey) { - const entry: CredentialEntry = { apiKey: secret.apiKey }; - writeCredential(profile.id, entry); - credCount++; - } - } - - // Write accounts into catalog (HC-2: cat-catalog.json is runtime write source) - const mergedAccounts = { ...(v2.accounts ?? {}), ...accounts }; - writeCatCatalog(projectRoot, { ...v2, accounts: mergedAccounts }); - - return { - migrated: true, - accountsMigrated: Object.keys(accounts).length, - credentialsMigrated: credCount, - }; -} diff --git a/packages/api/src/config/runtime-cat-catalog.ts b/packages/api/src/config/runtime-cat-catalog.ts index be97a2e3b..bacf31de6 100644 --- a/packages/api/src/config/runtime-cat-catalog.ts +++ b/packages/api/src/config/runtime-cat-catalog.ts @@ -4,9 +4,9 @@ import type { CatBreed, CatCafeConfig, CatColor, - CatProvider, CatVariant, CliConfig, + ClientId, CoCreatorConfig, ContextBudget, } from '@cat-cafe/shared'; @@ -33,14 +33,15 @@ export interface RuntimeCatInput { caution?: string | null; strengths?: string[]; sessionChain?: boolean; - provider: CatProvider; + clientId: ClientId; defaultModel: string; mcpSupport: boolean; cli: CliConfig; commandArgs?: string[]; cliConfigArgs?: string[]; contextBudget?: ContextBudget; - ocProviderName?: string; + /** F340 P5: Model provider name (renamed from ocProviderName). */ + provider?: string; } export interface RuntimeCatUpdate { @@ -57,14 +58,15 @@ export interface RuntimeCatUpdate { caution?: string | null; strengths?: string[]; sessionChain?: boolean; - provider?: CatProvider; + clientId?: ClientId; defaultModel?: string; mcpSupport?: boolean; cli?: CliConfig; commandArgs?: string[]; cliConfigArgs?: string[]; contextBudget?: ContextBudget | null; - ocProviderName?: string | null; + /** F340 P5: Model provider name (renamed from ocProviderName). */ + provider?: string | null; available?: boolean; } @@ -214,16 +216,16 @@ function createBreedFromInput(input: RuntimeCatInput): CatBreed { variants: [ { id: variantId, - provider: input.provider, + clientId: input.clientId, defaultModel: input.defaultModel, mcpSupport: input.mcpSupport, cli: input.cli, ...(input.accountRef != null && input.accountRef.trim().length > 0 - ? { accountRef: input.accountRef.trim(), providerProfileId: input.accountRef.trim() } + ? { accountRef: input.accountRef.trim() } : {}), ...(input.commandArgs && input.commandArgs.length > 0 ? { commandArgs: input.commandArgs } : {}), ...(input.cliConfigArgs && input.cliConfigArgs.length > 0 ? { cliConfigArgs: input.cliConfigArgs } : {}), - ...(input.ocProviderName ? { ocProviderName: input.ocProviderName } : {}), + ...(input.provider ? { provider: input.provider } : {}), ...(input.contextBudget ? { contextBudget: input.contextBudget } : {}), ...(input.personality != null && input.personality.trim().length > 0 ? { personality: input.personality } : {}), ...(input.teamStrengths != null && input.teamStrengths.trim().length > 0 @@ -347,12 +349,9 @@ export function updateRuntimeCat(projectRoot: string, catId: string, patch: Runt if (patch.accountRef !== undefined) { if (patch.accountRef && patch.accountRef.trim().length > 0) { - const normalizedAccountRef = patch.accountRef.trim(); - variant.accountRef = normalizedAccountRef; - variant.providerProfileId = normalizedAccountRef; + variant.accountRef = patch.accountRef.trim(); } else { delete variant.accountRef; - delete variant.providerProfileId; } } if (patch.personality !== undefined) { @@ -386,7 +385,7 @@ export function updateRuntimeCat(projectRoot: string, catId: string, patch: Runt variant.sessionChain = patch.sessionChain; } } - if (patch.provider !== undefined) variant.provider = patch.provider; + if (patch.clientId !== undefined) variant.clientId = patch.clientId; if (patch.defaultModel !== undefined) variant.defaultModel = patch.defaultModel; if (patch.mcpSupport !== undefined) variant.mcpSupport = patch.mcpSupport; if (patch.cli !== undefined) variant.cli = patch.cli; @@ -411,11 +410,11 @@ export function updateRuntimeCat(projectRoot: string, catId: string, patch: Runt delete variant.cliConfigArgs; } } - if (patch.ocProviderName !== undefined) { - if (patch.ocProviderName) { - variant.ocProviderName = patch.ocProviderName; + if (patch.provider !== undefined) { + if (patch.provider) { + variant.provider = patch.provider; } else { - delete variant.ocProviderName; + delete variant.provider; } } if (patch.available !== undefined && catalog.version === 2) { diff --git a/packages/api/src/config/session-strategy.ts b/packages/api/src/config/session-strategy.ts index cebcb4474..4d3b6216b 100644 --- a/packages/api/src/config/session-strategy.ts +++ b/packages/api/src/config/session-strategy.ts @@ -152,7 +152,7 @@ function resolveFallbackStrategy(catName: string): { } // Provider default or global default - const provider = catRegistry.tryGet(catName)?.config.provider ?? CAT_CONFIGS[catName]?.provider; + const provider = catRegistry.tryGet(catName)?.config.clientId ?? CAT_CONFIGS[catName]?.clientId; if (provider && DEFAULT_STRATEGY_BY_PROVIDER[provider]) { return { effective: base, source: 'provider_default' }; } @@ -180,7 +180,7 @@ export function mergeStrategyConfig( function getBaseStrategy(catName: string): SessionStrategyConfig { // Try catRegistry first (runtime, includes variants), then static CAT_CONFIGS fallback - const provider = catRegistry.tryGet(catName)?.config.provider ?? CAT_CONFIGS[catName]?.provider; + const provider = catRegistry.tryGet(catName)?.config.clientId ?? CAT_CONFIGS[catName]?.clientId; if (provider) { const providerDefault = DEFAULT_STRATEGY_BY_PROVIDER[provider]; if (providerDefault) return providerDefault; @@ -196,7 +196,7 @@ function validateProviderCapability(config: SessionStrategyConfig, catName: stri if (config.strategy !== 'hybrid') return config; const entry = catRegistry.tryGet(catName); - const provider = entry?.config.provider; + const provider = entry?.config.clientId; if (!provider || !HOOK_CAPABLE_PROVIDERS.has(provider)) { log.warn( 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 f80190a95..ef3c753fe 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 @@ -15,7 +15,6 @@ import { rm } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; import { type CatId, type ContextHealth, catRegistry, type MessageContent } from '@cat-cafe/shared'; import { - resolveAnthropicRuntimeProfile, resolveBuiltinClientForProvider, resolveForClient, validateRuntimeProviderBinding, @@ -603,7 +602,7 @@ export async function* invokeSingleCat(deps: InvocationDeps, params: InvocationP await tryGovernanceBootstrap(workingDirectory, catCafeRoot); const { checkGovernancePreflight } = await import('../../../../../config/governance/governance-preflight.js'); const catEntry = catRegistry.tryGet(catId as string); - const preflight = await checkGovernancePreflight(workingDirectory, catCafeRoot, catEntry?.config.provider); + const preflight = await checkGovernancePreflight(workingDirectory, catCafeRoot, catEntry?.config.clientId); if (!preflight.ready) { const reasonKind = preflight.needsBootstrap ? 'needs_bootstrap' @@ -664,9 +663,8 @@ export async function* invokeSingleCat(deps: InvocationDeps, params: InvocationP // F127 account injection: // Members bind to a concrete accountRef (builtin oauth account or generic api_key account). - // Legacy providerProfileId is still read as a migration fallback. const catConfig = catRegistry.tryGet(catId as string)?.config; - const provider = catConfig?.provider; + const provider = catConfig?.clientId; const builtinClient = provider ? resolveBuiltinClientForProvider(provider) : null; const defaultModel = catConfig?.defaultModel?.trim() || undefined; // Account resolution, proxy registration, and runtime config always use the @@ -724,20 +722,32 @@ export async function* invokeSingleCat(deps: InvocationDeps, params: InvocationP ); } - // Protocol is determined by provider — account.protocol is retired for all - // fixed-protocol providers. OpenCode is the sole exception: it can target - // multiple backends, so its env injection uses the bound account's protocol. + // F340: Protocol is fully derived from client/provider identity — account.protocol retired. + // Non-opencode clients have a fixed protocol. OpenCode derives protocol from the + // variant's model provider name or model string prefix, defaulting to anthropic. const protocolForProvider: Record = { anthropic: 'anthropic', openai: 'openai', google: 'google', dare: 'openai', + opencode: 'anthropic', + openrouter: 'openai', }; - const effectiveProtocol = provider - ? provider === 'opencode' && resolvedAccount?.protocol - ? resolvedAccount.protocol - : (protocolForProvider[provider] ?? null) - : null; + let effectiveProtocol: string | null = provider ? (protocolForProvider[provider] ?? null) : null; + if (provider === 'opencode') { + // Priority 1: explicit variant.provider field + const modelProviderHint = catConfig?.provider?.trim(); + if (modelProviderHint && protocolForProvider[modelProviderHint]) { + effectiveProtocol = protocolForProvider[modelProviderHint]; + } else { + // Priority 2: model string prefix (e.g. 'openrouter/google/model' → openrouter → openai) + const trimmedModel = typeof defaultModel === 'string' ? defaultModel.trim() : ''; + const parsed = trimmedModel ? parseOpenCodeModel(trimmedModel) : null; + if (parsed && protocolForProvider[parsed.providerName]) { + effectiveProtocol = protocolForProvider[parsed.providerName]; + } + } + } // effectiveProtocol is used below for env injection branching (anthropic/openai/google) // but is NOT passed to callbackEnv — it should not influence CLI routing decisions. @@ -811,31 +821,31 @@ export async function* invokeSingleCat(deps: InvocationDeps, params: InvocationP } const trimmedDefaultModel = typeof defaultModel === 'string' ? defaultModel.trim() : undefined; - const ocProviderName = catConfig?.ocProviderName?.trim() || undefined; + const modelProviderName = catConfig?.provider?.trim() || undefined; const parsedOpenCodeModel = provider === 'opencode' && trimmedDefaultModel ? parseOpenCodeModel(trimmedDefaultModel) : null; // F189 intake: determine effective provider + model. // Three cases for defaultModel shape: - // 1. Canonical "provider/model" where parsed provider === ocProviderName → use as-is - // 2. Namespaced "ns/model" where parsed prefix ≠ ocProviderName → prefix with ocProviderName - // 3. Bare "model" → prefix with ocProviderName if available - // When ocProviderName is absent, parseOpenCodeModel is the sole source. + // 1. Canonical "provider/model" where parsed provider === modelProviderName → use as-is + // 2. Namespaced "ns/model" where parsed prefix ≠ modelProviderName → prefix with modelProviderName + // 3. Bare "model" → prefix with modelProviderName if available + // When modelProviderName is absent, parseOpenCodeModel is the sole source. let effectiveProviderName: string | undefined; let effectiveModel: string | undefined; if (parsedOpenCodeModel) { - if (ocProviderName && parsedOpenCodeModel.providerName !== ocProviderName) { + if (modelProviderName && parsedOpenCodeModel.providerName !== modelProviderName) { // Namespace case: model's "/" is a namespace separator, not provider prefix - effectiveProviderName = ocProviderName; - effectiveModel = `${ocProviderName}/${trimmedDefaultModel}`; + effectiveProviderName = modelProviderName; + effectiveModel = `${modelProviderName}/${trimmedDefaultModel}`; } else { - // Canonical provider/model (with or without matching ocProviderName) - effectiveProviderName = ocProviderName || parsedOpenCodeModel.providerName; + // Canonical provider/model (with or without matching modelProviderName) + effectiveProviderName = modelProviderName || parsedOpenCodeModel.providerName; effectiveModel = trimmedDefaultModel!; } - } else if (ocProviderName && trimmedDefaultModel) { - // Bare model + ocProviderName fallback - effectiveProviderName = ocProviderName; - effectiveModel = `${ocProviderName}/${trimmedDefaultModel}`; + } else if (modelProviderName && trimmedDefaultModel) { + // Bare model + modelProviderName fallback + effectiveProviderName = modelProviderName; + effectiveModel = `${modelProviderName}/${trimmedDefaultModel}`; } if (provider === 'opencode') { @@ -854,7 +864,7 @@ export async function* invokeSingleCat(deps: InvocationDeps, params: InvocationP } : null, defaultModel: trimmedDefaultModel ?? null, - ocProviderName: ocProviderName ?? null, + modelProviderName: modelProviderName ?? null, parsedOpenCodeModel, effectiveProviderName: effectiveProviderName ?? null, effectiveModel: effectiveModel ?? null, @@ -862,11 +872,11 @@ export async function* invokeSingleCat(deps: InvocationDeps, params: InvocationP 'Resolved OpenCode runtime inputs', ); } - // fix(#280): explicit ocProviderName means we must force the F189 path so the + // fix(#280): explicit provider name means we must force the F189 path so the // effective "provider/model" string is injected into opencode, even for builtin - // providers. For legacy members without ocProviderName, only synthesize runtime + // providers. For legacy members without provider name, only synthesize runtime // config when the fully-qualified model is not already routable by `opencode models`. - const hasExplicitOcProvider = Boolean(ocProviderName); + const hasExplicitOcProvider = Boolean(modelProviderName); if ( provider === 'opencode' && resolvedAccount != null && @@ -1264,7 +1274,7 @@ export async function* invokeSingleCat(deps: InvocationDeps, params: InvocationP // 1) api_key + approx health can be noisy on third-party gateways // 2) api_key + compress strategy should not be force-sealed here // Keep context_health observability in both cases. - const provider = catRegistry.tryGet(catId as string)?.config.provider; + const provider = catRegistry.tryGet(catId as string)?.config.clientId; const profileMode = callbackEnv[ANTHROPIC_PROFILE_MODE_KEY]; const strategy = getSessionStrategy(catId as string); const isAnthropicApiKey = provider === 'anthropic' && profileMode === ANTHROPIC_PROFILE_MODE_API_KEY; diff --git a/packages/api/src/domains/cats/services/agents/providers/acp/AcpClient.ts b/packages/api/src/domains/cats/services/agents/providers/acp/AcpClient.ts index 0a6a0bffc..f48789ded 100644 --- a/packages/api/src/domains/cats/services/agents/providers/acp/AcpClient.ts +++ b/packages/api/src/domains/cats/services/agents/providers/acp/AcpClient.ts @@ -297,7 +297,10 @@ export class AcpClient { const nextMs = idleWarningFired ? Math.max(0, idleStallMs - idleWarningMs) : idleWarningMs; idleTimer = setTimeout(() => { if (done || eventCount === 0) return; - const idleSinceMs = Date.now() - lastEventAt; + // Clamp to at least the threshold that triggered this timer — a threshold + // event must never report a duration smaller than its own trigger point. + const rawIdle = Date.now() - lastEventAt; + const idleSinceMs = Math.max(rawIdle, idleWarningFired ? idleStallMs : idleWarningMs); if (!idleWarningFired) { idleWarningFired = true; if (pendingTool) { diff --git a/packages/api/src/domains/cats/services/agents/providers/opencode-config-template.ts b/packages/api/src/domains/cats/services/agents/providers/opencode-config-template.ts index bf3109df3..5439f8691 100644 --- a/packages/api/src/domains/cats/services/agents/providers/opencode-config-template.ts +++ b/packages/api/src/domains/cats/services/agents/providers/opencode-config-template.ts @@ -82,14 +82,14 @@ const NPM_ADAPTER_FOR_API_TYPE: Record = { }; /** - * Derive the OpenCode API type from the member's ocProviderName binding. + * Derive the OpenCode API type from the member's provider name binding. * * Account-level protocol is no longer used — it was removed from the UI and - * should not drive runtime routing. The sole authority is ocProviderName, + * should not drive runtime routing. The sole authority is the provider name, * which the user explicitly sets in the member editor "Provider 名称" field. */ -export function deriveOpenCodeApiType(ocProviderName: string | undefined): OpenCodeApiType { - const normalized = ocProviderName?.toLowerCase(); +export function deriveOpenCodeApiType(providerName: string | undefined): OpenCodeApiType { + const normalized = providerName?.toLowerCase(); if (normalized === 'openai-responses') return 'openai-responses'; if (normalized === 'anthropic') return 'anthropic'; if (normalized === 'google') return 'google'; diff --git a/packages/api/src/domains/cats/services/context/SystemPromptBuilder.ts b/packages/api/src/domains/cats/services/context/SystemPromptBuilder.ts index 567de0fe0..1c43b40fa 100644 --- a/packages/api/src/domains/cats/services/context/SystemPromptBuilder.ts +++ b/packages/api/src/domains/cats/services/context/SystemPromptBuilder.ts @@ -346,7 +346,7 @@ export function buildStaticIdentity(catId: CatId, options?: StaticIdentityOption const config = getConfig(catId as string); if (!config) return ''; - const providerLabel = PROVIDER_LABELS[config.provider] ?? config.provider; + const providerLabel = PROVIDER_LABELS[config.clientId] ?? config.clientId; const lines: string[] = []; // Identity diff --git a/packages/api/src/domains/cats/services/game/LlmAIProvider.ts b/packages/api/src/domains/cats/services/game/LlmAIProvider.ts index 84a156d7c..1ff98d359 100644 --- a/packages/api/src/domains/cats/services/game/LlmAIProvider.ts +++ b/packages/api/src/domains/cats/services/game/LlmAIProvider.ts @@ -11,7 +11,6 @@ * - 10s timeout per call; fallback to null on failure (caller handles fallback). */ -import type { AccountProtocol } from '@cat-cafe/shared'; import { catRegistry } from '@cat-cafe/shared'; import { resolveForClient } from '../../../../config/account-resolver.js'; import { getCatModel } from '../../../../config/cat-models.js'; @@ -30,7 +29,7 @@ export class LlmAIProvider implements AIProvider { constructor(catId: string) { this.model = getCatModel(catId); const entry = catRegistry.tryGet(catId); - this.provider = entry?.config.provider ?? 'anthropic'; + this.provider = entry?.config.clientId ?? 'anthropic'; } async generateAction(prompt: string, _schema: Record): Promise { @@ -64,20 +63,15 @@ export class LlmAIProvider implements AIProvider { } } - /** Resolve API key for a given protocol through the unified resolver chain (F136 Phase 4b). */ - private resolveApiKey(protocol: AccountProtocol, ...envFallbacks: string[]): string | undefined { - const profile = resolveForClient(process.cwd(), protocol); - if (profile?.apiKey) return profile.apiKey; - for (const envKey of envFallbacks) { - const val = process.env[envKey]; - if (val) return val; - } - return undefined; + /** Resolve API key via full account discovery chain (well-known → builtin_ → installer-). */ + private resolveApiKey(client: 'anthropic' | 'openai' | 'google'): string | undefined { + const profile = resolveForClient(process.cwd(), client); + return profile?.apiKey; } private async callAnthropic(prompt: string, signal: AbortSignal): Promise { - const apiKey = this.resolveApiKey('anthropic', 'ANTHROPIC_API_KEY'); - if (!apiKey) throw new Error('No Anthropic API key in credentials or ANTHROPIC_API_KEY env'); + const apiKey = this.resolveApiKey('anthropic'); + if (!apiKey) throw new Error('No Anthropic API key in credentials.json — run install-auth-config.mjs to configure'); const resp = await fetch('https://api.anthropic.com/v1/messages', { method: 'POST', @@ -104,8 +98,8 @@ export class LlmAIProvider implements AIProvider { } private async callOpenAI(prompt: string, signal: AbortSignal): Promise { - const apiKey = this.resolveApiKey('openai', 'OPENAI_API_KEY'); - if (!apiKey) throw new Error('No OpenAI API key in credentials or OPENAI_API_KEY env'); + const apiKey = this.resolveApiKey('openai'); + if (!apiKey) throw new Error('No OpenAI API key in credentials.json — run install-auth-config.mjs to configure'); const resp = await fetch('https://api.openai.com/v1/chat/completions', { method: 'POST', @@ -131,8 +125,8 @@ export class LlmAIProvider implements AIProvider { } private async callGoogle(prompt: string, signal: AbortSignal): Promise { - const apiKey = this.resolveApiKey('google', 'GOOGLE_AI_API_KEY', 'GEMINI_API_KEY'); - if (!apiKey) throw new Error('No Google API key in credentials or GOOGLE_AI_API_KEY env'); + const apiKey = this.resolveApiKey('google'); + if (!apiKey) throw new Error('No Google API key in credentials.json — run install-auth-config.mjs to configure'); const resp = await fetch( `https://generativelanguage.googleapis.com/v1beta/models/${this.model}:generateContent?key=${apiKey}`, diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 1d1c0fb9c..1ce43ffcc 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -110,6 +110,7 @@ import { configSecretsRoutes } from './routes/config-secrets.js'; import { connectorWebhookRoutes } from './routes/connector-webhooks.js'; import { gameRoutes } from './routes/games.js'; import { + accountsRoutes, auditRoutes, authorizationRoutes, backlogRoutes, @@ -142,7 +143,6 @@ import { packsRoutes, projectSetupRoute, projectsRoutes, - providerProfilesRoutes, pushRoutes, queueRoutes, quotaRoutes, @@ -360,13 +360,9 @@ async function main(): Promise { projectRoot = thread.projectPath; } const catConfig = catRegistry.tryGet(catId)?.config; - if (catConfig?.provider === 'anthropic' || catConfig?.provider === 'opencode') { - const boundAccountRef = resolveBoundAccountRefForCat( - projectRoot, - catId, - catConfig as CatConfig & { providerProfileId?: string }, - ); - const runtime = resolveForClient(projectRoot, catConfig.provider, boundAccountRef); + if (catConfig?.clientId === 'anthropic' || catConfig?.clientId === 'opencode') { + const boundAccountRef = resolveBoundAccountRefForCat(projectRoot, catId, catConfig); + const runtime = resolveForClient(projectRoot, catConfig.clientId, boundAccountRef); if (!runtime?.apiKey) return null; return { apiKey: runtime.apiKey, baseUrl: runtime.baseUrl || 'https://api.anthropic.com' }; } @@ -557,7 +553,7 @@ async function main(): Promise { // Abstractive summary API config resolution (priority order): // 1. F102_API_BASE + F102_API_KEY (explicit override) - // 2. ANTHROPIC_API_KEY + local proxy (http://127.0.0.1:{ANTHROPIC_PROXY_PORT}/{first-slug}) + // 2. Unified accounts system (credentials.json) + local proxy // 3. null → skip abstractive const generateAbstractive = createAbstractiveClient( async () => { @@ -565,8 +561,9 @@ async function main(): Promise { if (process.env.F102_API_BASE && process.env.F102_API_KEY) { return { mode: 'api_key' as const, baseUrl: process.env.F102_API_BASE, apiKey: process.env.F102_API_KEY }; } - // Priority 2: use existing ANTHROPIC_API_KEY + local proxy - const apiKey = process.env.ANTHROPIC_API_KEY; + // Priority 2: resolve via full discovery chain (#340: system callers use resolveForClient) + const profile = resolveForClient(process.cwd(), 'anthropic'); + const apiKey = profile?.apiKey; if (!apiKey) return null; const proxyPort = process.env.ANTHROPIC_PROXY_PORT || '9877'; // Read first upstream slug from proxy-upstreams.json @@ -742,7 +739,7 @@ async function main(): Promise { // 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; - switch (config.provider) { + switch (config.clientId) { case 'anthropic': service = new ClaudeAgentService({ catId }); break; @@ -817,7 +814,7 @@ async function main(): Promise { break; } default: - app.log.warn(`[api] Unknown provider "${config.provider}" for cat "${id}". It will not be routable.`); + app.log.warn(`[api] Unknown client "${config.clientId}" for cat "${id}". It will not be routable.`); continue; } agentRegistry.register(id, service); @@ -1278,7 +1275,7 @@ async function main(): Promise { await app.register(configRoutes); await app.register(configSecretsRoutes); await app.register(featureDocDetailRoutes); - await app.register(providerProfilesRoutes); + await app.register(accountsRoutes); await app.register(claudeRescueRoutes); await app.register(auditRoutes, { threadStore }); await app.register(capabilitiesRoutes); @@ -1624,26 +1621,12 @@ async function main(): Promise { app.log.warn(`[api] CLI config regeneration failed (best-effort): ${String(err)}`); } - // F136 Phase 4a: Migrate provider-profiles → accounts + conflict scan (HC-3/HC-5/LL-043). - // HC-5: conflict is a HARD error — must propagate, not swallow. - // LL-043: legacy source present + accounts missing is a HARD error — don't run with empty accounts. - // Migration filesystem errors are best-effort. - try { + // F340: Account startup — fail-fast (LL-043 / migration conflict / corrupt credentials). + // Errors propagate to main().catch → process.exit(1). + { const { accountStartupHook } = await import('./config/account-startup.js'); const startupResult = accountStartupHook(findMonorepoRoot(process.cwd())); - if (startupResult.migration.migrated) { - app.log.info( - `[api] F136 account migration: ${startupResult.migration.accountsMigrated} account(s), ${startupResult.migration.credentialsMigrated} credential(s)`, - ); - } - } catch (err) { - // HC-5 and LL-043 errors are HARD errors — let them crash the server. - if (err instanceof Error && (err.message.includes('HC-5') || err.message.includes('LL-043'))) { - app.log.error(`[api] ${err.message}`); - throw err; - } - // Other errors (migration filesystem issues) are best-effort. - app.log.warn(`[api] F136 account startup hook failed (best-effort): ${String(err)}`); + app.log.info(`[api] F340 accounts: ${startupResult.accountCount} account(s) loaded`); } // F101 Phase G: Recover auto-play loops for active games after restart. diff --git a/packages/api/src/routes/provider-profiles.ts b/packages/api/src/routes/accounts.ts similarity index 51% rename from packages/api/src/routes/provider-profiles.ts rename to packages/api/src/routes/accounts.ts index b9a13615f..56c365594 100644 --- a/packages/api/src/routes/provider-profiles.ts +++ b/packages/api/src/routes/accounts.ts @@ -1,32 +1,44 @@ /** - * Provider Profiles API Routes — F136 Phase 4d + * Accounts API Routes — F136 Phase 4d → F340 renamed * - * Reads/writes exclusively through cat-catalog.json accounts + credentials.json. - * The legacy provider-profiles.json store has been retired. + * Reads/writes via global ~/.cat-cafe/accounts.json + credentials.json. */ +import { existsSync } from 'node:fs'; import { realpath, stat } from 'node:fs/promises'; +import { homedir } from 'node:os'; import { relative, resolve, win32 } from 'node:path'; -import type { AccountConfig, AccountProtocol } from '@cat-cafe/shared'; +import type { AccountConfig } from '@cat-cafe/shared'; import type { FastifyPluginAsync } from 'fastify'; import { z } from 'zod'; -import { validateAccountWrite } from '../config/account-conflict-guard.js'; -import { resolveByAccountRef } from '../config/account-resolver.js'; +import { resolveCatCatalogPath } from '../config/cat-catalog-store.js'; +import { loadCatConfig, toAllCatConfigs } from '../config/cat-config-loader.js'; import { deleteCatalogAccount, readCatalogAccounts, writeCatalogAccount } from '../config/catalog-accounts.js'; import { configEventBus, createChangeSetId } from '../config/config-event-bus.js'; import { deleteCredential, hasCredential, writeCredential } from '../config/credentials.js'; +import { resolveProjectTemplatePath } from '../config/project-template-path.js'; import { resolveActiveProjectRoot } from '../utils/active-project-root.js'; import { findMonorepoRoot } from '../utils/monorepo-root.js'; import { validateProjectPath } from '../utils/project-path.js'; import { resolveUserId } from '../utils/request-identity.js'; -import { buildProbeHeaders, isInvalidModelProbeError, readProbeError } from './provider-profiles-probe.js'; + +// F340: Derive client identity from well-known account IDs, not stored protocol. +const BUILTIN_CLIENT_FOR_ID: Record = { + claude: 'anthropic', + codex: 'openai', + gemini: 'google', + dare: 'dare', + opencode: 'opencode', + builtin_anthropic: 'anthropic', + builtin_openai: 'openai', + builtin_google: 'google', + builtin_dare: 'dare', + builtin_opencode: 'opencode', +}; /** Synthesize a ProviderProfileView-compatible object from AccountConfig (backward compat for Hub UI). */ function accountToView(id: string, account: AccountConfig, apiKeyPresent: boolean) { const isBuiltin = account.authType === 'oauth'; - // Non-standard builtins (dare, opencode) use standard protocols (openai, anthropic) - // but have their own distinct client identity for the Hub UI. - const NON_STANDARD_BUILTIN_CLIENTS = new Set(['dare', 'opencode']); - const builtinClient = NON_STANDARD_BUILTIN_CLIENTS.has(id) ? id : account.protocol; + const builtinClient = BUILTIN_CLIENT_FOR_ID[id] ?? id; return { id, name: account.displayName ?? id, @@ -34,8 +46,7 @@ function accountToView(id: string, account: AccountConfig, apiKeyPresent: boolea kind: isBuiltin ? 'builtin' : ('api_key' as const), authType: account.authType, builtin: isBuiltin, - ...(isBuiltin ? { client: builtinClient } : {}), - protocol: account.protocol, + ...(isBuiltin ? { clientId: builtinClient } : {}), ...(account.baseUrl ? { baseUrl: account.baseUrl } : {}), models: account.models ? [...account.models] : [], hasApiKey: apiKeyPresent, @@ -59,9 +70,42 @@ function deriveAccountId(displayName: string, existingIds: Set): string return `${seed}-${counter}`; } +function resolveGlobalConfigRoot(projectRoot?: string): string { + const envRoot = process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT?.trim(); + if (envRoot) return resolve(envRoot); + if (projectRoot) return resolve(projectRoot); + return resolve(homedir()); +} + +function isProjectScopedGlobalStore(projectRoot: string): boolean { + return resolve(projectRoot) === resolveGlobalConfigRoot(projectRoot); +} + +/** Scan both runtime catalog and template for variant→account bindings. Returns Error on parse failure. */ +function findBoundCatIds(projectRoot: string, accountRef: string): string[] | Error { + const catalogPath = resolveCatCatalogPath(projectRoot); + const templatePath = resolveProjectTemplatePath(projectRoot); + const sources: Array<{ path: string; exists: boolean }> = [ + { path: catalogPath, exists: existsSync(catalogPath) }, + { path: templatePath, exists: existsSync(templatePath) }, + ]; + const bound = new Set(); + for (const src of sources) { + if (!src.exists) continue; + try { + const allCats = toAllCatConfigs(loadCatConfig(src.path)); + for (const [id, cat] of Object.entries(allCats)) { + if (cat.accountRef === accountRef) bound.add(id); + } + } catch { + return new Error(`config at ${src.path} failed to parse`); + } + } + return [...bound]; +} + const MONOREPO_ROOT = findMonorepoRoot(); -const protocolEnum = z.enum(['anthropic', 'openai', 'openai-responses', 'google']); const authTypeEnum = z.enum(['oauth', 'api_key']); const modeEnum = z.enum(['subscription', 'api_key']); @@ -77,7 +121,6 @@ const createBodySchema = z displayName: z.string().trim().min(1).optional(), mode: modeEnum.optional(), authType: authTypeEnum.optional(), - protocol: protocolEnum.optional(), baseUrl: z.string().optional(), apiKey: z.string().optional(), modelOverride: z.string().optional(), @@ -91,7 +134,6 @@ const createBodySchema = z .pipe(z.string().min(1)), ) .optional(), - setActive: z.boolean().optional(), }) .superRefine((value, ctx) => { if (!value.name && !value.displayName) { @@ -110,7 +152,6 @@ const updateBodySchema = z.object({ displayName: z.string().trim().min(1).optional(), mode: modeEnum.optional(), authType: authTypeEnum.optional(), - protocol: protocolEnum.optional(), baseUrl: z.string().optional(), apiKey: z.string().optional(), modelOverride: z.string().nullable().optional(), @@ -126,15 +167,9 @@ const updateBodySchema = z.object({ .optional(), }); -const activateBodySchema = z.object({ +const deleteBodySchema = z.object({ projectPath: z.string().optional(), - provider: z.string().trim().min(1).optional(), -}); - -const testBodySchema = z.object({ - projectPath: z.string().optional(), - provider: z.string().trim().min(1).optional(), - protocol: protocolEnum.optional(), + force: z.boolean().optional(), }); async function resolveProjectRoot(projectPath?: string): Promise { @@ -159,80 +194,8 @@ async function resolveProjectRoot(projectPath?: string): Promise } } -function normalizeBaseUrl(baseUrl: string): string { - return baseUrl.replace(/\/+$/, ''); -} - -function probeUrl(baseUrl: string, path: string): string { - return `${normalizeBaseUrl(baseUrl)}${path.startsWith('/') ? path : `/${path}`}`; -} - -function inferProbeProtocol( - baseUrl: string | undefined, - selector: string | undefined, - models: readonly string[] | undefined = [], - ...nameHints: Array -): 'anthropic' | 'openai' | 'google' { - const normalizedSelector = selector?.trim().toLowerCase(); - if (normalizedSelector === 'anthropic' || normalizedSelector === 'claude' || normalizedSelector === 'opencode') { - return 'anthropic'; - } - if (normalizedSelector === 'google' || normalizedSelector === 'gemini') { - return 'google'; - } - if (normalizedSelector === 'openai' || normalizedSelector === 'codex' || normalizedSelector === 'dare') { - return 'openai'; - } - - const normalizedModels = models.map((model) => model.trim().toLowerCase()).filter(Boolean); - if (normalizedModels.some((model) => model.includes('claude') || model.includes('anthropic'))) { - return 'anthropic'; - } - if (normalizedModels.some((model) => model.includes('gemini') || model.includes('google'))) { - return 'google'; - } - if (normalizedModels.some((model) => model.includes('gpt') || model.includes('o1') || model.includes('o3'))) { - return 'openai'; - } - - const normalizedHints = nameHints - .map((hint) => hint?.trim().toLowerCase() ?? '') - .filter(Boolean) - .join(' '); - if ( - normalizedHints.includes('claude') || - normalizedHints.includes('anthropic') || - normalizedHints.includes('opencode') - ) { - return 'anthropic'; - } - if (normalizedHints.includes('gemini') || normalizedHints.includes('google')) { - return 'google'; - } - if (normalizedHints.includes('codex') || normalizedHints.includes('openai') || normalizedHints.includes('dare')) { - return 'openai'; - } - - const normalizedBaseUrl = normalizeBaseUrl(baseUrl ?? '').toLowerCase(); - if (normalizedBaseUrl.includes('anthropic')) return 'anthropic'; - if ( - normalizedBaseUrl.includes('googleapis.com') || - normalizedBaseUrl.includes('generativelanguage') || - normalizedBaseUrl.includes('gemini') - ) { - return 'google'; - } - return 'openai'; -} - -export interface ProviderProfilesRoutesOptions { - fetchImpl?: typeof fetch; -} - -export const providerProfilesRoutes: FastifyPluginAsync = async (app, opts) => { - const fetchImpl = opts.fetchImpl ?? fetch; - - app.get('/api/provider-profiles', async (request, reply) => { +export const accountsRoutes: FastifyPluginAsync = async (app) => { + app.get('/api/accounts', async (request, reply) => { const userId = resolveUserId(request); if (!userId) { reply.status(401); @@ -251,16 +214,16 @@ export const providerProfilesRoutes: FastifyPluginAsync accountToView(id, account, hasCredential(id))); + const providers = Object.entries(accounts).map(([id, account]) => + accountToView(id, account, hasCredential(id, projectRoot)), + ); return { projectPath: projectRoot, - activeProfileId: null, providers, - bootstrapBindings: {}, }; }); - app.post('/api/provider-profiles', async (request, reply) => { + app.post('/api/accounts', async (request, reply) => { const userId = resolveUserId(request); if (!userId) { reply.status(401); @@ -280,11 +243,10 @@ export const providerProfilesRoutes: FastifyPluginAsync { + app.patch('/api/accounts/:profileId', async (request, reply) => { const userId = resolveUserId(request); if (!userId) { reply.status(401); @@ -339,22 +300,9 @@ export const providerProfilesRoutes: FastifyPluginAsync { - const userId = resolveUserId(request); - if (!userId) { - reply.status(401); - return { error: 'Identity required (X-Cat-Cafe-User header or userId query)' }; - } - - const parsed = activateBodySchema.safeParse(request.body ?? {}); - if (!parsed.success) { - reply.status(400); - return { error: 'Invalid body', details: parsed.error.issues }; - } - const projectRoot = await resolveProjectRoot(parsed.data.projectPath); - if (!projectRoot) { - reply.status(400); - return { error: 'Invalid project path: must be an existing directory under allowed roots' }; - } - const params = request.params as { profileId: string }; - - try { - deleteCatalogAccount(projectRoot, params.profileId); - deleteCredential(params.profileId); - configEventBus.emitChange({ - source: 'accounts', - scope: 'key', - changedKeys: [params.profileId], - changeSetId: createChangeSetId(), - timestamp: Date.now(), - }); - return { ok: true }; - } catch (err) { - reply.status(400); - return { error: err instanceof Error ? err.message : String(err) }; - } - }); - - app.post('/api/provider-profiles/:profileId/activate', async (request, reply) => { + app.delete('/api/accounts/:profileId', async (request, reply) => { const userId = resolveUserId(request); if (!userId) { reply.status(401); return { error: 'Identity required (X-Cat-Cafe-User header or userId query)' }; } - const parsed = activateBodySchema.safeParse(request.body); + const parsed = deleteBodySchema.safeParse(request.body ?? {}); if (!parsed.success) { reply.status(400); return { error: 'Invalid body', details: parsed.error.issues }; @@ -449,123 +360,50 @@ export const providerProfilesRoutes: FastifyPluginAsync { - const userId = resolveUserId(request); - if (!userId) { - reply.status(401); - return { error: 'Identity required (X-Cat-Cafe-User header or userId query)' }; - } - - const parsed = testBodySchema.safeParse(request.body); - if (!parsed.success) { - reply.status(400); - return { error: 'Invalid body', details: parsed.error.issues }; - } - const projectRoot = await resolveProjectRoot(parsed.data.projectPath); - if (!projectRoot) { - reply.status(400); - return { error: 'Invalid project path: must be an existing directory under allowed roots' }; - } - const params = request.params as { profileId: string }; - - const runtime = resolveByAccountRef(projectRoot, params.profileId); - if (!runtime || runtime.authType !== 'api_key' || !runtime.baseUrl || !runtime.apiKey) { - reply.status(400); - return { error: 'Only api_key providers can be tested' }; - } - - const baseUrl = normalizeBaseUrl(runtime.baseUrl); - const probeProtocol = - runtime.protocol ?? - inferProbeProtocol( - runtime.baseUrl, - parsed.data.protocol ?? parsed.data.provider, - runtime.models, - params.profileId, - ); - const modelProbePaths = probeProtocol === 'google' ? ['/v1beta/models', '/models', '/v1/models'] : ['/v1/models']; - let modelsRes: Response | null = null; - let modelsError: string | null = null; try { - for (const path of modelProbePaths) { - const next = await fetchImpl(probeUrl(baseUrl, path), { - method: 'GET', - headers: buildProbeHeaders(probeProtocol, runtime.apiKey), - }); - modelsRes = next; - if (next.ok) { + const accounts = readCatalogAccounts(projectRoot); + const accountExists = Object.hasOwn(accounts, params.profileId); + + // F340: Check current project's catalog AND template for dangling references. + // Template-only projects (pre-bootstrap) may still bind accountRefs in variants. + if (!parsed.data.force && accountExists) { + const boundCatIds = findBoundCatIds(projectRoot, params.profileId); + if (boundCatIds instanceof Error) { + reply.status(500); return { - ok: true, - mode: 'api_key', - status: next.status, + error: `Cannot verify account references — ${boundCatIds.message}. Pass { "force": true } to override.`, }; } - modelsError = await readProbeError(next); - if (next.status !== 404) break; - } - - if (!modelsRes) { - return { - ok: false, - mode: 'api_key', - error: 'Provider test did not execute', - }; - } - - if (probeProtocol === 'anthropic' && modelsRes.status === 404) { - const messagesRes = await fetchImpl(probeUrl(baseUrl, '/v1/messages'), { - method: 'POST', - headers: { - ...buildProbeHeaders(probeProtocol, runtime.apiKey), - 'content-type': 'application/json', - }, - body: JSON.stringify({ - model: 'claude-3-5-haiku-latest', - max_tokens: 1, - messages: [{ role: 'user', content: 'ping' }], - }), - }); - if (messagesRes.ok) { + if (boundCatIds.length > 0) { + reply.status(409); return { - ok: true, - mode: 'api_key', - status: messagesRes.status, + error: `Account "${params.profileId}" is still referenced by: ${boundCatIds.join(', ')}. Remove bindings first or pass { "force": true }.`, + boundCatIds, }; } - const messagesError = await readProbeError(messagesRes); - if (messagesRes.status === 400 && isInvalidModelProbeError(messagesError)) { + if (!isProjectScopedGlobalStore(projectRoot)) { + reply.status(409); return { - ok: true, - mode: 'api_key', - status: 200, - message: 'baseUrl and apiKey are valid; gateway rejected the probe model identifier', + error: + `Account "${params.profileId}" lives in shared global store ${resolveGlobalConfigRoot(projectRoot)} ` + + `and non-force deletion cannot verify bindings in other projects. Audit all project catalogs or pass { "force": true }.`, }; } - return { - ok: false, - mode: 'api_key', - status: messagesRes.status, - error: messagesError, - }; } - return { - ok: false, - mode: 'api_key', - status: modelsRes.status, - error: modelsError ?? (await readProbeError(modelsRes)), - }; + deleteCatalogAccount(projectRoot, params.profileId); + deleteCredential(params.profileId, projectRoot); + configEventBus.emitChange({ + source: 'accounts', + scope: 'key', + changedKeys: [params.profileId], + changeSetId: createChangeSetId(), + timestamp: Date.now(), + }); + return { ok: true }; } catch (err) { - reply.status(500); - return { - ok: false, - mode: 'api_key', - error: err instanceof Error ? err.message : String(err), - }; + reply.status(400); + return { error: err instanceof Error ? err.message : String(err) }; } }); }; diff --git a/packages/api/src/routes/capabilities.ts b/packages/api/src/routes/capabilities.ts index edc1159d6..42cc60777 100644 --- a/packages/api/src/routes/capabilities.ts +++ b/packages/api/src/routes/capabilities.ts @@ -650,7 +650,7 @@ export const capabilitiesRoutes: FastifyPluginAsync = async (app) => { const cats: Record = {}; for (const catId of catIds) { const entry = catRegistry.tryGet(catId); - const provider = entry?.config.provider ?? 'unknown'; + const provider = entry?.config.clientId ?? 'unknown'; const presentForProvider = (providerSkills[provider] ?? []).includes(cap.id); if (!presentForProvider) continue; // Sparse cats: omit irrelevant cats so frontend filter works const override = cap.overrides?.find((o) => o.catId === catId); diff --git a/packages/api/src/routes/cats.ts b/packages/api/src/routes/cats.ts index 0601d69fa..aa3b745f6 100644 --- a/packages/api/src/routes/cats.ts +++ b/packages/api/src/routes/cats.ts @@ -7,9 +7,9 @@ import { resolve } from 'node:path'; import { type CatConfig, - type CatProvider, CLI_EFFORT_VALUES, type CliConfig, + type ClientId, type ContextBudget, catRegistry, getCliEffortOptionsForProvider, @@ -75,7 +75,6 @@ const baseCatSchema = z.object({ color: colorSchema, mentionPatterns: z.array(z.string().min(1)).min(1), accountRef: z.string().min(1).optional(), - providerProfileId: z.string().min(1).optional(), contextBudget: contextBudgetSchema.optional(), roleDescription: z.string().min(1), personality: z.string().optional(), @@ -93,21 +92,21 @@ const modelSchema = z .pipe(z.string().min(1)); const createNormalCatSchema = baseCatSchema.extend({ - client: clientSchema.exclude(['antigravity']), + clientId: clientSchema.exclude(['antigravity']), defaultModel: modelSchema, mcpSupport: z.boolean().optional(), cli: cliSchema.optional(), cliConfigArgs: z.array(z.string().min(1)).optional(), - ocProviderName: z.string().min(1).optional(), + provider: z.string().min(1).optional(), }); const createAntigravityCatSchema = baseCatSchema.extend({ - client: z.literal('antigravity'), + clientId: z.literal('antigravity'), defaultModel: modelSchema, commandArgs: z.array(z.string().min(1)).min(1).optional(), }); -const createCatSchema = z.discriminatedUnion('client', [createNormalCatSchema, createAntigravityCatSchema]); +const createCatSchema = z.discriminatedUnion('clientId', [createNormalCatSchema, createAntigravityCatSchema]); const updateCatSchema = z.object({ name: z.string().min(1).optional(), @@ -117,7 +116,6 @@ const updateCatSchema = z.object({ color: colorSchema.optional(), mentionPatterns: z.array(z.string().min(1)).min(1).optional(), accountRef: z.string().min(1).nullable().optional(), - providerProfileId: z.string().min(1).nullable().optional(), contextBudget: contextBudgetSchema.nullable().optional(), roleDescription: z.string().min(1).optional(), personality: z.string().optional(), @@ -126,13 +124,13 @@ const updateCatSchema = z.object({ strengths: z.array(z.string().min(1)).optional(), sessionChain: z.boolean().optional(), available: z.boolean().optional(), - client: clientSchema.optional(), + clientId: clientSchema.optional(), defaultModel: modelSchema.optional(), mcpSupport: z.boolean().optional(), cli: cliSchema.optional(), commandArgs: z.array(z.string().min(1)).optional(), cliConfigArgs: z.array(z.string().min(1)).optional(), - ocProviderName: z.string().min(1).nullable().optional(), + provider: z.string().min(1).nullable().optional(), }); type UpdateCatRequestBody = z.infer; @@ -184,7 +182,7 @@ function buildCatResponseMetadataResolver(projectRoot: string) { }); } -function defaultCliForClient(client: CatProvider): { command: string; outputFormat: string } { +function defaultCliForClient(client: ClientId): { command: string; outputFormat: string } { switch (client) { case 'anthropic': return { command: 'claude', outputFormat: 'stream-json' }; @@ -207,7 +205,7 @@ function defaultCliForClient(client: CatProvider): { command: string; outputForm type CliPatch = z.infer; -function buildResolvedCliConfig(client: CatProvider, baseCli: CliConfig, patch?: CliPatch): CliConfig { +function buildResolvedCliConfig(client: ClientId, baseCli: CliConfig, patch?: CliPatch): CliConfig { const defaultArgs = patch?.defaultArgs !== undefined ? patch.defaultArgs.length > 0 @@ -235,16 +233,12 @@ function buildResolvedCliConfig(client: CatProvider, baseCli: CliConfig, patch?: }; } -function resolveAccountRef(body: { - accountRef?: string | null; - providerProfileId?: string | null; -}): string | undefined | null { - if (body.providerProfileId !== undefined) return body.providerProfileId; +function resolveAccountRef(body: { accountRef?: string | null }): string | undefined | null { if (body.accountRef !== undefined) return body.accountRef; return undefined; } -function resolveDefaultAccountRefForClient(projectRoot: string, client: CatProvider): string | undefined { +function resolveDefaultAccountRefForClient(projectRoot: string, client: ClientId): string | undefined { const builtinClient = resolveBuiltinClientForProvider(client); if (!builtinClient) return undefined; return resolveForClient(projectRoot, builtinClient)?.id ?? builtinAccountIdForClient(builtinClient); @@ -266,11 +260,14 @@ function resolveTargetAccountRef(params: { const { body, currentCat, currentExplicitAccountRef, currentEffectiveAccountRef } = params; const nextAccountRef = resolveAccountRef(body); - const isClientSwitch = body.client !== undefined && body.client !== currentCat.provider; + const isClientSwitch = body.clientId !== undefined && body.clientId !== currentCat.clientId; const carriesCurrentEffectiveBinding = nextAccountRef !== undefined && (nextAccountRef ?? undefined) === currentEffectiveAccountRef; - return isClientSwitch && !currentExplicitAccountRef && carriesCurrentEffectiveBinding ? undefined : nextAccountRef; + // Return null (clear the persisted accountRef) so the seed cat inherits the new client's default. + // undefined would mean "don't touch" — but the bootstrap may have persisted the old default as an + // explicit accountRef, which would survive the switch and point at the wrong client. + return isClientSwitch && !currentExplicitAccountRef && carriesCurrentEffectiveBinding ? null : nextAccountRef; } /** @@ -284,7 +281,7 @@ function resolveEffectiveAccountRefForUpdate(params: { currentCat: CatConfig; currentExplicitAccountRef: string | undefined; currentEffectiveAccountRef: string | undefined; - effectiveClient: CatProvider; + effectiveClient: ClientId; targetAccountRef: string | null | undefined; }): string | undefined { const { @@ -297,9 +294,18 @@ function resolveEffectiveAccountRefForUpdate(params: { targetAccountRef, } = params; - if (targetAccountRef !== undefined) return targetAccountRef ?? undefined; + const isClientSwitch = body.clientId !== undefined && body.clientId !== currentCat.clientId; + + // null targetAccountRef from client-switch rebase: seed cat inherits new client's default. + // null from explicit user clear: validation should catch "requires a provider binding". + if (targetAccountRef === null) { + if (isClientSwitch && !currentExplicitAccountRef) { + return resolveDefaultAccountRefForClient(projectRoot, effectiveClient); + } + return undefined; + } + if (targetAccountRef !== undefined) return targetAccountRef; - const isClientSwitch = body.client !== undefined && body.client !== currentCat.provider; if (isClientSwitch && !currentExplicitAccountRef) { return resolveDefaultAccountRefForClient(projectRoot, effectiveClient); } @@ -320,12 +326,12 @@ function resolveEffectiveAccountRefForUpdate(params: { function resolveNextCli(params: { body: UpdateCatRequestBody; currentCat: CatConfig; - effectiveClient: CatProvider; + effectiveClient: ClientId; hasCommandArgsPatch: boolean; nextCommandArgs: string[]; }): CliConfig | undefined { const { body, currentCat, effectiveClient, hasCommandArgsPatch, nextCommandArgs } = params; - const isClientSwitch = body.client !== undefined && body.client !== currentCat.provider; + const isClientSwitch = body.clientId !== undefined && body.clientId !== currentCat.clientId; const defaultCli = defaultCliForClient(effectiveClient); const defaultEffort = getDefaultCliEffortForProvider(effectiveClient); @@ -368,7 +374,7 @@ function buildEffectiveAccountRefResolver(projectRoot: string) { if (explicitAccountRef !== undefined) return explicitAccountRef; if (!isSeedCat(projectRoot, cat.id)) return cat.accountRef; - const builtinClient = resolveBuiltinClientForProvider(cat.provider); + const builtinClient = resolveBuiltinClientForProvider(cat.clientId); if (!builtinClient) return cat.accountRef; let runtimeProfilePromise = inheritedBindingCache.get(builtinClient); @@ -384,10 +390,10 @@ function buildEffectiveAccountRefResolver(projectRoot: string) { async function validateAccountBindingOrThrow( projectRoot: string, - client: CatProvider, + client: ClientId, accountRef?: string | null, defaultModel?: string | null, - ocProviderName?: string | null, + providerName?: string | null, options?: { legacyCompat?: boolean }, ): Promise { const trimmedAccountRef = accountRef?.trim(); @@ -406,7 +412,7 @@ async function validateAccountBindingOrThrow( if (compatibilityError) { throw new Error(compatibilityError); } - const modelFormatError = validateModelFormatForProvider(client, defaultModel, runtimeProfile.kind, ocProviderName, { + const modelFormatError = validateModelFormatForProvider(client, defaultModel, runtimeProfile.kind, providerName, { ...options, accountModels: runtimeProfile.models, }); @@ -429,7 +435,7 @@ async function toCatResponse( mentionPatterns: cat.mentionPatterns, breedId: cat.breedId, accountRef: await resolveEffectiveAccountRef(cat), - provider: cat.provider, + clientId: cat.clientId, defaultModel: cat.defaultModel, cli: cat.cli, contextBudget: cat.contextBudget, @@ -442,7 +448,7 @@ async function toCatResponse( sessionChain: cat.sessionChain, commandArgs: cat.commandArgs, cliConfigArgs: cat.cliConfigArgs, - ocProviderName: cat.ocProviderName, + provider: cat.provider, variantLabel: cat.variantLabel ?? undefined, isDefaultVariant: cat.isDefaultVariant ?? undefined, breedDisplayName: cat.breedDisplayName ?? undefined, @@ -457,7 +463,7 @@ async function toCatResponse( } : null, source: metadata.source, - adapterMode: cat.provider === 'google' ? (getAcpConfig(cat.id as string) ? 'acp' : 'cli') : undefined, + adapterMode: cat.clientId === 'google' ? (getAcpConfig(cat.id as string) ? 'acp' : 'cli') : undefined, }; } @@ -544,16 +550,16 @@ export const catsRoutes: FastifyPluginAsync = async (app) => { const accountRef = resolveAccountRef(body); try { - const ocProviderNameForValidation = 'ocProviderName' in body ? body.ocProviderName : undefined; + const providerNameForValidation = 'provider' in body ? body.provider : undefined; await validateAccountBindingOrThrow( projectRoot, - body.client, + body.clientId, accountRef, body.defaultModel, - ocProviderNameForValidation, + providerNameForValidation, ); const resolvedAvatar = body.avatar ?? '/avatars/default.png'; - if (body.client === 'antigravity') { + if (body.clientId === 'antigravity') { createRuntimeCat(projectRoot, { catId: body.catId, name: body.name, @@ -570,7 +576,7 @@ export const catsRoutes: FastifyPluginAsync = async (app) => { caution: body.caution, strengths: body.strengths, sessionChain: body.sessionChain, - provider: 'antigravity', + clientId: 'antigravity', defaultModel: body.defaultModel, mcpSupport: false, cli: { @@ -580,7 +586,7 @@ export const catsRoutes: FastifyPluginAsync = async (app) => { commandArgs: body.commandArgs, }); } else { - const resolvedCli = buildResolvedCliConfig(body.client, defaultCliForClient(body.client), body.cli); + const resolvedCli = buildResolvedCliConfig(body.clientId, defaultCliForClient(body.clientId), body.cli); createRuntimeCat(projectRoot, { catId: body.catId, name: body.name, @@ -597,17 +603,17 @@ export const catsRoutes: FastifyPluginAsync = async (app) => { caution: body.caution, strengths: body.strengths, sessionChain: body.sessionChain, - provider: body.client, + clientId: body.clientId, defaultModel: body.defaultModel, mcpSupport: body.mcpSupport ?? - (body.client === 'anthropic' || - body.client === 'openai' || - body.client === 'google' || - body.client === 'opencode'), + (body.clientId === 'anthropic' || + body.clientId === 'openai' || + body.clientId === 'google' || + body.clientId === 'opencode'), cli: resolvedCli, ...(body.cliConfigArgs ? { cliConfigArgs: body.cliConfigArgs } : {}), - ...(body.ocProviderName ? { ocProviderName: body.ocProviderName } : {}), + ...(body.provider ? { provider: body.provider } : {}), }); } } catch (err) { @@ -668,7 +674,7 @@ export const catsRoutes: FastifyPluginAsync = async (app) => { reply.status(404); return { error: `Cat "${request.params.id}" not found` }; } - const effectiveClient = body.client ?? currentCat.provider; + const effectiveClient = body.clientId ?? currentCat.clientId; const currentExplicitAccountRef = resolveBoundAccountRefForCat(projectRoot, request.params.id, currentCat); const currentEffectiveAccountRef = await resolveEffectiveAccountRef(currentCat); const targetAccountRef = resolveTargetAccountRef({ @@ -688,26 +694,25 @@ export const catsRoutes: FastifyPluginAsync = async (app) => { }); const effectiveDefaultModel = body.defaultModel !== undefined ? body.defaultModel : currentCat.defaultModel; const providerConfigTouched = - body.client !== undefined || + body.clientId !== undefined || body.defaultModel !== undefined || targetAccountRef !== undefined || - body.ocProviderName !== undefined; + body.provider !== undefined; if (providerConfigTouched) { try { - const effectiveOcProviderName = - body.ocProviderName !== undefined ? body.ocProviderName : currentCat.ocProviderName; - // Legacy compat: existing opencode+api_key members without ocProviderName + const effectiveProviderName = body.provider !== undefined ? body.provider : currentCat.provider; + // Legacy compat: existing opencode+api_key members without provider name // can still be edited for non-binding changes (name, model, etc.). - // NOT allowed when: switching accountRef, or switching client to opencode - // from another provider — both create a new binding that must have ocProviderName. + // NOT allowed when: switching accountRef, or switching clientId to opencode + // from another client — both create a new binding that must have provider name. // Compare against current binding — editor always sends accountRef even when unchanged. const isBindingChange = targetAccountRef !== undefined && targetAccountRef !== currentEffectiveAccountRef; - const isClientSwitch = body.client !== undefined && body.client !== currentCat.provider; - const isExistingOpencode = currentCat.provider === 'opencode'; + const isClientSwitch = body.clientId !== undefined && body.clientId !== currentCat.clientId; + const isExistingOpencode = currentCat.clientId === 'opencode'; const legacyCompat = - body.ocProviderName === undefined && - !currentCat.ocProviderName && + body.provider === undefined && + !currentCat.provider && !isBindingChange && !isClientSwitch && isExistingOpencode; @@ -716,7 +721,7 @@ export const catsRoutes: FastifyPluginAsync = async (app) => { effectiveClient, effectiveAccountRef, effectiveDefaultModel, - effectiveOcProviderName, + effectiveProviderName, { legacyCompat }, ); } catch (err) { @@ -752,7 +757,7 @@ export const catsRoutes: FastifyPluginAsync = async (app) => { ...(body.caution !== undefined ? { caution: body.caution } : {}), ...(body.strengths !== undefined ? { strengths: body.strengths } : {}), ...(body.sessionChain !== undefined ? { sessionChain: body.sessionChain } : {}), - ...(body.client !== undefined ? { provider: body.client } : {}), + ...(body.clientId !== undefined ? { clientId: body.clientId } : {}), ...(body.defaultModel !== undefined ? { defaultModel: body.defaultModel } : {}), ...(body.mcpSupport !== undefined ? { mcpSupport: body.mcpSupport } : {}), ...(hasCommandArgsPatch @@ -763,10 +768,10 @@ export const catsRoutes: FastifyPluginAsync = async (app) => { ...(nextCli !== undefined ? { cli: nextCli } : {}), ...(body.available !== undefined ? { available: body.available } : {}), ...(body.cliConfigArgs !== undefined ? { cliConfigArgs: body.cliConfigArgs } : {}), - ...(body.ocProviderName !== undefined - ? body.ocProviderName === null - ? { ocProviderName: null } - : { ocProviderName: body.ocProviderName } + ...(body.provider !== undefined + ? body.provider === null + ? { provider: null } + : { provider: body.provider } : {}), }); const resolved = await reconcileCatRegistry(projectRoot, managedIdsBefore); diff --git a/packages/api/src/routes/index.ts b/packages/api/src/routes/index.ts index e69d28d49..616ec71ac 100644 --- a/packages/api/src/routes/index.ts +++ b/packages/api/src/routes/index.ts @@ -1,3 +1,4 @@ +export { accountsRoutes } from './accounts.js'; export { auditRoutes } from './audit.js'; export { authorizationRoutes } from './authorization.js'; export { backlogRoutes } from './backlog.js'; @@ -32,7 +33,6 @@ export { packsRoutes } from './packs.js'; export { projectsRoutes } from './projects.js'; export { mkdirRoute } from './projects-mkdir.js'; export { projectSetupRoute } from './projects-setup.js'; -export { providerProfilesRoutes } from './provider-profiles.js'; export { pushRoutes } from './push.js'; export { queueRoutes } from './queue.js'; export { quotaRoutes } from './quota.js'; diff --git a/packages/api/src/routes/provider-profiles-probe.ts b/packages/api/src/routes/provider-profiles-probe.ts deleted file mode 100644 index b38e05b67..000000000 --- a/packages/api/src/routes/provider-profiles-probe.ts +++ /dev/null @@ -1,24 +0,0 @@ -export function buildProbeHeaders( - protocol: 'anthropic' | 'openai' | 'openai-responses' | 'google', - apiKey: string, -): Record { - switch (protocol) { - case 'anthropic': - return { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' }; - case 'google': - return { 'x-goog-api-key': apiKey }; - case 'openai': - case 'openai-responses': - default: - return { authorization: `Bearer ${apiKey}` }; - } -} - -export async function readProbeError(res: Response): Promise { - const text = await res.text().catch(() => ''); - return text.slice(0, 400); -} - -export function isInvalidModelProbeError(errorText: string): boolean { - return /(invalid model|model[^a-z0-9]*(not found|does not exist|unsupported))/i.test(errorText); -} diff --git a/packages/api/src/routes/session-strategy-config.ts b/packages/api/src/routes/session-strategy-config.ts index a12bcd254..58572d575 100644 --- a/packages/api/src/routes/session-strategy-config.ts +++ b/packages/api/src/routes/session-strategy-config.ts @@ -49,13 +49,13 @@ export async function sessionStrategyConfigRoutes(app: FastifyInstance, _opts: F cats.push({ catId, displayName: entry.config.displayName, - provider: entry.config.provider, + clientId: entry.config.clientId, breedId: entry.config.breedId, effective, source, hasOverride: override != null, override: override ?? null, - hybridCapable: HOOK_CAPABLE_PROVIDERS.has(entry.config.provider), + hybridCapable: HOOK_CAPABLE_PROVIDERS.has(entry.config.clientId), sessionChainEnabled: isSessionChainEnabled(catId), }); } @@ -98,12 +98,12 @@ export async function sessionStrategyConfigRoutes(app: FastifyInstance, _opts: F } // Guard: hybrid requires hook-capable provider - if (override.strategy === 'hybrid' && !HOOK_CAPABLE_PROVIDERS.has(entry.config.provider)) { + if (override.strategy === 'hybrid' && !HOOK_CAPABLE_PROVIDERS.has(entry.config.clientId)) { reply.status(422); return { error: `hybrid strategy requires a hook-capable provider (${[...HOOK_CAPABLE_PROVIDERS].join(', ')}), ` + - `but "${catId}" uses provider "${entry.config.provider}"`, + `but "${catId}" uses provider "${entry.config.clientId}"`, }; } diff --git a/packages/api/test/account-conflict-guard.test.js b/packages/api/test/account-conflict-guard.test.js deleted file mode 100644 index 1e7d0153a..000000000 --- a/packages/api/test/account-conflict-guard.test.js +++ /dev/null @@ -1,305 +0,0 @@ -import assert from 'node:assert/strict'; -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join, resolve } from 'node:path'; -import { afterEach, beforeEach, describe, it } from 'node:test'; - -describe('account conflict detection guard (HC-5)', () => { - let globalRoot; - let previousGlobalRoot; - - beforeEach(async () => { - globalRoot = await mkdtemp(join(tmpdir(), 'acct-conflict-')); - previousGlobalRoot = process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT; - process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT = globalRoot; - }); - - afterEach(async () => { - if (previousGlobalRoot === undefined) delete process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT; - else process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT = previousGlobalRoot; - await rm(globalRoot, { recursive: true, force: true }); - }); - - async function writeKnownRoots(roots) { - const dir = join(globalRoot, '.cat-cafe'); - await mkdir(dir, { recursive: true }); - await writeFile(join(dir, 'known-project-roots.json'), JSON.stringify(roots), 'utf-8'); - } - - async function writeCatalogWithAccounts(projectRoot, accounts) { - const dir = join(projectRoot, '.cat-cafe'); - await mkdir(dir, { recursive: true }); - const catalog = { - version: 2, - breeds: [], - roster: {}, - reviewPolicy: { - requireDifferentFamily: true, - preferActiveInThread: true, - preferLead: true, - excludeUnavailable: true, - }, - accounts, - }; - await writeFile(join(dir, 'cat-catalog.json'), JSON.stringify(catalog, null, 2), 'utf-8'); - } - - it('no conflict when same accountRef has identical config across projects', async () => { - const { detectAccountConflicts } = await import(`../dist/config/account-conflict-guard.js?t=${Date.now()}`); - const projectA = await mkdtemp(join(tmpdir(), 'proj-a-')); - const projectB = await mkdtemp(join(tmpdir(), 'proj-b-')); - - try { - await writeKnownRoots([projectA, projectB]); - const sameAccount = { authType: 'api_key', protocol: 'openai', baseUrl: 'https://api.example.com/v1' }; - await writeCatalogWithAccounts(projectA, { shared: sameAccount }); - await writeCatalogWithAccounts(projectB, { shared: sameAccount }); - - const conflicts = detectAccountConflicts(projectA); - assert.equal(conflicts.length, 0); - } finally { - await rm(projectA, { recursive: true, force: true }); - await rm(projectB, { recursive: true, force: true }); - } - }); - - it('detects conflict when same accountRef has different protocol', async () => { - const { detectAccountConflicts } = await import(`../dist/config/account-conflict-guard.js?t=${Date.now()}-1`); - const projectA = await mkdtemp(join(tmpdir(), 'proj-a-')); - const projectB = await mkdtemp(join(tmpdir(), 'proj-b-')); - - try { - await writeKnownRoots([projectA, projectB]); - await writeCatalogWithAccounts(projectA, { - myacct: { authType: 'api_key', protocol: 'openai' }, - }); - await writeCatalogWithAccounts(projectB, { - myacct: { authType: 'api_key', protocol: 'anthropic' }, - }); - - const conflicts = detectAccountConflicts(projectA); - assert.equal(conflicts.length, 1); - assert.equal(conflicts[0].accountRef, 'myacct'); - assert.ok(conflicts[0].details.includes('protocol')); - } finally { - await rm(projectA, { recursive: true, force: true }); - await rm(projectB, { recursive: true, force: true }); - } - }); - - it('detects conflict when same accountRef has different authType', async () => { - const { detectAccountConflicts } = await import(`../dist/config/account-conflict-guard.js?t=${Date.now()}-2`); - const projectA = await mkdtemp(join(tmpdir(), 'proj-a-')); - const projectB = await mkdtemp(join(tmpdir(), 'proj-b-')); - - try { - await writeKnownRoots([projectA, projectB]); - await writeCatalogWithAccounts(projectA, { - myacct: { authType: 'oauth', protocol: 'openai' }, - }); - await writeCatalogWithAccounts(projectB, { - myacct: { authType: 'api_key', protocol: 'openai' }, - }); - - const conflicts = detectAccountConflicts(projectA); - assert.equal(conflicts.length, 1); - assert.ok(conflicts[0].details.includes('authType')); - } finally { - await rm(projectA, { recursive: true, force: true }); - await rm(projectB, { recursive: true, force: true }); - } - }); - - it('normalizes baseUrl trailing slash before comparison (HC-5 gpt52)', async () => { - const { detectAccountConflicts } = await import(`../dist/config/account-conflict-guard.js?t=${Date.now()}-3`); - const projectA = await mkdtemp(join(tmpdir(), 'proj-a-')); - const projectB = await mkdtemp(join(tmpdir(), 'proj-b-')); - - try { - await writeKnownRoots([projectA, projectB]); - await writeCatalogWithAccounts(projectA, { - myacct: { authType: 'api_key', protocol: 'openai', baseUrl: 'https://api.openai.com/v1' }, - }); - await writeCatalogWithAccounts(projectB, { - myacct: { authType: 'api_key', protocol: 'openai', baseUrl: 'https://api.openai.com/v1/' }, - }); - - const conflicts = detectAccountConflicts(projectA); - assert.equal(conflicts.length, 0, 'trailing slash difference should not be a conflict'); - } finally { - await rm(projectA, { recursive: true, force: true }); - await rm(projectB, { recursive: true, force: true }); - } - }); - - it('detects conflict when baseUrl is genuinely different', async () => { - const { detectAccountConflicts } = await import(`../dist/config/account-conflict-guard.js?t=${Date.now()}-4`); - const projectA = await mkdtemp(join(tmpdir(), 'proj-a-')); - const projectB = await mkdtemp(join(tmpdir(), 'proj-b-')); - - try { - await writeKnownRoots([projectA, projectB]); - await writeCatalogWithAccounts(projectA, { - myacct: { authType: 'api_key', protocol: 'openai', baseUrl: 'https://api.openai.com/v1' }, - }); - await writeCatalogWithAccounts(projectB, { - myacct: { authType: 'api_key', protocol: 'openai', baseUrl: 'https://other.api.com/v1' }, - }); - - const conflicts = detectAccountConflicts(projectA); - assert.equal(conflicts.length, 1); - assert.ok(conflicts[0].details.includes('baseUrl')); - } finally { - await rm(projectA, { recursive: true, force: true }); - await rm(projectB, { recursive: true, force: true }); - } - }); - - it('returns empty when no known roots file exists', async () => { - const { detectAccountConflicts } = await import(`../dist/config/account-conflict-guard.js?t=${Date.now()}-5`); - const projectA = await mkdtemp(join(tmpdir(), 'proj-a-')); - try { - await writeCatalogWithAccounts(projectA, { - myacct: { authType: 'api_key', protocol: 'openai' }, - }); - const conflicts = detectAccountConflicts(projectA); - assert.equal(conflicts.length, 0); - } finally { - await rm(projectA, { recursive: true, force: true }); - } - }); - - it('validateAccountWrite reuses same conflict logic (write-path guard)', async () => { - const { validateAccountWrite } = await import(`../dist/config/account-conflict-guard.js?t=${Date.now()}-6`); - const projectA = await mkdtemp(join(tmpdir(), 'proj-a-')); - const projectB = await mkdtemp(join(tmpdir(), 'proj-b-')); - - try { - await writeKnownRoots([projectA, projectB]); - await writeCatalogWithAccounts(projectB, { - myacct: { authType: 'api_key', protocol: 'anthropic' }, - }); - - // Attempting to write 'myacct' with protocol: 'openai' in projectA should error - assert.throws( - () => validateAccountWrite(projectA, 'myacct', { authType: 'api_key', protocol: 'openai' }), - (err) => err.message.includes('myacct') && err.message.includes('protocol'), - ); - } finally { - await rm(projectA, { recursive: true, force: true }); - await rm(projectB, { recursive: true, force: true }); - } - }); - - it('validateAccountWrite allows write when no conflict', async () => { - const { validateAccountWrite } = await import(`../dist/config/account-conflict-guard.js?t=${Date.now()}-7`); - const projectA = await mkdtemp(join(tmpdir(), 'proj-a-')); - const projectB = await mkdtemp(join(tmpdir(), 'proj-b-')); - - try { - await writeKnownRoots([projectA, projectB]); - await writeCatalogWithAccounts(projectB, { - myacct: { authType: 'api_key', protocol: 'openai' }, - }); - - // Same config should not throw - assert.doesNotThrow(() => validateAccountWrite(projectA, 'myacct', { authType: 'api_key', protocol: 'openai' })); - } finally { - await rm(projectA, { recursive: true, force: true }); - await rm(projectB, { recursive: true, force: true }); - } - }); - - /** Helper: set up two dirs as main repo + worktree of the same git project. */ - async function setupWorktreePair() { - const mainRepo = await mkdtemp(join(tmpdir(), 'main-repo-')); - const worktree = await mkdtemp(join(tmpdir(), 'worktree-')); - // Main repo: .git/ directory - const gitDir = join(mainRepo, '.git'); - await mkdir(gitDir, { recursive: true }); - // Worktree: .git file pointing to main repo's .git/worktrees/ - const wtName = 'wt-runtime'; - const worktreesDir = join(gitDir, 'worktrees', wtName); - await mkdir(worktreesDir, { recursive: true }); - await writeFile(join(worktree, '.git'), `gitdir: ${worktreesDir}\n`, 'utf-8'); - return { - mainRepo, - worktree, - cleanup: () => - Promise.all([rm(mainRepo, { recursive: true, force: true }), rm(worktree, { recursive: true, force: true })]), - }; - } - - it('detectAccountConflicts ignores worktrees of the same git project', async () => { - const { detectAccountConflicts } = await import(`../dist/config/account-conflict-guard.js?t=${Date.now()}-wt1`); - const { mainRepo, worktree, cleanup } = await setupWorktreePair(); - - try { - await writeKnownRoots([mainRepo, worktree]); - await writeCatalogWithAccounts(mainRepo, { - minimax: { authType: 'api_key', protocol: 'openai', baseUrl: 'https://api.minimax.io/v1' }, - }); - await writeCatalogWithAccounts(worktree, { - minimax: { authType: 'api_key', protocol: 'openai', baseUrl: 'https://api.minimax.io/v11' }, - }); - - const conflicts = detectAccountConflicts(mainRepo); - assert.equal(conflicts.length, 0, 'worktrees of the same project should not trigger conflict'); - } finally { - await cleanup(); - } - }); - - it('validateAccountWrite allows update when conflict is from a worktree of the same project', async () => { - const { validateAccountWrite } = await import(`../dist/config/account-conflict-guard.js?t=${Date.now()}-wt2`); - const { mainRepo, worktree, cleanup } = await setupWorktreePair(); - - try { - await writeKnownRoots([mainRepo, worktree]); - // Main repo has old baseUrl - await writeCatalogWithAccounts(mainRepo, { - minimax: { authType: 'api_key', protocol: 'openai', baseUrl: 'https://api.minimax.io/v1' }, - }); - - // Writing updated baseUrl from worktree should NOT throw - assert.doesNotThrow(() => - validateAccountWrite(worktree, 'minimax', { - authType: 'api_key', - protocol: 'openai', - baseUrl: 'https://api.minimax.io/v11', - }), - ); - } finally { - await cleanup(); - } - }); - - it('still detects conflict between genuinely different projects (not worktrees)', async () => { - const { validateAccountWrite } = await import(`../dist/config/account-conflict-guard.js?t=${Date.now()}-wt3`); - const { mainRepo, worktree, cleanup } = await setupWorktreePair(); - const unrelatedProject = await mkdtemp(join(tmpdir(), 'unrelated-')); - // Give unrelated project its own .git dir - await mkdir(join(unrelatedProject, '.git'), { recursive: true }); - - try { - await writeKnownRoots([mainRepo, worktree, unrelatedProject]); - await writeCatalogWithAccounts(unrelatedProject, { - minimax: { authType: 'api_key', protocol: 'openai', baseUrl: 'https://api.minimax.io/v1' }, - }); - - // Writing different baseUrl from worktree SHOULD throw — unrelated project has conflicting config - assert.throws( - () => - validateAccountWrite(worktree, 'minimax', { - authType: 'api_key', - protocol: 'openai', - baseUrl: 'https://api.minimax.io/v11', - }), - (err) => err.message.includes('minimax') && err.message.includes('baseUrl'), - ); - } finally { - await cleanup(); - await rm(unrelatedProject, { recursive: true, force: true }); - } - }); -}); diff --git a/packages/api/test/account-resolver.test.js b/packages/api/test/account-resolver.test.js index 187bfdf25..ed0c84abe 100644 --- a/packages/api/test/account-resolver.test.js +++ b/packages/api/test/account-resolver.test.js @@ -55,7 +55,6 @@ describe('account-resolver (4b unified runtime resolution)', () => { await writeCatalog({ 'my-glm': { authType: 'api_key', - protocol: 'openai', baseUrl: 'https://open.bigmodel.cn/api/paas/v4', models: ['glm-5'], displayName: 'My GLM', @@ -68,7 +67,8 @@ describe('account-resolver (4b unified runtime resolution)', () => { assert.equal(profile.id, 'my-glm'); assert.equal(profile.authType, 'api_key'); assert.equal(profile.kind, 'api_key'); - assert.equal(profile.protocol, 'openai'); + // F340: protocol no longer on custom accounts — derived at runtime by client/provider + assert.equal(profile.protocol, undefined); assert.equal(profile.baseUrl, 'https://open.bigmodel.cn/api/paas/v4'); assert.equal(profile.apiKey, 'glm-xxx'); assert.deepEqual(profile.models, ['glm-5']); @@ -79,7 +79,6 @@ describe('account-resolver (4b unified runtime resolution)', () => { await writeCatalog({ claude: { authType: 'oauth', - protocol: 'anthropic', models: ['claude-opus-4-6', 'claude-sonnet-4-6'], }, }); @@ -105,7 +104,7 @@ describe('account-resolver (4b unified runtime resolution)', () => { it('resolveByAccountRef injects apiKey from credentials', async () => { const { resolveByAccountRef } = await import(`../dist/config/account-resolver.js?t=${Date.now()}-3`); await writeCatalog({ - custom: { authType: 'api_key', protocol: 'anthropic' }, + custom: { authType: 'api_key' }, }); await writeCredentials({ custom: { apiKey: 'sk-custom-key' } }); @@ -114,10 +113,10 @@ describe('account-resolver (4b unified runtime resolution)', () => { assert.equal(profile.apiKey, 'sk-custom-key'); }); - it('resolveByAccountRef maps client from protocol for builtin accounts', async () => { + it('resolveByAccountRef maps client from well-known ID for builtin accounts', async () => { const { resolveByAccountRef } = await import(`../dist/config/account-resolver.js?t=${Date.now()}-4`); await writeCatalog({ - codex: { authType: 'oauth', protocol: 'openai', models: ['gpt-5.3-codex'] }, + codex: { authType: 'oauth', models: ['gpt-5.3-codex'] }, }); await writeCredentials({}); @@ -126,24 +125,25 @@ describe('account-resolver (4b unified runtime resolution)', () => { assert.equal(profile.client, 'openai'); }); - it('resolveForClient resolves by protocol via accounts', async () => { + it('resolveForClient resolves by well-known builtin ID', async () => { const { resolveForClient } = await import(`../dist/config/account-resolver.js?t=${Date.now()}-5`); await writeCatalog({ - claude: { authType: 'oauth', protocol: 'anthropic', models: ['claude-opus-4-6'] }, - codex: { authType: 'oauth', protocol: 'openai', models: ['gpt-5.3-codex'] }, + claude: { authType: 'oauth', models: ['claude-opus-4-6'] }, + codex: { authType: 'oauth', models: ['gpt-5.3-codex'] }, }); await writeCredentials({}); const profile = resolveForClient(projectRoot, 'anthropic'); assert.ok(profile); + assert.equal(profile.id, 'claude'); assert.equal(profile.protocol, 'anthropic'); }); it('resolveForClient prefers preferredAccountRef when provided', async () => { const { resolveForClient } = await import(`../dist/config/account-resolver.js?t=${Date.now()}-6`); await writeCatalog({ - claude: { authType: 'oauth', protocol: 'anthropic' }, - 'my-ant': { authType: 'api_key', protocol: 'anthropic', baseUrl: 'https://custom.ant.com' }, + claude: { authType: 'oauth' }, + 'my-ant': { authType: 'api_key', baseUrl: 'https://custom.ant.com' }, }); await writeCredentials({ 'my-ant': { apiKey: 'sk-custom' } }); @@ -154,53 +154,118 @@ describe('account-resolver (4b unified runtime resolution)', () => { assert.equal(profile.apiKey, 'sk-custom'); }); - it('resolveForClient returns null when multiple accounts match same protocol (ambiguous)', async () => { - const { resolveForClient } = await import(`../dist/config/account-resolver.js?t=${Date.now()}-8`); + it('resolveForClient returns null when explicit preferredAccountRef is not found (fail closed)', async () => { + const { resolveForClient } = await import(`../dist/config/account-resolver.js?t=${Date.now()}-7`); await writeCatalog({ - 'claude-main': { authType: 'api_key', protocol: 'anthropic', displayName: 'Claude Main' }, - 'claude-backup': { authType: 'api_key', protocol: 'anthropic', displayName: 'Claude Backup' }, + claude: { authType: 'oauth' }, }); await writeCredentials({}); - // With two anthropic accounts and no preference, result must be null (not arbitrary first match) - const profile = resolveForClient(projectRoot, 'anthropic'); - assert.equal(profile, null); + // Explicit ref that doesn't exist must return null, not silently fall back to 'claude' + const profile = resolveForClient(projectRoot, 'anthropic', 'deleted-custom-account'); + assert.equal(profile, null, 'explicit preferredAccountRef miss must fail closed'); + }); + + it('resolveForClient discovers installer-${client} API key account when no builtin exists', async () => { + const { resolveForClient } = await import(`../dist/config/account-resolver.js?t=${Date.now()}-7b`); + // Only installer-openai exists — no canonical 'codex' or 'builtin_openai' + await writeCatalog({ + 'installer-openai': { authType: 'api_key', displayName: 'Installer OpenAI' }, + }); + await writeCredentials({ 'installer-openai': { apiKey: 'sk-installer-key' } }); + + const profile = resolveForClient(projectRoot, 'openai'); + assert.ok(profile, 'installer-openai should be discoverable'); + assert.equal(profile.id, 'installer-openai'); + assert.equal(profile.apiKey, 'sk-installer-key'); }); - it('resolveForClient returns the account when only one matches protocol', async () => { - const { resolveForClient } = await import(`../dist/config/account-resolver.js?t=${Date.now()}-9`); + it('resolveForClient falls through to synthetic builtin when no well-known ID matches', async () => { + const { resolveForClient } = await import(`../dist/config/account-resolver.js?t=${Date.now()}-8`); await writeCatalog({ - 'my-ant': { authType: 'api_key', protocol: 'anthropic' }, - codex: { authType: 'api_key', protocol: 'openai' }, + 'claude-main': { authType: 'api_key', displayName: 'Claude Main' }, + 'claude-backup': { authType: 'api_key', displayName: 'Claude Backup' }, }); await writeCredentials({}); + // F340: No protocol matching — custom accounts not discoverable by client. + // Falls through to synthetic builtin for 'anthropic' → 'claude'. const profile = resolveForClient(projectRoot, 'anthropic'); assert.ok(profile); - assert.equal(profile.id, 'my-ant'); + assert.equal(profile.id, 'claude'); + assert.equal(profile.kind, 'builtin'); }); - it('resolveForClient returns baseUrl from custom account (game domain P2-1 pattern)', async () => { + it('resolveForClient finds custom account via preferredAccountRef (not protocol)', async () => { const { resolveForClient } = await import(`../dist/config/account-resolver.js?t=${Date.now()}-10`); await writeCatalog({ 'custom-ant': { authType: 'api_key', - protocol: 'anthropic', baseUrl: 'https://custom-proxy.example.com', }, }); await writeCredentials({ 'custom-ant': { apiKey: 'sk-custom-proxy' } }); - const profile = resolveForClient(projectRoot, 'anthropic'); + // F340: Custom accounts require explicit preferredAccountRef + const profile = resolveForClient(projectRoot, 'anthropic', 'custom-ant'); assert.ok(profile); assert.equal(profile.apiKey, 'sk-custom-proxy'); assert.equal(profile.baseUrl, 'https://custom-proxy.example.com'); }); + it('resolveForClient prefers credentialed installer account over uncredentialed OAuth builtin', async () => { + const { resolveForClient } = await import(`../dist/config/account-resolver.js?t=${Date.now()}-12`); + // Scenario: 'claude' exists as OAuth (no API key), 'installer-anthropic' has an API key. + // The resolver should skip 'claude' and return 'installer-anthropic'. + await writeCatalog({ + claude: { authType: 'oauth', models: ['claude-opus-4-6'] }, + 'installer-anthropic': { authType: 'api_key', displayName: 'Installer Anthropic' }, + }); + await writeCredentials({ 'installer-anthropic': { apiKey: 'sk-installer-ant' } }); + + const profile = resolveForClient(projectRoot, 'anthropic'); + assert.ok(profile, 'should resolve an account'); + assert.equal(profile.apiKey, 'sk-installer-ant', 'should prefer the credentialed installer account'); + assert.equal(profile.id, 'installer-anthropic'); + }); + + it('resolveForClient prefers api_key installer account over a credentialed OAuth builtin', async () => { + const { resolveForClient } = await import(`../dist/config/account-resolver.js?t=${Date.now()}-12b`); + await writeCatalog({ + codex: { authType: 'oauth', models: ['gpt-5.3-codex'] }, + 'installer-openai': { authType: 'api_key', displayName: 'Installer OpenAI' }, + }); + await writeCredentials({ + codex: { apiKey: 'sk-oauth-stale' }, + 'installer-openai': { apiKey: 'sk-installer-openai' }, + }); + + const profile = resolveForClient(projectRoot, 'openai'); + assert.ok(profile, 'should resolve an account'); + assert.equal(profile.id, 'installer-openai'); + assert.equal(profile.authType, 'api_key'); + assert.equal(profile.apiKey, 'sk-installer-openai'); + }); + + it('resolveForClient returns OAuth builtin when no candidate has credentials (subscription mode)', async () => { + const { resolveForClient } = await import(`../dist/config/account-resolver.js?t=${Date.now()}-13`); + // Scenario: only 'claude' OAuth exists, no credentials anywhere. + // Should still return 'claude' for subscription mode — not null. + await writeCatalog({ + claude: { authType: 'oauth', models: ['claude-opus-4-6'] }, + }); + await writeCredentials({}); + + const profile = resolveForClient(projectRoot, 'anthropic'); + assert.ok(profile, 'should still resolve the OAuth builtin'); + assert.equal(profile.id, 'claude'); + assert.equal(profile.apiKey, undefined, 'no credential expected'); + }); + it('env fallback retired (#329): resolveByAccountRef returns undefined apiKey when credentials absent', async () => { - const { resolveByAccountRef } = await import(`../dist/config/account-resolver.js?t=${Date.now()}-7`); + const { resolveByAccountRef } = await import(`../dist/config/account-resolver.js?t=${Date.now()}-11`); await writeCatalog({ - custom: { authType: 'api_key', protocol: 'anthropic' }, + custom: { authType: 'api_key' }, }); // No credentials written — env fallback removed in #329 (protocol退場) const profile = resolveByAccountRef(projectRoot, 'custom'); diff --git a/packages/api/test/account-startup.test.js b/packages/api/test/account-startup.test.js index 347c0a07b..ed18ef3d4 100644 --- a/packages/api/test/account-startup.test.js +++ b/packages/api/test/account-startup.test.js @@ -1,10 +1,10 @@ import assert from 'node:assert/strict'; -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, it } from 'node:test'; -describe('accountStartupHook (HC-3 migration + HC-5 conflict scan at startup)', () => { +describe('accountStartupHook (F340 fail-fast)', () => { let globalRoot; let projectRoot; let previousGlobalRoot; @@ -25,206 +25,92 @@ describe('accountStartupHook (HC-3 migration + HC-5 conflict scan at startup)', await rm(projectRoot, { recursive: true, force: true }); }); - function writeCatalog(root, accounts) { - const catalog = { - version: 2, - breeds: [], - roster: {}, - reviewPolicy: { - requireDifferentFamily: true, - preferActiveInThread: true, - preferLead: true, - excludeUnavailable: true, - }, - accounts, - }; - return writeFile(join(root, '.cat-cafe', 'cat-catalog.json'), JSON.stringify(catalog, null, 2), 'utf-8'); - } - - function writeV3Meta(profiles) { - const meta = { version: 3, activeProfileId: null, providers: profiles, bootstrapBindings: {} }; - return writeFile(join(globalRoot, '.cat-cafe', 'provider-profiles.json'), JSON.stringify(meta, null, 2), 'utf-8'); - } - - function writeV3Secrets(profileSecrets) { - const secrets = { version: 3, profiles: profileSecrets }; - return writeFile( - join(globalRoot, '.cat-cafe', 'provider-profiles.secrets.local.json'), - JSON.stringify(secrets, null, 2), - 'utf-8', - ); - } - - it('runs migration and returns migrated accounts + conflicts', async () => { + it('returns zero accountCount when no accounts and no legacy source', async () => { const { accountStartupHook } = await import(`../dist/config/account-startup.js?t=${Date.now()}`); - - // Setup: old provider-profiles + catalog (so migration can write) - await writeV3Meta([ - { id: 'custom-ant', authType: 'api_key', protocol: 'anthropic', baseUrl: 'https://ant.example.com' }, - ]); - await writeV3Secrets({ 'custom-ant': { apiKey: 'sk-test-123' } }); - await writeCatalog(projectRoot, {}); + const { resetMigrationState } = await import('../dist/config/catalog-accounts.js'); + resetMigrationState(); const result = accountStartupHook(projectRoot); - assert.ok(result, 'hook should return a result'); - assert.ok(result.migration, 'should include migration result'); - assert.equal(result.migration.migrated, true); - assert.equal(result.migration.accountsMigrated, 1); - assert.ok(Array.isArray(result.conflicts), 'should include conflicts array'); + assert.equal(result.accountCount, 0); }); - it('skips migration when project already has all old accounts', async () => { + it('returns correct accountCount for healthy state', async () => { const { accountStartupHook } = await import(`../dist/config/account-startup.js?t=${Date.now()}-1`); + const { writeCatalogAccount, resetMigrationState } = await import('../dist/config/catalog-accounts.js'); + resetMigrationState(); - // Old profiles exist in global config - await writeV3Meta([ - { id: 'custom-ant', authType: 'api_key', protocol: 'anthropic', baseUrl: 'https://ant.example.com' }, - ]); - // Project catalog already has the account → migration should be skipped - await writeCatalog(projectRoot, { 'custom-ant': { authType: 'api_key', protocol: 'anthropic' } }); + writeCatalogAccount(projectRoot, 'claude', { authType: 'oauth' }); + writeCatalogAccount(projectRoot, 'codex', { authType: 'oauth' }); + resetMigrationState(); const result = accountStartupHook(projectRoot); - assert.equal(result.migration.migrated, false); - assert.equal(result.migration.reason, 'already-migrated'); + assert.equal(result.accountCount, 2); }); - it('detects cross-project conflicts at startup', async () => { + it('includes migrated project-level accounts in count', async () => { const { accountStartupHook } = await import(`../dist/config/account-startup.js?t=${Date.now()}-2`); + const { resetMigrationState } = await import('../dist/config/catalog-accounts.js'); + resetMigrationState(); - // Create a second project with a conflicting account - const otherProject = await mkdtemp(join(tmpdir(), 'acct-startup-other-')); - await mkdir(join(otherProject, '.cat-cafe'), { recursive: true }); + const catalog = { + version: 2, + breeds: [], + roster: {}, + reviewPolicy: {}, + accounts: { + 'custom-ant': { authType: 'api_key' }, + }, + }; + await writeFile(join(projectRoot, '.cat-cafe', 'cat-catalog.json'), JSON.stringify(catalog), 'utf-8'); - // Write known-project-roots.json - await writeFile( - join(globalRoot, '.cat-cafe', 'known-project-roots.json'), - JSON.stringify([projectRoot, otherProject]), - 'utf-8', - ); + const result = accountStartupHook(projectRoot); + assert.equal(result.accountCount, 1); + }); - // Write conflicting accounts: same ref, different protocol - await writeCatalog(projectRoot, { - shared: { authType: 'api_key', protocol: 'anthropic' }, - }); - await writeCatalog(otherProject, { - shared: { authType: 'api_key', protocol: 'openai' }, - }); + it('LL-043: throws when legacy source exists but no accounts after migration', async () => { + const { accountStartupHook } = await import(`../dist/config/account-startup.js?t=${Date.now()}-3`); + const { resetMigrationState } = await import('../dist/config/catalog-accounts.js'); + resetMigrationState(); - // Write migration marker to skip migration + // Legacy profiles with no valid entries → 0 accounts migrated await writeFile( - join(globalRoot, '.cat-cafe', 'accounts-migration-done.json'), - JSON.stringify({ migratedAt: new Date().toISOString() }), + join(globalRoot, '.cat-cafe', 'provider-profiles.json'), + JSON.stringify({ version: 2, providers: [] }), 'utf-8', ); - assert.throws( - () => accountStartupHook(projectRoot), - (err) => { - assert.ok(err instanceof Error); - assert.ok(err.message.includes('shared'), 'error should name the conflicting accountRef'); - assert.ok(err.message.includes('protocol'), 'error should describe the conflict'); - return true; - }, - 'HC-5: startup conflict must be a hard error, not warn-only', - ); - - await rm(otherProject, { recursive: true, force: true }); + assert.throws(() => accountStartupHook(projectRoot), /LL-043/); }); - it('LL-043: does NOT throw when legacy file exists but has zero providers', async () => { + it('LL-043: wraps migration conflict error when legacy source exists', async () => { const { accountStartupHook } = await import(`../dist/config/account-startup.js?t=${Date.now()}-4`); + const { resetMigrationState, writeCatalogAccount } = await import('../dist/config/catalog-accounts.js'); + resetMigrationState(); - // Legacy file exists but with empty providers — nothing to migrate - await writeV3Meta([]); - await writeCatalog(projectRoot, {}); + // Pre-existing account that conflicts with legacy + writeCatalogAccount(projectRoot, 'shared', { authType: 'oauth' }); + resetMigrationState(); - // Should NOT throw — empty providers means nothing was ever configured - const result = accountStartupHook(projectRoot); - assert.equal(result.migration.migrated, false); - assert.equal(result.migration.reason, 'already-migrated'); - }); - - it('LL-043: throws when legacy file is corrupt (unparseable) and catalog has no accounts', async () => { - const { accountStartupHook } = await import(`../dist/config/account-startup.js?t=${Date.now()}-5`); - - // Corrupt legacy file — can't parse, but file IS present - await writeFile(join(globalRoot, '.cat-cafe', 'provider-profiles.json'), '{', 'utf-8'); - await writeCatalog(projectRoot, {}); - - assert.throws( - () => accountStartupHook(projectRoot), - (err) => { - assert.ok(err instanceof Error); - assert.ok(err.message.includes('LL-043'), 'error should reference LL-043'); - return true; - }, - 'LL-043: corrupt legacy file + empty accounts must throw', + await writeFile( + join(projectRoot, '.cat-cafe', 'provider-profiles.json'), + JSON.stringify({ + version: 2, + providers: [{ id: 'shared', authType: 'api_key', baseUrl: 'https://conflict.example' }], + }), + 'utf-8', ); - }); - it('LL-043: throws when legacy source exists but catalog is corrupted JSON', async () => { - const { accountStartupHook } = await import(`../dist/config/account-startup.js?t=${Date.now()}-6`); - - // Legacy file with providers - await writeV3Meta([ - { id: 'custom-ant', authType: 'api_key', protocol: 'anthropic', baseUrl: 'https://ant.example.com' }, - ]); - // Catalog exists but is corrupted — JSON.parse will fail - await writeFile(join(projectRoot, '.cat-cafe', 'cat-catalog.json'), '{ broken json', 'utf-8'); - - // Should throw LL-043 — corrupted catalog means migration can't succeed, - // and legacy data exists that should have been migrated - assert.throws( - () => accountStartupHook(projectRoot), - (err) => { - assert.ok(err instanceof Error); - assert.ok(err.message.includes('LL-043'), 'error should reference LL-043, not a raw JSON parse error'); - return true; - }, - 'LL-043: corrupted catalog + legacy providers must throw LL-043, not a raw parse error', - ); + assert.throws(() => accountStartupHook(projectRoot), /LL-043/); }); - it('LL-043: throws when legacy source exists but catalog has no accounts after migration', async () => { - const { accountStartupHook } = await import(`../dist/config/account-startup.js?t=${Date.now()}-3`); + it('fails fast when credentials.json is malformed', async () => { + const { accountStartupHook } = await import(`../dist/config/account-startup.js?t=${Date.now()}-5`); + const { resetMigrationState, writeCatalogAccount } = await import('../dist/config/catalog-accounts.js'); + resetMigrationState(); - // Legacy provider-profiles.json exists with a profile - await writeV3Meta([ - { id: 'custom-ant', authType: 'api_key', protocol: 'anthropic', baseUrl: 'https://ant.example.com' }, - ]); - // Catalog exists but has NO accounts — simulates migration failing silently - // (e.g. writeCatCatalog throws inside migrateProviderProfilesToAccounts, but the - // function is wrapped in a caller's try/catch that swallows the error) - await writeCatalog(projectRoot, {}); - - // The hook runs migration first. If migration succeeds, accounts will be present - // and the invariant won't fire. To test the invariant, we need migration to - // "succeed" but NOT write accounts. We simulate this by writing a catalog - // that already "has" the old IDs (so migration skips as already-migrated) - // but then removing them. - // Simpler approach: write catalog with the ID so migration skips, - // then remove accounts to trigger the invariant. - // Actually: migration will run and succeed here (adding accounts). - // The invariant only fires when migration fails silently AND leaves 0 accounts. - - // To properly test: we need migration to return without writing accounts. - // This happens when catalog is null (no-catalog reason). But then there's no catalog to read. - // Better: remove the catalog file to make migration return no-catalog, - // but the invariant checks hasLegacyProviderProfiles + readCatalogAccounts. - // If there's no catalog at all, readCatalogAccounts returns {} — invariant fires. - const { rm: rmSync } = await import('node:fs'); - const { unlinkSync } = await import('node:fs'); - unlinkSync(join(projectRoot, '.cat-cafe', 'cat-catalog.json')); - - assert.throws( - () => accountStartupHook(projectRoot), - (err) => { - assert.ok(err instanceof Error); - assert.ok(err.message.includes('LL-043'), 'error should reference LL-043'); - assert.ok(err.message.includes('legacy'), 'error should mention legacy source'); - return true; - }, - 'LL-043: must throw when legacy exists but no accounts in catalog', - ); + writeCatalogAccount(projectRoot, 'claude', { authType: 'oauth' }); + await writeFile(join(globalRoot, '.cat-cafe', 'credentials.json'), '{not valid json', 'utf-8'); + + assert.throws(() => accountStartupHook(projectRoot), /credentials read failed/i); }); }); diff --git a/packages/api/test/accounts-route.test.js b/packages/api/test/accounts-route.test.js new file mode 100644 index 000000000..13ec2a99e --- /dev/null +++ b/packages/api/test/accounts-route.test.js @@ -0,0 +1,601 @@ +// @ts-check +import './helpers/setup-cat-registry.js'; +import assert from 'node:assert/strict'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { describe, it } from 'node:test'; + +const AUTH_HEADERS = { 'x-cat-cafe-user': 'test-user' }; + +/** @param {string} prefix */ +async function makeTmpDir(prefix) { + return mkdtemp(join(homedir(), `.cat-cafe-provider-profile-route-${prefix}-`)); +} + +/** @param {string} prefix */ +async function makeWorkspaceDir(prefix) { + return mkdtemp(join(process.cwd(), '..', '..', `.cat-cafe-provider-profile-route-workspace-${prefix}-`)); +} + +async function writeBoundCatalog(projectDir, accountRef) { + mkdirSync(join(projectDir, '.cat-cafe'), { recursive: true }); + writeFileSync( + join(projectDir, '.cat-cafe', 'cat-catalog.json'), + JSON.stringify({ + version: 2, + breeds: [ + { + id: 'ragdoll', + catId: 'opus', + name: '布偶猫', + displayName: '布偶猫', + avatar: '/avatars/opus.png', + color: { primary: '#9B7EBD', secondary: '#E8DFF5' }, + mentionPatterns: ['@opus'], + roleDescription: '主架构师', + defaultVariantId: 'opus-default', + variants: [ + { + id: 'opus-default', + clientId: 'anthropic', + accountRef, + defaultModel: 'claude-opus-4-6', + mcpSupport: true, + cli: { command: 'claude', outputFormat: 'stream-json' }, + }, + ], + }, + ], + roster: { + opus: { + family: 'ragdoll', + roles: ['architect'], + lead: true, + available: true, + evaluation: 'primary', + }, + }, + reviewPolicy: { + requireDifferentFamily: true, + preferActiveInThread: true, + preferLead: true, + excludeUnavailable: true, + }, + }), + ); +} + +describe('accounts routes', () => { + /** @type {string | undefined} */ let savedGlobalRoot; + + function setGlobalRoot(dir) { + savedGlobalRoot = process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT; + process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT = dir; + } + + function restoreGlobalRoot() { + if (savedGlobalRoot === undefined) delete process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT; + else process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT = savedGlobalRoot; + } + + // F136 Phase 4d: legacy v1/v2 migration tests removed — old provider-profiles.js store retired. + // Migration to accounts is tested in account-startup-hook.test.js. + + it('GET /api/accounts requires identity', async () => { + const Fastify = (await import('fastify')).default; + const { accountsRoutes } = await import('../dist/routes/accounts.js'); + const app = Fastify(); + await app.register(accountsRoutes); + await app.ready(); + + const res = await app.inject({ method: 'GET', url: '/api/accounts' }); + assert.equal(res.statusCode, 401); + + await app.close(); + }); + + it('create + list profile flow', async () => { + const Fastify = (await import('fastify')).default; + const { accountsRoutes } = await import('../dist/routes/accounts.js'); + const app = Fastify(); + await app.register(accountsRoutes); + await app.ready(); + + const projectDir = await makeTmpDir('crud'); + setGlobalRoot(projectDir); + try { + const createRes = await app.inject({ + method: 'POST', + url: '/api/accounts', + headers: { ...AUTH_HEADERS, 'content-type': 'application/json' }, + payload: JSON.stringify({ + projectPath: projectDir, + provider: 'anthropic', + displayName: 'sponsor-route', + authType: 'api_key', + baseUrl: 'https://api.route.dev', + apiKey: 'sk-route', + models: ['claude-opus-4-6'], + }), + }); + assert.equal(createRes.statusCode, 200); + const created = createRes.json(); + assert.equal(created.profile.authType, 'api_key'); + assert.equal(created.profile.hasApiKey, true); + + const listRes = await app.inject({ + method: 'GET', + url: `/api/accounts?projectPath=${encodeURIComponent(projectDir)}`, + headers: AUTH_HEADERS, + }); + assert.equal(listRes.statusCode, 200); + const list = listRes.json(); + assert.ok(Array.isArray(list.providers)); + // F340: activeProfileId removed — activate concept retired + assert.equal(list.activeProfileId, undefined); + const listed = list.providers.find((p) => p.id === created.profile.id); + assert.ok(listed, 'created profile should appear in list'); + assert.equal(listed.hasApiKey, true); + } finally { + restoreGlobalRoot(); + await rm(projectDir, { recursive: true, force: true }); + await app.close(); + } + }); + + // F340: POST /api/accounts/:id/test route removed — incomplete feature with no frontend entry. + // Probe/heuristic protocol inference deleted alongside. + + it('rejects blank profile name in create request', async () => { + const Fastify = (await import('fastify')).default; + const { accountsRoutes } = await import('../dist/routes/accounts.js'); + const app = Fastify(); + await app.register(accountsRoutes); + await app.ready(); + + const projectDir = await makeTmpDir('blank-name'); + setGlobalRoot(projectDir); + try { + const createRes = await app.inject({ + method: 'POST', + url: '/api/accounts', + headers: { ...AUTH_HEADERS, 'content-type': 'application/json' }, + payload: JSON.stringify({ + projectPath: projectDir, + displayName: ' ', + authType: 'api_key', + }), + }); + assert.equal(createRes.statusCode, 400); + } finally { + restoreGlobalRoot(); + await rm(projectDir, { recursive: true, force: true }); + await app.close(); + } + }); + + it('POST /api/accounts assigns unique IDs when displayName collides', async () => { + const Fastify = (await import('fastify')).default; + const { accountsRoutes } = await import('../dist/routes/accounts.js'); + const app = Fastify(); + await app.register(accountsRoutes); + await app.ready(); + + const projectDir = await makeTmpDir('slug-collision'); + setGlobalRoot(projectDir); + try { + const first = await app.inject({ + method: 'POST', + url: '/api/accounts', + headers: { ...AUTH_HEADERS, 'content-type': 'application/json' }, + payload: JSON.stringify({ + projectPath: projectDir, + displayName: 'My Sponsor', + authType: 'api_key', + baseUrl: 'https://api.first.example', + apiKey: 'sk-first', + }), + }); + assert.equal(first.statusCode, 200, 'first create should succeed'); + const firstId = first.json().profile.id; + + const second = await app.inject({ + method: 'POST', + url: '/api/accounts', + headers: { ...AUTH_HEADERS, 'content-type': 'application/json' }, + payload: JSON.stringify({ + projectPath: projectDir, + displayName: 'My Sponsor', + authType: 'api_key', + baseUrl: 'https://api.second.example', + apiKey: 'sk-second', + }), + }); + assert.equal(second.statusCode, 200, 'second create with same name should succeed'); + const secondId = second.json().profile.id; + assert.notEqual(firstId, secondId, 'duplicate displayName must produce different IDs'); + + const listRes = await app.inject({ + method: 'GET', + url: `/api/accounts?projectPath=${encodeURIComponent(projectDir)}`, + headers: AUTH_HEADERS, + }); + const list = listRes.json(); + const ids = list.providers.map((p) => p.id); + assert.ok(ids.includes(firstId), 'first profile must still exist'); + assert.ok(ids.includes(secondId), 'second profile must exist alongside first'); + } finally { + restoreGlobalRoot(); + await rm(projectDir, { recursive: true, force: true }); + await app.close(); + } + }); + + it('PATCH /api/accounts/:id clears credential when apiKey is empty string', async () => { + const Fastify = (await import('fastify')).default; + const { accountsRoutes } = await import('../dist/routes/accounts.js'); + const app = Fastify(); + await app.register(accountsRoutes); + await app.ready(); + + const projectDir = await makeTmpDir('clear-cred'); + setGlobalRoot(projectDir); + try { + // Create profile with apiKey + const createRes = await app.inject({ + method: 'POST', + url: '/api/accounts', + headers: { ...AUTH_HEADERS, 'content-type': 'application/json' }, + payload: JSON.stringify({ + projectPath: projectDir, + displayName: 'Clearable', + authType: 'api_key', + apiKey: 'sk-to-clear', + }), + }); + assert.equal(createRes.statusCode, 200); + const profileId = createRes.json().profile.id; + assert.equal(createRes.json().profile.hasApiKey, true, 'should have credential after create'); + + // PATCH with empty apiKey to clear credential + const patchRes = await app.inject({ + method: 'PATCH', + url: `/api/accounts/${profileId}`, + headers: { ...AUTH_HEADERS, 'content-type': 'application/json' }, + payload: JSON.stringify({ + projectPath: projectDir, + apiKey: '', + }), + }); + assert.equal(patchRes.statusCode, 200); + assert.equal( + patchRes.json().profile.hasApiKey, + false, + 'credential should be cleared after PATCH with empty apiKey', + ); + + // Verify via GET + const listRes = await app.inject({ + method: 'GET', + url: `/api/accounts?projectPath=${encodeURIComponent(projectDir)}`, + headers: AUTH_HEADERS, + }); + const profile = listRes.json().providers.find((p) => p.id === profileId); + assert.equal(profile.hasApiKey, false, 'credential should remain cleared'); + } finally { + restoreGlobalRoot(); + await rm(projectDir, { recursive: true, force: true }); + await app.close(); + } + }); + + it('accepts workspace projectPath even when validateProjectPath allowlist excludes it', async () => { + const Fastify = (await import('fastify')).default; + const { accountsRoutes } = await import('../dist/routes/accounts.js'); + const app = Fastify(); + await app.register(accountsRoutes); + await app.ready(); + + const workspaceDir = await makeWorkspaceDir('switch'); + setGlobalRoot(workspaceDir); + const previousRoots = process.env.PROJECT_ALLOWED_ROOTS; + const previousAppend = process.env.PROJECT_ALLOWED_ROOTS_APPEND; + process.env.PROJECT_ALLOWED_ROOTS = '/tmp'; + delete process.env.PROJECT_ALLOWED_ROOTS_APPEND; + + try { + const res = await app.inject({ + method: 'GET', + url: `/api/accounts?projectPath=${encodeURIComponent(workspaceDir)}`, + headers: AUTH_HEADERS, + }); + assert.equal(res.statusCode, 200); + assert.equal(res.json().projectPath, await realpath(workspaceDir)); + } finally { + restoreGlobalRoot(); + if (previousRoots === undefined) delete process.env.PROJECT_ALLOWED_ROOTS; + else process.env.PROJECT_ALLOWED_ROOTS = previousRoots; + if (previousAppend === undefined) delete process.env.PROJECT_ALLOWED_ROOTS_APPEND; + else process.env.PROJECT_ALLOWED_ROOTS_APPEND = previousAppend; + await rm(workspaceDir, { recursive: true, force: true }); + await app.close(); + } + }); + + it('defaults projectPath to CAT_TEMPLATE_PATH directory when query omits projectPath', async () => { + const Fastify = (await import('fastify')).default; + const { accountsRoutes } = await import('../dist/routes/accounts.js'); + const app = Fastify(); + await app.register(accountsRoutes); + await app.ready(); + + const projectDir = await makeTmpDir('default-root'); + setGlobalRoot(projectDir); + const templatePath = join(projectDir, 'cat-template.json'); + await writeFile(templatePath, '{}\n', 'utf-8'); + const prevTemplate = process.env.CAT_TEMPLATE_PATH; + process.env.CAT_TEMPLATE_PATH = templatePath; + + try { + const res = await app.inject({ + method: 'GET', + url: '/api/accounts', + headers: AUTH_HEADERS, + }); + assert.equal(res.statusCode, 200); + assert.equal(res.json().projectPath, await realpath(projectDir)); + } finally { + restoreGlobalRoot(); + if (prevTemplate === undefined) delete process.env.CAT_TEMPLATE_PATH; + else process.env.CAT_TEMPLATE_PATH = prevTemplate; + await rm(projectDir, { recursive: true, force: true }); + await app.close(); + } + }); + + it('GET /api/accounts returns correct client for non-standard builtins (dare/opencode)', async () => { + const { writeCatalogAccount } = await import('../dist/config/catalog-accounts.js'); + const Fastify = (await import('fastify')).default; + const { accountsRoutes } = await import('../dist/routes/accounts.js'); + const app = Fastify(); + await app.register(accountsRoutes); + await app.ready(); + + const projectDir = await makeTmpDir('client-field'); + setGlobalRoot(projectDir); + try { + // Bootstrap minimal catalog + const catCafeDir = join(projectDir, '.cat-cafe'); + mkdirSync(catCafeDir, { recursive: true }); + writeFileSync( + join(catCafeDir, 'cat-catalog.json'), + JSON.stringify({ version: 2, breeds: [], roster: {}, reviewPolicy: {}, accounts: {} }), + ); + + // Write builtin accounts — protocol derived at runtime, not stored + writeCatalogAccount(projectDir, 'claude', { authType: 'oauth', models: ['m1'] }); + writeCatalogAccount(projectDir, 'dare', { authType: 'oauth', models: ['glm'] }); + writeCatalogAccount(projectDir, 'opencode', { authType: 'oauth', models: ['m2'] }); + + const res = await app.inject({ + method: 'GET', + url: `/api/accounts?projectPath=${encodeURIComponent(projectDir)}`, + headers: AUTH_HEADERS, + }); + assert.equal(res.statusCode, 200); + const providers = res.json().providers; + + const claude = providers.find((p) => p.id === 'claude'); + assert.equal(claude.clientId, 'anthropic', 'claude builtin clientId should be protocol (anthropic)'); + + const dare = providers.find((p) => p.id === 'dare'); + assert.equal(dare.clientId, 'dare', 'dare builtin clientId should be its own ID, not protocol'); + + const opencode = providers.find((p) => p.id === 'opencode'); + assert.equal(opencode.clientId, 'opencode', 'opencode builtin clientId should be its own ID, not protocol'); + } finally { + restoreGlobalRoot(); + await rm(projectDir, { recursive: true, force: true }); + await app.close(); + } + }); + + it('DELETE /api/accounts blocks non-force deletion when another project may share the global store', async () => { + const { readCatalogAccounts, resetMigrationState, writeCatalogAccount } = await import( + '../dist/config/catalog-accounts.js' + ); + const Fastify = (await import('fastify')).default; + const { accountsRoutes } = await import('../dist/routes/accounts.js'); + const app = Fastify(); + await app.register(accountsRoutes); + await app.ready(); + + const globalRoot = await makeTmpDir('shared-global-root'); + const projectA = await makeTmpDir('shared-delete-a'); + const projectB = await makeTmpDir('shared-delete-b'); + setGlobalRoot(globalRoot); + resetMigrationState(); + try { + writeCatalogAccount(projectA, 'shared-account', { + authType: 'api_key', + displayName: 'Shared Account', + }); + await writeBoundCatalog(projectB, 'shared-account'); + + const res = await app.inject({ + method: 'DELETE', + url: '/api/accounts/shared-account', + headers: { ...AUTH_HEADERS, 'content-type': 'application/json' }, + payload: JSON.stringify({ projectPath: projectA }), + }); + assert.equal(res.statusCode, 409); + assert.match(res.json().error, /shared global store|other projects|force/i); + assert.ok(readCatalogAccounts(projectA)['shared-account'], 'account must remain in global store'); + } finally { + restoreGlobalRoot(); + await rm(globalRoot, { recursive: true, force: true }); + await rm(projectA, { recursive: true, force: true }); + await rm(projectB, { recursive: true, force: true }); + await app.close(); + } + }); + + it('DELETE /api/accounts allows non-force deletion when the global store is project-isolated', async () => { + const { readCatalogAccounts, resetMigrationState, writeCatalogAccount } = await import( + '../dist/config/catalog-accounts.js' + ); + const Fastify = (await import('fastify')).default; + const { accountsRoutes } = await import('../dist/routes/accounts.js'); + const app = Fastify(); + await app.register(accountsRoutes); + await app.ready(); + + const projectDir = await makeTmpDir('isolated-delete'); + setGlobalRoot(projectDir); + resetMigrationState(); + try { + writeCatalogAccount(projectDir, 'local-account', { + authType: 'api_key', + displayName: 'Local Account', + }); + + const res = await app.inject({ + method: 'DELETE', + url: '/api/accounts/local-account', + headers: { ...AUTH_HEADERS, 'content-type': 'application/json' }, + payload: JSON.stringify({ projectPath: projectDir }), + }); + assert.equal(res.statusCode, 200); + assert.equal(readCatalogAccounts(projectDir)['local-account'], undefined); + } finally { + restoreGlobalRoot(); + await rm(projectDir, { recursive: true, force: true }); + await app.close(); + } + }); + + it('#340 P1: DELETE /api/accounts allows non-force deletion when env is unset and accounts are project-local', async () => { + const { readCatalogAccounts, resetMigrationState, writeCatalogAccount } = await import( + '../dist/config/catalog-accounts.js' + ); + const Fastify = (await import('fastify')).default; + const { accountsRoutes } = await import('../dist/routes/accounts.js'); + const app = Fastify(); + await app.register(accountsRoutes); + await app.ready(); + + const projectDir = await makeTmpDir('no-env-delete'); + // Deliberately do NOT set CAT_CAFE_GLOBAL_CONFIG_ROOT — storage layer defaults to projectRoot + const savedRoot = process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT; + delete process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT; + resetMigrationState(); + try { + writeCatalogAccount(projectDir, 'project-local-account', { + authType: 'api_key', + displayName: 'Project Local', + }); + + const res = await app.inject({ + method: 'DELETE', + url: '/api/accounts/project-local-account', + headers: { ...AUTH_HEADERS, 'content-type': 'application/json' }, + payload: JSON.stringify({ projectPath: projectDir }), + }); + assert.equal(res.statusCode, 200, `expected 200 but got ${res.statusCode}: ${res.json().error ?? ''}`); + assert.equal(readCatalogAccounts(projectDir)['project-local-account'], undefined); + } finally { + if (savedRoot === undefined) delete process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT; + else process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT = savedRoot; + await rm(projectDir, { recursive: true, force: true }); + await app.close(); + } + }); + + it('DELETE /api/accounts stays idempotent when the account is already missing from a shared global store', async () => { + const { readCatalogAccounts, resetMigrationState } = await import('../dist/config/catalog-accounts.js'); + const Fastify = (await import('fastify')).default; + const { accountsRoutes } = await import('../dist/routes/accounts.js'); + const app = Fastify(); + await app.register(accountsRoutes); + await app.ready(); + + const globalRoot = await makeTmpDir('shared-missing-root'); + const projectA = await makeTmpDir('shared-missing-a'); + const projectB = await makeTmpDir('shared-missing-b'); + setGlobalRoot(globalRoot); + resetMigrationState(); + try { + await writeBoundCatalog(projectB, 'missing-account'); + + const res = await app.inject({ + method: 'DELETE', + url: '/api/accounts/missing-account', + headers: { ...AUTH_HEADERS, 'content-type': 'application/json' }, + payload: JSON.stringify({ projectPath: projectA }), + }); + assert.equal(res.statusCode, 200); + assert.equal(readCatalogAccounts(projectA)['missing-account'], undefined); + } finally { + restoreGlobalRoot(); + await rm(globalRoot, { recursive: true, force: true }); + await rm(projectA, { recursive: true, force: true }); + await rm(projectB, { recursive: true, force: true }); + await app.close(); + } + }); + + it('DELETE /api/accounts returns structured error when account migration conflicts surface during existence check', async () => { + const { resetMigrationState, writeCatalogAccount } = await import('../dist/config/catalog-accounts.js'); + const Fastify = (await import('fastify')).default; + const { accountsRoutes } = await import('../dist/routes/accounts.js'); + const app = Fastify(); + await app.register(accountsRoutes); + await app.ready(); + + const globalRoot = await makeTmpDir('delete-conflict-root'); + const projectDir = await makeTmpDir('delete-conflict-project'); + setGlobalRoot(globalRoot); + resetMigrationState(); + try { + writeCatalogAccount(projectDir, 'shared', { + authType: 'api_key', + baseUrl: 'https://global.example/v1', + displayName: 'Global Shared', + }); + resetMigrationState(); + mkdirSync(join(projectDir, '.cat-cafe'), { recursive: true }); + writeFileSync( + join(projectDir, '.cat-cafe', 'cat-catalog.json'), + JSON.stringify({ + version: 2, + breeds: [], + roster: {}, + reviewPolicy: {}, + accounts: { + shared: { + authType: 'api_key', + baseUrl: 'https://project.example/v1', + displayName: 'Project Shared', + }, + }, + }), + ); + + const res = await app.inject({ + method: 'DELETE', + url: '/api/accounts/shared', + headers: { ...AUTH_HEADERS, 'content-type': 'application/json' }, + payload: JSON.stringify({ projectPath: projectDir }), + }); + assert.equal(res.statusCode, 400); + assert.match(res.json().error, /account conflict/i); + } finally { + restoreGlobalRoot(); + await rm(globalRoot, { recursive: true, force: true }); + await rm(projectDir, { recursive: true, force: true }); + await app.close(); + } + }); +}); diff --git a/packages/api/test/agent-router.test.js b/packages/api/test/agent-router.test.js index 201db403c..28a7af4f0 100644 --- a/packages/api/test/agent-router.test.js +++ b/packages/api/test/agent-router.test.js @@ -238,7 +238,7 @@ function createAvailabilityConfigProject(availabilityOverrides = {}) { variants: [ { id: `${id}-default`, - provider, + clientId: provider, defaultModel, mcpSupport: true, cli: { diff --git a/packages/api/test/antigravity-registration.test.js b/packages/api/test/antigravity-registration.test.js index 3ffb93ba6..5c8d3ab68 100644 --- a/packages/api/test/antigravity-registration.test.js +++ b/packages/api/test/antigravity-registration.test.js @@ -8,7 +8,7 @@ describe('Antigravity provider registration', () => { const bengal = config.breeds.find((b) => b.id === 'bengal'); assert.ok(bengal, 'bengal breed should exist in config'); assert.ok(bengal.variants.length > 0, 'bengal should have variants'); - assert.equal(bengal.variants[0].provider, 'antigravity'); + assert.equal(bengal.variants[0].clientId, 'antigravity'); }); test('AntigravityAgentService is importable', async () => { diff --git a/packages/api/test/capability-orchestrator.test.js b/packages/api/test/capability-orchestrator.test.js index 3a5934c8c..e5f6eeccb 100644 --- a/packages/api/test/capability-orchestrator.test.js +++ b/packages/api/test/capability-orchestrator.test.js @@ -1119,7 +1119,7 @@ describe('generateCliConfigs', () => { it('removes managed commandless entries from Gemini settings', async () => { const hasGoogleCat = catRegistry.getAllIds().some((id) => { const entry = catRegistry.tryGet(id); - return entry?.config.provider === 'google'; + return entry?.config.clientId === 'google'; }); if (!hasGoogleCat) return; diff --git a/packages/api/test/cat-account-binding.test.js b/packages/api/test/cat-account-binding.test.js index 1ab4f3f0e..8e242eb63 100644 --- a/packages/api/test/cat-account-binding.test.js +++ b/packages/api/test/cat-account-binding.test.js @@ -1,11 +1,14 @@ import assert from 'node:assert/strict'; import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import { describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); async function seedTemplate(projectRoot, mutateTemplate) { - const templatePath = join(process.cwd(), '..', '..', 'cat-template.json'); + const templatePath = join(__dirname, '..', '..', '..', 'cat-template.json'); const template = JSON.parse(await readFile(templatePath, 'utf-8')); if (mutateTemplate) mutateTemplate(template); await writeFile(join(projectRoot, 'cat-template.json'), `${JSON.stringify(template, null, 2)}\n`, 'utf-8'); @@ -33,7 +36,7 @@ describe('cat account binding', () => { } }); - it('returns explicit seed providerProfileId markers after bootstrap', async () => { + it('returns explicit seed accountRef markers after bootstrap', async () => { const { bootstrapCatCatalog, resolveCatCatalogPath } = await import('../dist/config/cat-catalog-store.js'); const { loadCatConfig, toAllCatConfigs } = await import('../dist/config/cat-config-loader.js'); const { resolveBoundAccountRefForCat } = await import('../dist/config/cat-account-binding.js'); @@ -45,7 +48,7 @@ describe('cat account binding', () => { await seedTemplate(projectRoot, (template) => { const codexBreed = template.breeds.find((breed) => breed.catId === 'codex'); if (!codexBreed) throw new Error('codex breed missing from template'); - codexBreed.variants[0].providerProfileId = 'codex-pinned'; + codexBreed.variants[0].accountRef = 'codex-pinned'; }); bootstrapCatCatalog(projectRoot, join(projectRoot, 'cat-template.json')); const catConfig = toAllCatConfigs(loadCatConfig(resolveCatCatalogPath(projectRoot))).codex; @@ -83,9 +86,7 @@ describe('cat account binding', () => { } codexBreed.variants[0].accountRef = 'codex-sponsor'; - delete codexBreed.variants[0].providerProfileId; sparkVariant.accountRef = 'codex'; - delete sparkVariant.providerProfileId; await mkdir(join(projectRoot, '.cat-cafe'), { recursive: true }); await writeFile(catalogPath, `${JSON.stringify(runtimeCatalog, null, 2)}\n`, 'utf-8'); @@ -97,8 +98,8 @@ describe('cat account binding', () => { const migratedRaw = JSON.parse(await readFile(catalogPath, 'utf-8')); const migratedCodexBreed = migratedRaw.breeds.find((breed) => breed.catId === 'codex'); const migratedSparkVariant = migratedCodexBreed?.variants.find((variant) => variant.catId === 'spark'); - assert.equal(migratedCodexBreed?.variants[0]?.providerProfileId, 'codex-sponsor'); - assert.equal(migratedSparkVariant?.providerProfileId, undefined); + assert.equal(migratedCodexBreed?.variants[0]?.accountRef, 'codex-sponsor'); + assert.equal(migratedSparkVariant?.accountRef, 'codex'); } finally { if (previousTemplatePath === undefined) delete process.env.CAT_TEMPLATE_PATH; else process.env.CAT_TEMPLATE_PATH = previousTemplatePath; @@ -134,9 +135,7 @@ describe('cat account binding', () => { } codexBreed.variants[0].accountRef = 'codex-sponsor'; - delete codexBreed.variants[0].providerProfileId; sparkVariant.accountRef = 'codex'; - delete sparkVariant.providerProfileId; await writeFile(catalogPath, `${JSON.stringify(runtimeCatalog, null, 2)}\n`, 'utf-8'); const activatedProfile = await createProviderProfile(projectRoot, { diff --git a/packages/api/test/cat-catalog-store.test.js b/packages/api/test/cat-catalog-store.test.js index ce213143b..5736fb840 100644 --- a/packages/api/test/cat-catalog-store.test.js +++ b/packages/api/test/cat-catalog-store.test.js @@ -246,50 +246,26 @@ describe('cat-catalog-store', () => { const catalogPath = bootstrapCatCatalog(projectRoot, templatePath); const runtimeCatalog = JSON.parse(readFileSync(catalogPath, 'utf-8')); - // F136 Phase 4d: without explicit bootstrap bindings, raw template variants - // pass through as-is. accountRef is filled in on first read via migration. + // Bootstrap persists template-default bindings for seed cats so activation can + // later retarget them deterministically, while runtime migrations remain non- + // backfilling for custom/runtime cats. assert.deepEqual( runtimeCatalog.breeds.map((breed) => [breed.id, breed.variants.map((variant) => variant.accountRef ?? null)]), [ - ['ragdoll', [null, null]], - ['maine-coon', [null, null]], - ['siamese', [null]], - ['dragon-li', [null]], - ['golden-chinchilla', [null]], + ['ragdoll', ['claude', 'claude']], + ['maine-coon', ['codex', 'codex']], + ['siamese', ['gemini']], + ['dragon-li', ['dare']], + ['golden-chinchilla', ['opencode']], ], ); }); - it('bootstraps installer api_key bindings while preserving skipped seed members', () => { + it('bootstrap ignores legacy provider-profiles.json and keeps template default bindings', () => { const projectRoot = mkdtempSync(join(tmpdir(), 'cat-catalog-store-f127-installer-')); process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT = projectRoot; const templatePath = join(projectRoot, 'cat-template.json'); writeFileSync(templatePath, JSON.stringify(makeF127BootstrapTemplate(), null, 2)); - mkdirSync(join(projectRoot, '.cat-cafe'), { recursive: true }); - writeFileSync( - join(projectRoot, '.cat-cafe', 'provider-profiles.json'), - JSON.stringify( - { - version: 3, - activeProfileId: null, - bootstrapBindings: { - anthropic: { enabled: true, mode: 'api_key', accountRef: 'api-key-1' }, - openai: { enabled: true, mode: 'oauth', accountRef: 'codex' }, - google: { enabled: false, mode: 'skip' }, - }, - providers: [ - { id: 'claude', kind: 'builtin', client: 'anthropic', authType: 'oauth', builtin: true }, - { id: 'codex', kind: 'builtin', client: 'openai', authType: 'oauth', builtin: true }, - { id: 'gemini', kind: 'builtin', client: 'google', authType: 'oauth', builtin: true }, - { id: 'dare', kind: 'builtin', client: 'dare', authType: 'oauth', builtin: true }, - { id: 'opencode', kind: 'builtin', client: 'opencode', authType: 'oauth', builtin: true }, - { id: 'api-key-1', kind: 'api_key', displayName: 'API Key 1', authType: 'api_key', builtin: false }, - ], - }, - null, - 2, - ), - ); const catalogPath = bootstrapCatCatalog(projectRoot, templatePath); const runtimeCatalog = JSON.parse(readFileSync(catalogPath, 'utf-8')); @@ -297,11 +273,11 @@ describe('cat-catalog-store', () => { assert.deepEqual( runtimeCatalog.breeds.map((breed) => [breed.id, breed.variants.map((variant) => variant.accountRef ?? null)]), [ - ['ragdoll', ['api-key-1']], + ['ragdoll', ['claude', 'claude']], ['maine-coon', ['codex', 'codex']], - ['siamese', [null]], - ['dragon-li', [null]], - ['golden-chinchilla', [null]], + ['siamese', ['gemini']], + ['dragon-li', ['dare']], + ['golden-chinchilla', ['opencode']], ], ); }); @@ -312,7 +288,7 @@ describe('cat-catalog-store', () => { const template = makeF127BootstrapTemplate(); const codexBreed = template.breeds.find((breed) => breed.catId === 'codex'); if (!codexBreed) throw new Error('codex breed missing from template'); - codexBreed.variants[0].providerProfileId = 'codex-pinned'; + codexBreed.variants[0].accountRef = 'codex-pinned'; writeFileSync(templatePath, JSON.stringify(template, null, 2)); const catalogPath = bootstrapCatCatalog(projectRoot, templatePath); @@ -321,9 +297,8 @@ describe('cat-catalog-store', () => { const runtimeCodexVariant = runtimeCodexBreed?.variants[0]; // F136 Phase 4d: without bootstrap bindings, raw variant passes through. - // providerProfileId is preserved; accountRef is derived on first migrating read. - assert.equal(runtimeCodexVariant?.accountRef, undefined); - assert.equal(runtimeCodexVariant?.providerProfileId, 'codex-pinned'); + // accountRef is preserved as-is on bootstrap. + assert.equal(runtimeCodexVariant?.accountRef, 'codex-pinned'); }); it('bootstraps .cat-cafe/cat-catalog.json from cat-template.json', () => { @@ -336,12 +311,61 @@ describe('cat-catalog-store', () => { assert.equal(catalogPath, resolveCatCatalogPath(projectRoot)); assert.ok(existsSync(catalogPath), 'runtime catalog should be created'); const runtimeCatalog = JSON.parse(readFileSync(catalogPath, 'utf-8')); - // F136 Phase 4d: fresh bootstrap without bindings → no accountRef on raw catalog - assert.deepEqual(runtimeCatalog.breeds[0]?.variants[0]?.accountRef, undefined); - assert.deepEqual(runtimeCatalog, template); + // Bootstrap persists the template's default seed binding into the runtime catalog. + assert.equal(runtimeCatalog.breeds[0]?.variants[0]?.accountRef, 'claude'); + }); + + it('bootstraps from legacy cat-config.json before falling back to cat-template.json', () => { + const projectRoot = mkdtempSync(join(tmpdir(), 'cat-catalog-store-legacy-bootstrap-')); + const templatePath = join(projectRoot, 'cat-template.json'); + const template = validConfig(); + template.breeds[0].displayName = '模板布偶猫'; + template.breeds[0].variants[0].defaultModel = 'template-model'; + writeFileSync(templatePath, JSON.stringify(template, null, 2)); + + const legacyConfig = validConfig(); + legacyConfig.breeds[0].displayName = '旧配置布偶猫'; + legacyConfig.breeds[0].variants[0].defaultModel = 'legacy-model'; + legacyConfig.roster.opus.evaluation = 'legacy-eval'; + writeFileSync(join(projectRoot, 'cat-config.json'), JSON.stringify(legacyConfig, null, 2)); + + const catalogPath = bootstrapCatCatalog(projectRoot, templatePath); + const runtimeCatalog = JSON.parse(readFileSync(catalogPath, 'utf-8')); + + assert.equal(runtimeCatalog.breeds[0]?.displayName, '旧配置布偶猫'); + assert.equal(runtimeCatalog.breeds[0]?.variants[0]?.defaultModel, 'legacy-model'); + assert.equal(runtimeCatalog.roster?.opus?.evaluation, 'legacy-eval'); }); - it('keeps existing .cat-cafe/cat-catalog.json runtime edits while backfilling missing accountRef bindings', () => { + it('only backfills bootstrap accountRef for seed members when legacy cat-config adds custom variants', () => { + const projectRoot = mkdtempSync(join(tmpdir(), 'cat-catalog-store-legacy-custom-variant-')); + const templatePath = join(projectRoot, 'cat-template.json'); + writeFileSync(templatePath, JSON.stringify(validConfig(), null, 2)); + + const legacyConfig = validConfig(); + legacyConfig.breeds[0].variants.push({ + id: 'opus-custom', + catId: 'opus-custom', + displayName: '自定义布偶猫', + mentionPatterns: ['@opus-custom'], + provider: 'openai', + defaultModel: 'gpt-5.4', + mcpSupport: true, + cli: { command: 'codex', outputFormat: 'json' }, + }); + writeFileSync(join(projectRoot, 'cat-config.json'), JSON.stringify(legacyConfig, null, 2)); + + const catalogPath = bootstrapCatCatalog(projectRoot, templatePath); + const runtimeCatalog = JSON.parse(readFileSync(catalogPath, 'utf-8')); + const legacyBreed = runtimeCatalog.breeds.find((breed) => breed.catId === 'opus'); + const seedVariant = legacyBreed?.variants.find((variant) => variant.id === 'opus-default'); + const customVariant = legacyBreed?.variants.find((variant) => variant.id === 'opus-custom'); + + assert.equal(seedVariant?.accountRef, 'claude'); + assert.equal(customVariant?.accountRef, undefined); + }); + + it('keeps existing .cat-cafe/cat-catalog.json runtime edits and leaves unbound variants alone', () => { const projectRoot = mkdtempSync(join(tmpdir(), 'cat-catalog-store-')); const templatePath = join(projectRoot, 'cat-template.json'); writeFileSync(templatePath, JSON.stringify(validConfig(), null, 2)); @@ -354,8 +378,52 @@ describe('cat-catalog-store', () => { const catalogPath = bootstrapCatCatalog(projectRoot, templatePath); const hydrated = JSON.parse(readFileSync(catalogPath, 'utf-8')); assert.equal(hydrated.breeds[0]?.displayName, '运行时布偶猫'); - // F136 Phase 4d: migration backfills 'claude' (legacy builtin ID for anthropic) - assert.equal(hydrated.breeds[0]?.variants[0]?.accountRef, 'claude'); + // F340: migration does NOT backfill accountRef — unbound variants stay unbound + assert.equal(hydrated.breeds[0]?.variants[0]?.accountRef, undefined); + }); + + it('keeps existing custom runtime cats unbound during catalog migration', () => { + const projectRoot = mkdtempSync(join(tmpdir(), 'cat-catalog-store-custom-runtime-')); + const templatePath = join(projectRoot, 'cat-template.json'); + writeFileSync(templatePath, JSON.stringify(validConfig(), null, 2)); + + const runtimeConfig = validConfig(); + runtimeConfig.breeds.push({ + id: 'custom-openai', + catId: 'custom-openai', + name: '自定义猫', + displayName: '自定义猫', + avatar: '/avatars/custom.png', + color: { primary: '#22c55e', secondary: '#dcfce7' }, + mentionPatterns: ['@custom-openai'], + roleDescription: '自定义运行时猫', + defaultVariantId: 'custom-openai-default', + variants: [ + { + id: 'custom-openai-default', + provider: 'openai', + defaultModel: 'gpt-5.4-mini', + mcpSupport: false, + cli: { command: 'codex', outputFormat: 'json' }, + }, + ], + }); + runtimeConfig.roster['custom-openai'] = { + family: 'custom-openai', + roles: ['assistant'], + lead: false, + available: true, + evaluation: 'runtime custom', + }; + + mkdirSync(join(projectRoot, '.cat-cafe'), { recursive: true }); + writeFileSync(join(projectRoot, '.cat-cafe', 'cat-catalog.json'), JSON.stringify(runtimeConfig, null, 2)); + + const catalogPath = bootstrapCatCatalog(projectRoot, templatePath); + const hydrated = JSON.parse(readFileSync(catalogPath, 'utf-8')); + const customBreed = hydrated.breeds.find((breed) => breed.catId === 'custom-openai'); + assert.ok(customBreed, 'custom runtime breed should be preserved'); + assert.equal(customBreed?.variants[0]?.accountRef, undefined); }); it('creates a new runtime member without corrupting v2 top-level fields', async () => { @@ -374,7 +442,7 @@ describe('cat-catalog-store', () => { mentionPatterns: ['@spark-lite', '@火花猫'], roleDescription: '快速执行', personality: '利落', - provider: 'openai', + clientId: 'openai', defaultModel: 'gpt-5.4-mini', mcpSupport: false, cli: { command: 'codex', outputFormat: 'json' }, @@ -396,7 +464,7 @@ describe('cat-catalog-store', () => { assert.ok(created, 'spark-lite breed should be created'); assert.equal(created.displayName, '火花猫'); assert.deepEqual(created.mentionPatterns, ['@spark-lite', '@火花猫']); - assert.equal(created.variants[0]?.provider, 'openai'); + assert.equal(created.variants[0]?.clientId, 'openai'); }); it('updates an existing runtime member in place', async () => { @@ -580,7 +648,7 @@ describe('cat-catalog-store', () => { color: { primary: '#f97316', secondary: '#fed7aa' }, mentionPatterns: ['@opus', '@spark-lite'], roleDescription: '快速执行', - provider: 'openai', + clientId: 'openai', defaultModel: 'gpt-5.4', mcpSupport: false, cli: { command: 'codex', outputFormat: 'json' }, @@ -607,7 +675,7 @@ describe('cat-catalog-store', () => { mentionPatterns: ['@temp-cat'], roleDescription: '临时成员', personality: '临时', - provider: 'dare', + clientId: 'dare', defaultModel: 'dare-1', mcpSupport: false, cli: { command: 'dare', outputFormat: 'json' }, @@ -674,7 +742,7 @@ describe('cat-catalog-store', () => { mentionPatterns: ['@temp-cat'], roleDescription: '临时成员', personality: '临时', - provider: 'dare', + clientId: 'dare', defaultModel: 'dare-1', mcpSupport: false, cli: { command: 'dare', outputFormat: 'json' }, @@ -719,7 +787,7 @@ describe('cat-catalog-store', () => { color: { primary: '#334155', secondary: '#cbd5f5' }, mentionPatterns: ['@shadow-seed'], roleDescription: '用于路径边界验证', - provider: 'dare', + clientId: 'dare', defaultModel: 'dare-1', mcpSupport: false, cli: { command: 'dare', outputFormat: 'json' }, @@ -742,74 +810,5 @@ describe('cat-catalog-store', () => { ); }); - it('api_key bootstrap uses profile model when template defaultModel is not in profile', () => { - const projectRoot = mkdtempSync(join(tmpdir(), 'cat-catalog-store-model-')); - process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT = projectRoot; - const templatePath = join(projectRoot, 'cat-template.json'); - const catCafeDir = join(projectRoot, '.cat-cafe'); - mkdirSync(catCafeDir, { recursive: true }); - - writeFileSync( - templatePath, - JSON.stringify({ - version: 2, - breeds: [ - { - id: 'ragdoll', - catId: 'opus', - name: '布偶猫', - displayName: '布偶猫', - avatar: '/avatars/opus.png', - color: { primary: '#9B7EBD', secondary: '#E8DFF5' }, - mentionPatterns: ['@opus'], - roleDescription: '主架构师', - defaultVariantId: 'opus-default', - variants: [ - { - id: 'opus-default', - provider: 'anthropic', - defaultModel: 'claude-opus-4-6', - cli: { command: 'claude' }, - }, - ], - }, - ], - }), - ); - - // API key profile with different models - writeFileSync( - join(catCafeDir, 'provider-profiles.json'), - JSON.stringify({ - version: 3, - activeProfileId: null, - providers: [ - { - id: 'installer-anthropic', - displayName: 'Installer anthropic API Key', - kind: 'api_key', - authType: 'api_key', - protocol: 'anthropic', - baseUrl: 'https://openrouter.ai/api', - models: ['z-ai/glm-4.7', 'z-ai/glm-4.6'], - }, - ], - bootstrapBindings: { - anthropic: { mode: 'api_key', accountRef: 'installer-anthropic' }, - }, - }), - ); - - bootstrapCatCatalog(projectRoot, templatePath); - - const catalog = readRuntimeCatCatalog(projectRoot); - const opus = catalog.breeds.find((b) => b.catId === 'opus'); - assert.ok(opus, 'opus seed cat should exist'); - const variant = opus.variants[0]; - assert.equal( - variant.defaultModel, - 'z-ai/glm-4.7', - 'defaultModel should fall back to first model from the API key profile', - ); - }); + // F340: removed api_key bootstrap model fallback test — filterBootstrapCatalog + bootstrapBindings deleted }); diff --git a/packages/api/test/cat-config-loader.test.js b/packages/api/test/cat-config-loader.test.js index fab6160ee..a15767abd 100644 --- a/packages/api/test/cat-config-loader.test.js +++ b/packages/api/test/cat-config-loader.test.js @@ -45,7 +45,7 @@ function validConfig() { variants: [ { id: 'opus-default', - provider: 'anthropic', + clientId: 'anthropic', defaultModel: 'claude-sonnet-4-5-20250929', mcpSupport: true, cli: { command: 'claude', outputFormat: 'stream-json' }, @@ -228,7 +228,10 @@ describe('cat-config-loader', () => { try { const config = loadCatConfig(); const variant = config.breeds[0].variants[0]; - assert.equal(variant.provider, 'anthropic'); + // F340: catalog's provider='anthropic' is kept (matches clientId, but retained to + // prevent template's stale provider='openai' from leaking through the merge). + assert.equal(variant.clientId, 'anthropic'); + assert.equal(variant.provider, 'anthropic', 'catalog provider must override template provider'); assert.deepEqual(variant.cli, { command: 'claude', outputFormat: 'stream-json', @@ -277,7 +280,7 @@ describe('cat-config-loader', () => { it('rejects invalid provider', () => { const bad = validConfig(); - bad.breeds[0].variants[0].provider = 'invalid-provider'; + bad.breeds[0].variants[0].clientId = 'invalid-provider'; const path = writeTempConfig(bad); assert.throws(() => loadCatConfig(path), /Invalid cat config/); }); @@ -297,7 +300,7 @@ describe('cat-config-loader', () => { variants: [ { id: 'dare-default', - provider: 'dare', + clientId: 'dare', defaultModel: 'zhipu/glm-4.7', mcpSupport: false, cli: { command: 'python', outputFormat: 'headless-json' }, @@ -308,7 +311,7 @@ describe('cat-config-loader', () => { const loaded = loadCatConfig(path); const cats = toAllCatConfigs(loaded); assert.ok(cats.dare); - assert.strictEqual(cats.dare.provider, 'dare'); + assert.strictEqual(cats.dare.clientId, 'dare'); }); it('accepts arbitrary catId (F32-a: any non-empty string is valid)', () => { @@ -328,7 +331,7 @@ describe('cat-config-loader', () => { const config = loadCatConfig(path); const variant = getDefaultVariant(config.breeds[0]); assert.equal(variant.id, 'opus-default'); - assert.equal(variant.provider, 'anthropic'); + assert.equal(variant.clientId, 'anthropic'); }); }); @@ -340,7 +343,7 @@ describe('cat-config-loader', () => { assert.ok(flat.opus); assert.equal(flat.opus.displayName, '布偶猫'); - assert.equal(flat.opus.provider, 'anthropic'); + assert.equal(flat.opus.clientId, 'anthropic'); assert.equal(flat.opus.mcpSupport, true); assert.deepEqual(flat.opus.mentionPatterns, ['@opus', '@布偶猫']); assert.equal(flat.opus.personality, '温柔'); @@ -361,7 +364,7 @@ describe('cat-config-loader', () => { variants: [ { id: 'codex-default', - provider: 'openai', + clientId: 'openai', defaultModel: 'codex', mcpSupport: false, cli: { command: 'codex', outputFormat: 'json' }, @@ -375,7 +378,7 @@ describe('cat-config-loader', () => { assert.ok(flat.opus); assert.ok(flat.codex); - assert.equal(flat.codex.provider, 'openai'); + assert.equal(flat.codex.clientId, 'openai'); }); }); @@ -460,7 +463,7 @@ describe('cat-config-loader', () => { cfg.breeds[0].variants.push({ id: 'opus-sonnet', catId: 'opus-sonnet', - provider: 'anthropic', + clientId: 'anthropic', defaultModel: 'claude-sonnet-4-5-20250929', mcpSupport: true, cli: { command: 'claude', outputFormat: 'stream-json' }, @@ -544,7 +547,7 @@ function multiVariantConfig() { variants: [ { id: 'opus-default', - provider: 'anthropic', + clientId: 'anthropic', defaultModel: 'claude-opus-4-6', mcpSupport: true, cli: { command: 'claude', outputFormat: 'stream-json' }, @@ -555,7 +558,7 @@ function multiVariantConfig() { catId: 'opus-45', displayName: '布偶猫 4.5', mentionPatterns: ['@opus-45', '@布偶猫4.5'], - provider: 'anthropic', + clientId: 'anthropic', defaultModel: 'claude-sonnet-4-5-20250929', mcpSupport: true, cli: { command: 'claude', outputFormat: 'stream-json' }, @@ -577,7 +580,7 @@ function multiVariantConfig() { variants: [ { id: 'gemini-default', - provider: 'google', + clientId: 'google', defaultModel: 'gemini-2.5-pro', mcpSupport: false, cli: { command: 'gemini', outputFormat: 'stream-json' }, @@ -617,7 +620,7 @@ describe('F32-b: toAllCatConfigs (multi-variant)', () => { cfg.breeds[0].variants.push({ id: 'opus-haiku', catId: 'opus-haiku', - provider: 'anthropic', + clientId: 'anthropic', defaultModel: 'claude-haiku-4-5-20251001', mcpSupport: false, cli: { command: 'claude', outputFormat: 'stream-json' }, @@ -634,7 +637,7 @@ describe('F32-b: toAllCatConfigs (multi-variant)', () => { id: 'opus-haiku-empty', catId: 'opus-haiku-empty', mentionPatterns: [], - provider: 'anthropic', + clientId: 'anthropic', defaultModel: 'claude-haiku-4-5-20251001', mcpSupport: false, cli: { command: 'claude', outputFormat: 'stream-json' }, @@ -676,6 +679,14 @@ describe('F32-b: toAllCatConfigs (multi-variant)', () => { assert.throws(() => toAllCatConfigs(loadCatConfig(writeTempConfig(cfg))), /Duplicate catId "opus"/); }); + it('preserves variant cli config in flattened output', () => { + const config = loadCatConfig(writeTempConfig(multiVariantConfig())); + const all = toAllCatConfigs(config); + assert.deepEqual(all.opus.cli, { command: 'claude', outputFormat: 'stream-json' }); + assert.deepEqual(all['opus-45'].cli, { command: 'claude', outputFormat: 'stream-json' }); + assert.deepEqual(all.gemini.cli, { command: 'gemini', outputFormat: 'stream-json' }); + }); + it('toFlatConfigs is an alias for toAllCatConfigs', () => { const config = loadCatConfig(writeTempConfig(multiVariantConfig())); const all = toAllCatConfigs(config); @@ -856,7 +867,7 @@ describe('getCatEffort', () => { it('returns provider-aware default when not configured', () => { const cfg = validConfig(); - cfg.breeds[0].variants[0].provider = 'openai'; + cfg.breeds[0].variants[0].clientId = 'openai'; cfg.breeds[0].variants[0].cli = { command: 'codex', outputFormat: 'json', @@ -870,7 +881,7 @@ describe('getCatEffort', () => { // Simulates a catalog written before the PATCH write-time cleanup was added: // an openai cat still carrying anthropic-only effort 'max'. const cfg = validConfig(); - cfg.breeds[0].variants[0].provider = 'openai'; + cfg.breeds[0].variants[0].clientId = 'openai'; cfg.breeds[0].variants[0].cli = { command: 'codex', outputFormat: 'json', @@ -892,7 +903,7 @@ describe('F32-b P4c: Sonnet variant in project config', () => { assert.ok(sonnetVariant, 'opus-sonnet variant exists'); assert.equal(sonnetVariant.catId, 'sonnet'); assert.equal(sonnetVariant.variantLabel, 'Sonnet'); - assert.equal(sonnetVariant.provider, 'anthropic'); + assert.equal(sonnetVariant.clientId, 'anthropic'); assert.equal(sonnetVariant.defaultModel, 'claude-sonnet-4-6'); }); diff --git a/packages/api/test/catalog-accounts.test.js b/packages/api/test/catalog-accounts.test.js index 45772a5ab..18913849b 100644 --- a/packages/api/test/catalog-accounts.test.js +++ b/packages/api/test/catalog-accounts.test.js @@ -1,134 +1,509 @@ import assert from 'node:assert/strict'; +import { existsSync } from 'node:fs'; import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, it } from 'node:test'; -describe('cat-catalog-store accounts section (HC-2)', () => { +describe('global accounts (F340)', () => { + let globalRoot; let projectRoot; let previousGlobalRoot; beforeEach(async () => { - projectRoot = await mkdtemp(join(tmpdir(), 'catalog-accounts-')); + globalRoot = await mkdtemp(join(tmpdir(), 'global-accounts-')); + projectRoot = await mkdtemp(join(tmpdir(), 'project-accounts-')); previousGlobalRoot = process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT; - process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT = projectRoot; + process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT = globalRoot; + await mkdir(join(globalRoot, '.cat-cafe'), { recursive: true }); await mkdir(join(projectRoot, '.cat-cafe'), { recursive: true }); }); afterEach(async () => { if (previousGlobalRoot === undefined) delete process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT; else process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT = previousGlobalRoot; + await rm(globalRoot, { recursive: true, force: true }); await rm(projectRoot, { recursive: true, force: true }); }); - function makeCatalog(accounts) { - return { + it('readCatalogAccounts returns empty object when no accounts file exists', async () => { + const { readCatalogAccounts, resetMigrationState } = await import('../dist/config/catalog-accounts.js'); + resetMigrationState(); + const result = readCatalogAccounts(projectRoot); + assert.deepEqual(result, {}); + }); + + it('writeCatalogAccount creates global accounts.json', async () => { + const { writeCatalogAccount, readCatalogAccounts, resetMigrationState } = await import( + '../dist/config/catalog-accounts.js' + ); + resetMigrationState(); + writeCatalogAccount(projectRoot, 'claude', { + authType: 'oauth', + protocol: 'anthropic', + }); + + const result = readCatalogAccounts(projectRoot); + assert.deepEqual(result.claude, { authType: 'oauth', protocol: 'anthropic' }); + + // Verify it's in global path + const raw = await readFile(join(globalRoot, '.cat-cafe', 'accounts.json'), 'utf-8'); + const parsed = JSON.parse(raw); + assert.equal(parsed.claude.protocol, 'anthropic'); + }); + + it('deleteCatalogAccount removes account from global', async () => { + const { writeCatalogAccount, deleteCatalogAccount, readCatalogAccounts, resetMigrationState } = await import( + '../dist/config/catalog-accounts.js' + ); + resetMigrationState(); + writeCatalogAccount(projectRoot, 'a', { authType: 'api_key', protocol: 'openai' }); + writeCatalogAccount(projectRoot, 'b', { authType: 'api_key', protocol: 'anthropic' }); + + deleteCatalogAccount(projectRoot, 'a'); + + const result = readCatalogAccounts(projectRoot); + assert.equal(result.a, undefined); + assert.ok(result.b); + }); + + it('migrates project-level accounts to global on first read', async () => { + const { readCatalogAccounts, resetMigrationState, resolveAccountsPath } = await import( + '../dist/config/catalog-accounts.js' + ); + resetMigrationState(); + + // Write a catalog with accounts section in project + const catalog = { version: 2, breeds: [], roster: {}, - reviewPolicy: { - requireDifferentFamily: true, - preferActiveInThread: true, - preferLead: true, - excludeUnavailable: true, + reviewPolicy: {}, + accounts: { + claude: { authType: 'oauth', protocol: 'anthropic' }, + 'my-glm': { authType: 'api_key', protocol: 'openai', baseUrl: 'https://open.bigmodel.cn/api/paas/v4' }, }, - ...(accounts !== undefined ? { accounts } : {}), }; - } + await writeFile(join(projectRoot, '.cat-cafe', 'cat-catalog.json'), JSON.stringify(catalog, null, 2), 'utf-8'); - it('readCatCatalog returns accounts section when present', async () => { - const { readCatCatalog, resolveCatCatalogPath } = await import('../dist/config/cat-catalog-store.js'); - const accounts = { - claude: { authType: 'oauth', protocol: 'anthropic', models: ['claude-opus-4-6'] }, - 'my-glm': { authType: 'api_key', protocol: 'openai', baseUrl: 'https://open.bigmodel.cn/api/paas/v4' }, - }; - const catalog = makeCatalog(accounts); - const catalogPath = resolveCatCatalogPath(projectRoot); - await writeFile(catalogPath, JSON.stringify(catalog, null, 2), 'utf-8'); + // First read triggers migration + const result = readCatalogAccounts(projectRoot); + assert.equal(result.claude.protocol, 'anthropic'); + assert.equal(result['my-glm'].baseUrl, 'https://open.bigmodel.cn/api/paas/v4'); + + // Global file should now contain accounts + const globalRaw = await readFile(resolveAccountsPath(), 'utf-8'); + const globalAccounts = JSON.parse(globalRaw); + assert.ok(globalAccounts.claude); + assert.ok(globalAccounts['my-glm']); - const loaded = readCatCatalog(projectRoot); - assert.ok(loaded, 'catalog should be loaded'); - assert.equal(loaded.version, 2); - assert.deepEqual(loaded.accounts, accounts); + // Project catalog keeps accounts section untouched (rollback compat) + const catalogRaw = await readFile(join(projectRoot, '.cat-cafe', 'cat-catalog.json'), 'utf-8'); + const updatedCatalog = JSON.parse(catalogRaw); + assert.ok(updatedCatalog.accounts?.claude, 'project accounts preserved for rollback compat'); + assert.ok(updatedCatalog.accounts?.['my-glm'], 'project accounts preserved for rollback compat'); + assert.equal(updatedCatalog.version, 2); }); - it('readCatCatalog returns undefined accounts when section missing', async () => { - const { readCatCatalog, resolveCatCatalogPath } = await import('../dist/config/cat-catalog-store.js'); - const catalog = makeCatalog(); - const catalogPath = resolveCatCatalogPath(projectRoot); - await writeFile(catalogPath, JSON.stringify(catalog, null, 2), 'utf-8'); + it('skips compatible duplicate project account IDs without overwriting; keeps skipped keys in project', async () => { + const { writeCatalogAccount, readCatalogAccounts, resetMigrationState } = await import( + '../dist/config/catalog-accounts.js' + ); + resetMigrationState(); - const loaded = readCatCatalog(projectRoot); - assert.ok(loaded); - assert.equal(loaded.accounts, undefined); - }); + // Pre-populate global with 'existing' account + writeCatalogAccount(projectRoot, 'existing', { authType: 'oauth', protocol: 'anthropic' }); + resetMigrationState(); - it('writeCatCatalog preserves accounts section', async () => { - const { writeCatCatalog, readCatCatalog } = await import('../dist/config/cat-catalog-store.js'); - const accounts = { - codex: { authType: 'oauth', protocol: 'openai', models: ['gpt-5.3-codex'] }, + // Write project catalog with an equivalent key + a new key + const catalog = { + version: 2, + breeds: [], + roster: {}, + reviewPolicy: {}, + accounts: { + existing: { authType: 'oauth', protocol: 'anthropic' }, + 'new-from-project': { authType: 'api_key', protocol: 'openai' }, + }, }; - const catalog = makeCatalog(accounts); - writeCatCatalog(projectRoot, catalog); + await writeFile(join(projectRoot, '.cat-cafe', 'cat-catalog.json'), JSON.stringify(catalog, null, 2), 'utf-8'); + + const result = readCatalogAccounts(projectRoot); + assert.equal(result.existing.authType, 'oauth', 'existing global key must not be overwritten'); + assert.ok(result['new-from-project'], 'new key from project should be merged'); - const reloaded = readCatCatalog(projectRoot); - assert.deepEqual(reloaded?.accounts, accounts); + // Skipped key must still be in project catalog (not silently deleted) + const catalogRaw = await readFile(join(projectRoot, '.cat-cafe', 'cat-catalog.json'), 'utf-8'); + const updatedCatalog = JSON.parse(catalogRaw); + assert.ok(updatedCatalog.accounts?.existing, 'skipped key must remain in project catalog'); + assert.ok( + updatedCatalog.accounts['new-from-project'], + 'merged key must also remain in project catalog (rollback compat)', + ); }); - it('readCatalogAccounts returns accounts from catalog', async () => { - const { writeCatCatalog } = await import('../dist/config/cat-catalog-store.js'); - const { readCatalogAccounts } = await import('../dist/config/catalog-accounts.js'); - const accounts = { - claude: { authType: 'oauth', protocol: 'anthropic' }, + it('migrates project-level legacy provider-profiles.json into global accounts', async () => { + const { readCatalogAccounts, resetMigrationState } = await import('../dist/config/catalog-accounts.js'); + resetMigrationState(); + + // Write legacy provider-profiles.json at project level (old installer output) + const legacyMeta = { + version: 2, + providers: [{ id: 'my-custom', authType: 'api_key', protocol: 'openai', baseUrl: 'https://custom.api/v1' }], }; - writeCatCatalog(projectRoot, makeCatalog(accounts)); + await writeFile(join(projectRoot, '.cat-cafe', 'provider-profiles.json'), JSON.stringify(legacyMeta), 'utf-8'); + // Write secrets file too + const legacySecrets = { profiles: { 'my-custom': { apiKey: 'sk-secret-123' } } }; + await writeFile( + join(projectRoot, '.cat-cafe', 'provider-profiles.secrets.local.json'), + JSON.stringify(legacySecrets), + 'utf-8', + ); + + // Reading accounts should trigger project-level legacy migration const result = readCatalogAccounts(projectRoot); - assert.deepEqual(result, accounts); + // F340: protocol not migrated — derived at runtime from well-known account IDs. + assert.equal(result['my-custom'].protocol, undefined); + assert.equal(result['my-custom'].baseUrl, 'https://custom.api/v1'); + + // Credentials should also be migrated to global + const credRaw = await readFile(join(globalRoot, '.cat-cafe', 'credentials.json'), 'utf-8'); + const creds = JSON.parse(credRaw); + assert.equal(creds['my-custom'].apiKey, 'sk-secret-123'); }); - it('readCatalogAccounts returns empty object when no accounts', async () => { - const { writeCatCatalog } = await import('../dist/config/cat-catalog-store.js'); - const { readCatalogAccounts } = await import('../dist/config/catalog-accounts.js'); - writeCatCatalog(projectRoot, makeCatalog()); + it('propagates global legacy provider-profile migration errors instead of failing open', async () => { + const { readCatalogAccounts, resetMigrationState } = await import('../dist/config/catalog-accounts.js'); + resetMigrationState(); + + await writeFile(join(globalRoot, '.cat-cafe', 'provider-profiles.json'), '{"version":2,"profiles":[', 'utf-8'); + + assert.throws(() => readCatalogAccounts(projectRoot), /Unexpected end of JSON input|JSON/i); + }); + + it('infers legacy api_key authType from mode/kind before migrating secrets', async () => { + const { readCatalogAccounts, resetMigrationState } = await import('../dist/config/catalog-accounts.js'); + resetMigrationState(); + + const legacyMeta = { + version: 1, + providers: { + anthropic: { + activeProfileId: 'installer-managed', + profiles: [ + { + id: 'installer-managed', + displayName: 'Installer API Key', + kind: 'api_key', + mode: 'api_key', + baseUrl: 'https://legacy.example/v1', + }, + ], + }, + }, + }; + await writeFile(join(projectRoot, '.cat-cafe', 'provider-profiles.json'), JSON.stringify(legacyMeta), 'utf-8'); + await writeFile( + join(projectRoot, '.cat-cafe', 'provider-profiles.secrets.local.json'), + JSON.stringify({ + version: 1, + providers: { anthropic: { 'installer-managed': { apiKey: 'sk-legacy-api-key' } } }, + }), + 'utf-8', + ); const result = readCatalogAccounts(projectRoot); - assert.deepEqual(result, {}); + assert.equal(result['installer-managed'].authType, 'api_key'); + + const credRaw = await readFile(join(globalRoot, '.cat-cafe', 'credentials.json'), 'utf-8'); + const creds = JSON.parse(credRaw); + assert.equal(creds['installer-managed'].apiKey, 'sk-legacy-api-key'); }); - it('writeCatalogAccount adds account to catalog', async () => { - const { writeCatCatalog, readCatCatalog } = await import('../dist/config/cat-catalog-store.js'); - const { writeCatalogAccount } = await import('../dist/config/catalog-accounts.js'); - writeCatCatalog(projectRoot, makeCatalog()); + it('migrates multiple projects without losing accounts', async () => { + const { readCatalogAccounts, resetMigrationState } = await import('../dist/config/catalog-accounts.js'); + resetMigrationState(); + + // Project A has account 'a' + const projectA = await mkdtemp(join(tmpdir(), 'project-a-')); + await mkdir(join(projectA, '.cat-cafe'), { recursive: true }); + await writeFile( + join(projectA, '.cat-cafe', 'cat-catalog.json'), + JSON.stringify({ + version: 2, + breeds: [], + roster: {}, + reviewPolicy: {}, + accounts: { a: { authType: 'oauth', protocol: 'anthropic' } }, + }), + 'utf-8', + ); - writeCatalogAccount(projectRoot, 'my-glm', { + // Project B has account 'b' + const projectB = await mkdtemp(join(tmpdir(), 'project-b-')); + await mkdir(join(projectB, '.cat-cafe'), { recursive: true }); + await writeFile( + join(projectB, '.cat-cafe', 'cat-catalog.json'), + JSON.stringify({ + version: 2, + breeds: [], + roster: {}, + reviewPolicy: {}, + accounts: { b: { authType: 'api_key', protocol: 'openai' } }, + }), + 'utf-8', + ); + + // Read A first, then B — both should migrate + readCatalogAccounts(projectA); + const result = readCatalogAccounts(projectB); + assert.ok(result.a, 'account from project A should exist'); + assert.ok(result.b, 'account from project B should exist'); + + const { rm: rmAsync } = await import('node:fs/promises'); + await rmAsync(projectA, { recursive: true, force: true }); + await rmAsync(projectB, { recursive: true, force: true }); + }); + + it('throws when project catalog migration hits an incompatible global account ID', async () => { + const { readCatalogAccounts, resetMigrationState, writeCatalogAccount } = await import( + '../dist/config/catalog-accounts.js' + ); + resetMigrationState(); + + writeCatalogAccount(projectRoot, 'shared', { authType: 'api_key', - protocol: 'openai', - baseUrl: 'https://open.bigmodel.cn/api/paas/v4', - models: ['glm-5'], + baseUrl: 'https://global.example/v1', + displayName: 'Global Shared', }); + resetMigrationState(); - const reloaded = readCatCatalog(projectRoot); - assert.ok(reloaded?.accounts?.['my-glm']); - assert.equal(reloaded.accounts['my-glm'].protocol, 'openai'); - assert.equal(reloaded.accounts['my-glm'].baseUrl, 'https://open.bigmodel.cn/api/paas/v4'); + await writeFile( + join(projectRoot, '.cat-cafe', 'cat-catalog.json'), + JSON.stringify({ + version: 2, + breeds: [], + roster: {}, + reviewPolicy: {}, + accounts: { + shared: { + authType: 'api_key', + baseUrl: 'https://project.example/v1', + displayName: 'Project Shared', + }, + }, + }), + 'utf-8', + ); + + assert.throws(() => readCatalogAccounts(projectRoot), /account conflict/i); }); - it('deleteCatalogAccount removes account from catalog', async () => { - const { writeCatCatalog, readCatCatalog } = await import('../dist/config/cat-catalog-store.js'); - const { writeCatalogAccount, deleteCatalogAccount } = await import('../dist/config/catalog-accounts.js'); - writeCatCatalog( - projectRoot, - makeCatalog({ - a: { authType: 'api_key', protocol: 'openai' }, - b: { authType: 'api_key', protocol: 'anthropic' }, + it('migrates v1 nested providers..profiles[] into flat accounts', async () => { + const { readCatalogAccounts, resetMigrationState } = await import('../dist/config/catalog-accounts.js'); + resetMigrationState(); + + // v1 format: providers keyed by client, each with a profiles array + const v1Meta = { + version: 1, + providers: { + anthropic: { + activeProfileId: 'my-proxy', + profiles: [ + { id: 'my-proxy', displayName: 'My Proxy', authType: 'api_key', baseUrl: 'https://proxy.example/v1' }, + { id: 'team-key', displayName: 'Team Key', authType: 'api_key' }, + ], + }, + }, + }; + await writeFile(join(projectRoot, '.cat-cafe', 'provider-profiles.json'), JSON.stringify(v1Meta), 'utf-8'); + + // v1 secrets: also nested under providers. + const v1Secrets = { + version: 1, + providers: { + anthropic: { + 'my-proxy': { apiKey: 'sk-proxy-key' }, + 'team-key': { apiKey: 'sk-team-key' }, + }, + }, + }; + await writeFile( + join(projectRoot, '.cat-cafe', 'provider-profiles.secrets.local.json'), + JSON.stringify(v1Secrets), + 'utf-8', + ); + + const result = readCatalogAccounts(projectRoot); + // Both profiles should be migrated as individual accounts (not "anthropic" shell) + assert.ok(result['my-proxy'], 'my-proxy account should exist'); + assert.equal(result['my-proxy'].authType, 'api_key'); + assert.equal(result['my-proxy'].displayName, 'My Proxy'); + assert.equal(result['my-proxy'].baseUrl, 'https://proxy.example/v1'); + assert.ok(result['team-key'], 'team-key account should exist'); + assert.equal(result['team-key'].authType, 'api_key'); + // Must NOT create an "anthropic" shell account from the parent key + assert.equal(result.anthropic, undefined, 'should not create shell account from client key'); + + // Credentials should also be migrated + const credRaw = await readFile(join(globalRoot, '.cat-cafe', 'credentials.json'), 'utf-8'); + const creds = JSON.parse(credRaw); + assert.equal(creds['my-proxy'].apiKey, 'sk-proxy-key'); + assert.equal(creds['team-key'].apiKey, 'sk-team-key'); + }); + + it('throws when legacy provider-profile migration hits an incompatible global account ID', async () => { + const { readCatalogAccounts, resetMigrationState, writeCatalogAccount } = await import( + '../dist/config/catalog-accounts.js' + ); + resetMigrationState(); + + writeCatalogAccount(projectRoot, 'shared', { + authType: 'oauth', + displayName: 'Global OAuth', + }); + resetMigrationState(); + + await writeFile( + join(projectRoot, '.cat-cafe', 'provider-profiles.json'), + JSON.stringify({ + version: 2, + providers: [ + { + id: 'shared', + authType: 'api_key', + baseUrl: 'https://legacy.example/v1', + displayName: 'Legacy Shared', + }, + ], }), + 'utf-8', ); - deleteCatalogAccount(projectRoot, 'a'); + assert.throws(() => readCatalogAccounts(projectRoot), /account conflict/i); + }); + + it('retries secret import when accounts already exist from previous migration', async () => { + const { readCatalogAccounts, resetMigrationState, writeCatalogAccount } = await import( + '../dist/config/catalog-accounts.js' + ); + resetMigrationState(); + + // Simulate previous successful account merge: account exists in global (same fields + // as what migration would produce) but credential is missing from a partial first run + writeCatalogAccount(projectRoot, 'my-custom', { authType: 'api_key', baseUrl: 'https://custom.api/v1' }); + resetMigrationState(); // reset so next read re-runs migration + + // Legacy source still has the profile + secret + const legacyMeta = { + version: 2, + providers: [{ id: 'my-custom', authType: 'api_key', baseUrl: 'https://custom.api/v1' }], + }; + await writeFile(join(projectRoot, '.cat-cafe', 'provider-profiles.json'), JSON.stringify(legacyMeta), 'utf-8'); + + const legacySecrets = { profiles: { 'my-custom': { apiKey: 'sk-retry-key' } } }; + await writeFile( + join(projectRoot, '.cat-cafe', 'provider-profiles.secrets.local.json'), + JSON.stringify(legacySecrets), + 'utf-8', + ); + + // On retry: accounts already exist (mergedIds empty), but credentials must still import + const result = readCatalogAccounts(projectRoot); + assert.ok(result['my-custom'], 'account should exist'); + + const credRaw = await readFile(join(globalRoot, '.cat-cafe', 'credentials.json'), 'utf-8'); + const creds = JSON.parse(credRaw); + assert.equal(creds['my-custom'].apiKey, 'sk-retry-key', 'credential must be imported on retry'); + }); + + it('fails before attaching a legacy secret to a pre-existing global account with colliding ID', async () => { + const { readCatalogAccounts, resetMigrationState, writeCatalogAccount } = await import( + '../dist/config/catalog-accounts.js' + ); + resetMigrationState(); + + // Pre-existing OAuth account in global — NOT from this legacy source + writeCatalogAccount(projectRoot, 'shared', { authType: 'oauth' }); + resetMigrationState(); + + // Legacy source happens to use the same "shared" ID but as api_key + const legacyMeta = { + version: 2, + providers: [{ id: 'shared', authType: 'api_key', baseUrl: 'https://legacy.api/v1' }], + }; + await writeFile(join(projectRoot, '.cat-cafe', 'provider-profiles.json'), JSON.stringify(legacyMeta), 'utf-8'); + + const legacySecrets = { profiles: { shared: { apiKey: 'sk-collision' } } }; + await writeFile( + join(projectRoot, '.cat-cafe', 'provider-profiles.secrets.local.json'), + JSON.stringify(legacySecrets), + 'utf-8', + ); + + assert.throws(() => readCatalogAccounts(projectRoot), /account conflict/i); + + // The legacy secret must NOT be imported for the colliding ID. + const credPath = join(globalRoot, '.cat-cafe', 'credentials.json'); + if (existsSync(credPath)) { + const creds = JSON.parse(await readFile(credPath, 'utf-8')); + assert.equal(creds.shared, undefined, 'legacy secret must NOT be attached to pre-existing OAuth account'); + } + // If credentials.json doesn't exist at all, that's also correct + }); + + it('stores accounts in projectRoot/.cat-cafe/ when env override is unset', async () => { + delete process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT; + const { writeCatalogAccount, readCatalogAccounts, resetMigrationState } = await import( + '../dist/config/catalog-accounts.js' + ); + resetMigrationState(); + + writeCatalogAccount(projectRoot, 'local-test', { authType: 'api_key' }); + const result = readCatalogAccounts(projectRoot); + assert.deepEqual(result['local-test'], { authType: 'api_key' }); + + // Must be in projectRoot, not globalRoot + const projectFile = join(projectRoot, '.cat-cafe', 'accounts.json'); + assert.ok(existsSync(projectFile), 'accounts.json should be in projectRoot/.cat-cafe/'); + const raw = JSON.parse(await readFile(projectFile, 'utf-8')); + assert.equal(raw['local-test'].authType, 'api_key'); + + const globalFile = join(globalRoot, '.cat-cafe', 'accounts.json'); + assert.ok(!existsSync(globalFile), 'accounts.json should NOT be in globalRoot when env unset'); + }); + + it('fails before attaching a legacy secret to a different-source api_key account with colliding ID', async () => { + const { readCatalogAccounts, resetMigrationState, writeCatalogAccount } = await import( + '../dist/config/catalog-accounts.js' + ); + resetMigrationState(); + + // Pre-existing api_key account — same type, different source (different baseUrl) + writeCatalogAccount(projectRoot, 'shared', { authType: 'api_key', baseUrl: 'https://existing.example/v1' }); + resetMigrationState(); + + // Legacy source: same ID, same authType, but different baseUrl → different source + const legacyMeta = { + version: 2, + providers: [{ id: 'shared', authType: 'api_key', baseUrl: 'https://legacy.example/v1' }], + }; + await writeFile(join(projectRoot, '.cat-cafe', 'provider-profiles.json'), JSON.stringify(legacyMeta), 'utf-8'); + + const legacySecrets = { profiles: { shared: { apiKey: 'sk-collision-api-key' } } }; + await writeFile( + join(projectRoot, '.cat-cafe', 'provider-profiles.secrets.local.json'), + JSON.stringify(legacySecrets), + 'utf-8', + ); + + assert.throws(() => readCatalogAccounts(projectRoot), /account conflict/i); - const reloaded = readCatCatalog(projectRoot); - assert.equal(reloaded?.accounts?.a, undefined); - assert.ok(reloaded?.accounts?.b); + const credPath = join(globalRoot, '.cat-cafe', 'credentials.json'); + if (existsSync(credPath)) { + const creds = JSON.parse(await readFile(credPath, 'utf-8')); + assert.equal(creds.shared, undefined, 'legacy secret must NOT be attached to different-source api_key account'); + } }); }); diff --git a/packages/api/test/cats-routes-runtime-catalog.test.js b/packages/api/test/cats-routes-runtime-catalog.test.js index b632e29da..14ac1db9a 100644 --- a/packages/api/test/cats-routes-runtime-catalog.test.js +++ b/packages/api/test/cats-routes-runtime-catalog.test.js @@ -1,14 +1,17 @@ import assert from 'node:assert/strict'; import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import { after, afterEach, beforeEach, describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); const tempDirs = []; let savedTemplatePath; let savedGlobalRoot; -function makeCatalog(catId, displayName, provider = 'openai', defaultModel = 'gpt-5.4') { +function makeCatalog(catId, displayName, clientId = 'openai', defaultModel = 'gpt-5.4') { return { version: 1, breeds: [ @@ -25,10 +28,10 @@ function makeCatalog(catId, displayName, provider = 'openai', defaultModel = 'gp variants: [ { id: `${catId}-default`, - provider, + clientId, defaultModel, - mcpSupport: provider !== 'antigravity', - cli: { command: provider === 'antigravity' ? 'antigravity' : 'codex', outputFormat: 'json' }, + mcpSupport: clientId !== 'antigravity', + cli: { command: clientId === 'antigravity' ? 'antigravity' : 'codex', outputFormat: 'json' }, }, ], }, @@ -89,7 +92,7 @@ function createMonorepoTemplateOnlyProject(template) { } function loadRepoTemplate() { - return JSON.parse(readFileSync(join(process.cwd(), '..', '..', 'cat-template.json'), 'utf-8')); + return JSON.parse(readFileSync(join(__dirname, '..', '..', '..', 'cat-template.json'), 'utf-8')); } describe('cats routes read runtime catalog', { concurrency: false }, () => { @@ -255,23 +258,24 @@ describe('cats routes read runtime catalog', { concurrency: false }, () => { } }); - it('GET /api/cats recomputes seed accountRef from the active bootstrap binding', async () => { + it('GET /api/cats resolves seed accountRef from well-known account ID', async () => { const projectRoot = createTemplateOnlyProject(loadRepoTemplate()); process.env.CAT_TEMPLATE_PATH = join(projectRoot, 'cat-template.json'); process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT = projectRoot; const { bootstrapCatCatalog } = await import('../dist/config/cat-catalog-store.js'); - const { activateProviderProfile, createProviderProfile } = await import('./helpers/create-test-account.js'); + const { writeCatalogAccount } = await import('../dist/config/catalog-accounts.js'); + const { writeCredential } = await import('../dist/config/credentials.js'); bootstrapCatCatalog(projectRoot, process.env.CAT_TEMPLATE_PATH); - const sponsorProfile = await createProviderProfile(projectRoot, { - displayName: 'Codex Sponsor', + // F340: Custom accounts require well-known ID or explicit accountRef binding. + // Overwrite the 'codex' well-known account with an api_key sponsor account. + writeCatalogAccount(projectRoot, 'codex', { authType: 'api_key', - protocol: 'openai', baseUrl: 'https://api.codex-sponsor.example', - apiKey: 'sk-codex-sponsor', models: ['gpt-5.4-mini'], + displayName: 'Codex Sponsor', }); - await activateProviderProfile(projectRoot, 'openai', sponsorProfile.id); + writeCredential('codex', { apiKey: 'sk-codex-sponsor' }); const Fastify = (await import('fastify')).default; const { catsRoutes } = await import('../dist/routes/cats.js'); @@ -285,7 +289,7 @@ describe('cats routes read runtime catalog', { concurrency: false }, () => { const codex = body.cats.find((cat) => cat.id === 'codex'); assert.ok(codex, 'codex should be listed'); assert.equal(codex.source, 'seed'); - assert.equal(codex.accountRef, sponsorProfile.id); + assert.equal(codex.accountRef, 'codex'); await app.close(); }); diff --git a/packages/api/test/cats-routes-runtime-crud.test.js b/packages/api/test/cats-routes-runtime-crud.test.js index 8923e9191..169eb8aa3 100644 --- a/packages/api/test/cats-routes-runtime-crud.test.js +++ b/packages/api/test/cats-routes-runtime-crud.test.js @@ -1,8 +1,12 @@ import assert from 'node:assert/strict'; import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import { after, afterEach, beforeEach, describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + import { CAT_CONFIGS, catRegistry, createCatId } from '@cat-cafe/shared'; const { parseA2AMentions } = await import('../dist/domains/cats/services/agents/routing/a2a-mentions.js'); @@ -37,7 +41,7 @@ function makeTemplate() { variants: [ { id: 'opus-default', - provider: 'anthropic', + clientId: 'anthropic', defaultModel: 'claude-sonnet-4-5-20250929', mcpSupport: true, cli: { command: 'claude', outputFormat: 'stream-json' }, @@ -86,8 +90,10 @@ function createProjectRootFromRepoTemplate() { const projectRoot = mkdtempSync(join(tmpdir(), 'cats-route-crud-seed-')); tempDirs.push(projectRoot); process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT = projectRoot; - const repoTemplate = JSON.parse(readFileSync(join(process.cwd(), '..', '..', 'cat-template.json'), 'utf-8')); - writeFileSync(join(projectRoot, 'cat-template.json'), JSON.stringify(repoTemplate, null, 2)); + const templateDest = join(projectRoot, 'cat-template.json'); + const repoTemplate = JSON.parse(readFileSync(join(__dirname, '..', '..', '..', 'cat-template.json'), 'utf-8')); + writeFileSync(templateDest, JSON.stringify(repoTemplate, null, 2)); + process.env.CAT_TEMPLATE_PATH = templateDest; return projectRoot; } @@ -150,7 +156,7 @@ describe('cats routes runtime CRUD', { concurrency: false }, () => { caution: '不会自动跑测试', strengths: ['precision', 'speed'], sessionChain: true, - client: 'openai', + clientId: 'openai', accountRef: 'codex', defaultModel: 'gpt-5.4', contextBudget: { @@ -166,7 +172,7 @@ describe('cats routes runtime CRUD', { concurrency: false }, () => { assert.equal(createRes.statusCode, 201); const createdBody = JSON.parse(createRes.body); assert.equal(createdBody.cat.id, 'runtime-spark'); - assert.equal(createdBody.cat.provider, 'openai'); + assert.equal(createdBody.cat.clientId, 'openai'); const patchRes = await app.inject({ method: 'PATCH', @@ -220,7 +226,7 @@ describe('cats routes runtime CRUD', { concurrency: false }, () => { 'x-cat-cafe-user': 'codex', }, body: JSON.stringify({ - providerProfileId: 'codex', + accountRef: 'codex', }), }); assert.equal(bindProviderRes.statusCode, 200); @@ -233,7 +239,7 @@ describe('cats routes runtime CRUD', { concurrency: false }, () => { 'x-cat-cafe-user': 'codex', }, body: JSON.stringify({ - providerProfileId: null, + accountRef: null, }), }); assert.equal(clearProviderRes.statusCode, 400); @@ -289,7 +295,7 @@ describe('cats routes runtime CRUD', { concurrency: false }, () => { color: { primary: '#16a34a', secondary: '#bbf7d0' }, mentionPatterns: ['@runtime-codex-effort'], roleDescription: '审查', - client: 'openai', + clientId: 'openai', accountRef: 'codex', defaultModel: 'gpt-5.4', cli: { command: 'codex', outputFormat: 'json', effort: 'xhigh' }, @@ -337,7 +343,7 @@ describe('cats routes runtime CRUD', { concurrency: false }, () => { color: { primary: '#16a34a', secondary: '#bbf7d0' }, mentionPatterns: ['@runtime-invalid-effort'], roleDescription: '审查', - client: 'openai', + clientId: 'openai', accountRef: 'codex', defaultModel: 'gpt-5.4', cli: { command: 'codex', outputFormat: 'json', effort: 'max' }, @@ -378,7 +384,7 @@ describe('cats routes runtime CRUD', { concurrency: false }, () => { color: { primary: '#2563eb', secondary: '#bfdbfe' }, mentionPatterns: ['@runtime-fallback'], roleDescription: '验证 project root fallback', - client: 'openai', + clientId: 'openai', accountRef: 'codex', defaultModel: 'gpt-5.4', }), @@ -419,7 +425,7 @@ describe('cats routes runtime CRUD', { concurrency: false }, () => { mentionPatterns: ['@runtime-antigravity'], roleDescription: '桥接通道', personality: '稳定', - client: 'antigravity', + clientId: 'antigravity', defaultModel: 'gemini-bridge', commandArgs: ['chat', '--mode', 'agent'], }), @@ -427,7 +433,7 @@ describe('cats routes runtime CRUD', { concurrency: false }, () => { assert.equal(res.statusCode, 201); const body = JSON.parse(res.body); assert.equal(body.cat.id, 'runtime-antigravity'); - assert.equal(body.cat.provider, 'antigravity'); + assert.equal(body.cat.clientId, 'antigravity'); assert.equal(body.cat.defaultModel, 'gemini-bridge'); const statusRes = await app.inject({ method: 'GET', url: '/api/cats/runtime-antigravity/status' }); @@ -462,7 +468,7 @@ describe('cats routes runtime CRUD', { concurrency: false }, () => { mentionPatterns: ['@runtime-antigravity-clear'], roleDescription: '桥接通道', personality: '稳定', - client: 'antigravity', + clientId: 'antigravity', defaultModel: 'gemini-bridge', commandArgs: ['chat', '--mode', 'agent'], }), @@ -496,8 +502,8 @@ describe('cats routes runtime CRUD', { concurrency: false }, () => { await app.register(catsRoutes); for (const spec of [ - { catId: 'runtime-openai', client: 'openai', accountRef: 'codex', model: 'gpt-5.4' }, - { catId: 'runtime-gemini', client: 'google', accountRef: 'gemini', model: 'gemini-2.5-pro' }, + { catId: 'runtime-openai', clientId: 'openai', accountRef: 'codex', model: 'gpt-5.4' }, + { catId: 'runtime-gemini', clientId: 'google', accountRef: 'gemini', model: 'gemini-2.5-pro' }, ]) { const res = await app.inject({ method: 'POST', @@ -514,7 +520,7 @@ describe('cats routes runtime CRUD', { concurrency: false }, () => { color: { primary: '#334155', secondary: '#cbd5e1' }, mentionPatterns: [`@${spec.catId}`], roleDescription: 'runtime', - client: spec.client, + clientId: spec.clientId, accountRef: spec.accountRef, defaultModel: spec.model, }), @@ -552,7 +558,7 @@ describe('cats routes runtime CRUD', { concurrency: false }, () => { color: { primary: '#16a34a', secondary: '#bbf7d0' }, mentionPatterns: ['@runtime-codex'], roleDescription: '审查', - client: 'openai', + clientId: 'openai', accountRef: 'codex', defaultModel: 'gpt-5.4', }), @@ -567,7 +573,7 @@ describe('cats routes runtime CRUD', { concurrency: false }, () => { 'x-cat-cafe-user': 'codex', }, body: JSON.stringify({ - providerProfileId: 'claude-oauth', + accountRef: 'claude-oauth', }), }); assert.equal(patchRes.statusCode, 400); @@ -610,10 +616,10 @@ describe('cats routes runtime CRUD', { concurrency: false }, () => { color: { primary: '#0f172a', secondary: '#e2e8f0' }, mentionPatterns: ['@runtime-opencode-crossproto'], roleDescription: '审查', - client: 'opencode', - providerProfileId: crossProtocolProfile.id, + clientId: 'opencode', + accountRef: crossProtocolProfile.id, defaultModel: 'openai/claude-sonnet-4-6', - ocProviderName: 'openai', + provider: 'openai', }), }); @@ -657,8 +663,8 @@ describe('cats routes runtime CRUD', { concurrency: false }, () => { color: { primary: '#ff0000', secondary: '#ffcccc' }, mentionPatterns: ['@cross-protocol-test'], roleDescription: '测试用', - client: 'anthropic', - providerProfileId: openaiAccount.id, + clientId: 'anthropic', + accountRef: openaiAccount.id, defaultModel: 'MiniMax-M2.7', }), }); @@ -701,8 +707,8 @@ describe('cats routes runtime CRUD', { concurrency: false }, () => { color: { primary: '#00ff00', secondary: '#ccffcc' }, mentionPatterns: ['@slash-test'], roleDescription: '测试用', - client: 'anthropic', - providerProfileId: 'anthropic-key', + clientId: 'anthropic', + accountRef: 'anthropic-key', defaultModel: 'claude-opus-4-6/', }), }); @@ -747,8 +753,8 @@ describe('cats routes runtime CRUD', { concurrency: false }, () => { color: { primary: '#ff0000', secondary: '#ffcccc' }, mentionPatterns: ['@pure-slash'], roleDescription: '测试用', - client: 'anthropic', - providerProfileId: 'anthropic-key', + clientId: 'anthropic', + accountRef: 'anthropic-key', defaultModel: '/', }), }); @@ -756,7 +762,7 @@ describe('cats routes runtime CRUD', { concurrency: false }, () => { assert.equal(createRes.statusCode, 400, 'pure slash model should be rejected'); }); - it('POST /api/cats opencode+api_key: provider/model is primary, ocProviderName is legacy fallback', async () => { + it('POST /api/cats opencode+api_key: provider/model is primary, provider is legacy fallback', async () => { const projectRoot = createProjectRoot(); process.env.CAT_TEMPLATE_PATH = join(projectRoot, 'cat-template.json'); @@ -777,7 +783,7 @@ describe('cats routes runtime CRUD', { concurrency: false }, () => { const app = Fastify(); await app.register(catsRoutes); - // Case 1: bare model WITHOUT ocProviderName → 400 (no way to infer provider) + // Case 1: bare model WITHOUT provider → 400 (no way to infer provider) const bareReject = await app.inject({ method: 'POST', url: '/api/cats', @@ -790,15 +796,15 @@ describe('cats routes runtime CRUD', { concurrency: false }, () => { color: { primary: '#0f172a', secondary: '#e2e8f0' }, mentionPatterns: ['@oc-bare-no-provider'], roleDescription: '审查', - client: 'opencode', - providerProfileId: openaiProfile.id, + clientId: 'opencode', + accountRef: openaiProfile.id, defaultModel: 'gpt-5.4', }), }); - assert.equal(bareReject.statusCode, 400, 'bare model without ocProviderName → 400'); + assert.equal(bareReject.statusCode, 400, 'bare model without provider → 400'); assert.match(JSON.parse(bareReject.body).error, /provider/i); - // Case 2: provider/model format WITHOUT ocProviderName → 201 (provider inferred from model) + // Case 2: provider/model format WITHOUT provider → 201 (provider inferred from model) const slashAccept = await app.inject({ method: 'POST', url: '/api/cats', @@ -811,14 +817,14 @@ describe('cats routes runtime CRUD', { concurrency: false }, () => { color: { primary: '#0f172a', secondary: '#e2e8f0' }, mentionPatterns: ['@oc-slash-no-provider'], roleDescription: '审查', - client: 'opencode', - providerProfileId: openaiProfile.id, + clientId: 'opencode', + accountRef: openaiProfile.id, defaultModel: 'openai/gpt-5.4', }), }); - assert.equal(slashAccept.statusCode, 201, 'provider/model without ocProviderName → 201'); + assert.equal(slashAccept.statusCode, 201, 'provider/model without provider → 201'); - // Case 3: bare model WITH ocProviderName → 201 (legacy fallback path) + // Case 3: bare model WITH provider → 201 (legacy fallback path) const bareAccept = await app.inject({ method: 'POST', url: '/api/cats', @@ -831,15 +837,15 @@ describe('cats routes runtime CRUD', { concurrency: false }, () => { color: { primary: '#0f172a', secondary: '#e2e8f0' }, mentionPatterns: ['@oc-bare-with-provider'], roleDescription: '审查', - client: 'opencode', - providerProfileId: openaiProfile.id, + clientId: 'opencode', + accountRef: openaiProfile.id, defaultModel: 'gpt-5.4', - ocProviderName: 'openai', + provider: 'openai', }), }); - assert.equal(bareAccept.statusCode, 201, 'bare model + ocProviderName → 201'); + assert.equal(bareAccept.statusCode, 201, 'bare model + provider → 201'); - // Case 4: trailing-slash model WITHOUT ocProviderName → 400 (not valid provider/model) + // Case 4: trailing-slash model WITHOUT provider → 400 (not valid provider/model) const trailingSlashReject = await app.inject({ method: 'POST', url: '/api/cats', @@ -852,14 +858,14 @@ describe('cats routes runtime CRUD', { concurrency: false }, () => { color: { primary: '#0f172a', secondary: '#e2e8f0' }, mentionPatterns: ['@oc-trailing-slash'], roleDescription: '审查', - client: 'opencode', - providerProfileId: openaiProfile.id, + clientId: 'opencode', + accountRef: openaiProfile.id, defaultModel: 'minimax/', }), }); - assert.equal(trailingSlashReject.statusCode, 400, 'trailing-slash model without ocProviderName → 400'); + assert.equal(trailingSlashReject.statusCode, 400, 'trailing-slash model without provider → 400'); - // Case 5: namespaced model from account's model list WITHOUT ocProviderName → 400 + // Case 5: namespaced model from account's model list WITHOUT provider → 400 // "z-ai/glm-4.7" exists in account models → it's a model namespace, not provider/model const { createProviderProfile: createProfile2 } = await import('./helpers/create-test-account.js'); const orProfile = await createProfile2(projectRoot, { @@ -882,16 +888,12 @@ describe('cats routes runtime CRUD', { concurrency: false }, () => { color: { primary: '#0f172a', secondary: '#e2e8f0' }, mentionPatterns: ['@oc-namespaced-no-provider'], roleDescription: '审查', - client: 'opencode', - providerProfileId: orProfile.id, + clientId: 'opencode', + accountRef: orProfile.id, defaultModel: 'z-ai/glm-4.7', }), }); - assert.equal( - namespacedReject.statusCode, - 400, - 'namespaced model from account model list without ocProviderName → 400', - ); + assert.equal(namespacedReject.statusCode, 400, 'namespaced model from account model list without provider → 400'); // Case 6: canonical provider/model that ALSO appears in account models → 201 // minimax account stores both bare "MiniMax-M2.7" and canonical "minimax/MiniMax-M2.7" @@ -916,8 +918,8 @@ describe('cats routes runtime CRUD', { concurrency: false }, () => { color: { primary: '#0f172a', secondary: '#e2e8f0' }, mentionPatterns: ['@oc-canonical-in-list'], roleDescription: '审查', - client: 'opencode', - providerProfileId: mmProfile.id, + clientId: 'opencode', + accountRef: mmProfile.id, defaultModel: 'minimax/MiniMax-M2.7', }), }); @@ -927,7 +929,7 @@ describe('cats routes runtime CRUD', { concurrency: false }, () => { 'canonical provider/model in account list (bare form also present) → 201', ); - // Case 7: canonical-only model list (no bare alias) WITHOUT ocProviderName → 201 + // Case 7: canonical-only model list (no bare alias) WITHOUT provider → 201 // Account stores only "openai/gpt-5.4" (no bare "gpt-5.4") — still canonical, not namespaced. // Distinguished from Case 5 by absence of sibling models sharing the same prefix. const { createProviderProfile: createProfile4 } = await import('./helpers/create-test-account.js'); @@ -951,14 +953,14 @@ describe('cats routes runtime CRUD', { concurrency: false }, () => { color: { primary: '#0f172a', secondary: '#e2e8f0' }, mentionPatterns: ['@oc-canonical-only'], roleDescription: '审查', - client: 'opencode', - providerProfileId: canonicalOnlyProfile.id, + clientId: 'opencode', + accountRef: canonicalOnlyProfile.id, defaultModel: 'openai/gpt-5.4', }), }); assert.equal(canonicalOnlyAccept.statusCode, 201, 'canonical-only model list (no bare alias, singleton) → 201'); - // Case 8: multi-model canonical provider list WITHOUT ocProviderName → 201 + // Case 8: multi-model canonical provider list WITHOUT provider → 201 // Account stores multiple models under the same known provider prefix. // Must NOT be confused with openrouter-style namespace siblings. const { createProviderProfile: createProfile5 } = await import('./helpers/create-test-account.js'); @@ -982,8 +984,8 @@ describe('cats routes runtime CRUD', { concurrency: false }, () => { color: { primary: '#0f172a', secondary: '#e2e8f0' }, mentionPatterns: ['@oc-multi-canonical'], roleDescription: '审查', - client: 'opencode', - providerProfileId: multiCanonicalProfile.id, + clientId: 'opencode', + accountRef: multiCanonicalProfile.id, defaultModel: 'openai/gpt-5.4', }), }); @@ -995,7 +997,7 @@ describe('cats routes runtime CRUD', { concurrency: false }, () => { }); it('F189 P1 regression: openrouter + foreign-prefix model preserves full model namespace', async () => { - // Regression test for: ocProviderName=openrouter + defaultModel=z-ai/glm-4.7 + // Regression test for: provider=openrouter + defaultModel=z-ai/glm-4.7 // The model's first segment "z-ai" is NOT the provider prefix — it is the // model's namespace within OpenRouter. stripOwnProviderPrefix must keep it. const { deriveOpenCodeApiType, generateOpenCodeRuntimeConfig } = await import( @@ -1062,8 +1064,8 @@ describe('cats routes runtime CRUD', { concurrency: false }, () => { ); }); - it('F189 P1 regression: apiType derived solely from ocProviderName (protocol retired)', async () => { - // deriveOpenCodeApiType now only uses ocProviderName; account-level protocol + it('F189 P1 regression: apiType derived solely from providerName (protocol retired)', async () => { + // deriveOpenCodeApiType now only uses providerName; account-level protocol // is no longer consulted. This test verifies the new single-source behavior. const { deriveOpenCodeApiType } = await import( '../dist/domains/cats/services/agents/providers/opencode-config-template.js' @@ -1085,9 +1087,9 @@ describe('cats routes runtime CRUD', { concurrency: false }, () => { } }); - it('F189 legacy compat: PATCH allows editing an opencode+api_key member without ocProviderName', async () => { + it('F189 legacy compat: PATCH allows editing an opencode+api_key member without provider', async () => { // Regression: legacy opencode+api_key configs created before F189 have no - // ocProviderName. Editing these members (e.g. changing defaultModel) must not + // provider. Editing these members (e.g. changing defaultModel) must not // fail validation. The invoke path skips the F189 config block when absent. const projectRoot = createProjectRoot(); process.env.CAT_TEMPLATE_PATH = join(projectRoot, 'cat-template.json'); @@ -1104,7 +1106,7 @@ describe('cats routes runtime CRUD', { concurrency: false }, () => { }); // Create the cat directly via createRuntimeCat (bypasses POST validation) - // to simulate a legacy config without ocProviderName. + // to simulate a legacy config without provider. const { createRuntimeCat } = await import('../dist/config/runtime-cat-catalog.js'); createRuntimeCat(projectRoot, { catId: 'legacy-oc-member', @@ -1114,12 +1116,12 @@ describe('cats routes runtime CRUD', { concurrency: false }, () => { color: { primary: '#0f172a', secondary: '#e2e8f0' }, mentionPatterns: ['@legacy-oc'], roleDescription: '测试', - provider: 'opencode', + clientId: 'opencode', accountRef: legacyProfile.id, defaultModel: 'glm-5', mcpSupport: false, cli: { command: 'opencode', outputFormat: 'text' }, - // No ocProviderName — this is the legacy state + // No provider (model provider name) — this is the legacy state }); const Fastify = (await import('fastify')).default; @@ -1137,7 +1139,7 @@ describe('cats routes runtime CRUD', { concurrency: false }, () => { }, body: JSON.stringify({ defaultModel: 'glm-4-plus' }), }); - assert.equal(patchRes.statusCode, 200, 'legacy member model edit should succeed without ocProviderName'); + assert.equal(patchRes.statusCode, 200, 'legacy member model edit should succeed without provider'); // Editor always sends accountRef even when unchanged — must still succeed const editorPatchRes = await app.inject({ @@ -1147,12 +1149,12 @@ describe('cats routes runtime CRUD', { concurrency: false }, () => { 'content-type': 'application/json', 'x-cat-cafe-user': 'codex', }, - body: JSON.stringify({ defaultModel: 'glm-4-plus', providerProfileId: legacyProfile.id }), + body: JSON.stringify({ defaultModel: 'glm-4-plus', accountRef: legacyProfile.id }), }); assert.equal(editorPatchRes.statusCode, 200, 'unchanged accountRef in PATCH should not defeat legacy compat'); - // But switching accountRef on a legacy member WITHOUT ocProviderName must be rejected — - // a new binding requires ocProviderName. + // But switching accountRef on a legacy member WITHOUT provider must be rejected — + // a new binding requires provider. const { createProviderProfile: createProfile2 } = await import('./helpers/create-test-account.js'); const newProfile = await createProfile2(projectRoot, { displayName: 'New DeepSeek Key', @@ -1170,13 +1172,9 @@ describe('cats routes runtime CRUD', { concurrency: false }, () => { 'content-type': 'application/json', 'x-cat-cafe-user': 'codex', }, - body: JSON.stringify({ providerProfileId: newProfile.id }), + body: JSON.stringify({ accountRef: newProfile.id }), }); - assert.equal( - switchRes.statusCode, - 400, - 'switching account on legacy member without ocProviderName should be rejected', - ); + assert.equal(switchRes.statusCode, 400, 'switching account on legacy member without provider should be rejected'); }); it('POST /api/cats rejects catId values that are not lowercase-safe identifiers', async () => { @@ -1204,8 +1202,8 @@ describe('cats routes runtime CRUD', { concurrency: false }, () => { color: { primary: '#0f172a', secondary: '#e2e8f0' }, mentionPatterns: ['@danger'], roleDescription: '审查', - client: 'openai', - providerProfileId: 'codex', + clientId: 'openai', + accountRef: 'codex', defaultModel: 'gpt-5.4', }), }); @@ -1237,14 +1235,14 @@ describe('cats routes runtime CRUD', { concurrency: false }, () => { const cases = [ { catId: 'runtime-dare-wrong-builtin', - client: 'dare', - providerProfileId: 'codex', + clientId: 'dare', + accountRef: 'codex', defaultModel: 'gpt-5.4', }, { catId: 'runtime-opencode-wrong-builtin', - client: 'opencode', - providerProfileId: 'claude', + clientId: 'opencode', + accountRef: 'claude', defaultModel: 'claude-sonnet-4-6', }, ]; @@ -1265,15 +1263,15 @@ describe('cats routes runtime CRUD', { concurrency: false }, () => { color: { primary: '#0f172a', secondary: '#e2e8f0' }, mentionPatterns: [`@${spec.catId}`], roleDescription: '审查', - client: spec.client, - providerProfileId: spec.providerProfileId, + clientId: spec.clientId, + accountRef: spec.accountRef, defaultModel: spec.defaultModel, }), }); assert.equal(createRes.statusCode, 400); const createBody = JSON.parse(createRes.body); - assert.match(createBody.error, new RegExp(`incompatible with client "${spec.client}"`, 'i')); + assert.match(createBody.error, new RegExp(`incompatible with client "${spec.clientId}"`, 'i')); } }); @@ -1312,8 +1310,8 @@ describe('cats routes runtime CRUD', { concurrency: false }, () => { color: { primary: '#0f172a', secondary: '#e2e8f0' }, mentionPatterns: ['@runtime-gemini-non-builtin'], roleDescription: '审查', - client: 'google', - providerProfileId: apiKeyProfile.id, + clientId: 'google', + accountRef: apiKeyProfile.id, defaultModel: 'openrouter/google/gemini-3-flash-preview', }), }); @@ -1328,17 +1326,17 @@ describe('cats routes runtime CRUD', { concurrency: false }, () => { process.env.CAT_TEMPLATE_PATH = join(projectRoot, 'cat-template.json'); const { bootstrapCatCatalog } = await import('../dist/config/cat-catalog-store.js'); - const { activateProviderProfile, createProviderProfile } = await import('./helpers/create-test-account.js'); + const { writeCatalogAccount } = await import('../dist/config/catalog-accounts.js'); + const { writeCredential } = await import('../dist/config/credentials.js'); bootstrapCatCatalog(projectRoot, process.env.CAT_TEMPLATE_PATH); - const sponsorProfile = await createProviderProfile(projectRoot, { - displayName: 'Codex Sponsor', + // F340: Overwrite the 'codex' well-known account with an api_key sponsor + writeCatalogAccount(projectRoot, 'codex', { authType: 'api_key', - protocol: 'openai', baseUrl: 'https://api.codex-sponsor.example', - apiKey: 'sk-codex-sponsor', models: ['gpt-5.4-mini'], + displayName: 'Codex Sponsor', }); - await activateProviderProfile(projectRoot, 'openai', sponsorProfile.id); + writeCredential('codex', { apiKey: 'sk-codex-sponsor' }); const Fastify = (await import('fastify')).default; const { catsRoutes } = await import('../dist/routes/cats.js'); @@ -1361,7 +1359,7 @@ describe('cats routes runtime CRUD', { concurrency: false }, () => { assert.equal(patchRes.statusCode, 200); const patchBody = JSON.parse(patchRes.body); assert.equal(patchBody.cat.defaultModel, 'gpt-5.4-mini'); - assert.equal(patchBody.cat.accountRef, sponsorProfile.id); + assert.equal(patchBody.cat.accountRef, 'codex'); }); it('PATCH /api/cats/:id rebases inherited seed binding when switching client families', async () => { @@ -1378,7 +1376,7 @@ describe('cats routes runtime CRUD', { concurrency: false }, () => { const beforeBody = JSON.parse(beforeRes.body); const opusBefore = beforeBody.cats.find((cat) => cat.id === 'opus'); assert.ok(opusBefore, 'seed opus member must exist'); - assert.equal(opusBefore.provider, 'anthropic'); + assert.equal(opusBefore.clientId, 'anthropic'); assert.equal(opusBefore.accountRef, 'claude'); const patchRes = await app.inject({ @@ -1390,15 +1388,15 @@ describe('cats routes runtime CRUD', { concurrency: false }, () => { }, // Simulate editor payload carrying the previous visible accountRef while switching client. body: JSON.stringify({ - client: 'openai', + clientId: 'openai', defaultModel: 'gpt-5.4', - providerProfileId: opusBefore.accountRef, + accountRef: opusBefore.accountRef, }), }); assert.equal(patchRes.statusCode, 200); const patchBody = JSON.parse(patchRes.body); - assert.equal(patchBody.cat.provider, 'openai'); + assert.equal(patchBody.cat.clientId, 'openai'); assert.equal(patchBody.cat.defaultModel, 'gpt-5.4'); assert.equal(patchBody.cat.accountRef, 'codex'); }); @@ -1446,14 +1444,14 @@ describe('cats routes runtime CRUD', { concurrency: false }, () => { 'x-cat-cafe-user': 'codex', }, body: JSON.stringify({ - client: 'openai', + clientId: 'openai', defaultModel: 'gpt-5.4', }), }); assert.equal(patchRes.statusCode, 200); const patchBody = JSON.parse(patchRes.body); - assert.equal(patchBody.cat.provider, 'openai'); + assert.equal(patchBody.cat.clientId, 'openai'); assert.equal(patchBody.cat.defaultModel, 'gpt-5.4'); // Verify CLI was reset to openai defaults (including effort) @@ -1523,7 +1521,7 @@ describe('cats routes runtime CRUD', { concurrency: false }, () => { color: { primary: '#334155', secondary: '#cbd5e1' }, mentionPatterns: ['@runtime-review-bot'], roleDescription: '审查', - client: 'openai', + clientId: 'openai', accountRef: 'codex', defaultModel: 'gpt-5.4', }), @@ -1571,7 +1569,7 @@ describe('cats routes runtime CRUD', { concurrency: false }, () => { color: { primary: '#0f172a', secondary: '#cbd5e1' }, mentionPatterns: ['@runtime-dare'], roleDescription: '审计', - client: 'dare', + clientId: 'dare', defaultModel: 'dare-1', }), }); @@ -1649,7 +1647,7 @@ describe('cats routes runtime CRUD', { concurrency: false }, () => { color: { primary: '#155e75', secondary: '#a5f3fc' }, mentionPatterns: ['@runtime-strategy-cat'], roleDescription: '策略验证', - client: 'openai', + clientId: 'openai', accountRef: 'codex', defaultModel: 'gpt-5.4', }), @@ -1697,7 +1695,7 @@ describe('cats routes runtime CRUD', { concurrency: false }, () => { mentionPatterns: ['@runtime-temp'], roleDescription: '临时成员', personality: '临时', - client: 'openai', + clientId: 'openai', accountRef: 'codex', defaultModel: 'gpt-5.4', mcpSupport: false, diff --git a/packages/api/test/config-registry.test.js b/packages/api/test/config-registry.test.js index b8c8a1aaf..421fcb6ed 100644 --- a/packages/api/test/config-registry.test.js +++ b/packages/api/test/config-registry.test.js @@ -108,7 +108,7 @@ describe('ConfigRegistry', () => { assert.ok(snapshot.cats.opus, 'has opus'); assert.ok(snapshot.cats.opus.displayName, 'opus has displayName'); - assert.ok(snapshot.cats.opus.provider, 'opus has provider'); + assert.ok(snapshot.cats.opus.clientId, 'opus has clientId'); assert.ok(snapshot.cats.opus.model, 'opus has model'); assert.equal(typeof snapshot.cats.opus.mcpSupport, 'boolean', 'opus has mcpSupport'); }); diff --git a/packages/api/test/env-registry.test.js b/packages/api/test/env-registry.test.js index 63f7267c1..f036a6c2c 100644 --- a/packages/api/test/env-registry.test.js +++ b/packages/api/test/env-registry.test.js @@ -109,20 +109,26 @@ describe('env-registry', () => { assert.equal(hindsightVars.length, 0, 'All HINDSIGHT_* vars should be removed'); }); - it('marks OPENAI_API_KEY, GITHUB_MCP_PAT, F102_API_KEY as sensitive + runtimeEditable', () => { - for (const name of ['OPENAI_API_KEY', 'GITHUB_MCP_PAT', 'F102_API_KEY']) { + it('marks GITHUB_MCP_PAT, F102_API_KEY as sensitive + runtimeEditable (#340 P6: OPENAI_API_KEY removed)', () => { + for (const name of ['GITHUB_MCP_PAT', 'F102_API_KEY']) { const def = ENV_VARS.find((v) => v.name === name); assert.ok(def, `${name} should be in registry`); assert.equal(def.sensitive, true, `${name} should be sensitive`); assert.equal(def.runtimeEditable, true, `${name} should be runtimeEditable`); assert.ok(isSensitiveEditableEnvVar(def), `${name} should pass isSensitiveEditableEnvVar`); } + // #340 P6: OPENAI_API_KEY is no longer runtimeEditable (managed by accounts system) + const openai = ENV_VARS.find((v) => v.name === 'OPENAI_API_KEY'); + assert.ok(openai, 'OPENAI_API_KEY should still be in registry'); + assert.equal(openai.sensitive, true, 'OPENAI_API_KEY should remain sensitive'); + assert.ok(!openai.runtimeEditable, 'OPENAI_API_KEY should not be runtimeEditable'); }); it('hasSensitiveEditableVars detects whitelisted sensitive vars', () => { - assert.ok(hasSensitiveEditableVars(['OPENAI_API_KEY'])); + assert.ok(hasSensitiveEditableVars(['GITHUB_MCP_PAT'])); assert.ok(hasSensitiveEditableVars(['FRONTEND_URL', 'F102_API_KEY'])); assert.ok(!hasSensitiveEditableVars(['FRONTEND_URL', 'AUDIT_LOG_DIR'])); + assert.ok(!hasSensitiveEditableVars(['OPENAI_API_KEY']), 'OPENAI_API_KEY is no longer editable (#340 P6)'); }); it('marks DEFAULT_OWNER_USER_ID as non-editable (trust anchor)', () => { @@ -386,7 +392,7 @@ describe('PATCH /api/config/env (route)', () => { } }); - it('rejects sensitive env writes when DEFAULT_OWNER_USER_ID is not configured', async () => { + it('rejects OPENAI_API_KEY env write since it is no longer runtimeEditable (#340 P6)', async () => { const { configRoutes } = await import('../dist/routes/config.js'); const tempRoot = mkdtempSync(resolve(tmpdir(), 'cat-cafe-env-')); const envFilePath = resolve(tempRoot, '.env'); @@ -411,9 +417,8 @@ describe('PATCH /api/config/env (route)', () => { }, }); - assert.equal(res.statusCode, 403); - const body = JSON.parse(res.payload); - assert.match(body.error, /DEFAULT_OWNER_USER_ID/); + // #340 P6: OPENAI_API_KEY is no longer runtimeEditable (managed by accounts system) + assert.equal(res.statusCode, 400); assert.equal(readFileSync(envFilePath, 'utf8'), 'OPENAI_API_KEY=sk-old\n'); } finally { await app.close(); diff --git a/packages/api/test/helpers/create-test-account.js b/packages/api/test/helpers/create-test-account.js index 3eb25e2cb..17627a261 100644 --- a/packages/api/test/helpers/create-test-account.js +++ b/packages/api/test/helpers/create-test-account.js @@ -1,14 +1,7 @@ /** - * F136 Phase 4d — Test helper replacing old createProviderProfile / activateProviderProfile. - * - * Drop-in shim: writes accounts to cat-catalog.json + credentials.json - * (the new canonical stores) and returns a profile-like object so existing - * test assertions on `profile.id` continue to work. - * - * Key difference from the old provider-profiles.js helper: accounts now live - * inside cat-catalog.json (same file as breeds). When no catalog exists yet, - * this helper bootstraps one from the project template so that the runtime - * catalog is valid (has breeds ≥ 1, roster, reviewPolicy). + * F340 — Test helper: writes accounts to global ~/.cat-cafe/accounts.json + * + credentials.json (the canonical stores) and returns a profile-like + * object so existing test assertions on `profile.id` continue to work. */ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { resolve } from 'node:path'; @@ -72,7 +65,7 @@ async function ensureCatalog(projectRoot) { variants: [ { id: 'stub-v', - provider: 'anthropic', + clientId: 'anthropic', defaultModel: 'stub', mcpSupport: false, cli: { command: 'echo', outputFormat: 'stream-json' }, @@ -136,22 +129,26 @@ export async function createProviderProfile(projectRoot, opts) { const authType = opts.authType || (opts.mode === 'api_key' ? 'api_key' : 'oauth'); const isBuiltin = authType === 'oauth'; - // Write account to catalog (bootstrap first if needed) - const catalogPath = await ensureCatalog(projectRoot); - const catalog = readCatalog(catalogPath); - if (!catalog.accounts) catalog.accounts = {}; - catalog.accounts[id] = { + // Ensure catalog exists (for breeds/roster, not for accounts) + await ensureCatalog(projectRoot); + + // F340: Write account to global ~/.cat-cafe/accounts.json + const globalRoot = process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT || projectRoot; + const globalCatCafeDir = resolve(globalRoot, '.cat-cafe'); + mkdirSync(globalCatCafeDir, { recursive: true }); + const accountsPath = resolve(globalCatCafeDir, 'accounts.json'); + const accounts = existsSync(accountsPath) ? JSON.parse(readFileSync(accountsPath, 'utf-8')) : {}; + // F340: protocol not persisted — derived at runtime from well-known account IDs. + accounts[id] = { authType, - protocol, ...(opts.displayName || opts.name ? { displayName: opts.displayName || opts.name } : {}), ...(opts.baseUrl ? { baseUrl: opts.baseUrl.trim().replace(/\/+$/, '') } : {}), ...(opts.models?.length ? { models: opts.models } : {}), }; - writeCatalog(catalogPath, catalog); + writeFileSync(accountsPath, `${JSON.stringify(accounts, null, 2)}\n`, 'utf-8'); // Write credential if API key provided if (opts.apiKey) { - const globalRoot = process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT || projectRoot; const credPath = ensureCredentials(globalRoot); const creds = JSON.parse(readFileSync(credPath, 'utf-8')); creds[id] = { apiKey: opts.apiKey }; @@ -167,7 +164,7 @@ export async function createProviderProfile(projectRoot, opts) { displayName: opts.name, ...(opts.baseUrl ? { baseUrl: opts.baseUrl } : {}), ...(opts.models?.length ? { models: [...opts.models] } : {}), - client: isBuiltin ? opts.provider : undefined, + clientId: isBuiltin ? opts.provider : undefined, }; } @@ -183,12 +180,13 @@ export async function activateProviderProfile(_projectRoot, _provider, _profileI * No-op replacement for the old deleteProviderProfile. */ export async function deleteProviderProfile(projectRoot, profileId, _activeProfileId) { - const catalogPath = resolve(projectRoot, '.cat-cafe', 'cat-catalog.json'); - if (!existsSync(catalogPath)) return; - const catalog = readCatalog(catalogPath); - if (catalog.accounts?.[profileId]) { - delete catalog.accounts[profileId]; - writeCatalog(catalogPath, catalog); + const globalRoot = process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT || projectRoot; + const accountsPath = resolve(globalRoot, '.cat-cafe', 'accounts.json'); + if (!existsSync(accountsPath)) return; + const accounts = JSON.parse(readFileSync(accountsPath, 'utf-8')); + if (accounts[profileId]) { + delete accounts[profileId]; + writeFileSync(accountsPath, `${JSON.stringify(accounts, null, 2)}\n`, 'utf-8'); } } @@ -196,14 +194,15 @@ export async function deleteProviderProfile(projectRoot, profileId, _activeProfi * No-op replacement for the old updateProviderProfile. */ export async function updateProviderProfile(projectRoot, profileId, _activeProfileId, updates) { - const catalogPath = resolve(projectRoot, '.cat-cafe', 'cat-catalog.json'); - if (!existsSync(catalogPath)) return { error: 'not_found' }; - const catalog = readCatalog(catalogPath); - const account = catalog.accounts?.[profileId]; + const globalRoot = process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT || projectRoot; + const accountsPath = resolve(globalRoot, '.cat-cafe', 'accounts.json'); + if (!existsSync(accountsPath)) return { error: 'not_found' }; + const accounts = JSON.parse(readFileSync(accountsPath, 'utf-8')); + const account = accounts[profileId]; if (!account) return { error: 'not_found' }; if (updates.name) account.displayName = updates.name; if (updates.baseUrl !== undefined) account.baseUrl = updates.baseUrl; if (updates.models) account.models = updates.models; - writeCatalog(catalogPath, catalog); + writeFileSync(accountsPath, `${JSON.stringify(accounts, null, 2)}\n`, 'utf-8'); return { id: profileId, ...account }; } diff --git a/packages/api/test/install-auth-config-script.test.js b/packages/api/test/install-auth-config-script.test.js index 49aa5212e..754019d2e 100644 --- a/packages/api/test/install-auth-config-script.test.js +++ b/packages/api/test/install-auth-config-script.test.js @@ -4,21 +4,27 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync import { tmpdir } from 'node:os'; import { join } from 'node:path'; import test from 'node:test'; -import { runHelper, runHelperResult, runHelperWithEnv } from './install-auth-config-test-helpers.js'; - +import { + runHelper, + runHelperNoGlobalOverride, + runHelperResult, + runHelperWithEnv, +} from './install-auth-config-test-helpers.js'; + +// F340: installer now writes to accounts.json + credentials.json (global) function readInstallerState(projectRoot) { - const profileDir = join(projectRoot, '.cat-cafe'); - const profileFile = join(profileDir, 'provider-profiles.json'); - const secretsFile = join(profileDir, 'provider-profiles.secrets.local.json'); + const catCafeDir = join(projectRoot, '.cat-cafe'); + const accountsFile = join(catCafeDir, 'accounts.json'); + const credentialsFile = join(catCafeDir, 'credentials.json'); return { - profileFile, - secretsFile, - profiles: JSON.parse(readFileSync(profileFile, 'utf8')), - secrets: JSON.parse(readFileSync(secretsFile, 'utf8')), + accountsFile, + credentialsFile, + accounts: existsSync(accountsFile) ? JSON.parse(readFileSync(accountsFile, 'utf8')) : {}, + credentials: existsSync(credentialsFile) ? JSON.parse(readFileSync(credentialsFile, 'utf8')) : {}, }; } -test('client-auth set creates a generic api key account and bootstrap binding for the selected client', () => { +test('client-auth set creates a generic api key account for the selected client', () => { const projectRoot = mkdtempSync(join(tmpdir(), 'clowder-install-client-auth-')); try { @@ -39,34 +45,22 @@ test('client-auth set creates a generic api key account and bootstrap binding fo 'https://proxy.example.dev', ]); - const { profiles, secrets } = readInstallerState(projectRoot); - const apiKeyAccount = profiles.providers.find((profile) => profile.id === 'installer-anthropic'); + const { accounts, credentials } = readInstallerState(projectRoot); + const account = accounts['installer-anthropic']; - assert.deepEqual(profiles.bootstrapBindings, { - anthropic: { enabled: true, mode: 'api_key', accountRef: 'installer-anthropic' }, - openai: { enabled: true, mode: 'oauth', accountRef: 'codex' }, - google: { enabled: true, mode: 'oauth', accountRef: 'gemini' }, - dare: { enabled: false, mode: 'skip' }, - opencode: { enabled: false, mode: 'skip' }, - }); - assert.deepEqual(apiKeyAccount, { - id: 'installer-anthropic', - displayName: 'API Key Account 1', - kind: 'api_key', - authType: 'api_key', - builtin: false, - baseUrl: 'https://proxy.example.dev', - createdAt: apiKeyAccount.createdAt, - updatedAt: apiKeyAccount.updatedAt, - }); - assert.equal(secrets.profiles['installer-anthropic'].apiKey, 'generic-key'); + assert.ok(account, 'installer-anthropic account should exist'); + assert.equal(account.authType, 'api_key'); + // F340: protocol no longer persisted on new accounts — derived at runtime + assert.equal(account.protocol, undefined, 'protocol should not be persisted'); + assert.equal(account.baseUrl, 'https://proxy.example.dev'); + assert.equal(credentials['installer-anthropic'].apiKey, 'generic-key'); } finally { rmSync(projectRoot, { recursive: true, force: true }); } }); -test('client-auth remove drops the installer api key account and restores oauth bootstrap', () => { - const projectRoot = mkdtempSync(join(tmpdir(), 'clowder-install-client-auth-remove-')); +test('client-auth remove without --force exits non-zero and preserves account', () => { + const projectRoot = mkdtempSync(join(tmpdir(), 'clowder-install-client-auth-remove-noop-')); try { runHelper([ @@ -82,42 +76,55 @@ test('client-auth remove drops the installer api key account and restores oauth 'codex-key', ]); - runHelper(['client-auth', 'remove', '--project-dir', projectRoot, '--client', 'openai']); + const result = runHelperResult(['client-auth', 'remove', '--project-dir', projectRoot, '--client', 'openai']); + assert.notEqual(result.status, 0, 'should exit non-zero without --force'); + assert.match(result.stderr, /--force/i, 'stderr should mention --force'); - const { profiles, secrets } = readInstallerState(projectRoot); - assert.equal( - profiles.providers.some((profile) => profile.id === 'installer-openai'), - false, - ); - assert.deepEqual(profiles.bootstrapBindings.openai, { - enabled: true, - mode: 'oauth', - accountRef: 'codex', - }); - assert.equal('installer-openai' in (secrets.profiles ?? {}), false); + const { accounts, credentials } = readInstallerState(projectRoot); + assert.ok(accounts['installer-openai'], 'account preserved without --force'); + assert.equal(credentials['installer-openai'].apiKey, 'codex-key', 'credential preserved without --force'); } finally { rmSync(projectRoot, { recursive: true, force: true }); } }); -test('client-auth set oauth restores builtin bindings for dare and opencode', () => { +test('client-auth remove --force drops the installer api key account', () => { + const projectRoot = mkdtempSync(join(tmpdir(), 'clowder-install-client-auth-remove-force-')); + + try { + runHelper([ + 'client-auth', + 'set', + '--project-dir', + projectRoot, + '--client', + 'openai', + '--mode', + 'api_key', + '--api-key', + 'codex-key', + ]); + + runHelper(['client-auth', 'remove', '--project-dir', projectRoot, '--client', 'openai', '--force', 'true']); + + const { accounts, credentials } = readInstallerState(projectRoot); + assert.equal(accounts['installer-openai'], undefined, 'account removed with --force'); + assert.equal(credentials['installer-openai'], undefined, 'credential removed with --force'); + } finally { + rmSync(projectRoot, { recursive: true, force: true }); + } +}); + +test('client-auth set oauth creates builtin accounts for dare and opencode', () => { const projectRoot = mkdtempSync(join(tmpdir(), 'clowder-install-client-auth-oauth-')); try { runHelper(['client-auth', 'set', '--project-dir', projectRoot, '--client', 'dare', '--mode', 'oauth']); runHelper(['client-auth', 'set', '--project-dir', projectRoot, '--client', 'opencode', '--mode', 'oauth']); - const { profiles } = readInstallerState(projectRoot); - assert.deepEqual(profiles.bootstrapBindings.dare, { - enabled: true, - mode: 'oauth', - accountRef: 'dare', - }); - assert.deepEqual(profiles.bootstrapBindings.opencode, { - enabled: true, - mode: 'oauth', - accountRef: 'opencode', - }); + const { accounts } = readInstallerState(projectRoot); + assert.equal(accounts.dare?.authType, 'oauth'); + assert.equal(accounts.opencode?.authType, 'oauth'); } finally { rmSync(projectRoot, { recursive: true, force: true }); } @@ -140,53 +147,33 @@ test('claude-profile create and remove keeps installer-managed account in sync', 'claude-model', ]); - const { profiles, secrets } = readInstallerState(projectRoot); - const installerManaged = profiles.providers.find((profile) => profile.id === 'installer-managed'); - - assert.equal(profiles.version, 3); - assert.deepEqual(profiles.bootstrapBindings.anthropic, { - enabled: true, - mode: 'api_key', - accountRef: 'installer-managed', - }); - assert.deepEqual(profiles.bootstrapBindings.openai, { - enabled: true, - mode: 'oauth', - accountRef: 'codex', - }); - assert.deepEqual(profiles.bootstrapBindings.google, { - enabled: true, - mode: 'oauth', - accountRef: 'gemini', - }); - assert.deepEqual(profiles.bootstrapBindings.dare, { enabled: false, mode: 'skip' }); - assert.deepEqual(profiles.bootstrapBindings.opencode, { enabled: false, mode: 'skip' }); - assert.deepEqual(installerManaged, { - id: 'installer-managed', - displayName: 'Installer API Key', - kind: 'api_key', - authType: 'api_key', - builtin: false, - baseUrl: 'https://claude.example', - models: ['claude-model'], - createdAt: installerManaged.createdAt, - updatedAt: installerManaged.updatedAt, - }); - assert.equal(secrets.profiles['installer-managed'].apiKey, 'claude-key'); - - runHelper(['claude-profile', 'remove', '--project-dir', projectRoot]); - - const afterRemove = readInstallerState(projectRoot); + const { accounts, credentials } = readInstallerState(projectRoot); + const installerManaged = accounts['installer-managed']; + + assert.ok(installerManaged, 'installer-managed account should exist'); + assert.equal(installerManaged.authType, 'api_key'); + // F340: protocol no longer persisted on new accounts — derived at runtime + assert.equal(installerManaged.protocol, undefined, 'protocol should not be persisted'); + assert.equal(installerManaged.baseUrl, 'https://claude.example'); + assert.deepEqual(installerManaged.models, ['claude-model']); + assert.equal(credentials['installer-managed'].apiKey, 'claude-key'); + + // Without --force: exits non-zero, nothing deleted + const safeResult = runHelperResult(['claude-profile', 'remove', '--project-dir', projectRoot]); + assert.notEqual(safeResult.status, 0, 'should exit non-zero without --force'); + const afterSafeRemove = readInstallerState(projectRoot); + assert.ok(afterSafeRemove.accounts['installer-managed'], 'account preserved without --force'); assert.equal( - afterRemove.profiles.providers.some((profile) => profile.id === 'installer-managed'), - false, + afterSafeRemove.credentials['installer-managed'].apiKey, + 'claude-key', + 'credential preserved without --force', ); - assert.deepEqual(afterRemove.profiles.bootstrapBindings.anthropic, { - enabled: true, - mode: 'oauth', - accountRef: 'claude', - }); - assert.equal('installer-managed' in (afterRemove.secrets.profiles ?? {}), false); + + // With --force: actually deletes + runHelper(['claude-profile', 'remove', '--project-dir', projectRoot, '--force', 'true']); + const afterRemove = readInstallerState(projectRoot); + assert.equal(afterRemove.accounts['installer-managed'], undefined, 'account removed with --force'); + assert.equal(afterRemove.credentials['installer-managed'], undefined, 'credential removed with --force'); } finally { rmSync(projectRoot, { recursive: true, force: true }); } @@ -254,28 +241,22 @@ test('client-auth remove fails when the installer-managed account is still refer assert.notEqual(result.status, 0); assert.match(String(result.stderr), /still referenced by runtime cats: runtime-codex/i); - const { profiles, secrets } = readInstallerState(projectRoot); - assert.equal( - profiles.providers.some((profile) => profile.id === 'installer-openai'), - true, - ); - assert.deepEqual(profiles.bootstrapBindings.openai, { - enabled: true, - mode: 'api_key', - accountRef: 'installer-openai', - }); - assert.equal(secrets.profiles['installer-openai'].apiKey, 'codex-key'); + const { accounts, credentials } = readInstallerState(projectRoot); + assert.ok(accounts['installer-openai'], 'account should NOT be removed'); + assert.equal(credentials['installer-openai'].apiKey, 'codex-key'); } finally { rmSync(projectRoot, { recursive: true, force: true }); } }); -test('claude-profile remove is a no-op on a fresh project without provider profile files', () => { +test('claude-profile remove is a no-op on a fresh project without config files', () => { const projectRoot = mkdtempSync(join(tmpdir(), 'clowder-install-claude-remove-empty-')); try { runHelper(['claude-profile', 'remove', '--project-dir', projectRoot]); - assert.equal(existsSync(join(projectRoot, '.cat-cafe')), false); + // Global .cat-cafe may be created but accounts.json should not exist + const { accounts } = readInstallerState(projectRoot); + assert.deepEqual(accounts, {}); } finally { rmSync(projectRoot, { recursive: true, force: true }); } @@ -289,14 +270,14 @@ test('claude-profile set accepts API key from _INSTALLER_API_KEY environment var _INSTALLER_API_KEY: 'env-api-key', }); - const { secrets } = readInstallerState(projectRoot); - assert.equal(secrets.profiles['installer-managed'].apiKey, 'env-api-key'); + const { credentials } = readInstallerState(projectRoot); + assert.equal(credentials['installer-managed'].apiKey, 'env-api-key'); } finally { rmSync(projectRoot, { recursive: true, force: true }); } }); -test('claude-profile set preserves non-anthropic bindings when migrating a legacy v2 file', () => { +test('claude-profile set migrates and preserves non-anthropic accounts from legacy v2 file', () => { const projectRoot = mkdtempSync(join(tmpdir(), 'clowder-install-claude-profile-legacy-v2-')); try { @@ -308,9 +289,7 @@ test('claude-profile set preserves non-anthropic bindings when migrating a legac { version: 2, activeProfileId: 'personal', - activeProfileIds: { - openai: 'openai-sponsor', - }, + activeProfileIds: { openai: 'openai-sponsor' }, profiles: [ { id: 'claude-oauth', @@ -357,23 +336,146 @@ test('claude-profile set preserves non-anthropic bindings when migrating a legac 'https://claude.example', ]); - const { profiles, secrets } = readInstallerState(projectRoot); - assert.equal(profiles.version, 3); - assert.deepEqual(profiles.bootstrapBindings.anthropic, { - enabled: true, - mode: 'api_key', - accountRef: 'installer-managed', - }); - assert.deepEqual(profiles.bootstrapBindings.openai, { - enabled: true, - mode: 'api_key', - accountRef: 'openai-sponsor', - }); - const openaiSponsor = profiles.providers.find((profile) => profile.id === 'openai-sponsor'); - assert.equal(Boolean(openaiSponsor), true); - assert.equal(openaiSponsor?.protocol, 'openai'); - assert.equal(secrets.profiles['openai-sponsor'].apiKey, 'openai-key'); - assert.equal(secrets.profiles['installer-managed'].apiKey, 'claude-key'); + const { accounts, credentials } = readInstallerState(projectRoot); + // Legacy openai-sponsor migrated (F340: protocol not migrated) + assert.ok(accounts['openai-sponsor'], 'legacy openai-sponsor should be migrated'); + assert.equal(accounts['openai-sponsor'].protocol, undefined, 'protocol should not be migrated'); + assert.equal(accounts['openai-sponsor'].baseUrl, 'https://openai.example'); + assert.equal(credentials['openai-sponsor'].apiKey, 'openai-key'); + // New installer-managed applied (F340: no protocol on new accounts) + assert.equal(accounts['installer-managed'].protocol, undefined, 'new account should not have protocol'); + assert.equal(credentials['installer-managed'].apiKey, 'claude-key'); + } finally { + rmSync(projectRoot, { recursive: true, force: true }); + } +}); + +test('client-auth set infers legacy api_key authType from mode/kind fields during migration', () => { + const projectRoot = mkdtempSync(join(tmpdir(), 'clowder-install-legacy-authtype-')); + + try { + const profileDir = join(projectRoot, '.cat-cafe'); + mkdirSync(profileDir, { recursive: true }); + writeFileSync( + join(profileDir, 'provider-profiles.json'), + `${JSON.stringify( + { + version: 2, + profiles: [ + { + id: 'legacy-mode-profile', + provider: 'legacy-mode-profile', + displayName: 'Legacy Mode Profile', + mode: 'api_key', + protocol: 'openai', + baseUrl: 'https://mode.example', + }, + { + id: 'legacy-kind-profile', + provider: 'legacy-kind-profile', + displayName: 'Legacy Kind Profile', + kind: 'api_key', + protocol: 'anthropic', + baseUrl: 'https://kind.example', + }, + ], + }, + null, + 2, + )}\n`, + 'utf8', + ); + writeFileSync( + join(profileDir, 'provider-profiles.secrets.local.json'), + `${JSON.stringify( + { + version: 2, + profiles: { + 'legacy-mode-profile': { apiKey: 'mode-key' }, + 'legacy-kind-profile': { apiKey: 'kind-key' }, + }, + }, + null, + 2, + )}\n`, + 'utf8', + ); + + runHelper(['client-auth', 'set', '--project-dir', projectRoot, '--client', 'openai', '--mode', 'oauth']); + + const { accounts, credentials } = readInstallerState(projectRoot); + assert.equal(accounts['legacy-mode-profile']?.authType, 'api_key'); + assert.equal(accounts['legacy-kind-profile']?.authType, 'api_key'); + assert.equal(credentials['legacy-mode-profile']?.apiKey, 'mode-key'); + assert.equal(credentials['legacy-kind-profile']?.apiKey, 'kind-key'); + } finally { + rmSync(projectRoot, { recursive: true, force: true }); + } +}); + +test('client-auth set migrates v1 nested provider profiles and secrets before writing new auth state', () => { + const projectRoot = mkdtempSync(join(tmpdir(), 'clowder-install-legacy-v1-profiles-')); + + try { + const profileDir = join(projectRoot, '.cat-cafe'); + mkdirSync(profileDir, { recursive: true }); + writeFileSync( + join(profileDir, 'provider-profiles.json'), + `${JSON.stringify( + { + version: 1, + providers: { + anthropic: { + activeProfileId: 'my-proxy', + profiles: [ + { + id: 'my-proxy', + displayName: 'My Proxy', + authType: 'api_key', + baseUrl: 'https://proxy.example/v1', + }, + { + id: 'team-key', + displayName: 'Team Key', + mode: 'api_key', + }, + ], + }, + }, + }, + null, + 2, + )}\n`, + 'utf8', + ); + writeFileSync( + join(profileDir, 'provider-profiles.secrets.local.json'), + `${JSON.stringify( + { + version: 1, + providers: { + anthropic: { + 'my-proxy': { apiKey: 'sk-proxy-key' }, + 'team-key': { apiKey: 'sk-team-key' }, + }, + }, + }, + null, + 2, + )}\n`, + 'utf8', + ); + + runHelper(['client-auth', 'set', '--project-dir', projectRoot, '--client', 'openai', '--mode', 'oauth']); + + const { accounts, credentials } = readInstallerState(projectRoot); + assert.equal(accounts['my-proxy']?.authType, 'api_key'); + assert.equal(accounts['my-proxy']?.displayName, 'My Proxy'); + assert.equal(accounts['my-proxy']?.baseUrl, 'https://proxy.example/v1'); + assert.equal(accounts['team-key']?.authType, 'api_key'); + assert.equal(accounts.anthropic, undefined, 'should not create a shell account from the client key'); + assert.equal(credentials['my-proxy']?.apiKey, 'sk-proxy-key'); + assert.equal(credentials['team-key']?.apiKey, 'sk-team-key'); } finally { rmSync(projectRoot, { recursive: true, force: true }); } @@ -385,18 +487,14 @@ test('claude-profile v2 migration preserves non-installer accounts and secrets o try { const profileDir = join(projectRoot, '.cat-cafe'); mkdirSync(profileDir, { recursive: true }); - const profileFile = join(profileDir, 'provider-profiles.json'); - const secretsFile = join(profileDir, 'provider-profiles.secrets.local.json'); writeFileSync( - profileFile, + join(profileDir, 'provider-profiles.json'), `${JSON.stringify( { version: 2, activeProfileId: 'personal', - activeProfileIds: { - anthropic: 'personal', - }, + activeProfileIds: { anthropic: 'personal' }, profiles: [ { id: 'installer-managed', @@ -428,7 +526,7 @@ test('claude-profile v2 migration preserves non-installer accounts and secrets o 'utf8', ); writeFileSync( - secretsFile, + join(profileDir, 'provider-profiles.secrets.local.json'), `${JSON.stringify( { version: 2, @@ -454,51 +552,31 @@ test('claude-profile v2 migration preserves non-installer accounts and secrets o 'https://installer.new', ]); - const migrated = readInstallerState(projectRoot); - const personalProfile = migrated.profiles.providers.find((profile) => profile.id === 'personal'); - const installerProfile = migrated.profiles.providers.find((profile) => profile.id === 'installer-managed'); - - assert.equal(migrated.profiles.version, 3); - assert.deepEqual(migrated.profiles.bootstrapBindings.anthropic, { - enabled: true, - mode: 'api_key', - accountRef: 'installer-managed', - }); - assert.equal(personalProfile?.baseUrl, 'https://personal.example'); - assert.equal(migrated.secrets.profiles.personal.apiKey, 'personal-key'); - assert.equal(installerProfile?.baseUrl, 'https://installer.new'); - assert.equal(migrated.secrets.profiles['installer-managed'].apiKey, 'new-installer-key'); + const { accounts, credentials } = readInstallerState(projectRoot); + assert.equal(accounts.personal?.baseUrl, 'https://personal.example'); + assert.equal(credentials.personal.apiKey, 'personal-key'); + // installer-managed overwritten by the new set command + assert.equal(accounts['installer-managed']?.baseUrl, 'https://installer.new'); + assert.equal(credentials['installer-managed'].apiKey, 'new-installer-key'); - runHelper(['claude-profile', 'remove', '--project-dir', projectRoot]); + runHelper(['claude-profile', 'remove', '--project-dir', projectRoot, '--force', 'true']); const afterRemove = readInstallerState(projectRoot); - assert.equal( - afterRemove.profiles.providers.some((profile) => profile.id === 'installer-managed'), - false, - ); - assert.deepEqual(afterRemove.profiles.bootstrapBindings.anthropic, { - enabled: true, - mode: 'oauth', - accountRef: 'claude', - }); - assert.equal( - afterRemove.profiles.providers.some((profile) => profile.id === 'personal'), - true, - ); - assert.equal(afterRemove.secrets.profiles.personal.apiKey, 'personal-key'); - assert.equal('installer-managed' in (afterRemove.secrets.profiles ?? {}), false); + assert.equal(afterRemove.accounts['installer-managed'], undefined, 'installer-managed removed with --force'); + assert.ok(afterRemove.accounts.personal, 'personal account preserved'); + assert.equal(afterRemove.credentials.personal.apiKey, 'personal-key'); + assert.equal(afterRemove.credentials['installer-managed'], undefined, 'installer credential removed'); } finally { rmSync(projectRoot, { recursive: true, force: true }); } }); -test('claude-profile set fails fast on malformed provider profile JSON', () => { +test('claude-profile set fails fast on malformed legacy provider profile JSON', () => { const projectRoot = mkdtempSync(join(tmpdir(), 'clowder-install-claude-bad-profile-')); try { const profileDir = join(projectRoot, '.cat-cafe'); const profileFile = join(profileDir, 'provider-profiles.json'); - const secretsFile = join(profileDir, 'provider-profiles.secrets.local.json'); mkdirSync(profileDir, { recursive: true }); writeFileSync(profileFile, '{"version": 1,', 'utf8'); @@ -513,9 +591,10 @@ test('claude-profile set fails fast on malformed provider profile JSON', () => { ]); assert.notEqual(result.status, 0); - assert.match(String(result.stderr), /provider-profiles\.json/); - assert.equal(readFileSync(profileFile, 'utf8'), originalContents); - assert.equal(existsSync(secretsFile), false); + // The error references the parse failure + assert.ok(String(result.stderr).length > 0, 'stderr should contain error details'); + assert.equal(readFileSync(profileFile, 'utf8'), originalContents, 'corrupt file must not be modified'); + assert.equal(existsSync(join(profileDir, 'accounts.json')), false, 'accounts.json should not be created'); } finally { rmSync(projectRoot, { recursive: true, force: true }); } @@ -564,3 +643,191 @@ test('env-apply escapes shell substitutions when apostrophe requires double quot rmSync(envRoot, { recursive: true, force: true }); } }); + +test('#340 P6 regression: OAuth switch with explicit remove-then-set cleans stale installer profile', () => { + const projectRoot = mkdtempSync(join(tmpdir(), 'clowder-install-oauth-switch-')); + + try { + // Step 1: Create an installer API-key profile for codex + runHelperWithEnv(['client-auth', 'set', '--project-dir', projectRoot, '--client', 'codex', '--mode', 'api_key'], { + _INSTALLER_API_KEY: 'sk-old-codex-key', + }); + const before = readInstallerState(projectRoot); + assert.ok(before.accounts['installer-openai'], 'installer-openai account should exist'); + + // Step 2: Switch to OAuth — caller must remove first, then set. + // set --mode oauth does NOT auto-delete installer accounts (they're global; + // only removeClientAuth has the safety checks for cross-project bindings). + runHelper(['client-auth', 'remove', '--project-dir', projectRoot, '--client', 'codex', '--force', 'true']); + runHelper(['client-auth', 'set', '--project-dir', projectRoot, '--client', 'codex', '--mode', 'oauth']); + + const after = readInstallerState(projectRoot); + assert.equal(after.accounts['installer-openai'], undefined, 'installer-openai must be removed by explicit remove'); + assert.equal(after.credentials['installer-openai'], undefined, 'credentials must be removed'); + assert.ok(after.accounts.codex, 'builtin codex account must exist'); + assert.equal(after.accounts.codex.authType, 'oauth'); + } finally { + rmSync(projectRoot, { recursive: true, force: true }); + } +}); + +test('set --mode oauth preserves stale installer account (global safety)', () => { + const projectRoot = mkdtempSync(join(tmpdir(), 'clowder-install-oauth-no-autodelete-')); + + try { + // Step 1: Create installer API-key account + runHelperWithEnv(['client-auth', 'set', '--project-dir', projectRoot, '--client', 'codex', '--mode', 'api_key'], { + _INSTALLER_API_KEY: 'sk-stale-key', + }); + + // Step 2: set --mode oauth WITHOUT remove — installer account must survive + runHelper(['client-auth', 'set', '--project-dir', projectRoot, '--client', 'codex', '--mode', 'oauth']); + + const after = readInstallerState(projectRoot); + // installer-openai intentionally preserved — global accounts can't be safely + // auto-deleted without cross-project enumeration + assert.ok(after.accounts['installer-openai'], 'installer-openai must NOT be auto-deleted'); + assert.ok(after.credentials['installer-openai'], 'credentials must NOT be auto-deleted'); + // OAuth account still created + assert.ok(after.accounts.codex, 'builtin codex account must exist'); + assert.equal(after.accounts.codex.authType, 'oauth'); + } finally { + rmSync(projectRoot, { recursive: true, force: true }); + } +}); + +test('client-auth set retries legacy secret import when account already exists from a partial migration', () => { + const projectRoot = mkdtempSync(join(tmpdir(), 'clowder-install-client-auth-retry-secret-')); + + try { + const profileDir = join(projectRoot, '.cat-cafe'); + mkdirSync(profileDir, { recursive: true }); + + writeFileSync( + join(profileDir, 'accounts.json'), + `${JSON.stringify( + { + 'my-custom': { + authType: 'api_key', + baseUrl: 'https://custom.api/v1', + }, + }, + null, + 2, + )}\n`, + 'utf8', + ); + writeFileSync( + join(profileDir, 'provider-profiles.json'), + `${JSON.stringify( + { + version: 2, + providers: [{ id: 'my-custom', authType: 'api_key', baseUrl: 'https://custom.api/v1' }], + }, + null, + 2, + )}\n`, + 'utf8', + ); + writeFileSync( + join(profileDir, 'provider-profiles.secrets.local.json'), + `${JSON.stringify( + { + profiles: { + 'my-custom': { apiKey: 'sk-retry-key' }, + }, + }, + null, + 2, + )}\n`, + 'utf8', + ); + + runHelper(['client-auth', 'set', '--project-dir', projectRoot, '--client', 'codex', '--mode', 'oauth']); + + const { accounts, credentials } = readInstallerState(projectRoot); + assert.ok(accounts['my-custom'], 'existing migrated account should still be present'); + assert.equal(credentials['my-custom']?.apiKey, 'sk-retry-key', 'missing credential should be imported on retry'); + } finally { + rmSync(projectRoot, { recursive: true, force: true }); + } +}); + +test('#340 P1: installer stores accounts in --project-dir without CAT_CAFE_GLOBAL_CONFIG_ROOT env', () => { + const projectRoot = mkdtempSync(join(tmpdir(), 'clowder-install-no-env-override-')); + + try { + // Run WITHOUT CAT_CAFE_GLOBAL_CONFIG_ROOT — exercises _activeProjectDir fallback. + // Before the fix, this would fall back to homedir() and write to ~/.cat-cafe/. + const result = runHelperNoGlobalOverride([ + 'client-auth', + 'set', + '--project-dir', + projectRoot, + '--client', + 'anthropic', + '--mode', + 'api_key', + '--api-key', + 'test-key-no-env', + '--display-name', + 'No Env Override', + ]); + assert.equal(result.status, 0, `installer should succeed, stderr: ${result.stderr}`); + + // Verify accounts landed in the project dir, not homedir + const { accounts, credentials } = readInstallerState(projectRoot); + assert.ok(accounts['installer-anthropic'], 'account should be in project-dir/.cat-cafe/'); + assert.equal(accounts['installer-anthropic'].authType, 'api_key'); + assert.equal(credentials['installer-anthropic']?.apiKey, 'test-key-no-env'); + } finally { + rmSync(projectRoot, { recursive: true, force: true }); + } +}); + +test('client-auth remove --force fails closed when the runtime catalog cannot be parsed', () => { + const projectRoot = mkdtempSync(join(tmpdir(), 'clowder-install-client-auth-remove-bad-catalog-')); + + try { + runHelper([ + 'client-auth', + 'set', + '--project-dir', + projectRoot, + '--client', + 'openai', + '--mode', + 'api_key', + '--api-key', + 'codex-key', + ]); + + const runtimeDir = join(projectRoot, '.cat-cafe'); + mkdirSync(runtimeDir, { recursive: true }); + writeFileSync(join(runtimeDir, 'cat-catalog.json'), '{"version": 2, "breeds": [', 'utf8'); + + const result = runHelperResult([ + 'client-auth', + 'remove', + '--project-dir', + projectRoot, + '--client', + 'openai', + '--force', + 'true', + ]); + + assert.notEqual(result.status, 0, 'forced remove should fail when catalog parsing fails'); + assert.match(String(result.stderr), /failed to parse|unexpected end|json/i); + + const { accounts, credentials } = readInstallerState(projectRoot); + assert.ok(accounts['installer-openai'], 'account should be preserved on parse failure'); + assert.equal( + credentials['installer-openai']?.apiKey, + 'codex-key', + 'credential should be preserved on parse failure', + ); + } finally { + rmSync(projectRoot, { recursive: true, force: true }); + } +}); diff --git a/packages/api/test/install-auth-config-test-helpers.js b/packages/api/test/install-auth-config-test-helpers.js index 605c4d950..f36a072ab 100644 --- a/packages/api/test/install-auth-config-test-helpers.js +++ b/packages/api/test/install-auth-config-test-helpers.js @@ -6,24 +6,45 @@ const testDir = dirname(fileURLToPath(import.meta.url)); export const repoRoot = resolve(testDir, '..', '..', '..'); const helperScript = resolve(repoRoot, 'scripts', 'install-auth-config.mjs'); +/** Extract --project-dir from args to use as global config root for test isolation. */ +function extractProjectDir(args) { + const idx = args.indexOf('--project-dir'); + return idx >= 0 && idx + 1 < args.length ? args[idx + 1] : undefined; +} + export function runHelper(args) { + const projectDir = extractProjectDir(args); return execFileSync('node', [helperScript, ...args], { cwd: repoRoot, encoding: 'utf8', + env: { ...process.env, ...(projectDir ? { CAT_CAFE_GLOBAL_CONFIG_ROOT: projectDir } : {}) }, }); } export function runHelperResult(args) { + const projectDir = extractProjectDir(args); return spawnSync('node', [helperScript, ...args], { cwd: repoRoot, encoding: 'utf8', + env: { ...process.env, ...(projectDir ? { CAT_CAFE_GLOBAL_CONFIG_ROOT: projectDir } : {}) }, }); } export function runHelperWithEnv(args, env) { + const projectDir = extractProjectDir(args); return execFileSync('node', [helperScript, ...args], { cwd: repoRoot, encoding: 'utf8', - env: { ...process.env, ...env }, + env: { ...process.env, ...(projectDir ? { CAT_CAFE_GLOBAL_CONFIG_ROOT: projectDir } : {}), ...env }, + }); +} + +/** Run installer WITHOUT CAT_CAFE_GLOBAL_CONFIG_ROOT — exercises _activeProjectDir fallback. */ +export function runHelperNoGlobalOverride(args) { + const { CAT_CAFE_GLOBAL_CONFIG_ROOT: _stripped, ...cleanEnv } = process.env; + return spawnSync('node', [helperScript, ...args], { + cwd: repoRoot, + encoding: 'utf8', + env: cleanEnv, }); } diff --git a/packages/api/test/install-script-env.test.js b/packages/api/test/install-script-env.test.js index 2a55c19ab..bc11fb849 100644 --- a/packages/api/test/install-script-env.test.js +++ b/packages/api/test/install-script-env.test.js @@ -2,6 +2,7 @@ import test from 'node:test'; import { assert, + existsSync, installScript, join, mkdirSync, @@ -33,18 +34,14 @@ printf '%s' "$resolved" } }); -test('install script clears stale OAuth/API env keys when switching back to OAuth', () => { - const envRoot = mkdtempSync(join(tmpdir(), 'clowder-install-env-oauth-')); +test('install script generic env helpers: collect_env + clear_env + write/delete (#340 P6)', () => { + const envRoot = mkdtempSync(join(tmpdir(), 'clowder-install-env-helpers-')); try { writeFileSync( join(envRoot, '.env'), - `CODEX_AUTH_MODE='api_key' -OPENAI_API_KEY='old-openai-key' -OPENAI_BASE_URL='https://old.example/v1?foo=1&bar=2' -CAT_CODEX_MODEL='gpt-old' -GEMINI_API_KEY='old-gemini-key' -CAT_GEMINI_MODEL='gemini-old' + `STALE_KEY='old-value' +KEEP_KEY='keep-me' `, 'utf8', ); @@ -52,56 +49,16 @@ CAT_GEMINI_MODEL='gemini-old' const output = runSourceOnlySnippet(` cd "${envRoot}" reset_env_changes -set_codex_oauth_mode -set_gemini_oauth_mode +collect_env "NEW_KEY" "new-value" +clear_env "STALE_KEY" for key in "\${ENV_DELETE_KEYS[@]}"; do delete_env_key "$key"; done for i in "\${!ENV_KEYS[@]}"; do write_env_key "\${ENV_KEYS[$i]}" "\${ENV_VALUES[$i]}"; done cat .env `); - assert.match(output, /^CODEX_AUTH_MODE='oauth'$/m); - assert.doesNotMatch(output, /^OPENAI_API_KEY=/m); - assert.doesNotMatch(output, /^OPENAI_BASE_URL=/m); - assert.doesNotMatch(output, /^CAT_CODEX_MODEL=/m); - assert.doesNotMatch(output, /^GEMINI_API_KEY=/m); - assert.doesNotMatch(output, /^CAT_GEMINI_MODEL=/m); - } finally { - rmSync(envRoot, { recursive: true, force: true }); - } -}); - -test('install script clears stale Codex and Gemini overrides when default values are selected', () => { - const envRoot = mkdtempSync(join(tmpdir(), 'clowder-install-env-defaults-')); - - try { - writeFileSync( - join(envRoot, '.env'), - `CODEX_AUTH_MODE='api_key' -OPENAI_API_KEY='old-openai-key' -OPENAI_BASE_URL='https://old.example/v1' -CAT_CODEX_MODEL='gpt-old' -GEMINI_API_KEY='old-gemini-key' -CAT_GEMINI_MODEL='gemini-old' -`, - 'utf8', - ); - - const output = runSourceOnlySnippet(` -cd "${envRoot}" -reset_env_changes -set_codex_api_key_mode "new-openai-key" "" "" -set_gemini_api_key_mode "new-gemini-key" "" "" -for key in "\${ENV_DELETE_KEYS[@]}"; do delete_env_key "$key"; done -for i in "\${!ENV_KEYS[@]}"; do write_env_key "\${ENV_KEYS[$i]}" "\${ENV_VALUES[$i]}"; done -cat .env -`); - - assert.match(output, /^CODEX_AUTH_MODE='api_key'$/m); - assert.match(output, /^OPENAI_API_KEY='new-openai-key'$/m); - assert.match(output, /^GEMINI_API_KEY='new-gemini-key'$/m); - assert.doesNotMatch(output, /^OPENAI_BASE_URL=/m); - assert.doesNotMatch(output, /^CAT_CODEX_MODEL=/m); - assert.doesNotMatch(output, /^CAT_GEMINI_MODEL=/m); + assert.match(output, /^NEW_KEY='new-value'$/m); + assert.match(output, /^KEEP_KEY='keep-me'$/m); + assert.doesNotMatch(output, /^STALE_KEY=/m); } finally { rmSync(envRoot, { recursive: true, force: true }); } @@ -135,22 +92,47 @@ test('Claude empty API key removes stale installer-managed profile', () => { runSourceOnlySnippet(` PROJECT_DIR="${envRoot}" -remove_claude_installer_profile +export CAT_CAFE_GLOBAL_CONFIG_ROOT="${envRoot}" +node scripts/install-auth-config.mjs claude-profile remove --project-dir "${envRoot}" --force true 2>/dev/null || true `); - const profiles = JSON.parse(readFileSync(join(catCafeDir, 'provider-profiles.json'), 'utf8')); - const secrets = JSON.parse(readFileSync(join(catCafeDir, 'provider-profiles.secrets.local.json'), 'utf8')); - const anthropic = profiles.providers?.anthropic; - assert.ok(anthropic, 'anthropic provider entry should still exist'); - const installerProfile = (anthropic.profiles ?? []).find((profile) => profile.id === 'installer-managed'); - assert.equal(installerProfile, undefined, 'installer-managed profile must be removed'); - assert.notEqual(anthropic.activeProfileId, 'installer-managed', 'active profile must not be stale'); - assert.equal(secrets.providers?.anthropic?.['installer-managed'], undefined, 'secret must be removed'); + // After migration + remove, accounts.json should not contain installer-managed + const accountsPath = join(catCafeDir, 'accounts.json'); + const accounts = existsSync(accountsPath) ? JSON.parse(readFileSync(accountsPath, 'utf8')) : {}; + assert.equal(accounts['installer-managed'], undefined, 'installer-managed account must be removed'); + const credPath = join(catCafeDir, 'credentials.json'); + const creds = existsSync(credPath) ? JSON.parse(readFileSync(credPath, 'utf8')) : {}; + assert.equal(creds['installer-managed'], undefined, 'installer-managed credential must be removed'); } finally { rmSync(envRoot, { recursive: true, force: true }); } }); +test('OAuth selection does not force-remove global installer accounts before set', () => { + const installScriptText = readFileSync(installScript, 'utf8'); + const configureAuthBody = installScriptText.match(/configure_agent_auth\(\) \{([\s\S]*?)^}\n/m)?.[1] ?? ''; + + assert.notEqual(configureAuthBody, '', 'expected configure_agent_auth body'); + assert.match(configureAuthBody, /client-auth set \\/); + assert.match(configureAuthBody, /--mode oauth/); + assert.doesNotMatch(configureAuthBody, /client-auth remove/); + assert.doesNotMatch(configureAuthBody, /--force true/); +}); + +test('empty API key fallback to OAuth does not force-remove global installer accounts', () => { + const installScriptText = readFileSync(installScript, 'utf8'); + const emptyKeyBranch = + installScriptText.match( + /# No key provided — set OAuth mode via unified path([\s\S]*?)warn "\$name: no key provided, keeping OAuth"/m, + )?.[1] ?? ''; + + assert.notEqual(emptyKeyBranch, '', 'expected empty API key OAuth fallback branch'); + assert.match(emptyKeyBranch, /client-auth set \\/); + assert.match(emptyKeyBranch, /--mode oauth/); + assert.doesNotMatch(emptyKeyBranch, /client-auth remove/); + assert.doesNotMatch(emptyKeyBranch, /--force true/); +}); + test('npm_global_install succeeds when a custom registry is configured', () => { const output = runSourceOnlySnippet(` SUDO="" diff --git a/packages/api/test/install-script-test-helpers.js b/packages/api/test/install-script-test-helpers.js index f182c88fa..8fc7a68bd 100644 --- a/packages/api/test/install-script-test-helpers.js +++ b/packages/api/test/install-script-test-helpers.js @@ -1,6 +1,15 @@ import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; -import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; import { tmpdir } from 'node:os'; import { basename, dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -13,6 +22,7 @@ export const installScript = resolve(repoRoot, 'scripts', 'install.sh'); export { assert, basename, + existsSync, join, mkdirSync, mkdtempSync, diff --git a/packages/api/test/invoke-single-cat.test.js b/packages/api/test/invoke-single-cat.test.js index 0081a7761..ee630e9de 100644 --- a/packages/api/test/invoke-single-cat.test.js +++ b/packages/api/test/invoke-single-cat.test.js @@ -7,7 +7,11 @@ import './helpers/setup-cat-registry.js'; import assert from 'node:assert/strict'; import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + import { afterEach, before, beforeEach, describe, it, mock } from 'node:test'; import { catRegistry } from '@cat-cafe/shared'; @@ -31,6 +35,9 @@ beforeEach(async () => { // Provider profiles are global; each test gets its own isolated global store. testGlobalConfigRoot = await mkdtemp(join(tmpdir(), 'invoke-single-cat-global-')); process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT = testGlobalConfigRoot; + // F340: reset global accounts migration cache between tests + const { resetMigrationState } = await import('../dist/config/catalog-accounts.js'); + resetMigrationState(); }); afterEach(async () => { @@ -1396,15 +1403,11 @@ describe('invokeSingleCat audit events (P1 fix)', () => { async function withSanitizedOpencodeConfig(run) { const { loadCatConfig, toAllCatConfigs } = await import('../dist/config/cat-config-loader.js'); const registrySnapshot = catRegistry.getAllConfigs(); - const baselineConfigs = toAllCatConfigs(loadCatConfig(join(process.cwd(), '..', '..', 'cat-template.json'))); + const baselineConfigs = toAllCatConfigs(loadCatConfig(join(__dirname, '..', '..', '..', 'cat-template.json'))); const baselineOpencodeConfig = baselineConfigs.opencode; assert.ok(baselineOpencodeConfig, 'opencode config should exist in baseline catalog'); - const { - accountRef: _ignoredAccountRef, - providerProfileId: _ignoredProviderProfileId, - ...sanitizedOpencodeConfig - } = baselineOpencodeConfig; + const { accountRef: _ignoredAccountRef, ...sanitizedOpencodeConfig } = baselineOpencodeConfig; sanitizedOpencodeConfig.defaultModel = 'anthropic/claude-opus-4-6'; catRegistry.reset(); @@ -2683,8 +2686,8 @@ describe('invokeSingleCat audit events (P1 fix)', () => { ...originalConfig, id: boundCatId, mentionPatterns: [`@${boundCatId}`], - provider: 'openai', - providerProfileId: boundProfile.id, + clientId: 'openai', + accountRef: boundProfile.id, defaultModel: 'gpt-5.4', }); @@ -2796,7 +2799,7 @@ describe('invokeSingleCat audit events (P1 fix)', () => { const apiDir = join(root, 'packages', 'api'); await mkdir(apiDir, { recursive: true }); await writeFile(join(root, 'pnpm-workspace.yaml'), 'packages:\n - "packages/*"\n', 'utf-8'); - const templateRaw = await readFile(join(process.cwd(), '..', '..', 'cat-template.json'), 'utf-8'); + const templateRaw = await readFile(join(__dirname, '..', '..', '..', 'cat-template.json'), 'utf-8'); await writeFile(join(root, 'cat-template.json'), templateRaw, 'utf-8'); const prevGlobalRoot = process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT; process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT = root; @@ -2818,7 +2821,11 @@ describe('invokeSingleCat audit events (P1 fix)', () => { const codexBreed = runtimeCatalog.breeds.find((breed) => breed.catId === 'codex'); assert.equal(codexBreed?.variants[0]?.accountRef, 'codex'); - await activateProviderProfile(root, 'openai', activatedProfile.id); + // F340: "activation" = updating the catalog variant's accountRef binding. + // The old activate API was a no-op; explicitly bind the variant instead. + const codexVariant = codexBreed?.variants[0]; + if (codexVariant) codexVariant.accountRef = activatedProfile.id; + await writeFile(catalogPath, JSON.stringify(runtimeCatalog, null, 2), 'utf-8'); const registrySnapshot = catRegistry.getAllConfigs(); catRegistry.reset(); @@ -2903,8 +2910,8 @@ describe('invokeSingleCat audit events (P1 fix)', () => { ...originalConfig, id: boundCatId, mentionPatterns: [`@${boundCatId}`], - provider: 'openai', - providerProfileId: boundProfile.id, + clientId: 'openai', + accountRef: boundProfile.id, defaultModel: 'gpt-5.4', }); @@ -2952,6 +2959,15 @@ describe('invokeSingleCat audit events (P1 fix)', () => { await mkdir(apiDir, { recursive: true }); await writeFile(join(root, 'pnpm-workspace.yaml'), 'packages:\n - "packages/*"\n', 'utf-8'); + // F340: Seed builtin codex account in global accounts store + const globalCatCafe = join(testGlobalConfigRoot, '.cat-cafe'); + await mkdir(globalCatCafe, { recursive: true }); + await writeFile( + join(globalCatCafe, 'accounts.json'), + JSON.stringify({ codex: { authType: 'oauth', protocol: 'openai' } }, null, 2), + 'utf-8', + ); + const originalCodexAuthMode = process.env.CODEX_AUTH_MODE; const originalOpenAIApiKey = process.env.OPENAI_API_KEY; const originalOpenAIBaseUrl = process.env.OPENAI_BASE_URL; @@ -2969,8 +2985,8 @@ describe('invokeSingleCat audit events (P1 fix)', () => { ...originalConfig, id: boundCatId, mentionPatterns: [`@${boundCatId}`], - provider: 'openai', - providerProfileId: 'codex', + clientId: 'openai', + accountRef: 'codex', defaultModel: 'gpt-5.4', }); @@ -3030,13 +3046,13 @@ describe('invokeSingleCat audit events (P1 fix)', () => { const registrySnapshot = catRegistry.getAllConfigs(); const originalConfig = catRegistry.tryGet('codex')?.config; assert.ok(originalConfig, 'codex config should exist in registry'); - const { accountRef: _accountRef, providerProfileId: _providerProfileId, ...unboundConfig } = originalConfig; + const { accountRef: _accountRef, ...unboundConfig } = originalConfig; const unboundCatId = 'codex-env-auth-test'; catRegistry.register(unboundCatId, { ...unboundConfig, id: unboundCatId, mentionPatterns: [`@${unboundCatId}`], - provider: 'openai', + clientId: 'openai', defaultModel: 'gpt-5.4', }); @@ -3106,8 +3122,8 @@ describe('invokeSingleCat audit events (P1 fix)', () => { ...originalConfig, id: boundCatId, mentionPatterns: [`@${boundCatId}`], - provider: 'opencode', - providerProfileId: boundProfile.id, + clientId: 'opencode', + accountRef: boundProfile.id, defaultModel: 'claude-sonnet-4-6', }); @@ -3176,8 +3192,8 @@ describe('invokeSingleCat audit events (P1 fix)', () => { ...originalConfig, id: boundCatId, mentionPatterns: [`@${boundCatId}`], - provider: 'opencode', - providerProfileId: noKeyProfile.id, + clientId: 'opencode', + accountRef: noKeyProfile.id, defaultModel: 'gpt-4o', }); @@ -3243,8 +3259,8 @@ describe('invokeSingleCat audit events (P1 fix)', () => { ...originalConfig, id: boundCatId, mentionPatterns: [`@${boundCatId}`], - provider: 'opencode', - providerProfileId: openrouterProfile.id, + clientId: 'opencode', + accountRef: openrouterProfile.id, defaultModel: 'openrouter/google/gemini-3-flash-preview', }); @@ -3316,8 +3332,8 @@ describe('invokeSingleCat audit events (P1 fix)', () => { ...originalConfig, id: boundCatId, mentionPatterns: [`@${boundCatId}`], - provider: 'opencode', - providerProfileId: customProfile.id, + clientId: 'opencode', + accountRef: customProfile.id, defaultModel: 'maas/glm-5', }); @@ -3369,7 +3385,7 @@ describe('invokeSingleCat audit events (P1 fix)', () => { await assert.rejects(readFile(seenConfigPath, 'utf-8')); }); - it('F189: bare model + ocProviderName assembles composite model for custom provider routing', async () => { + it('F189: bare model + provider assembles composite model for custom provider routing', async () => { const { createProviderProfile } = await import('./helpers/create-test-account.js'); const root = await mkdtemp(join(tmpdir(), 'f189-oc-bare-model-')); const apiDir = join(root, 'packages', 'api'); @@ -3396,10 +3412,10 @@ describe('invokeSingleCat audit events (P1 fix)', () => { ...originalConfig, id: boundCatId, mentionPatterns: [`@${boundCatId}`], - provider: 'opencode', - providerProfileId: customProfile.id, + clientId: 'opencode', + accountRef: customProfile.id, defaultModel: 'MiniMax-M2.7', - ocProviderName: 'minimax', + provider: 'minimax', }); let seenConfigPath; @@ -3478,10 +3494,10 @@ describe('invokeSingleCat audit events (P1 fix)', () => { ...originalConfig, id: boundCatId, mentionPatterns: [`@${boundCatId}`], - provider: 'opencode', - providerProfileId: customProfile.id, + clientId: 'opencode', + accountRef: customProfile.id, defaultModel: 'MiniMax-M2.7', - ocProviderName: 'anthropic', + provider: 'anthropic', }); let seenConfigPath; @@ -3558,10 +3574,10 @@ describe('invokeSingleCat audit events (P1 fix)', () => { ...originalConfig, id: boundCatId, mentionPatterns: [`@${boundCatId}`], - provider: 'opencode', - providerProfileId: anthropicProfile.id, + clientId: 'opencode', + accountRef: anthropicProfile.id, defaultModel: 'claude-opus-4-6', - ocProviderName: 'anthropic', + provider: 'anthropic', }); let seenConfigPath; @@ -3610,7 +3626,7 @@ describe('invokeSingleCat audit events (P1 fix)', () => { await assert.rejects(readFile(seenConfigPath, 'utf-8')); }); - it('fix(#280): known legacy model without ocProviderName skips runtime config', async () => { + it('fix(#280): known legacy model without provider skips runtime config', async () => { const mod = await import('../dist/domains/cats/services/agents/invocation/invoke-single-cat.js'); mod._resetOpenCodeKnownModels(new Set(['anthropic/claude-opus-4-6'])); const { createProviderProfile } = await import('./helpers/create-test-account.js'); @@ -3639,8 +3655,8 @@ describe('invokeSingleCat audit events (P1 fix)', () => { ...originalConfig, id: boundCatId, mentionPatterns: [`@${boundCatId}`], - provider: 'opencode', - providerProfileId: anthropicProfile.id, + clientId: 'opencode', + accountRef: anthropicProfile.id, defaultModel: 'anthropic/claude-opus-4-6', }); @@ -3682,10 +3698,10 @@ describe('invokeSingleCat audit events (P1 fix)', () => { assert.equal(callbackEnv.CAT_CAFE_ANTHROPIC_MODEL_OVERRIDE, undefined); }); - it('F189-P1: ocProviderName takes priority over parseOpenCodeModel for namespaced models', async () => { - // Regression (砚砚 review): defaultModel="z-ai/glm-4.7" + ocProviderName="openrouter" + it('F189-P1: provider takes priority over parseOpenCodeModel for namespaced models', async () => { + // Regression (砚砚 review): defaultModel="z-ai/glm-4.7" + provider="openrouter" // parseOpenCodeModel parses "z-ai" as providerName, but the real provider is "openrouter". - // ocProviderName must take priority when set — the "/" in the model is a namespace separator. + // provider must take priority when set — the "/" in the model is a namespace separator. const { createProviderProfile } = await import('./helpers/create-test-account.js'); const root = await mkdtemp(join(tmpdir(), 'f189-namespace-priority-')); const apiDir = join(root, 'packages', 'api'); @@ -3712,10 +3728,10 @@ describe('invokeSingleCat audit events (P1 fix)', () => { ...originalConfig, id: boundCatId, mentionPatterns: [`@${boundCatId}`], - provider: 'opencode', - providerProfileId: orProfile.id, + clientId: 'opencode', + accountRef: orProfile.id, defaultModel: 'z-ai/glm-4.7', - ocProviderName: 'openrouter', + provider: 'openrouter', }); let seenRuntimeConfig; @@ -3755,19 +3771,19 @@ describe('invokeSingleCat audit events (P1 fix)', () => { } const callbackEnv = optionsSeen[0]?.callbackEnv ?? {}; - // Key assertions: ocProviderName "openrouter" must win over parsed "z-ai" + // Key assertions: provider "openrouter" must win over parsed "z-ai" assert.equal( callbackEnv.CAT_CAFE_ANTHROPIC_MODEL_OVERRIDE, 'openrouter/z-ai/glm-4.7', - 'effective model must use ocProviderName as provider prefix, not parsed z-ai', + 'effective model must use provider as provider prefix, not parsed z-ai', ); assert.ok(callbackEnv.OPENCODE_CONFIG, 'OPENCODE_CONFIG must be set'); assert.equal(seenRuntimeConfig?.model, 'openrouter/z-ai/glm-4.7'); assert.ok(seenRuntimeConfig?.provider?.openrouter, 'runtime config provider must be openrouter, not z-ai'); }); - it('F189-P1-2: same-provider prefix in defaultModel + ocProviderName must NOT double-prefix', async () => { - // Regression (砚砚 review R2): defaultModel="openai/gpt-5.4" + ocProviderName="openai" + it('F189-P1-2: same-provider prefix in defaultModel + provider must NOT double-prefix', async () => { + // Regression (砚砚 review R2): defaultModel="openai/gpt-5.4" + provider="openai" // Must produce effectiveModel="openai/gpt-5.4", NOT "openai/openai/gpt-5.4". const { createProviderProfile } = await import('./helpers/create-test-account.js'); const root = await mkdtemp(join(tmpdir(), 'f189-double-prefix-')); @@ -3795,10 +3811,10 @@ describe('invokeSingleCat audit events (P1 fix)', () => { ...originalConfig, id: boundCatId, mentionPatterns: [`@${boundCatId}`], - provider: 'opencode', - providerProfileId: oaiProfile.id, + clientId: 'opencode', + accountRef: oaiProfile.id, defaultModel: 'openai/gpt-5.4', - ocProviderName: 'openai', + provider: 'openai', }); const optionsSeen = []; @@ -3848,9 +3864,10 @@ describe('invokeSingleCat audit events (P1 fix)', () => { const apiDir = join(root, 'packages', 'api'); await mkdir(apiDir, { recursive: true }); await writeFile(join(root, 'pnpm-workspace.yaml'), 'packages:\n - "packages/*"\n', 'utf-8'); + // F340: Use well-known ID 'claude' so resolveForClient('anthropic') discovers it. await createProviderProfile(root, { provider: 'anthropic', - name: 'sponsor-gateway', + name: 'claude', mode: 'api_key', baseUrl: 'https://api.sponsor.example', apiKey: 'sk-sponsor', @@ -4280,10 +4297,10 @@ describe('invokeSingleCat audit events (P1 fix)', () => { ...originalConfig, id: boundCatId, mentionPatterns: [`@${boundCatId}`], - provider: 'opencode', - providerProfileId: customProfile.id, + clientId: 'opencode', + accountRef: customProfile.id, defaultModel: 'custom-model', - ocProviderName: 'custom', + provider: 'custom', }); const optionsSeen = []; diff --git a/packages/api/test/migrate-provider-profiles.test.js b/packages/api/test/migrate-provider-profiles.test.js deleted file mode 100644 index b7719a6d7..000000000 --- a/packages/api/test/migrate-provider-profiles.test.js +++ /dev/null @@ -1,303 +0,0 @@ -import assert from 'node:assert/strict'; -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { afterEach, beforeEach, describe, it } from 'node:test'; - -describe('migrateProviderProfilesToAccounts', () => { - let globalRoot; - let projectRoot; - let previousGlobalRoot; - - beforeEach(async () => { - globalRoot = await mkdtemp(join(tmpdir(), 'migrate-pp-global-')); - projectRoot = await mkdtemp(join(tmpdir(), 'migrate-pp-project-')); - previousGlobalRoot = process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT; - process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT = globalRoot; - await mkdir(join(globalRoot, '.cat-cafe'), { recursive: true }); - await mkdir(join(projectRoot, '.cat-cafe'), { recursive: true }); - }); - - afterEach(async () => { - if (previousGlobalRoot === undefined) delete process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT; - else process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT = previousGlobalRoot; - await rm(globalRoot, { recursive: true, force: true }); - await rm(projectRoot, { recursive: true, force: true }); - }); - - function writeV3Meta(profiles, bootstrapBindings) { - const meta = { - version: 3, - activeProfileId: null, - providers: profiles, - bootstrapBindings: bootstrapBindings ?? {}, - }; - return writeFile(join(globalRoot, '.cat-cafe', 'provider-profiles.json'), JSON.stringify(meta, null, 2), 'utf-8'); - } - - function writeV3Secrets(profileSecrets) { - const secrets = { version: 3, profiles: profileSecrets }; - return writeFile( - join(globalRoot, '.cat-cafe', 'provider-profiles.secrets.local.json'), - JSON.stringify(secrets, null, 2), - 'utf-8', - ); - } - - function writeCatalog(catalog) { - return writeFile(join(projectRoot, '.cat-cafe', 'cat-catalog.json'), JSON.stringify(catalog, null, 2), 'utf-8'); - } - - function makeCatalog(overrides) { - return { - version: 2, - breeds: [], - roster: {}, - reviewPolicy: { - requireDifferentFamily: true, - preferActiveInThread: true, - preferLead: true, - excludeUnavailable: true, - }, - ...overrides, - }; - } - - it('migrates api_key profile to accounts + credentials', async () => { - const { migrateProviderProfilesToAccounts } = await import( - `../dist/config/migrate-provider-profiles.js?t=${Date.now()}` - ); - - await writeV3Meta([ - { - id: 'my-glm', - displayName: 'My GLM', - kind: 'api_key', - authType: 'api_key', - builtin: false, - protocol: 'openai', - baseUrl: 'https://open.bigmodel.cn/api/paas/v4', - models: ['glm-5'], - createdAt: '2026-01-01', - updatedAt: '2026-01-01', - }, - ]); - await writeV3Secrets({ 'my-glm': { apiKey: 'glm-key-xxx' } }); - await writeCatalog(makeCatalog()); - - const result = migrateProviderProfilesToAccounts(projectRoot); - assert.equal(result.migrated, true); - assert.equal(result.accountsMigrated, 1); - - // Check accounts were written to catalog - const catalog = JSON.parse(await readFile(join(projectRoot, '.cat-cafe', 'cat-catalog.json'), 'utf-8')); - assert.ok(catalog.accounts?.['my-glm']); - assert.equal(catalog.accounts['my-glm'].protocol, 'openai'); - assert.equal(catalog.accounts['my-glm'].authType, 'api_key'); - assert.equal(catalog.accounts['my-glm'].baseUrl, 'https://open.bigmodel.cn/api/paas/v4'); - assert.deepEqual(catalog.accounts['my-glm'].models, ['glm-5']); - - // Check credentials were written - const creds = JSON.parse(await readFile(join(globalRoot, '.cat-cafe', 'credentials.json'), 'utf-8')); - assert.equal(creds['my-glm']?.apiKey, 'glm-key-xxx'); - }); - - it('migrates builtin profiles to accounts', async () => { - const { migrateProviderProfilesToAccounts } = await import( - `../dist/config/migrate-provider-profiles.js?t=${Date.now()}-1` - ); - - await writeV3Meta([ - { - id: 'claude', - displayName: 'Claude (OAuth)', - kind: 'builtin', - authType: 'oauth', - builtin: true, - client: 'anthropic', - protocol: 'anthropic', - models: ['claude-opus-4-6', 'claude-sonnet-4-6'], - createdAt: '2026-01-01', - updatedAt: '2026-01-01', - }, - ]); - await writeV3Secrets({}); - await writeCatalog(makeCatalog()); - - const result = migrateProviderProfilesToAccounts(projectRoot); - assert.equal(result.migrated, true); - - const catalog = JSON.parse(await readFile(join(projectRoot, '.cat-cafe', 'cat-catalog.json'), 'utf-8')); - assert.ok(catalog.accounts?.claude); - assert.equal(catalog.accounts.claude.authType, 'oauth'); - assert.equal(catalog.accounts.claude.protocol, 'anthropic'); - }); - - it('skips migration when project already has all old accounts', async () => { - const { migrateProviderProfilesToAccounts } = await import( - `../dist/config/migrate-provider-profiles.js?t=${Date.now()}-2` - ); - - await writeV3Meta([ - { - id: 'test', - displayName: 'Test', - kind: 'api_key', - authType: 'api_key', - builtin: false, - protocol: 'openai', - createdAt: '', - updatedAt: '', - }, - ]); - await writeV3Secrets({}); - // Catalog already contains the 'test' account → migration should be skipped - await writeCatalog(makeCatalog({ accounts: { test: { authType: 'api_key', protocol: 'openai' } } })); - - const result = migrateProviderProfilesToAccounts(projectRoot); - assert.equal(result.migrated, false); - assert.equal(result.reason, 'already-migrated'); - }); - - it('skips when no provider-profiles.json exists', async () => { - const { migrateProviderProfilesToAccounts } = await import( - `../dist/config/migrate-provider-profiles.js?t=${Date.now()}-3` - ); - - await writeCatalog(makeCatalog()); - - const result = migrateProviderProfilesToAccounts(projectRoot); - assert.equal(result.migrated, false); - assert.equal(result.reason, 'no-source'); - }); - - it('does not delete old files after migration (HC-3)', async () => { - const { migrateProviderProfilesToAccounts } = await import( - `../dist/config/migrate-provider-profiles.js?t=${Date.now()}-4` - ); - - await writeV3Meta([ - { - id: 'test', - displayName: 'Test', - kind: 'api_key', - authType: 'api_key', - builtin: false, - protocol: 'openai', - createdAt: '', - updatedAt: '', - }, - ]); - await writeV3Secrets({ test: { apiKey: 'sk-test' } }); - await writeCatalog(makeCatalog()); - - migrateProviderProfilesToAccounts(projectRoot); - - // Old files should still exist - const metaExists = await readFile(join(globalRoot, '.cat-cafe', 'provider-profiles.json'), 'utf-8').then( - () => true, - () => false, - ); - const secretsExists = await readFile( - join(globalRoot, '.cat-cafe', 'provider-profiles.secrets.local.json'), - 'utf-8', - ).then( - () => true, - () => false, - ); - assert.equal(metaExists, true, 'old meta file should be preserved'); - assert.equal(secretsExists, true, 'old secrets file should be preserved'); - }); - - it('migrates second project under same global root (per-project detection)', async () => { - const { migrateProviderProfilesToAccounts } = await import( - `../dist/config/migrate-provider-profiles.js?t=${Date.now()}-6` - ); - - // Two projects sharing the same global config root - const projectRootB = await mkdtemp(join(tmpdir(), 'migrate-pp-projectB-')); - await mkdir(join(projectRootB, '.cat-cafe'), { recursive: true }); - - const profile = { - id: 'shared-acct', - displayName: 'Shared', - kind: 'api_key', - authType: 'api_key', - builtin: false, - protocol: 'openai', - createdAt: '', - updatedAt: '', - }; - await writeV3Meta([profile]); - await writeV3Secrets({ 'shared-acct': { apiKey: 'sk-shared' } }); - - // Both projects have catalogs but neither has accounts yet - await writeCatalog(makeCatalog()); - await writeFile( - join(projectRootB, '.cat-cafe', 'cat-catalog.json'), - JSON.stringify(makeCatalog(), null, 2), - 'utf-8', - ); - - // Migrate project A - const resultA = migrateProviderProfilesToAccounts(projectRoot); - assert.equal(resultA.migrated, true, 'project A should migrate'); - - // Migrate project B — must NOT be skipped - const resultB = migrateProviderProfilesToAccounts(projectRootB); - assert.equal(resultB.migrated, true, 'project B must also migrate (not skipped by global marker)'); - - // Verify project B has accounts - const catalogB = JSON.parse(await readFile(join(projectRootB, '.cat-cafe', 'cat-catalog.json'), 'utf-8')); - assert.ok(catalogB.accounts?.['shared-acct'], 'project B should have migrated accounts'); - - await rm(projectRootB, { recursive: true, force: true }); - }); - - it('preserves bootstrapBindings semantics in accountRef', async () => { - const { migrateProviderProfilesToAccounts } = await import( - `../dist/config/migrate-provider-profiles.js?t=${Date.now()}-5` - ); - - await writeV3Meta( - [ - { - id: 'claude', - displayName: 'Claude (OAuth)', - kind: 'builtin', - authType: 'oauth', - builtin: true, - client: 'anthropic', - protocol: 'anthropic', - models: ['claude-opus-4-6'], - createdAt: '', - updatedAt: '', - }, - { - id: 'my-custom', - displayName: 'Custom API', - kind: 'api_key', - authType: 'api_key', - builtin: false, - protocol: 'anthropic', - baseUrl: 'https://custom.api.com/v1', - models: [], - createdAt: '', - updatedAt: '', - }, - ], - { - anthropic: { enabled: true, mode: 'api_key', accountRef: 'my-custom' }, - }, - ); - await writeV3Secrets({ 'my-custom': { apiKey: 'sk-custom' } }); - await writeCatalog(makeCatalog()); - - const result = migrateProviderProfilesToAccounts(projectRoot); - assert.equal(result.migrated, true); - - const catalog = JSON.parse(await readFile(join(projectRoot, '.cat-cafe', 'cat-catalog.json'), 'utf-8')); - assert.ok(catalog.accounts?.['my-custom']); - assert.equal(catalog.accounts['my-custom'].protocol, 'anthropic'); - }); -}); diff --git a/packages/api/test/mock-agent-integration.test.js b/packages/api/test/mock-agent-integration.test.js index ac8b718a5..79075aed8 100644 --- a/packages/api/test/mock-agent-integration.test.js +++ b/packages/api/test/mock-agent-integration.test.js @@ -29,7 +29,7 @@ const MOCK_CAT_CONFIG = { displayName: '模拟猫', nickname: null, color: '#888888', - provider: 'mock', + clientId: 'mock', defaultModel: 'mock-v1', mentionPatterns: ['@mock-cat', '@模拟猫'], mcpSupport: false, @@ -56,7 +56,7 @@ describe('F32-a Mock Agent Integration', () => { assert.ok(catRegistry.has('mock-cat')); const entry = catRegistry.getOrThrow('mock-cat'); assert.equal(entry.config.displayName, '模拟猫'); - assert.equal(entry.config.provider, 'mock'); + assert.equal(entry.config.clientId, 'mock'); }); test('getAllIds includes mock-cat alongside built-in cats', () => { diff --git a/packages/api/test/opencode-config-template.test.js b/packages/api/test/opencode-config-template.test.js index 8a0795a04..9841aaf2c 100644 --- a/packages/api/test/opencode-config-template.test.js +++ b/packages/api/test/opencode-config-template.test.js @@ -178,7 +178,7 @@ describe('parseOpenCodeModel', () => { }); describe('deriveOpenCodeApiType', () => { - test('derives apiType solely from ocProviderName', () => { + test('derives apiType solely from providerName', () => { const scenarios = [ { ocProviderName: 'anthropic', expected: 'anthropic' }, { ocProviderName: 'google', expected: 'google' }, diff --git a/packages/api/test/opencode-config-validation.test.js b/packages/api/test/opencode-config-validation.test.js index 2b8be44bd..9417520d2 100644 --- a/packages/api/test/opencode-config-validation.test.js +++ b/packages/api/test/opencode-config-validation.test.js @@ -32,11 +32,11 @@ describe('cat-template.json — 金渐层 (opencode) validation', () => { assert.ok(breed.mentionPatterns.includes('@金渐层')); }); - test('opencode-default variant has correct provider and model', () => { + test('opencode-default variant has correct clientId and model', () => { const breed = config.breeds.find((b) => b.id === 'golden-chinchilla'); const variant = breed.variants.find((v) => v.id === 'opencode-default'); assert.ok(variant, 'opencode-default variant should exist'); - assert.strictEqual(variant.provider, 'opencode'); + assert.strictEqual(variant.clientId, 'opencode'); assert.strictEqual(variant.defaultModel, 'claude-opus-4-6'); assert.strictEqual(variant.mcpSupport, true); }); diff --git a/packages/api/test/provider-profiles-route.test.js b/packages/api/test/provider-profiles-route.test.js deleted file mode 100644 index 7548f9741..000000000 --- a/packages/api/test/provider-profiles-route.test.js +++ /dev/null @@ -1,781 +0,0 @@ -// @ts-check -import './helpers/setup-cat-registry.js'; -import assert from 'node:assert/strict'; -import { mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'; -import { homedir } from 'node:os'; -import { join } from 'node:path'; -import { describe, it } from 'node:test'; - -const AUTH_HEADERS = { 'x-cat-cafe-user': 'test-user' }; - -/** @param {string} prefix */ -async function makeTmpDir(prefix) { - return mkdtemp(join(homedir(), `.cat-cafe-provider-profile-route-${prefix}-`)); -} - -/** @param {string} prefix */ -async function makeWorkspaceDir(prefix) { - return mkdtemp(join(process.cwd(), '..', '..', `.cat-cafe-provider-profile-route-workspace-${prefix}-`)); -} - -describe('provider profiles routes', () => { - /** @type {string | undefined} */ let savedGlobalRoot; - - function setGlobalRoot(dir) { - savedGlobalRoot = process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT; - process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT = dir; - } - - function restoreGlobalRoot() { - if (savedGlobalRoot === undefined) delete process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT; - else process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT = savedGlobalRoot; - } - - // F136 Phase 4d: legacy v1/v2 migration tests removed — old provider-profiles.js store retired. - // Migration to accounts is tested in account-startup-hook.test.js. - - it('GET /api/provider-profiles requires identity', async () => { - const Fastify = (await import('fastify')).default; - const { providerProfilesRoutes } = await import('../dist/routes/provider-profiles.js'); - const app = Fastify(); - await app.register(providerProfilesRoutes); - await app.ready(); - - const res = await app.inject({ method: 'GET', url: '/api/provider-profiles' }); - assert.equal(res.statusCode, 401); - - await app.close(); - }); - - it('create + activate + list profile flow', async () => { - const Fastify = (await import('fastify')).default; - const { providerProfilesRoutes } = await import('../dist/routes/provider-profiles.js'); - const app = Fastify(); - await app.register(providerProfilesRoutes); - await app.ready(); - - const projectDir = await makeTmpDir('crud'); - setGlobalRoot(projectDir); - try { - const createRes = await app.inject({ - method: 'POST', - url: '/api/provider-profiles', - headers: { ...AUTH_HEADERS, 'content-type': 'application/json' }, - payload: JSON.stringify({ - projectPath: projectDir, - provider: 'anthropic', - displayName: 'sponsor-route', - authType: 'api_key', - baseUrl: 'https://api.route.dev', - apiKey: 'sk-route', - models: ['claude-opus-4-6'], - setActive: true, - }), - }); - assert.equal(createRes.statusCode, 200); - const created = createRes.json(); - assert.equal(created.profile.authType, 'api_key'); - assert.equal(created.profile.hasApiKey, true); - - const listRes = await app.inject({ - method: 'GET', - url: `/api/provider-profiles?projectPath=${encodeURIComponent(projectDir)}`, - headers: AUTH_HEADERS, - }); - assert.equal(listRes.statusCode, 200); - const list = listRes.json(); - assert.ok(Array.isArray(list.providers)); - // F136 Phase 4d: new response format — no legacy bootstrapBindings - assert.equal(list.activeProfileId, null); - assert.deepEqual(list.bootstrapBindings, {}); - const listed = list.providers.find((p) => p.id === created.profile.id); - assert.ok(listed, 'created profile should appear in list'); - assert.equal(listed.hasApiKey, true); - } finally { - restoreGlobalRoot(); - await rm(projectDir, { recursive: true, force: true }); - await app.close(); - } - }); - - it('POST /api/provider-profiles/:id/test validates api_key profile via fetch', async () => { - const Fastify = (await import('fastify')).default; - const { providerProfilesRoutes } = await import('../dist/routes/provider-profiles.js'); - const app = Fastify(); - await app.register(providerProfilesRoutes, { - fetchImpl: async () => new Response('{}', { status: 200 }), - }); - await app.ready(); - - const projectDir = await makeTmpDir('test'); - setGlobalRoot(projectDir); - try { - const createRes = await app.inject({ - method: 'POST', - url: '/api/provider-profiles', - headers: { ...AUTH_HEADERS, 'content-type': 'application/json' }, - payload: JSON.stringify({ - projectPath: projectDir, - displayName: 'sponsor-test', - authType: 'api_key', - baseUrl: 'https://api.route.dev', - apiKey: 'sk-route', - models: ['claude-opus-4-6'], - setActive: false, - }), - }); - const profileId = createRes.json().profile.id; - - const testRes = await app.inject({ - method: 'POST', - url: `/api/provider-profiles/${profileId}/test`, - headers: { ...AUTH_HEADERS, 'content-type': 'application/json' }, - payload: JSON.stringify({ - projectPath: projectDir, - protocol: 'anthropic', - }), - }); - assert.equal(testRes.statusCode, 200); - const body = testRes.json(); - assert.equal(body.ok, true); - } finally { - restoreGlobalRoot(); - await rm(projectDir, { recursive: true, force: true }); - await app.close(); - } - }); - - it('POST /api/provider-profiles/:id/test falls back to /v1/messages when /v1/models is 404', async () => { - const Fastify = (await import('fastify')).default; - const calls = []; - const { providerProfilesRoutes } = await import('../dist/routes/provider-profiles.js'); - const app = Fastify(); - await app.register(providerProfilesRoutes, { - fetchImpl: async (url, init) => { - const urlString = String(url); - calls.push({ method: init?.method ?? 'GET', url: urlString }); - if (urlString.endsWith('/v1/models')) { - return new Response('Not Found', { status: 404 }); - } - if (urlString.endsWith('/v1/messages')) { - return new Response('{"id":"msg_test"}', { status: 200 }); - } - return new Response('Unhandled URL', { status: 500 }); - }, - }); - await app.ready(); - - const projectDir = await makeTmpDir('test-fallback'); - setGlobalRoot(projectDir); - try { - const createRes = await app.inject({ - method: 'POST', - url: '/api/provider-profiles', - headers: { ...AUTH_HEADERS, 'content-type': 'application/json' }, - payload: JSON.stringify({ - projectPath: projectDir, - displayName: 'felix', - authType: 'api_key', - baseUrl: 'https://chat.nuoda.vip/claudecode', - apiKey: 'sk-route', - models: ['claude-opus-4-6'], - setActive: false, - }), - }); - const profileId = createRes.json().profile.id; - - const testRes = await app.inject({ - method: 'POST', - url: `/api/provider-profiles/${profileId}/test`, - headers: { ...AUTH_HEADERS, 'content-type': 'application/json' }, - payload: JSON.stringify({ - projectPath: projectDir, - protocol: 'anthropic', - }), - }); - assert.equal(testRes.statusCode, 200); - const body = testRes.json(); - assert.equal(body.ok, true); - assert.equal(body.status, 200); - assert.deepEqual( - calls.map((call) => `${call.method} ${new URL(call.url).pathname}`), - ['GET /claudecode/v1/models', 'POST /claudecode/v1/messages'], - ); - } finally { - restoreGlobalRoot(); - await rm(projectDir, { recursive: true, force: true }); - await app.close(); - } - }); - - it('POST /api/provider-profiles/:id/test treats invalid-model 400 as compatible success', async () => { - const Fastify = (await import('fastify')).default; - const calls = []; - const { providerProfilesRoutes } = await import('../dist/routes/provider-profiles.js'); - const app = Fastify(); - await app.register(providerProfilesRoutes, { - fetchImpl: async (url, init) => { - const urlString = String(url); - calls.push({ method: init?.method ?? 'GET', url: urlString }); - if (urlString.endsWith('/v1/models')) { - return new Response('Not Found', { status: 404 }); - } - if (urlString.endsWith('/v1/messages')) { - return new Response('{"type":"error","error":{"type":"invalid_request_error","message":"invalid model"}}', { - status: 400, - }); - } - return new Response('Unhandled URL', { status: 500 }); - }, - }); - await app.ready(); - - const projectDir = await makeTmpDir('test-invalid-model'); - setGlobalRoot(projectDir); - try { - const createRes = await app.inject({ - method: 'POST', - url: '/api/provider-profiles', - headers: { ...AUTH_HEADERS, 'content-type': 'application/json' }, - payload: JSON.stringify({ - projectPath: projectDir, - displayName: 'felix-invalid-model', - authType: 'api_key', - baseUrl: 'https://chat.nuoda.vip/claudecode', - apiKey: 'sk-route', - models: ['claude-opus-4-6'], - setActive: false, - }), - }); - const profileId = createRes.json().profile.id; - - const testRes = await app.inject({ - method: 'POST', - url: `/api/provider-profiles/${profileId}/test`, - headers: { ...AUTH_HEADERS, 'content-type': 'application/json' }, - payload: JSON.stringify({ - projectPath: projectDir, - }), - }); - assert.equal(testRes.statusCode, 200); - const body = testRes.json(); - assert.equal(body.ok, true); - assert.deepEqual( - calls.map((call) => `${call.method} ${new URL(call.url).pathname}`), - ['GET /claudecode/v1/models', 'POST /claudecode/v1/messages'], - ); - } finally { - restoreGlobalRoot(); - await rm(projectDir, { recursive: true, force: true }); - await app.close(); - } - }); - - it('rejects blank profile name in create request', async () => { - const Fastify = (await import('fastify')).default; - const { providerProfilesRoutes } = await import('../dist/routes/provider-profiles.js'); - const app = Fastify(); - await app.register(providerProfilesRoutes); - await app.ready(); - - const projectDir = await makeTmpDir('blank-name'); - setGlobalRoot(projectDir); - try { - const createRes = await app.inject({ - method: 'POST', - url: '/api/provider-profiles', - headers: { ...AUTH_HEADERS, 'content-type': 'application/json' }, - payload: JSON.stringify({ - projectPath: projectDir, - displayName: ' ', - authType: 'api_key', - }), - }); - assert.equal(createRes.statusCode, 400); - } finally { - restoreGlobalRoot(); - await rm(projectDir, { recursive: true, force: true }); - await app.close(); - } - }); - - it('POST /api/provider-profiles assigns unique IDs when displayName collides', async () => { - const Fastify = (await import('fastify')).default; - const { providerProfilesRoutes } = await import('../dist/routes/provider-profiles.js'); - const app = Fastify(); - await app.register(providerProfilesRoutes); - await app.ready(); - - const projectDir = await makeTmpDir('slug-collision'); - setGlobalRoot(projectDir); - try { - const first = await app.inject({ - method: 'POST', - url: '/api/provider-profiles', - headers: { ...AUTH_HEADERS, 'content-type': 'application/json' }, - payload: JSON.stringify({ - projectPath: projectDir, - displayName: 'My Sponsor', - authType: 'api_key', - baseUrl: 'https://api.first.example', - apiKey: 'sk-first', - }), - }); - assert.equal(first.statusCode, 200, 'first create should succeed'); - const firstId = first.json().profile.id; - - const second = await app.inject({ - method: 'POST', - url: '/api/provider-profiles', - headers: { ...AUTH_HEADERS, 'content-type': 'application/json' }, - payload: JSON.stringify({ - projectPath: projectDir, - displayName: 'My Sponsor', - authType: 'api_key', - baseUrl: 'https://api.second.example', - apiKey: 'sk-second', - }), - }); - assert.equal(second.statusCode, 200, 'second create with same name should succeed'); - const secondId = second.json().profile.id; - assert.notEqual(firstId, secondId, 'duplicate displayName must produce different IDs'); - - const listRes = await app.inject({ - method: 'GET', - url: `/api/provider-profiles?projectPath=${encodeURIComponent(projectDir)}`, - headers: AUTH_HEADERS, - }); - const list = listRes.json(); - const ids = list.providers.map((p) => p.id); - assert.ok(ids.includes(firstId), 'first profile must still exist'); - assert.ok(ids.includes(secondId), 'second profile must exist alongside first'); - } finally { - restoreGlobalRoot(); - await rm(projectDir, { recursive: true, force: true }); - await app.close(); - } - }); - - it('PATCH /api/provider-profiles/:id clears credential when apiKey is empty string', async () => { - const Fastify = (await import('fastify')).default; - const { providerProfilesRoutes } = await import('../dist/routes/provider-profiles.js'); - const app = Fastify(); - await app.register(providerProfilesRoutes); - await app.ready(); - - const projectDir = await makeTmpDir('clear-cred'); - setGlobalRoot(projectDir); - try { - // Create profile with apiKey - const createRes = await app.inject({ - method: 'POST', - url: '/api/provider-profiles', - headers: { ...AUTH_HEADERS, 'content-type': 'application/json' }, - payload: JSON.stringify({ - projectPath: projectDir, - displayName: 'Clearable', - authType: 'api_key', - apiKey: 'sk-to-clear', - }), - }); - assert.equal(createRes.statusCode, 200); - const profileId = createRes.json().profile.id; - assert.equal(createRes.json().profile.hasApiKey, true, 'should have credential after create'); - - // PATCH with empty apiKey to clear credential - const patchRes = await app.inject({ - method: 'PATCH', - url: `/api/provider-profiles/${profileId}`, - headers: { ...AUTH_HEADERS, 'content-type': 'application/json' }, - payload: JSON.stringify({ - projectPath: projectDir, - apiKey: '', - }), - }); - assert.equal(patchRes.statusCode, 200); - assert.equal( - patchRes.json().profile.hasApiKey, - false, - 'credential should be cleared after PATCH with empty apiKey', - ); - - // Verify via GET - const listRes = await app.inject({ - method: 'GET', - url: `/api/provider-profiles?projectPath=${encodeURIComponent(projectDir)}`, - headers: AUTH_HEADERS, - }); - const profile = listRes.json().providers.find((p) => p.id === profileId); - assert.equal(profile.hasApiKey, false, 'credential should remain cleared'); - } finally { - restoreGlobalRoot(); - await rm(projectDir, { recursive: true, force: true }); - await app.close(); - } - }); - - it('PATCH /api/provider-profiles/:id preserves existing protocol when baseUrl changes without explicit override', async () => { - const Fastify = (await import('fastify')).default; - const { providerProfilesRoutes } = await import('../dist/routes/provider-profiles.js'); - const app = Fastify(); - await app.register(providerProfilesRoutes); - await app.ready(); - - const projectDir = await makeTmpDir('reinfer-proto'); - setGlobalRoot(projectDir); - try { - // Create an anthropic account behind a vendor-neutral proxy URL. - const createRes = await app.inject({ - method: 'POST', - url: '/api/provider-profiles', - headers: { ...AUTH_HEADERS, 'content-type': 'application/json' }, - payload: JSON.stringify({ - projectPath: projectDir, - displayName: 'Anthropic Proxy', - authType: 'api_key', - protocol: 'anthropic', - baseUrl: 'https://proxy.example.com/v1', - apiKey: 'sk-test', - models: ['claude-sonnet-4-5'], - }), - }); - assert.equal(createRes.statusCode, 200); - const profileId = createRes.json().profile.id; - assert.equal(createRes.json().profile.protocol, 'anthropic'); - - // Normal proxy baseUrl maintenance must not silently rewrite the account family. - const patchRes = await app.inject({ - method: 'PATCH', - url: `/api/provider-profiles/${profileId}`, - headers: { ...AUTH_HEADERS, 'content-type': 'application/json' }, - payload: JSON.stringify({ - projectPath: projectDir, - baseUrl: 'https://proxy-2.example.com/v1', - }), - }); - assert.equal(patchRes.statusCode, 200); - assert.equal( - patchRes.json().profile.protocol, - 'anthropic', - 'hidden protocol must be preserved across baseUrl-only edits', - ); - } finally { - restoreGlobalRoot(); - await rm(projectDir, { recursive: true, force: true }); - await app.close(); - } - }); - - it('PATCH keeps current protocol even when the new baseUrl would hint another family', async () => { - const Fastify = (await import('fastify')).default; - const { providerProfilesRoutes } = await import('../dist/routes/provider-profiles.js'); - const app = Fastify(); - await app.register(providerProfilesRoutes); - await app.ready(); - - const projectDir = await makeTmpDir('reinfer-name-trap'); - setGlobalRoot(projectDir); - try { - // displayName "Codex Sponsor" contains "codex" → would match openai in nameHints - const createRes = await app.inject({ - method: 'POST', - url: '/api/provider-profiles', - headers: { ...AUTH_HEADERS, 'content-type': 'application/json' }, - payload: JSON.stringify({ - projectPath: projectDir, - displayName: 'Codex Sponsor', - authType: 'api_key', - baseUrl: 'https://proxy.example.com', - apiKey: 'sk-test', - models: ['gpt-5.4'], - }), - }); - assert.equal(createRes.statusCode, 200); - const profileId = createRes.json().profile.id; - assert.equal(createRes.json().profile.protocol, 'openai'); - - // PATCH baseUrl to an anthropic-looking endpoint — protocol should stay openai. - const patchRes = await app.inject({ - method: 'PATCH', - url: `/api/provider-profiles/${profileId}`, - headers: { ...AUTH_HEADERS, 'content-type': 'application/json' }, - payload: JSON.stringify({ - projectPath: projectDir, - baseUrl: 'https://api.minimaxi.com/anthropic', - }), - }); - assert.equal(patchRes.statusCode, 200); - assert.equal( - patchRes.json().profile.protocol, - 'openai', - 'hidden protocol must not be silently reclassified by a new baseUrl hint', - ); - } finally { - restoreGlobalRoot(); - await rm(projectDir, { recursive: true, force: true }); - await app.close(); - } - }); - - it('PATCH /api/provider-profiles/:id accepts explicit protocol override for API clients', async () => { - const Fastify = (await import('fastify')).default; - const { providerProfilesRoutes } = await import('../dist/routes/provider-profiles.js'); - const app = Fastify(); - await app.register(providerProfilesRoutes); - await app.ready(); - - const projectDir = await makeTmpDir('patch-explicit-protocol'); - setGlobalRoot(projectDir); - try { - const createRes = await app.inject({ - method: 'POST', - url: '/api/provider-profiles', - headers: { ...AUTH_HEADERS, 'content-type': 'application/json' }, - payload: JSON.stringify({ - projectPath: projectDir, - displayName: 'proxy-account', - authType: 'api_key', - baseUrl: 'https://proxy.example.com', - apiKey: 'sk-test', - models: ['gpt-5.4'], - }), - }); - assert.equal(createRes.statusCode, 200); - const profileId = createRes.json().profile.id; - - const patchRes = await app.inject({ - method: 'PATCH', - url: `/api/provider-profiles/${profileId}`, - headers: { ...AUTH_HEADERS, 'content-type': 'application/json' }, - payload: JSON.stringify({ - projectPath: projectDir, - baseUrl: 'https://api.minimaxi.com/anthropic', - protocol: 'anthropic', - }), - }); - assert.equal(patchRes.statusCode, 200); - assert.equal(patchRes.json().profile.protocol, 'anthropic'); - } finally { - restoreGlobalRoot(); - await rm(projectDir, { recursive: true, force: true }); - await app.close(); - } - }); - - it('POST /api/provider-profiles/:id/test validates openai api_key providers via fetch', async () => { - const Fastify = (await import('fastify')).default; - const calls = []; - const { providerProfilesRoutes } = await import('../dist/routes/provider-profiles.js'); - const app = Fastify(); - await app.register(providerProfilesRoutes, { - fetchImpl: async (url, init) => { - calls.push({ url: String(url), headers: init?.headers }); - return new Response('{}', { status: 200 }); - }, - }); - await app.ready(); - - const projectDir = await makeTmpDir('test-openai'); - setGlobalRoot(projectDir); - try { - const createRes = await app.inject({ - method: 'POST', - url: '/api/provider-profiles', - headers: { ...AUTH_HEADERS, 'content-type': 'application/json' }, - payload: JSON.stringify({ - projectPath: projectDir, - displayName: 'codex-sponsor', - authType: 'api_key', - baseUrl: 'https://api.openai-proxy.dev', - apiKey: 'sk-openai', - models: ['gpt-5.4'], - setActive: false, - }), - }); - assert.equal(createRes.statusCode, 200); - const profileId = createRes.json().profile.id; - - const testRes = await app.inject({ - method: 'POST', - url: `/api/provider-profiles/${profileId}/test`, - headers: { ...AUTH_HEADERS, 'content-type': 'application/json' }, - payload: JSON.stringify({ - projectPath: projectDir, - }), - }); - assert.equal(testRes.statusCode, 200); - assert.equal(testRes.json().ok, true); - assert.equal(new URL(calls[0].url).pathname, '/v1/models'); - assert.equal(calls[0].headers.authorization, 'Bearer sk-openai'); - } finally { - restoreGlobalRoot(); - await rm(projectDir, { recursive: true, force: true }); - await app.close(); - } - }); - - it('POST /api/provider-profiles/:id/test probes Gemini-style /v1beta/models endpoints', async () => { - const Fastify = (await import('fastify')).default; - const calls = []; - const { providerProfilesRoutes } = await import('../dist/routes/provider-profiles.js'); - const app = Fastify(); - await app.register(providerProfilesRoutes, { - fetchImpl: async (url, init) => { - calls.push({ url: String(url), headers: init?.headers }); - const path = new URL(String(url)).pathname; - if (path.endsWith('/v1beta/models')) return new Response('{}', { status: 200 }); - return new Response('not found', { status: 404 }); - }, - }); - await app.ready(); - - const projectDir = await makeTmpDir('test-google'); - setGlobalRoot(projectDir); - try { - const createRes = await app.inject({ - method: 'POST', - url: '/api/provider-profiles', - headers: { ...AUTH_HEADERS, 'content-type': 'application/json' }, - payload: JSON.stringify({ - projectPath: projectDir, - displayName: 'gemini-sponsor', - authType: 'api_key', - baseUrl: 'https://generativelanguage.googleapis.com', - apiKey: 'gsk-google', - models: ['gemini-2.5-pro'], - setActive: false, - }), - }); - assert.equal(createRes.statusCode, 200); - const profileId = createRes.json().profile.id; - - const testRes = await app.inject({ - method: 'POST', - url: `/api/provider-profiles/${profileId}/test`, - headers: { ...AUTH_HEADERS, 'content-type': 'application/json' }, - payload: JSON.stringify({ - projectPath: projectDir, - }), - }); - assert.equal(testRes.statusCode, 200); - assert.equal(testRes.json().ok, true); - assert.equal(new URL(calls[0].url).pathname, '/v1beta/models'); - } finally { - restoreGlobalRoot(); - await rm(projectDir, { recursive: true, force: true }); - await app.close(); - } - }); - - it('accepts workspace projectPath even when validateProjectPath allowlist excludes it', async () => { - const Fastify = (await import('fastify')).default; - const { providerProfilesRoutes } = await import('../dist/routes/provider-profiles.js'); - const app = Fastify(); - await app.register(providerProfilesRoutes); - await app.ready(); - - const workspaceDir = await makeWorkspaceDir('switch'); - setGlobalRoot(workspaceDir); - const previousRoots = process.env.PROJECT_ALLOWED_ROOTS; - const previousAppend = process.env.PROJECT_ALLOWED_ROOTS_APPEND; - process.env.PROJECT_ALLOWED_ROOTS = '/tmp'; - delete process.env.PROJECT_ALLOWED_ROOTS_APPEND; - - try { - const res = await app.inject({ - method: 'GET', - url: `/api/provider-profiles?projectPath=${encodeURIComponent(workspaceDir)}`, - headers: AUTH_HEADERS, - }); - assert.equal(res.statusCode, 200); - assert.equal(res.json().projectPath, await realpath(workspaceDir)); - } finally { - restoreGlobalRoot(); - if (previousRoots === undefined) delete process.env.PROJECT_ALLOWED_ROOTS; - else process.env.PROJECT_ALLOWED_ROOTS = previousRoots; - if (previousAppend === undefined) delete process.env.PROJECT_ALLOWED_ROOTS_APPEND; - else process.env.PROJECT_ALLOWED_ROOTS_APPEND = previousAppend; - await rm(workspaceDir, { recursive: true, force: true }); - await app.close(); - } - }); - - it('defaults projectPath to CAT_TEMPLATE_PATH directory when query omits projectPath', async () => { - const Fastify = (await import('fastify')).default; - const { providerProfilesRoutes } = await import('../dist/routes/provider-profiles.js'); - const app = Fastify(); - await app.register(providerProfilesRoutes); - await app.ready(); - - const projectDir = await makeTmpDir('default-root'); - setGlobalRoot(projectDir); - const templatePath = join(projectDir, 'cat-template.json'); - await writeFile(templatePath, '{}\n', 'utf-8'); - const prevTemplate = process.env.CAT_TEMPLATE_PATH; - process.env.CAT_TEMPLATE_PATH = templatePath; - - try { - const res = await app.inject({ - method: 'GET', - url: '/api/provider-profiles', - headers: AUTH_HEADERS, - }); - assert.equal(res.statusCode, 200); - assert.equal(res.json().projectPath, await realpath(projectDir)); - } finally { - restoreGlobalRoot(); - if (prevTemplate === undefined) delete process.env.CAT_TEMPLATE_PATH; - else process.env.CAT_TEMPLATE_PATH = prevTemplate; - await rm(projectDir, { recursive: true, force: true }); - await app.close(); - } - }); - - it('GET /api/provider-profiles returns correct client for non-standard builtins (dare/opencode)', async () => { - const { writeFileSync, mkdirSync } = await import('node:fs'); - const { writeCatalogAccount } = await import('../dist/config/catalog-accounts.js'); - const Fastify = (await import('fastify')).default; - const { providerProfilesRoutes } = await import('../dist/routes/provider-profiles.js'); - const app = Fastify(); - await app.register(providerProfilesRoutes); - await app.ready(); - - const projectDir = await makeTmpDir('client-field'); - setGlobalRoot(projectDir); - try { - // Bootstrap minimal catalog - const catCafeDir = join(projectDir, '.cat-cafe'); - mkdirSync(catCafeDir, { recursive: true }); - writeFileSync( - join(catCafeDir, 'cat-catalog.json'), - JSON.stringify({ version: 2, breeds: [], roster: {}, reviewPolicy: {}, accounts: {} }), - ); - - // Write builtin accounts with standard and non-standard clients - writeCatalogAccount(projectDir, 'claude', { authType: 'oauth', protocol: 'anthropic', models: ['m1'] }); - writeCatalogAccount(projectDir, 'dare', { authType: 'oauth', protocol: 'openai', models: ['glm'] }); - writeCatalogAccount(projectDir, 'opencode', { authType: 'oauth', protocol: 'anthropic', models: ['m2'] }); - - const res = await app.inject({ - method: 'GET', - url: `/api/provider-profiles?projectPath=${encodeURIComponent(projectDir)}`, - headers: AUTH_HEADERS, - }); - assert.equal(res.statusCode, 200); - const providers = res.json().providers; - - const claude = providers.find((p) => p.id === 'claude'); - assert.equal(claude.client, 'anthropic', 'claude builtin client should be protocol (anthropic)'); - - const dare = providers.find((p) => p.id === 'dare'); - assert.equal(dare.client, 'dare', 'dare builtin client should be its own ID, not protocol'); - - const opencode = providers.find((p) => p.id === 'opencode'); - assert.equal(opencode.client, 'opencode', 'opencode builtin client should be its own ID, not protocol'); - } finally { - restoreGlobalRoot(); - await rm(projectDir, { recursive: true, force: true }); - await app.close(); - } - }); -}); diff --git a/packages/api/test/proxy-fallback.test.js b/packages/api/test/proxy-fallback.test.js index dbf9d05d3..9d9f81293 100644 --- a/packages/api/test/proxy-fallback.test.js +++ b/packages/api/test/proxy-fallback.test.js @@ -39,7 +39,8 @@ describe('F115 AC-C3: proxy fallback to direct upstream', () => { await writeFile(join(root, 'pnpm-workspace.yaml'), 'packages:\n - "packages/*"\n', 'utf-8'); process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT = root; - // F136 Phase 4d: create account via cat-catalog.json + credentials.json + // F340: Use well-known 'claude' ID so resolveForClient('anthropic') discovers it. + // Protocol retired — derived at runtime from BUILTIN_ACCOUNT_MAP. await writeFile( join(catCafeDir, 'cat-catalog.json'), JSON.stringify( @@ -47,9 +48,8 @@ describe('F115 AC-C3: proxy fallback to direct upstream', () => { version: 2, breeds: [], accounts: { - 'test-gateway': { + claude: { authType: 'api_key', - protocol: 'anthropic', baseUrl: 'https://api.test-gateway.example', displayName: 'test-gateway', }, @@ -64,7 +64,7 @@ describe('F115 AC-C3: proxy fallback to direct upstream', () => { join(catCafeDir, 'credentials.json'), JSON.stringify( { - 'test-gateway': { apiKey: 'sk-test-fallback' }, + claude: { apiKey: 'sk-test-fallback' }, }, null, 2, @@ -146,7 +146,7 @@ describe('F115 AC-C3: proxy fallback to direct upstream', () => { await writeFile(join(root, 'pnpm-workspace.yaml'), 'packages:\n - "packages/*"\n', 'utf-8'); process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT = root; - // F136 Phase 4d: create account via cat-catalog.json + credentials.json + // F340: Use well-known 'claude' ID so resolveForClient('anthropic') discovers it. await writeFile( join(catCafeDir, 'cat-catalog.json'), JSON.stringify( @@ -154,9 +154,8 @@ describe('F115 AC-C3: proxy fallback to direct upstream', () => { version: 2, breeds: [], accounts: { - 'nan-port-gateway': { + claude: { authType: 'api_key', - protocol: 'anthropic', baseUrl: 'https://api.nan-port.example', displayName: 'nan-port-gateway', }, @@ -171,7 +170,7 @@ describe('F115 AC-C3: proxy fallback to direct upstream', () => { join(catCafeDir, 'credentials.json'), JSON.stringify( { - 'nan-port-gateway': { apiKey: 'sk-nan-port' }, + claude: { apiKey: 'sk-nan-port' }, }, null, 2, diff --git a/packages/api/test/sensitive-env-write.test.js b/packages/api/test/sensitive-env-write.test.js index 4fc689dd6..ce8bfa8bb 100644 --- a/packages/api/test/sensitive-env-write.test.js +++ b/packages/api/test/sensitive-env-write.test.js @@ -1,6 +1,6 @@ /** * F136 follow-up: Sensitive env write — owner gate + audit - * Tests the PATCH /api/config/env owner gate for sensitive vars (OPENAI_API_KEY etc.) + * Tests the PATCH /api/config/env owner gate for sensitive vars (F102_API_KEY etc.) */ import assert from 'node:assert/strict'; @@ -30,7 +30,7 @@ describe('PATCH /api/config/env — sensitive env owner gate', () => { const { configRoutes } = await import('../dist/routes/config.js'); const tempRoot = mkdtempSync(resolve(tmpdir(), 'cat-cafe-env-')); const envFilePath = resolve(tempRoot, '.env'); - writeFileSync(envFilePath, 'OPENAI_API_KEY=sk-old\n', 'utf8'); + writeFileSync(envFilePath, 'F102_API_KEY=sk-old\n', 'utf8'); setEnv('DEFAULT_OWNER_USER_ID', 'you'); const app = Fastify({ logger: false }); @@ -46,12 +46,12 @@ describe('PATCH /api/config/env — sensitive env owner gate', () => { method: 'PATCH', url: '/api/config/env', headers: { 'x-cat-cafe-user': 'codex' }, - payload: { updates: [{ name: 'OPENAI_API_KEY', value: 'sk-new' }] }, + payload: { updates: [{ name: 'F102_API_KEY', value: 'sk-new' }] }, }); assert.equal(res.statusCode, 403); assert.match(JSON.parse(res.payload).error, /only be modified by the owner/); - assert.equal(readFileSync(envFilePath, 'utf8'), 'OPENAI_API_KEY=sk-old\n'); + assert.equal(readFileSync(envFilePath, 'utf8'), 'F102_API_KEY=sk-old\n'); } finally { await app.close(); rmSync(tempRoot, { recursive: true, force: true }); @@ -62,7 +62,7 @@ describe('PATCH /api/config/env — sensitive env owner gate', () => { const { configRoutes } = await import('../dist/routes/config.js'); const tempRoot = mkdtempSync(resolve(tmpdir(), 'cat-cafe-env-')); const envFilePath = resolve(tempRoot, '.env'); - writeFileSync(envFilePath, 'OPENAI_API_KEY=sk-old\n', 'utf8'); + writeFileSync(envFilePath, 'F102_API_KEY=sk-old\n', 'utf8'); setEnv('DEFAULT_OWNER_USER_ID', 'you'); const auditEvents = []; @@ -79,7 +79,7 @@ describe('PATCH /api/config/env — sensitive env owner gate', () => { method: 'PATCH', url: '/api/config/env', headers: { 'x-cat-cafe-user': 'you' }, - payload: { updates: [{ name: 'OPENAI_API_KEY', value: 'sk-new-key-123' }] }, + payload: { updates: [{ name: 'F102_API_KEY', value: 'sk-new-key-123' }] }, }); assert.equal(res.statusCode, 200); @@ -87,21 +87,21 @@ describe('PATCH /api/config/env — sensitive env owner gate', () => { assert.equal(body.ok, true); // .env file updated - assert.match(readFileSync(envFilePath, 'utf8'), /OPENAI_API_KEY=/); + assert.match(readFileSync(envFilePath, 'utf8'), /F102_API_KEY=/); // process.env updated - assert.equal(process.env.OPENAI_API_KEY, 'sk-new-key-123'); + assert.equal(process.env.F102_API_KEY, 'sk-new-key-123'); // Summary masks value as *** - const entry = body.summary?.find((v) => v.name === 'OPENAI_API_KEY'); - assert.ok(entry, 'OPENAI_API_KEY should be in summary'); + const entry = body.summary?.find((v) => v.name === 'F102_API_KEY'); + assert.ok(entry, 'F102_API_KEY should be in summary'); assert.equal(entry.currentValue, '***', 'sensitive value must be masked in summary'); // Dual audit: config_updated + env_sensitive_write assert.equal(auditEvents.length, 2); assert.equal(auditEvents[0].type, 'config_updated'); assert.equal(auditEvents[1].type, 'env_sensitive_write'); - assert.deepEqual(auditEvents[1].data.keys, ['OPENAI_API_KEY']); + assert.deepEqual(auditEvents[1].data.keys, ['F102_API_KEY']); assert.equal(auditEvents[1].data.operator, 'you'); } finally { await app.close(); @@ -143,7 +143,7 @@ describe('PATCH /api/config/env — sensitive env owner gate', () => { const { configRoutes } = await import('../dist/routes/config.js'); const tempRoot = mkdtempSync(resolve(tmpdir(), 'cat-cafe-env-')); const envFilePath = resolve(tempRoot, '.env'); - writeFileSync(envFilePath, 'FRONTEND_URL=http://old\nOPENAI_API_KEY=sk-old\n', 'utf8'); + writeFileSync(envFilePath, 'FRONTEND_URL=http://old\nF102_API_KEY=sk-old\n', 'utf8'); setEnv('DEFAULT_OWNER_USER_ID', 'you'); const auditEvents = []; @@ -163,7 +163,7 @@ describe('PATCH /api/config/env — sensitive env owner gate', () => { payload: { updates: [ { name: 'FRONTEND_URL', value: 'http://new' }, - { name: 'OPENAI_API_KEY', value: 'sk-new' }, + { name: 'F102_API_KEY', value: 'sk-new' }, ], }, }); @@ -171,7 +171,7 @@ describe('PATCH /api/config/env — sensitive env owner gate', () => { assert.equal(res.statusCode, 200); const sensitiveAudit = auditEvents.find((e) => e.type === 'env_sensitive_write'); assert.ok(sensitiveAudit, 'should have env_sensitive_write event'); - assert.deepEqual(sensitiveAudit.data.keys, ['OPENAI_API_KEY']); + assert.deepEqual(sensitiveAudit.data.keys, ['F102_API_KEY']); assert.ok(!sensitiveAudit.data.keys.includes('FRONTEND_URL'), 'non-sensitive key must not appear'); } finally { await app.close(); diff --git a/packages/api/test/session-strategy-config-route.test.js b/packages/api/test/session-strategy-config-route.test.js index 419f233f1..bc8389969 100644 --- a/packages/api/test/session-strategy-config-route.test.js +++ b/packages/api/test/session-strategy-config-route.test.js @@ -50,7 +50,7 @@ describe('session-strategy-config routes', () => { const first = body.cats[0]; assert.ok(first.catId); assert.ok(first.displayName); - assert.ok(first.provider); + assert.ok(first.clientId); assert.ok(first.effective); assert.ok(first.source); assert.equal(typeof first.hasOverride, 'boolean'); diff --git a/packages/api/test/start-dev-script.test.js b/packages/api/test/start-dev-script.test.js index de30636bc..231c8bf95 100644 --- a/packages/api/test/start-dev-script.test.js +++ b/packages/api/test/start-dev-script.test.js @@ -5,11 +5,20 @@ import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { test } from 'node:test'; -function runSourceOnlySnippet(scriptPath, snippet) { +function baseShellEnv(overrides = {}) { + return { + PATH: process.env.PATH ?? '', + HOME: process.env.HOME ?? '', + TERM: process.env.TERM ?? 'xterm-256color', + ...overrides, + }; +} + +function runSourceOnlySnippet(scriptPath, snippet, envOverrides = {}) { const result = spawnSync( 'bash', ['-lc', `set -e\nsource "${scriptPath}" --source-only >/dev/null 2>&1\ntrap - EXIT INT TERM\n${snippet}`], - { encoding: 'utf8' }, + { encoding: 'utf8', env: baseShellEnv(envOverrides) }, ); assert.equal(result.status, 0, `snippet failed\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`); @@ -135,12 +144,11 @@ test('explicit port env vars override .env values for direct startup', () => { ], { encoding: 'utf8', - env: { - ...process.env, + env: baseShellEnv({ FRONTEND_PORT: '3023', API_SERVER_PORT: '3024', REDIS_PORT: '6409', - }, + }), }, ); @@ -158,10 +166,9 @@ test('explicit NEXT_PUBLIC_API_URL override survives project .env during direct ], { encoding: 'utf8', - env: { - ...process.env, + env: baseShellEnv({ NEXT_PUBLIC_API_URL: 'http://localhost:3035', - }, + }), }, ); @@ -179,10 +186,9 @@ test('explicit PREVIEW_GATEWAY_PORT override survives project .env during direct ], { encoding: 'utf8', - env: { - ...process.env, + env: baseShellEnv({ PREVIEW_GATEWAY_PORT: '5120', - }, + }), }, ); @@ -195,12 +201,9 @@ test('direct command mode can prefer current .env ports over ambient shell ports const tempRoot = mkdtempSync(join(tmpdir(), 'cat-cafe-start-dev-dotenv-ports-')); const tempScriptPath = join(tempRoot, 'scripts', 'start-dev.sh'); const tempOverridesPath = join(tempRoot, 'scripts', 'download-source-overrides.sh'); - const baseEnv = { - PATH: process.env.PATH ?? '', - HOME: process.env.HOME ?? '', - TERM: process.env.TERM ?? 'xterm-256color', + const baseEnv = baseShellEnv({ CAT_CAFE_RESPECT_DOTENV_PORTS: '1', - }; + }); try { mkdirSync(join(tempRoot, 'scripts'), { recursive: true }); @@ -258,12 +261,7 @@ test('raw dev entry remaps setup-style Redis 6399 defaults to dev Redis 6398', ( { cwd: tempRoot, encoding: 'utf8', - env: { - ...process.env, - PATH: process.env.PATH ?? '', - HOME: process.env.HOME ?? '', - TERM: process.env.TERM ?? 'xterm-256color', - }, + env: baseShellEnv(), }, ); @@ -295,13 +293,9 @@ test('respect-dotenv mode keeps explicit Redis 6399 defaults intact for wrappers { cwd: tempRoot, encoding: 'utf8', - env: { - ...process.env, - PATH: process.env.PATH ?? '', - HOME: process.env.HOME ?? '', - TERM: process.env.TERM ?? 'xterm-256color', + env: baseShellEnv({ CAT_CAFE_RESPECT_DOTENV_PORTS: '1', - }, + }), }, ); @@ -325,11 +319,10 @@ test('redis port override also recomputes isolated redis dirs', () => { ], { encoding: 'utf8', - env: { - ...process.env, + env: baseShellEnv({ HOME: tempHome, REDIS_PORT: '6409', - }, + }), }, ); diff --git a/packages/api/test/windows-portable-redis-tools.test.js b/packages/api/test/windows-portable-redis-tools.test.js index 9c122b4b2..b82d1a141 100644 --- a/packages/api/test/windows-portable-redis-tools.test.js +++ b/packages/api/test/windows-portable-redis-tools.test.js @@ -128,6 +128,27 @@ test('Windows command forwarding helpers avoid PowerShell automatic $args collis assert.doesNotMatch(helpersScript, /\$args = @\("claude-profile", "set"/); }); +test('Windows OAuth helpers do not force-remove global installer accounts before set', () => { + const codexOAuthBody = helpersScript.match(/function Set-CodexOAuthMode \{([\s\S]*?)^}/m)?.[1] ?? ''; + const geminiOAuthBody = helpersScript.match(/function Set-GeminiOAuthMode \{([\s\S]*?)^}/m)?.[1] ?? ''; + const claudeRemoveBody = helpersScript.match(/function Remove-ClaudeInstallerProfile \{([\s\S]*?)^}/m)?.[1] ?? ''; + + assert.notEqual(codexOAuthBody, '', 'expected Set-CodexOAuthMode body'); + assert.notEqual(geminiOAuthBody, '', 'expected Set-GeminiOAuthMode body'); + assert.notEqual(claudeRemoveBody, '', 'expected Remove-ClaudeInstallerProfile body'); + + assert.match(codexOAuthBody, /"client-auth", "set".*"--mode", "oauth"/s); + assert.doesNotMatch(codexOAuthBody, /"client-auth", "remove"/); + assert.doesNotMatch(codexOAuthBody, /"--force", "true"/); + + assert.match(geminiOAuthBody, /"client-auth", "set".*"--mode", "oauth"/s); + assert.doesNotMatch(geminiOAuthBody, /"client-auth", "remove"/); + assert.doesNotMatch(geminiOAuthBody, /"--force", "true"/); + + assert.match(claudeRemoveBody, /"claude-profile", "remove"/); + assert.doesNotMatch(claudeRemoveBody, /"--force", "true"/); +}); + test('Windows installer probes the npm shim path when pnpm is installed but not yet on PATH', () => { assert.match( commandHelpersScript, diff --git a/packages/shared/src/types/cat-breed.ts b/packages/shared/src/types/cat-breed.ts index cc9642db2..21bf63b34 100644 --- a/packages/shared/src/types/cat-breed.ts +++ b/packages/shared/src/types/cat-breed.ts @@ -7,7 +7,7 @@ * Phase 4-F: 支持多 Variant(多版本猫召唤) */ -import type { CatColor, CatProvider } from './cat.js'; +import type { CatColor, ClientId } from './cat.js'; import type { CatId } from './ids.js'; import type { VoiceConfig } from './tts.js'; @@ -64,7 +64,8 @@ export interface CatVariant { readonly mentionPatterns?: readonly string[]; /** F127: member-side binding to a concrete account config (built-in or API key). */ readonly accountRef?: string; - readonly provider: CatProvider; + /** F340 P5: CLI client identity (renamed from `provider`). */ + readonly clientId: ClientId; readonly defaultModel: string; readonly mcpSupport: boolean; readonly cli: CliConfig; @@ -91,10 +92,11 @@ export interface CatVariant { /** F127: Extra CLI --config key=value pairs passed to the client at invocation time. * Each entry is a raw config string, e.g. 'model_reasoning_effort="low"'. */ readonly cliConfigArgs?: readonly string[]; - /** F189: OpenCode custom provider name (e.g. "maas", "deepseek"). - * Used with api_key auth — runtime assembles `ocProviderName/defaultModel` for the -m flag + /** F340 P5: Model provider name for api_key routing (renamed from `ocProviderName`). + * e.g. "openrouter", "maas", "deepseek". + * Used with api_key auth — runtime assembles `provider/defaultModel` for the -m flag * and generates an OPENCODE_CONFIG runtime config file for the provider. */ - readonly ocProviderName?: string; + readonly provider?: string; } /** @@ -197,12 +199,11 @@ export interface ReviewPolicy { export type AccountProtocol = 'anthropic' | 'openai' | 'openai-responses' | 'google'; /** - * Account configuration — lives in cat-catalog.json `accounts` section. + * Account configuration — lives in ~/.cat-cafe/accounts.json (global). * Maps an accountRef to its LLM endpoint metadata (no secrets). */ export interface AccountConfig { readonly authType: 'oauth' | 'api_key'; - readonly protocol: AccountProtocol; readonly baseUrl?: string; readonly models?: readonly string[]; readonly displayName?: string; @@ -253,7 +254,11 @@ export interface CatCafeConfigV2 { readonly roster: Roster; readonly reviewPolicy: ReviewPolicy; readonly coCreator?: CoCreatorConfig; - /** F136 Phase 4: Account metadata (accountRef → config). HC-2: runtime write source. */ + /** + * @deprecated F340: Accounts moved to global ~/.cat-cafe/accounts.json. + * This field is only read during one-time migration (catalog → global). + * New code must use catalog-accounts.ts which reads the global file. + */ readonly accounts?: Readonly>; } diff --git a/packages/shared/src/types/cat.ts b/packages/shared/src/types/cat.ts index 66bf1a89c..e7c40c73a 100644 --- a/packages/shared/src/types/cat.ts +++ b/packages/shared/src/types/cat.ts @@ -8,9 +8,13 @@ import type { CatId, SessionId } from './ids.js'; import { createCatId } from './ids.js'; /** - * AI provider behind a cat + * CLI client identity used to invoke a cat (e.g. 'anthropic' → claude CLI, 'openai' → codex CLI). + * Renamed from CatProvider in F340 P5. */ -export type CatProvider = 'anthropic' | 'openai' | 'google' | 'dare' | 'antigravity' | 'opencode' | 'a2a'; +export type ClientId = 'anthropic' | 'openai' | 'google' | 'dare' | 'antigravity' | 'opencode' | 'a2a'; + +/** @deprecated F340: Use {@link ClientId} instead. Kept as alias for backward compatibility. */ +export type CatProvider = ClientId; /** * Cat status in the system @@ -38,7 +42,8 @@ export interface CatConfig { readonly color: CatColor; readonly mentionPatterns: readonly string[]; readonly accountRef?: string; - readonly provider: CatProvider; + /** F340 P5: CLI client identity (renamed from `provider`). */ + readonly clientId: ClientId; readonly defaultModel: string; readonly mcpSupport: boolean; readonly cli?: CliConfig; @@ -64,8 +69,9 @@ export interface CatConfig { readonly sessionChain?: boolean; /** F127: Extra CLI --config key=value pairs passed to the client at invocation time. */ readonly cliConfigArgs?: readonly string[]; - /** F189: OpenCode custom provider name for api_key routing (runtime assembles provider/model). */ - readonly ocProviderName?: string; + /** F340 P5: Model provider name for api_key routing (renamed from `ocProviderName`). + * e.g. "openrouter", "maas", "deepseek". Runtime assembles provider/model for the -m flag. */ + readonly provider?: string; } /** @@ -97,7 +103,7 @@ export const CAT_CONFIGS: Record = { secondary: '#E8DFF5', }, mentionPatterns: ['@opus', '@布偶猫', '@布偶', '@ragdoll', '@宪宪'], - provider: 'anthropic', + clientId: 'anthropic', defaultModel: 'claude-sonnet-4-5-20250929', mcpSupport: true, breedId: 'ragdoll', @@ -115,7 +121,7 @@ export const CAT_CONFIGS: Record = { secondary: '#D4E6D3', }, mentionPatterns: ['@codex', '@缅因猫', '@缅因', '@maine', '@砚砚'], - provider: 'openai', + clientId: 'openai', defaultModel: 'codex', mcpSupport: false, breedId: 'maine-coon', @@ -132,7 +138,7 @@ export const CAT_CONFIGS: Record = { secondary: '#D6E9F8', }, mentionPatterns: ['@gemini', '@暹罗猫', '@暹罗', '@siamese', '@暄罗猫', '@暄罗'], - provider: 'google', + clientId: 'google', defaultModel: 'gemini-2.5-pro', mcpSupport: false, breedId: 'siamese', diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 0c572d609..dc13f3fad 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -92,9 +92,11 @@ export type { export type { CatColor, CatConfig, + /** @deprecated F340: Use ClientId instead. */ CatProvider, CatState, CatStatus, + ClientId, } from './cat.js'; export { CAT_CONFIGS, diff --git a/packages/web/src/components/CatCafeHub.tsx b/packages/web/src/components/CatCafeHub.tsx index c29b41082..17ccd4038 100644 --- a/packages/web/src/components/CatCafeHub.tsx +++ b/packages/web/src/components/CatCafeHub.tsx @@ -14,6 +14,7 @@ import { resolveRequestedHubTab, } from './cat-cafe-hub.navigation'; import { CatOverviewTab, type ConfigData, SystemTab } from './config-viewer-tabs'; +import { HubAccountsTab } from './HubAccountsTab'; import { HubCapabilityTab } from './HubCapabilityTab'; import { HubCatEditor } from './HubCatEditor'; import { HubClaudeRescueSection } from './HubClaudeRescueSection'; @@ -23,7 +24,6 @@ import { HubEnvFilesTab } from './HubEnvFilesTab'; import { HubGovernanceTab } from './HubGovernanceTab'; import { HubLeaderboardTab } from './HubLeaderboardTab'; import { HubMemoryTab } from './HubMemoryTab'; -import { HubProviderProfilesTab } from './HubProviderProfilesTab'; import { HubRoutingPolicyTab } from './HubRoutingPolicyTab'; import { HubToolUsageTab } from './HubToolUsageTab'; import { PushSettingsPanel } from './PushSettingsPanel'; @@ -243,7 +243,7 @@ export function CatCafeHub() { {tab === 'routing' && } {tab === 'tool-usage' && } {tab === 'env' && } - {tab === 'provider-profiles' && } + {tab === 'accounts' && } {tab === 'voice' && } {tab === 'notify' && } {tab === 'governance' && } diff --git a/packages/web/src/components/HubProviderProfileItem.tsx b/packages/web/src/components/HubAccountItem.tsx similarity index 70% rename from packages/web/src/components/HubProviderProfileItem.tsx rename to packages/web/src/components/HubAccountItem.tsx index 618760e02..ae4880d41 100644 --- a/packages/web/src/components/HubProviderProfileItem.tsx +++ b/packages/web/src/components/HubAccountItem.tsx @@ -1,89 +1,58 @@ 'use client'; import { useCallback, useState } from 'react'; -import type { ProfileItem } from './hub-provider-profiles.types'; +import type { ProfileItem } from './hub-accounts.types'; import { TagEditor } from './hub-tag-editor'; import { useConfirm } from './useConfirm'; export interface ProfileEditPayload { displayName: string; - protocol?: string; baseUrl?: string; apiKey?: string; models?: string[]; modelOverride?: string | null; } -interface HubProviderProfileItemProps { +interface HubAccountItemProps { profile: ProfileItem; busy: boolean; onSave: (profileId: string, payload: ProfileEditPayload) => Promise; onDelete: (profileId: string) => void; } -type ApiProtocol = 'anthropic' | 'openai' | 'openai-responses' | 'google'; - -const PROTOCOL_OPTIONS: { value: ApiProtocol; label: string }[] = [ - { value: 'openai', label: 'OpenAI 兼容 (Chat)' }, - { value: 'openai-responses', label: 'OpenAI Responses' }, - { value: 'anthropic', label: 'Anthropic 兼容' }, - { value: 'google', label: 'Google 兼容' }, -]; - -const PROTOCOL_LABELS: Record = Object.fromEntries(PROTOCOL_OPTIONS.map((o) => [o.value, o.label])); - -function protocolLabel(protocol: string | undefined): string { - return (protocol && PROTOCOL_LABELS[protocol]) ?? protocol ?? '自动'; -} - function summaryText(profile: ProfileItem): string | null { if (profile.builtin) return null; const host = profile.baseUrl?.replace(/^https?:\/\//, '') ?? '(未设置)'; return `${host} · ${profile.hasApiKey ? '已配置' : '未配置'}`; } -export function HubProviderProfileItem({ profile, busy, onSave, onDelete }: HubProviderProfileItemProps) { +export function HubAccountItem({ profile, busy, onSave, onDelete }: HubAccountItemProps) { const confirm = useConfirm(); const [editing, setEditing] = useState(false); const [editDisplayName, setEditDisplayName] = useState(profile.displayName); - const [editProtocol, setEditProtocol] = useState(profile.protocol ?? 'openai'); const [editBaseUrl, setEditBaseUrl] = useState(profile.baseUrl ?? ''); const [editApiKey, setEditApiKey] = useState(''); const [apiKeyTouched, setApiKeyTouched] = useState(false); const [editModels, setEditModels] = useState(profile.models ?? []); - const [showAdvanced, setShowAdvanced] = useState(false); const startEdit = useCallback(() => { setEditDisplayName(profile.displayName); - setEditProtocol(profile.protocol ?? 'openai'); setEditBaseUrl(profile.baseUrl ?? ''); setEditApiKey(''); setApiKeyTouched(false); setEditModels(profile.models ?? []); - setShowAdvanced(false); setEditing(true); - }, [profile.baseUrl, profile.displayName, profile.models, profile.protocol]); + }, [profile.baseUrl, profile.displayName, profile.models]); const saveEdit = useCallback(async () => { await onSave(profile.id, { displayName: editDisplayName.trim(), - protocol: editProtocol, ...(profile.authType === 'api_key' ? { baseUrl: editBaseUrl.trim() } : {}), ...(apiKeyTouched ? { apiKey: editApiKey.trim() } : {}), models: editModels, }); setEditing(false); - }, [ - apiKeyTouched, - editApiKey, - editBaseUrl, - editDisplayName, - editModels, - editProtocol, - onSave, - profile.authType, - profile.id, - ]); + }, [apiKeyTouched, editApiKey, editBaseUrl, editDisplayName, editModels, onSave, profile.authType, profile.id]); if (editing) { return ( @@ -133,45 +102,6 @@ export function HubProviderProfileItem({ profile, busy, onSave, onDelete }: HubP ) : null} - {!profile.builtin && ( -
- - {showAdvanced && ( -
-

API 协议

- -

- 通常自动推断即可。若模型调用出现协议不匹配,可在此手动修正。 -

-
- )} -
- )}
{summaryText(profile) ?

{summaryText(profile)}

: null}
diff --git a/packages/web/src/components/HubProviderProfilesTab.tsx b/packages/web/src/components/HubAccountsTab.tsx similarity index 57% rename from packages/web/src/components/HubProviderProfilesTab.tsx rename to packages/web/src/components/HubAccountsTab.tsx index 95e190fba..4a01ecd83 100644 --- a/packages/web/src/components/HubProviderProfilesTab.tsx +++ b/packages/web/src/components/HubAccountsTab.tsx @@ -2,31 +2,31 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { apiFetch } from '@/utils/api-client'; -import { HubProviderProfileItem, type ProfileEditPayload } from './HubProviderProfileItem'; -import { CreateApiKeyProfileSection, ProviderProfilesSummaryCard } from './hub-provider-profiles.sections'; -import type { ProviderProfilesResponse } from './hub-provider-profiles.types'; -import { ensureBuiltinProviderProfiles, resolveAccountActionId } from './hub-provider-profiles.view'; +import { HubAccountItem, type ProfileEditPayload } from './HubAccountItem'; +import { AccountsSummaryCard, CreateApiKeyAccountSection } from './hub-accounts.sections'; +import type { AccountsResponse } from './hub-accounts.types'; +import { ensureBuiltinAccounts, resolveAccountActionId } from './hub-accounts.view'; -export function HubProviderProfilesTab() { +export function HubAccountsTab() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [data, setData] = useState(null); + const [data, setData] = useState(null); const [busyId, setBusyId] = useState(null); const [createDisplayName, setCreateDisplayName] = useState(''); const [createBaseUrl, setCreateBaseUrl] = useState(''); const [createApiKey, setCreateApiKey] = useState(''); const [createModels, setCreateModels] = useState([]); - const fetchProfiles = useCallback(async () => { + const fetchAccounts = useCallback(async () => { setError(null); try { - const res = await apiFetch('/api/provider-profiles'); + const res = await apiFetch('/api/accounts'); if (!res.ok) { const body = (await res.json().catch(() => ({}))) as Record; setError((body.error as string) ?? '加载失败'); return; } - const body = (await res.json()) as ProviderProfilesResponse; + const body = (await res.json()) as AccountsResponse; setData(body); } catch { setError('网络错误'); @@ -37,8 +37,8 @@ export function HubProviderProfilesTab() { useEffect(() => { setLoading(true); - fetchProfiles(); - }, [fetchProfiles]); + fetchAccounts(); + }, [fetchAccounts]); const callApi = useCallback(async (path: string, init: RequestInit) => { const res = await apiFetch(path, { @@ -55,7 +55,7 @@ export function HubProviderProfilesTab() { return body; }, []); - const createProfile = useCallback(async () => { + const createAccount = useCallback(async () => { if (!createDisplayName.trim()) { setError('请输入账号显示名'); return; @@ -67,7 +67,7 @@ export function HubProviderProfilesTab() { setBusyId('create'); setError(null); try { - await callApi('/api/provider-profiles', { + await callApi('/api/accounts', { method: 'POST', body: JSON.stringify({ displayName: createDisplayName.trim(), @@ -81,56 +81,56 @@ export function HubProviderProfilesTab() { setCreateBaseUrl(''); setCreateApiKey(''); setCreateModels([]); - await fetchProfiles(); - window.dispatchEvent(new CustomEvent('provider-profiles-changed')); + await fetchAccounts(); + window.dispatchEvent(new CustomEvent('accounts-changed')); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setBusyId(null); } - }, [callApi, createApiKey, createBaseUrl, createDisplayName, createModels, fetchProfiles]); + }, [callApi, createApiKey, createBaseUrl, createDisplayName, createModels, fetchAccounts]); - const deleteProfile = useCallback( - async (profileId: string) => { - setBusyId(profileId); + const deleteAccount = useCallback( + async (accountId: string) => { + setBusyId(accountId); setError(null); try { - await callApi(`/api/provider-profiles/${profileId}`, { method: 'DELETE' }); - await fetchProfiles(); - window.dispatchEvent(new CustomEvent('provider-profiles-changed')); + await callApi(`/api/accounts/${accountId}`, { method: 'DELETE' }); + await fetchAccounts(); + window.dispatchEvent(new CustomEvent('accounts-changed')); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setBusyId(null); } }, - [callApi, fetchProfiles], + [callApi, fetchAccounts], ); - const saveProfile = useCallback( - async (profileId: string, payload: ProfileEditPayload) => { - setBusyId(profileId); + const saveAccount = useCallback( + async (accountId: string, payload: ProfileEditPayload) => { + setBusyId(accountId); setError(null); try { - await callApi(`/api/provider-profiles/${profileId}`, { + await callApi(`/api/accounts/${accountId}`, { method: 'PATCH', body: JSON.stringify(payload), }); - await fetchProfiles(); - window.dispatchEvent(new CustomEvent('provider-profiles-changed')); + await fetchAccounts(); + window.dispatchEvent(new CustomEvent('accounts-changed')); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setBusyId(null); } }, - [callApi, fetchProfiles], + [callApi, fetchAccounts], ); - const displayProfiles = useMemo(() => ensureBuiltinProviderProfiles(data?.providers ?? []), [data?.providers]); - const builtinProfiles = useMemo(() => displayProfiles.filter((profile) => profile.builtin), [displayProfiles]); - const customProfiles = useMemo(() => displayProfiles.filter((profile) => !profile.builtin), [displayProfiles]); - const displayCards = useMemo(() => [...builtinProfiles, ...customProfiles], [builtinProfiles, customProfiles]); + const displayAccounts = useMemo(() => ensureBuiltinAccounts(data?.providers ?? []), [data?.providers]); + const builtinAccounts = useMemo(() => displayAccounts.filter((a) => a.builtin), [displayAccounts]); + const customAccounts = useMemo(() => displayAccounts.filter((a) => !a.builtin), [displayAccounts]); + const displayCards = useMemo(() => [...builtinAccounts, ...customAccounts], [builtinAccounts, customAccounts]); if (loading) return

加载中...

; if (!data) return

暂无数据

; @@ -139,21 +139,21 @@ export function HubProviderProfilesTab() {
{error &&

{error}

} - + -
- {displayCards.map((profile) => ( - saveProfile(resolveAccountActionId(profile), payload)} - onDelete={() => deleteProfile(resolveAccountActionId(profile))} +
+ {displayCards.map((account) => ( + saveAccount(resolveAccountActionId(account), payload)} + onDelete={() => deleteAccount(resolveAccountActionId(account))} /> ))}
-

secrets 存储在 `~/.cat-cafe/credentials.json`(全局),Git 忽略。 diff --git a/packages/web/src/components/HubAddMemberWizard.tsx b/packages/web/src/components/HubAddMemberWizard.tsx index 49700528d..00dbdbd76 100644 --- a/packages/web/src/components/HubAddMemberWizard.tsx +++ b/packages/web/src/components/HubAddMemberWizard.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react'; import { apiFetch } from '@/utils/api-client'; +import type { AccountsResponse, ProfileItem } from './hub-accounts.types'; import { ChoiceButton, CLIENT_ROW_1, @@ -13,11 +14,10 @@ import { } from './hub-add-member-wizard.parts'; import { builtinAccountIdForClient, - type ClientValue, + type ClientId, filterAccounts, type HubCatEditorDraft, } from './hub-cat-editor.model'; -import type { ProfileItem, ProviderProfilesResponse } from './hub-provider-profiles.types'; interface HubAddMemberWizardProps { open: boolean; @@ -28,18 +28,18 @@ interface HubAddMemberWizardProps { export function HubAddMemberWizard({ open, onClose, onComplete }: HubAddMemberWizardProps) { const [profiles, setProfiles] = useState([]); const [seedCats, setSeedCats] = useState< - Array<{ provider: string; source?: string; defaultModel?: string; commandArgs?: string[] }> + Array<{ clientId: string; source?: string; defaultModel?: string; commandArgs?: string[] }> >([]); const [loadingProfiles, setLoadingProfiles] = useState(false); const [error, setError] = useState(null); - const [client, setClient] = useState(null); + const [client, setClient] = useState(null); const [selectedProfileId, setSelectedProfileId] = useState(''); const [defaultModel, setDefaultModel] = useState(''); const [commandArgs, setCommandArgs] = useState(FALLBACK_ANTIGRAVITY_ARGS); const antigravityDefaults = useMemo((): { command: string; models: string[] } => { const templateAntigravity = seedCats.filter( - (cat) => cat.provider === 'antigravity' && (cat.source === 'seed' || cat.source === undefined), + (cat) => cat.clientId === 'antigravity' && (cat.source === 'seed' || cat.source === undefined), ); const command = templateAntigravity.find((cat) => (cat.commandArgs?.length ?? 0) > 0)?.commandArgs?.join(' '); const models = templateAntigravity.map((cat) => cat.defaultModel?.trim() ?? '').filter((value) => value.length > 0); @@ -88,10 +88,10 @@ export function HubAddMemberWizard({ open, onClose, onComplete }: HubAddMemberWi if (!open) return; let cancelled = false; setLoadingProfiles(true); - apiFetch('/api/provider-profiles') + apiFetch('/api/accounts') .then(async (res) => { if (!res.ok) throw new Error(`账号配置加载失败 (${res.status})`); - return (await res.json()) as ProviderProfilesResponse; + return (await res.json()) as AccountsResponse; }) .then((body) => { if (!cancelled) setProfiles(body.providers); @@ -114,7 +114,7 @@ export function HubAddMemberWizard({ open, onClose, onComplete }: HubAddMemberWi .then(async (res) => { if (!res.ok) throw new Error(`成员模板加载失败 (${res.status})`); return (await res.json()) as { - cats?: Array<{ provider: string; source?: string; defaultModel?: string; commandArgs?: string[] }>; + cats?: Array<{ clientId: string; source?: string; defaultModel?: string; commandArgs?: string[] }>; }; }) .then((body) => { @@ -153,7 +153,7 @@ export function HubAddMemberWizard({ open, onClose, onComplete }: HubAddMemberWi (client === 'antigravity' ? commandArgs.trim().length > 0 : Boolean(selectedProfile)), ); - const handleClientSelect = (nextClient: ClientValue) => { + const handleClientSelect = (nextClient: ClientId) => { setClient(nextClient); setSelectedProfileId(nextClient === 'antigravity' ? '' : (builtinAccountIdForClient(nextClient) ?? '')); setDefaultModel(nextClient === 'antigravity' ? (antigravityDefaults.models[0] ?? '') : ''); @@ -170,7 +170,7 @@ export function HubAddMemberWizard({ open, onClose, onComplete }: HubAddMemberWi if (!client || !defaultModel.trim()) return; if (client === 'antigravity') { onComplete({ - client, + clientId: client, defaultModel: defaultModel.trim(), commandArgs: commandArgs.trim(), }); @@ -180,7 +180,7 @@ export function HubAddMemberWizard({ open, onClose, onComplete }: HubAddMemberWi availableProfiles.find((profile) => profile.id === selectedProfileId)?.id ?? selectedProfileId.trim(); if (!resolvedProfileId) return; onComplete({ - client, + clientId: client, accountRef: resolvedProfileId, defaultModel: defaultModel.trim(), }); diff --git a/packages/web/src/components/HubCatEditor.tsx b/packages/web/src/components/HubCatEditor.tsx index 9fa3d23c8..0d87d4b85 100644 --- a/packages/web/src/components/HubCatEditor.tsx +++ b/packages/web/src/components/HubCatEditor.tsx @@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from 'react'; import type { CatData } from '@/hooks/useCatData'; import { apiFetch } from '@/utils/api-client'; import type { ConfigData } from './config-viewer-types'; +import type { AccountsResponse, ProfileItem } from './hub-accounts.types'; import { buildEditorLoadingNote, uploadAvatarAsset } from './hub-cat-editor.client'; import { buildCatPayload, @@ -24,7 +25,6 @@ import { import { AccountSection, IdentitySection, RoutingSection } from './hub-cat-editor.sections'; import { AdvancedRuntimeSection } from './hub-cat-editor-advanced'; import { PersistenceBanner } from './hub-cat-editor-fields'; -import type { ProfileItem, ProviderProfilesResponse } from './hub-provider-profiles.types'; import type { CatStrategyEntry } from './hub-strategy-types'; import { useConfirm } from './useConfirm'; @@ -56,16 +56,16 @@ export function HubCatEditor({ cat, draft, open, onClose, onSaved }: HubCatEdito const [codexSettings, setCodexSettings] = useState(null); const [codexSettingsBaseline, setCodexSettingsBaseline] = useState(null); - const availableProfiles = useMemo(() => filterAccounts(form.client, profiles), [form.client, profiles]); + const availableProfiles = useMemo(() => filterAccounts(form.clientId, profiles), [form.clientId, profiles]); const selectedProfile = useMemo( () => availableProfiles.find((profile) => profile.id === form.accountRef) ?? null, [availableProfiles, form.accountRef], ); const modelOptions = useMemo(() => { - if (form.client === 'antigravity') return []; + if (form.clientId === 'antigravity') return []; return selectedProfile?.models ?? []; - }, [form.client, selectedProfile]); - const showCodexSettings = form.client === 'openai'; + }, [form.clientId, selectedProfile]); + const showCodexSettings = form.clientId === 'openai'; const codexSettingsEditable = !showCodexSettings || codexSettingsBaseline !== null; useEffect(() => { @@ -84,18 +84,18 @@ export function HubCatEditor({ cat, draft, open, onClose, onSaved }: HubCatEdito const [profilesVersion, setProfilesVersion] = useState(0); useEffect(() => { const handler = () => setProfilesVersion((v) => v + 1); - window.addEventListener('provider-profiles-changed', handler); - return () => window.removeEventListener('provider-profiles-changed', handler); + window.addEventListener('accounts-changed', handler); + return () => window.removeEventListener('accounts-changed', handler); }, []); useEffect(() => { if (!open) return; let cancelled = false; setLoadingProfiles(true); - apiFetch('/api/provider-profiles') + apiFetch('/api/accounts') .then(async (res) => { if (!res.ok) throw new Error(`账号配置加载失败 (${res.status})`); - return (await res.json()) as ProviderProfilesResponse; + return (await res.json()) as AccountsResponse; }) .then((body) => { if (!cancelled) setProfiles(body.providers); @@ -184,7 +184,7 @@ export function HubCatEditor({ cat, draft, open, onClose, onSaved }: HubCatEdito }, [cat, open, showCodexSettings]); useEffect(() => { - if (form.client === 'antigravity') { + if (form.clientId === 'antigravity') { setForm((prev) => (prev.accountRef === '' ? prev : { ...prev, accountRef: '' })); return; } @@ -193,7 +193,7 @@ export function HubCatEditor({ cat, draft, open, onClose, onSaved }: HubCatEdito return prev; } if (availableProfiles.length === 0) return prev; - const preferredBuiltin = builtinAccountIdForClient(prev.client); + const preferredBuiltin = builtinAccountIdForClient(prev.clientId); const nextProfile = availableProfiles.find((profile) => profile.id === prev.accountRef) ?? (preferredBuiltin ? availableProfiles.find((profile) => profile.id === preferredBuiltin) : null) ?? @@ -203,26 +203,26 @@ export function HubCatEditor({ cat, draft, open, onClose, onSaved }: HubCatEdito if (prev.accountRef === nextProfile.id) return prev; return { ...prev, accountRef: nextProfile.id }; }); - }, [availableProfiles, cat, draft, form.client]); + }, [availableProfiles, cat, draft, form.clientId]); useEffect(() => { - if (form.client === 'antigravity' || modelOptions.length === 0) return; + if (form.clientId === 'antigravity' || modelOptions.length === 0) return; if (form.defaultModel.trim().length > 0) return; setForm((prev) => { - if (prev.client === 'antigravity' || prev.defaultModel.trim().length > 0) return prev; + if (prev.clientId === 'antigravity' || prev.defaultModel.trim().length > 0) return prev; return { ...prev, defaultModel: modelOptions[0] ?? '' }; }); - }, [form.client, form.defaultModel, modelOptions]); + }, [form.clientId, form.defaultModel, modelOptions]); useEffect(() => { - if (form.client !== 'antigravity') return; + if (form.clientId !== 'antigravity') return; if (form.commandArgs.trim().length > 0) return; setForm((prev) => { - if (prev.client !== 'antigravity') return prev; + if (prev.clientId !== 'antigravity') return prev; if (prev.commandArgs.trim().length > 0) return prev; return { ...prev, commandArgs: DEFAULT_ANTIGRAVITY_COMMAND_ARGS }; }); - }, [form.client, form.commandArgs]); + }, [form.clientId, form.commandArgs]); if (!open) return null; @@ -237,7 +237,7 @@ export function HubCatEditor({ cat, draft, open, onClose, onSaved }: HubCatEdito if (patch.name !== undefined || patch.roleDescription !== undefined) { setFieldErrors((prev) => ({ ...prev, identity: false })); } - if (patch.defaultModel !== undefined || patch.client !== undefined) { + if (patch.defaultModel !== undefined || patch.clientId !== undefined) { setFieldErrors((prev) => ({ ...prev, account: false })); } }; @@ -290,9 +290,9 @@ export function HubCatEditor({ cat, draft, open, onClose, onSaved }: HubCatEdito errors.account = true; errorMessages.push('Model'); } else if ( - form.client === 'opencode' && + form.clientId === 'opencode' && selectedProfile?.authType === 'api_key' && - !form.ocProviderName.trim() && + !form.provider.trim() && (() => { const m = form.defaultModel.trim(); const si = m.indexOf('/'); diff --git a/packages/web/src/components/HubMemberOverviewCard.tsx b/packages/web/src/components/HubMemberOverviewCard.tsx index 3373c17dd..7a4f4107a 100644 --- a/packages/web/src/components/HubMemberOverviewCard.tsx +++ b/packages/web/src/components/HubMemberOverviewCard.tsx @@ -8,31 +8,31 @@ function safeAvatarSrc(value: string | null | undefined): string | null { return null; } -function humanizeProvider(provider: string) { - if (provider === 'openai') return 'OpenAI'; - if (provider === 'anthropic') return 'Anthropic'; - if (provider === 'google') return 'Gemini'; - if (provider === 'dare') return 'Dare'; - if (provider === 'opencode') return 'OpenCode'; - if (provider === 'antigravity') return 'Antigravity'; - return provider; +function humanizeClientId(clientId: string) { + if (clientId === 'openai') return 'OpenAI'; + if (clientId === 'anthropic') return 'Anthropic'; + if (clientId === 'google') return 'Gemini'; + if (clientId === 'dare') return 'Dare'; + if (clientId === 'opencode') return 'OpenCode'; + if (clientId === 'antigravity') return 'Antigravity'; + return clientId; } function clientRuntimeLabel(cat: CatData, configCat?: CatConfig) { - const accountRef = (cat.accountRef ?? cat.providerProfileId ?? '').toLowerCase(); + const accountRef = (cat.accountRef ?? '').toLowerCase(); if (accountRef.includes('claude')) return 'Claude'; if (accountRef.includes('codex')) return 'Codex'; if (accountRef.includes('gemini')) return 'Gemini'; if (accountRef.includes('opencode')) return 'OpenCode'; if (accountRef.includes('dare')) return 'Dare'; - if (cat.provider === 'antigravity') return 'Antigravity'; - if (cat.source === 'runtime' && cat.provider === 'openai') return 'OpenAI-Compatible'; - return humanizeProvider(configCat?.provider ?? cat.provider); + if (cat.clientId === 'antigravity') return 'Antigravity'; + if (cat.source === 'runtime' && cat.clientId === 'openai') return 'OpenAI-Compatible'; + return humanizeClientId(configCat?.clientId ?? cat.clientId); } function accountSummary(cat: CatData) { - const accountRef = cat.accountRef?.trim() ?? cat.providerProfileId?.trim() ?? ''; - if (!accountRef) return humanizeProvider(cat.provider); + const accountRef = cat.accountRef?.trim() ?? ''; + if (!accountRef) return humanizeClientId(cat.clientId); if ( accountRef === 'claude' || accountRef === 'codex' || @@ -46,7 +46,7 @@ function accountSummary(cat: CatData) { } function getMetaSummary(cat: CatData, configCat?: CatConfig) { - if (cat.provider === 'antigravity') { + if (cat.clientId === 'antigravity') { return `Antigravity · ${configCat?.model ?? cat.defaultModel} · CLI Bridge`; } diff --git a/packages/web/src/components/HubQuotaBoardTab.tsx b/packages/web/src/components/HubQuotaBoardTab.tsx index 65644a777..4c260c848 100644 --- a/packages/web/src/components/HubQuotaBoardTab.tsx +++ b/packages/web/src/components/HubQuotaBoardTab.tsx @@ -4,7 +4,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useCatData } from '@/hooks/useCatData'; import { apiFetch } from '@/utils/api-client'; -import type { ProviderProfilesResponse } from './hub-provider-profiles.types'; +import type { AccountsResponse } from './hub-accounts.types'; import { type AccountQuotaPoolGroup, buildAccountQuotaGroups } from './hub-quota-pools'; import { type CodexUsageItem, QuotaPoolRow, type QuotaResponse, riskDotClass, toUtilization } from './quota-cards'; @@ -56,7 +56,7 @@ export function HubQuotaBoardTab() { const { cats } = useCatData(); const [quota, setQuota] = useState(null); const [quotaError, setQuotaError] = useState(null); - const [profiles, setProfiles] = useState([]); + const [profiles, setProfiles] = useState([]); const [profilesError, setProfilesError] = useState(null); const [refreshing, setRefreshing] = useState(false); const [refreshError, setRefreshError] = useState(null); @@ -88,13 +88,13 @@ export function HubQuotaBoardTab() { useEffect(() => { let cancelled = false; setProfilesError(null); - apiFetch('/api/provider-profiles') + apiFetch('/api/accounts') .then(async (res) => { if (!res.ok) { if (!cancelled) setProfilesError(`账号配置加载失败 (${res.status}),额度池成员归属可能不完整`); return null; } - return (await res.json()) as ProviderProfilesResponse; + return (await res.json()) as AccountsResponse; }) .then((body) => { if (!cancelled && body) { diff --git a/packages/web/src/components/HubStrategyCard.tsx b/packages/web/src/components/HubStrategyCard.tsx index 1c3f8136d..be5eeb05f 100644 --- a/packages/web/src/components/HubStrategyCard.tsx +++ b/packages/web/src/components/HubStrategyCard.tsx @@ -101,7 +101,7 @@ export function CatStrategyCard({ entry, onSaved }: { entry: CatStrategyEntry; o

{entry.displayName} {entry.catId} - {entry.provider} + {entry.clientId}
{entry.sessionChainEnabled ? ( @@ -134,7 +134,7 @@ export function CatStrategyCard({ entry, onSaved }: { entry: CatStrategyEntry; o
)} {!entry.hybridCapable && ( -
Provider {entry.provider} 不支持 hybrid 策略
+
Provider {entry.clientId} 不支持 hybrid 策略
)}