diff --git a/.trellis/tasks/08-31-grok-first-class-iteration/HANDOFF.md b/.trellis/tasks/08-31-grok-first-class-iteration/HANDOFF.md new file mode 100644 index 000000000..3e1a6e7ce --- /dev/null +++ b/.trellis/tasks/08-31-grok-first-class-iteration/HANDOFF.md @@ -0,0 +1,55 @@ +# 交接:Grok 一等公民(明天 Mac 继续) + +写给明天的 William。密码、验证码、邮箱、账号名、token 不要写进仓库或结果表。 + +## 分支 + +- 仓库:`fy-agent/fyagent`(远端 `origin`) +- 分支:`feat/grok-first-class-iteration` +- 从 `main` / `79092221` 切出,**不要**推 `main` +- 明天若分支名或目标仓库不对,直接改;这份说明跟着分支走 + +对齐源:本目录 [`summary.md`](./summary.md)。亲测表:[`research/hil-matrix.md`](./research/hil-matrix.md)。 + +## 现在程序里有什么 + +三条登录已经分开,不要混: + +| 路 | 人怎么走 | 对的样子 | +|---|---|---| +| 官方 Grok | 新界面 → AI软件配置 → Grok Build → 模型 →「登录」 | 终端 `grok login` / `grok logout`。只说门打开了,**不说**已登录。 | +| SuperGrok 扫码 | 同一页点「打开认证中心扫 SuperGrok」 | 弹出旧认证中心,在 **xAI (Grok OAuth)** 扫一次。这把钥匙给后面几家共用。 | +| API 钥匙 | 侧栏模型管理 → Grok Build,或 Grok 页「配置 API 钥匙」 | 只填钥匙。没有 `grok login` 说明书。 | + +SuperGrok 扫完之后,分别去各家模型页写入(每家一张单,不要混): + +1. Claude Code:模型页 →「绑定到 Claude Code」→ 先看 → 确认 +2. Claude Desktop:同一页「绑定到 Claude Desktop」(目录没有单独一页是正常的) +3. Codex:模型页 →「创建 SuperGrok Provider」→ 先看 → 确认创建 → 再确认切换预览 +4. WorkBuddy:模型页 →「用 SuperGrok 拉名单」→ 走它自己的保存,不是 Codex 那扇门 + +官方 `grok login` **不会**写进 Codex。只用 Grok Build 的人,终端自己跑 `grok login` 就可以。 + +## 今天修过的交互 + +- 认证状态只出现在「模型」分段。Skill / MCP / 提示词顶上不再钉认证条。 +- Grok / Codex 不再显示「刷新状态」。刷新不会再把交接成功条清掉、看起来像退登。 +- Grok 页主按钮改成「配置 API 钥匙」,并加了去 Codex / Claude / WorkBuddy 绑定 SuperGrok 的门。 +- 「打开认证中心」会弹出旧认证中心(不把扫码搬进新界面,也不新做一套 OAuth)。 + +## 明天 Mac 怎么走 + +1. checkout `feat/grok-first-class-iteration`(或你改过的分支名)。 +2. 不要装、不要升级 Grok。不要做 ChatGPT 登录。不要写 Qoder / TRAE。 +3. 按 [`research/hil-matrix.md`](./research/hil-matrix.md) 走完 H1–H9。 +4. 结果只写屏幕事实。Windows 结果表还在本机:`C:\Users\wq241\Downloads\FYAGENT-GROK-HIL-WINDOWS-结果.md`。Mac 可写私人交接仓 `results/mac.md`。 +5. 少一家、少一台电脑,都不算完。不要关 GitHub #42 / #43。 + +Windows 上官方登录(H1)已经走过一轮,当时「刷新状态」会像退登、「进入模型管理」会掉进 API Key 页;这两处今天已改,Mac 请按新交互测。SuperGrok → 四家写入(H3–H8)Windows 还没走完。 + +## 明确不要做 + +- 不把官方登录显示成「已验证 / 已登录」 +- 不读 `~/.grok/auth.json` 假装已登录 +- 不把旧认证中心整页搬进 `src/v2` +- 不提交 `.qoder/`、截图、`MEMORY.md`、密钥 diff --git a/.trellis/tasks/08-31-grok-first-class-iteration/check.jsonl b/.trellis/tasks/08-31-grok-first-class-iteration/check.jsonl new file mode 100644 index 000000000..db2bb3563 --- /dev/null +++ b/.trellis/tasks/08-31-grok-first-class-iteration/check.jsonl @@ -0,0 +1,8 @@ +{"file": ".trellis/tasks/08-31-grok-first-class-iteration/summary.md", "reason": "Reject work that drifts from the shared plain plan"} +{"file": ".trellis/spec/backend/external-agent-p0.md", "reason": "Reject verified Grok login or auth.json inference"} +{"file": ".trellis/spec/frontend/v2-agent-models.md", "reason": "Reject login controls on Models Quick Setup"} +{"file": ".trellis/spec/frontend/reuse.md", "reason": "Reject page-local OAuth or Settings rewrite"} +{"file": ".trellis/tasks/08-31-grok-first-class-iteration/research/hil-matrix.md", "reason": "Both machines and all live cases required"} +{"file": ".trellis/tasks/08-31-grok-login-trichotomy/research/current-login-surfaces.md", "reason": "Three roads must stay on their current owners"} +{"file": ".trellis/spec/backend/workbuddy-configuration.md", "reason": "Reject treating WorkBuddy as a Provider write"} +{"file": ".trellis/tasks/08-31-grok-supergrok-to-workbuddy/research/current-workbuddy-save-path.md", "reason": "Reject refresh-token copy into models.json"} diff --git a/.trellis/tasks/08-31-grok-first-class-iteration/design.md b/.trellis/tasks/08-31-grok-first-class-iteration/design.md new file mode 100644 index 000000000..03d32bad0 --- /dev/null +++ b/.trellis/tasks/08-31-grok-first-class-iteration/design.md @@ -0,0 +1,67 @@ +# Design — Grok login and SuperGrok placement + +先读 [summary.md](./summary.md)。这里只写怎么接现有零件,不另起炉灶。 + +## Architecture + +不新开登录系统,不新开第四套保存。 + +```text +新界面 Agent(Grok) + → 打开终端 grok login / logout + → 只说「门打开了」,不说「已经登进去」 + +旧认证中心(SuperGrok 扫码) + → 账号存在 FyAgent 自己的保险柜(xai_oauth) + → 这把钥匙给下面几家共用,每家各自写入 + +新界面模型(Grok) + → 只填 API 钥匙 + → 不讲 grok login,不讲扫码 +``` + +| 地方 | 这轮做什么 | 不要做什么 | +|---|---|---| +| 新界面 Agent | 官方登录的路标说清楚 | 假装已经登录;去翻 Grok 秘密文件 | +| 认证中心 | SuperGrok 扫码仍只在这里 | 搬进新界面;新做一套 OAuth | +| Claude Code | 已有账号能绑上去;新界面能看见这条路 | 和 Codex 写进同一张预览单 | +| Claude Desktop | 旧界面走通绑定;目录没有单独一页就不要硬造 | 假装它在新界面 Agent 目录里 | +| Codex | 现有 Change Plan 开窄口:已有托管账号才放行 | 第四套保存;预览单里放钥匙 | +| WorkBuddy | 走它自己的 Change Plan | 走 Codex upsert;把刷新令牌抄进 models.json | +| 新界面模型 | API 钥匙保持原样 | 把官方登录说明书贴过来 | + +## Data flow + +1. 官方登录:界面只说「给 Grok 登录或退出」。程序打开官方命令,立刻结束。不像 Claude 那样再查一遍「真的登进去了没有」。 +2. 扫码:人在认证中心登完。钥匙放在 `xai_oauth_auth.json`,不写进 Grok 官方那个秘密文件。 +3. Claude Code / Claude Desktop:旧界面已经能用 `xAI (Grok)` 的 `xai_oauth` 预设绑定。这轮复用这套绑定,每家一张独立保存。Claude 没有 Change Plan 适配器,不要为它新开第四个执行器。Desktop 亲测走旧界面。 +4. Codex:旧界面已经能绑。新界面 Change Plan 今天会拒绝 SuperGrok。这轮仍用这一套预览,只允许「认证中心里已经有这个账号」。预览单里仍然不能出现钥匙。不要把旧表单搬进新界面。 +5. WorkBuddy:走 `create_workbuddy_save_plan`。已扫码的,先用这份账号拉模型名单,不要再扫一次。WorkBuddy 自己的文件只认地址和钥匙:不要把 OAuth 刷新令牌抄进去。能少填一把钥匙就少填;做不到就老实说卡在文件格式,不要谎报已经写进去。 + +调研: + +- `../08-31-grok-login-trichotomy/research/current-login-surfaces.md` +- `../08-31-grok-supergrok-to-codex/research/current-supergrok-codex-path.md` +- `../08-31-grok-supergrok-to-workbuddy/research/current-workbuddy-save-path.md` + +## Compatibility + +- Claude「能查到是否登录」的路不变。 +- Codex「去认证中心管账号」的说法不变。 +- 不关 #42 / #43 整张工单。 +- 不装、不升级 Grok。 +- ChatGPT 登录(`codex_oauth`)这轮不动。 +- 额度查询继续可以读 Grok 秘密文件;登录成功不能靠它。 +- 没改模型草稿,#141 B7 就标「这轮没碰」。 + +## Tradeoffs + +- 新界面没有认证中心这一页。扫码用路标指回去,不整页搬迁。 +- 官方登录没有「查一下登没登」的命令。双机亲测看的是门开对了、字写对了、人能在终端做完,不是软件显示「已登录」。 +- Claude / Desktop 继续走已有 Provider 绑定,不新造 Claude Change Plan。 +- Codex 要在现有预览上开窄门。 +- WorkBuddy 和 Codex 不是同一扇门。关联的是同一把扫码钥匙,不是同一段写入代码。 + +## Rollback + +登录路标、Claude/Desktop/Codex 写入、WorkBuddy 写入可以分开撤。不要把别人已经做完的登录合同整段撤掉。 diff --git a/.trellis/tasks/08-31-grok-first-class-iteration/implement.jsonl b/.trellis/tasks/08-31-grok-first-class-iteration/implement.jsonl new file mode 100644 index 000000000..98ff91378 --- /dev/null +++ b/.trellis/tasks/08-31-grok-first-class-iteration/implement.jsonl @@ -0,0 +1,12 @@ +{"file": ".trellis/spec/backend/external-agent-p0.md", "reason": "Grok handoff-only auth contract and no vendor-token inference"} +{"file": ".trellis/spec/backend/reuse.md", "reason": "Reuse Auth Center and Change Plan; no second OAuth or executor"} +{"file": ".trellis/spec/frontend/reuse.md", "reason": "Extend AgentAuthStatusPanel and existing chrome instead of forking"} +{"file": ".trellis/spec/frontend/v2-agent-models.md", "reason": "Grok Build Models remain Quick Setup; login is not this page"} +{"file": ".trellis/spec/guides/code-reuse-thinking-guide.md", "reason": "Force reuse of current owners before any new surface"} +{"file": ".trellis/tasks/08-31-grok-login-trichotomy/research/current-login-surfaces.md", "reason": "Three-road start surfaces and copy after PR 167"} +{"file": ".trellis/tasks/08-31-grok-first-class-iteration/summary.md", "reason": "Shared plain-language plan; all windows must match this"} +{"file": ".trellis/tasks/08-31-grok-first-class-iteration/research/hil-matrix.md", "reason": "Dual-machine live acceptance cases"} +{"file": ".trellis/spec/backend/change-plan-executor.md", "reason": "Reuse closed Codex adapters; no fourth operation"} +{"file": ".trellis/tasks/08-31-grok-supergrok-to-codex/research/current-supergrok-codex-path.md", "reason": "Change Plan rejects xai_oauth today; V2 does not observe accounts"} +{"file": ".trellis/spec/backend/workbuddy-configuration.md", "reason": "WorkBuddy save is a separate closed path"} +{"file": ".trellis/tasks/08-31-grok-supergrok-to-workbuddy/research/current-workbuddy-save-path.md", "reason": "WorkBuddy must not copy refresh tokens or use Codex upsert"} diff --git a/.trellis/tasks/08-31-grok-first-class-iteration/implement.md b/.trellis/tasks/08-31-grok-first-class-iteration/implement.md new file mode 100644 index 000000000..36c83c456 --- /dev/null +++ b/.trellis/tasks/08-31-grok-first-class-iteration/implement.md @@ -0,0 +1,58 @@ +# Implement — Grok first-class iteration + +先读 [summary.md](./summary.md) 和 [use-cases.md](./use-cases.md)。父任务不改产品代码。 + +下游开工读序(每个子任务都要齐):`summary.md` → 子任务 `prd.md` → `design.md` → `implement.md` → `use-cases.md` → 该任务 `research/` → `implement.jsonl` 里的 spec。 + +不要从空白开始,也不要只读父任务摘要就改代码。 + +## Feature inventory + +| 编号 | 人能做成的事 | 谁做 | 回写 | +|---|---|---|---| +| F1 | Grok 官方登录/退出找得到,并且不说已经登录 | 登录窗口 | #43 | +| F2 | SuperGrok 扫码的下一步指向认证中心 | 登录窗口 | #43 | +| F3 | 模型页继续只填 API 钥匙 | 登录窗口 | #43 | +| F4 | SuperGrok 能进 Claude Code、Claude Desktop、Codex:每家先看、再改、再检查(Desktop 可在旧界面完成) | 投放窗口 | #42 / #41 / #63 | +| F5 | SuperGrok 能进 WorkBuddy:先看、再改、再检查 | WorkBuddy 窗口 | #42 | +| F6 | 名单上有名字,不等于已经完全支持 | 各窗口改字时都遵守 | #22 / #106 | + +## Change inventory + +| 编号 | 要改 | 不要改 | +|---|---|---| +| C1 | Grok 认证区的字,点名去终端跑 `grok login` | Claude 那种「查一下真的登了」 | +| C2 | 扫码的下一步指到认证中心 | 新做一套登录;把旧设置页搬进新界面 | +| C3 | Claude Code / Desktop:复用已有 `xai_oauth` 绑定;每家独立保存 | Claude Change Plan 新执行器;和 Codex 写一张单 | +| C4 | 现有 Codex 预览:认证中心已有 SuperGrok 账号时放行;新界面能看见这条路 | 第四套保存;预览单里放钥匙;不打招呼就盖掉原来的 API 钥匙槽 | +| C5 | WorkBuddy 自己的保存预览能用已扫码账号拉模型 | 走 Codex upsert;把刷新令牌抄进 `models.json` | +| C6 | 用自动检查把 F1–F5 钉住 | 没必要就别动模型草稿 | + +## Ordered work + +1. 先立登录路标,免得投放还在叫人去跑 `grok login`。 +2. 再开 Claude / Desktop / Codex:每家独立预览和保存。Codex 先改「准不准预览」,再补新界面能看见的路。 +3. 再开 WorkBuddy:走它自己的 Change Plan。 +4. 总控把三条线接成一次能走完。 +5. William 在两台电脑上按 `research/hil-matrix.md` 亲测。 +6. 回写 GitHub,不关整张 #42 / #43。 + +## Validation + +- 各窗口自己的程序检查。 +- 总控:`research/hil-matrix.md` 两台电脑都打勾。 +- 密码不进仓库。 + +## Risky files + +- `src/v2/pages/agents/AgentAuthStatusPanel.tsx` +- Grok 登录交接(先看,不要轻易改短路径) +- Codex 预览是否放行(`prove_codex_target_credential_capability` 一带) +- Claude / Desktop 的 `xai_oauth` 预设和 `ProviderForm` 绑定 +- WorkBuddy `create_workbuddy_save_plan` / `models.json` 写入 +- 认证中心现有扫码界面(尽量只指路,不重做) + +## Rollback points + +- 登录路标撤了,不影响投放写入。 +- Claude / Desktop / Codex / WorkBuddy 可以单独关一扇门,不要互相连坐。 diff --git a/.trellis/tasks/08-31-grok-first-class-iteration/prd.md b/.trellis/tasks/08-31-grok-first-class-iteration/prd.md new file mode 100644 index 000000000..3a71c6933 --- /dev/null +++ b/.trellis/tasks/08-31-grok-first-class-iteration/prd.md @@ -0,0 +1,59 @@ +# Finish Grok login and SuperGrok placement into supported tools + +先读 [summary.md](./summary.md)。用例总表:[use-cases.md](./use-cases.md)。亲测勾选:[research/hil-matrix.md](./research/hil-matrix.md)。 + +## Goal + +分清三种 Grok 登录;扫码一次 SuperGrok,能用到 Claude Code、Claude Desktop、Codex 和 WorkBuddy。William 在 Windows 和 Mac mini 上亲自走完才算完成。 + +## Background + +- 意图:[Discussion #106](https://github.com/fy-agent/fyagent/discussions/106)。登录回写 [#43](https://github.com/fy-agent/fyagent/issues/43),投放回写 [#42](https://github.com/fy-agent/fyagent/issues/42)。 +- 2026-08-31:William 决定关联投放一起做,不拆成「先只做 Codex」。 +- 子任务:登录路标;Claude/Desktop/Codex 投放;WorkBuddy 投放。 + +## Confirmed facts + +登录(`08-31-grok-login-trichotomy/research/current-login-surfaces.md`): + +- 三条路散在三处。官方登录只交接,不验证。没有 Grok 登录状态命令。不能用 `~/.grok/auth.json` 证明已登录。 +- ChatGPT 登录是 `codex_oauth`,和 SuperGrok 扫码不是一把钥匙。 + +投放(`08-31-grok-supergrok-to-codex/research/current-supergrok-codex-path.md`): + +- SuperGrok 扫码是共用认证中心。旧界面已能绑 Claude Code / Claude Desktop / Codex 的 `xai_oauth` 预设。 +- 新界面 Change Plan / Quick Setup 只认 API 钥匙,会拒绝托管扫码。有没有账号,页面长得一样。 +- Claude Desktop 不在新界面 Agent 目录里,亲测走旧界面。 + +WorkBuddy: + +- 目录允许自己换模型。保存走自己的 Change Plan(地址 + 钥匙 + 模型名),不是 Provider Quick Setup。 +- 现在没有 `xai_oauth` 预设。Qoder 不能配第三方模型;TRAE 不能代写模型。 + +## Requirements + +- R1. 三种登录路标分开。官方登录不说已登录。 +- R2. SuperGrok 扫码仍只在认证中心。 +- R3. 同一份已登录 SuperGrok,能分别写进 Claude Code、Claude Desktop、Codex。每家一张独立预览/保存。失败不连累别人。 +- R4. 同一份脑子能写进 WorkBuddy。优先用已扫码账号,不要无故再要一把钥匙。走 WorkBuddy 自己的保存。 +- R5. Qoder / TRAE 不写第三方模型。ChatGPT 登录这轮不做。 +- R6. 双机亲测全部路径。密码不进仓库。 + +## Acceptance Criteria + +- [ ] AC1. 人能分清官方登录、扫码、API 钥匙。 +- [ ] AC2. 官方登录不出现「已验证」。Claude 原来能验证的路还在。 +- [ ] AC3. SuperGrok → Claude Code、Claude Desktop、Codex 都能先看再改再检查(Desktop 可在旧界面完成)。 +- [ ] AC4. SuperGrok → WorkBuddy 能保存并回读。 +- [ ] AC5. 一家失败不谎报另一家成功。 +- [ ] AC6. 不关 #42 / #43 整张工单。#141 B7 按有没有改草稿标记。 +- [ ] AC7. Windows 和 Mac mini 都按 `research/hil-matrix.md` 走完。 + +## Out of scope + +- 安装升级 Grok(#31、#32) +- 新界面额度看板 +- ChatGPT 登录 +- Qoder / TRAE 模型写入 +- 总门卫 +- 写 `~/.grok/auth.json` 冒充登录 diff --git a/.trellis/tasks/08-31-grok-first-class-iteration/research/github-decision-106.md b/.trellis/tasks/08-31-grok-first-class-iteration/research/github-decision-106.md new file mode 100644 index 000000000..c066161d6 --- /dev/null +++ b/.trellis/tasks/08-31-grok-first-class-iteration/research/github-decision-106.md @@ -0,0 +1,12 @@ +## 2026-08-31 迭代决策(回写,已扩范围) + +本轮不新开「把 Grok 收完」的平行 Issue。产品意图继续挂在本讨论。 + +**本迭代做:** + +1. 把官方 `grok login`、FyAgent 自管 xAI 设备码、API Key 三条路在界面上拆开(落地 [#43](https://github.com/fy-agent/fyagent/issues/43))。官方态没有结构化 status,就保持 handoff,不读 `~/.grok/auth.json` 冒充已登录。ChatGPT 登录是另一把钥匙,这轮不做。 +2. 用同一份 SuperGrok 设备码,分别投到 Claude Code、Claude Desktop、Codex、WorkBuddy(落地 [#42](https://github.com/fy-agent/fyagent/issues/42);Codex 写入复用 [#41](https://github.com/fy-agent/fyagent/issues/41) / [#63](https://github.com/fy-agent/fyagent/issues/63);WorkBuddy 走自己的保存)。 + +**本迭代不做:** Grok 安装/升级([#31](https://github.com/fy-agent/fyagent/issues/31)、[#32](https://github.com/fy-agent/fyagent/issues/32))、V2 额度看板、ChatGPT 登录、Qoder / TRAE 模型写入、总门卫(#133)。 + +不关闭 #42 / #43 整张工单,只回写 Grok 这一刀。 diff --git a/.trellis/tasks/08-31-grok-first-class-iteration/research/github-decision-141.md b/.trellis/tasks/08-31-grok-first-class-iteration/research/github-decision-141.md new file mode 100644 index 000000000..9db6a79bd --- /dev/null +++ b/.trellis/tasks/08-31-grok-first-class-iteration/research/github-decision-141.md @@ -0,0 +1,5 @@ +## 2026-08-31 范围说明(B7) + +本迭代若改到 Grok Build 模型草稿,会在最新 main 上复验 B7:空草稿未交互不得直接显示 validation error。 + +完成后在本 Issue 把 B7 标成 `fixed` / `still applies` / `not touched`。B9(Grok Bot 发行方)不在本迭代。 diff --git a/.trellis/tasks/08-31-grok-first-class-iteration/research/github-decision-31-32.md b/.trellis/tasks/08-31-grok-first-class-iteration/research/github-decision-31-32.md new file mode 100644 index 000000000..26de7a619 --- /dev/null +++ b/.trellis/tasks/08-31-grok-first-class-iteration/research/github-decision-31-32.md @@ -0,0 +1,5 @@ +## 2026-08-31 范围说明 + +Grok Build 的多份安装、版本和更新(#31)以及安装后健康探测(#32)仍然有效,但 **不在本迭代**。 + +本迭代先收登录三分法和 SuperGrok → Codex。Grok 上的 PATH / `os error 2` / 版本冲突继续留在这两张单上。 diff --git a/.trellis/tasks/08-31-grok-first-class-iteration/research/github-decision-42.md b/.trellis/tasks/08-31-grok-first-class-iteration/research/github-decision-42.md new file mode 100644 index 000000000..41e10ead8 --- /dev/null +++ b/.trellis/tasks/08-31-grok-first-class-iteration/research/github-decision-42.md @@ -0,0 +1,16 @@ +## 2026-08-31 迭代决策(Grok 切片,已扩范围) + +#42 仍是「同一接入源投给多个 Agent」的总单,**不关闭本 Issue**。 + +本迭代把同一份 SuperGrok 扫码,分别投到现在能写的地方: + +- Claude Code +- Claude Desktop(旧界面完成即可) +- Codex(新界面 Change Plan 开窄口) +- WorkBuddy(走它自己的模型保存,不是 Codex 那扇门) + +每一家一张独立预览/保存。一家失败不谎报另一家已应用。 + +不做:Qoder / TRAE 模型写入、ChatGPT 登录、安装升级 Grok。 + +登录边界见 [#43](https://github.com/fy-agent/fyagent/issues/43)。产品意图见 [#106](https://github.com/fy-agent/fyagent/discussions/106)。 diff --git a/.trellis/tasks/08-31-grok-first-class-iteration/research/github-decision-43.md b/.trellis/tasks/08-31-grok-first-class-iteration/research/github-decision-43.md new file mode 100644 index 000000000..8fd8cad03 --- /dev/null +++ b/.trellis/tasks/08-31-grok-first-class-iteration/research/github-decision-43.md @@ -0,0 +1,12 @@ +## 2026-08-31 迭代决策(Grok 切片) + +#43 仍是「官方订阅逐厂商准入」的总单,本迭代只做 Grok 这一刀,**不关闭本 Issue**。 + +对齐本 Issue 的验收: + +- 官方态:`grok login` / `grok logout`,没有已审查的结构化 status,标为 assisted / handoff,不把打开终端写成已验证。 +- 设备码:继续走 FyAgent 认证中心的 xAI OAuth,不读、不写 `~/.grok/auth.json`。 +- API Key:第三条路,不和上面两条抢文案。 +- 没有官方依据就不做 token relay,也不用额度接口反推登录成功。 + +产品意图见 [#106](https://github.com/fy-agent/fyagent/discussions/106)。同一订阅分别投到 Claude Code、Claude Desktop、Codex、WorkBuddy 见 [#42](https://github.com/fy-agent/fyagent/issues/42)。 diff --git a/.trellis/tasks/08-31-grok-first-class-iteration/research/github-dual-machine-hil.md b/.trellis/tasks/08-31-grok-first-class-iteration/research/github-dual-machine-hil.md new file mode 100644 index 000000000..d84a3ebde --- /dev/null +++ b/.trellis/tasks/08-31-grok-first-class-iteration/research/github-dual-machine-hil.md @@ -0,0 +1,3 @@ +## 2026-08-31 验收补充 + +亲测机器定为当前 Windows 与 Mac mini **都要过**。缺一台的证据不得标完成。凭据仍不进 Git / Issue。 diff --git a/.trellis/tasks/08-31-grok-first-class-iteration/research/github-hil-required.md b/.trellis/tasks/08-31-grok-first-class-iteration/research/github-hil-required.md new file mode 100644 index 000000000..f1a9c5b86 --- /dev/null +++ b/.trellis/tasks/08-31-grok-first-class-iteration/research/github-hil-required.md @@ -0,0 +1,5 @@ +## 2026-08-31 验收补充 + +本迭代是必要功能。William 有真实 SuperGrok 账号,**必须亲身体验全部在范围内的路径后才能算完成**。合同、测试和 CI 不能替代这次 HIL。 + +凭据、token、账号标识不进 Git / Issue。完成后只回写不含秘密的路径结论。 diff --git a/.trellis/tasks/08-31-grok-first-class-iteration/research/github-plain-summary.md b/.trellis/tasks/08-31-grok-first-class-iteration/research/github-plain-summary.md new file mode 100644 index 000000000..2fa1fcefb --- /dev/null +++ b/.trellis/tasks/08-31-grok-first-class-iteration/research/github-plain-summary.md @@ -0,0 +1,10 @@ +## 2026-08-31 用白话对齐(同一份意思) + +这次做两件连在一起的事: + +1. 把 Grok 的三种登录说清楚:终端里的 `grok login`、认证中心里的 SuperGrok 扫码、模型页里的 API 钥匙。打开官方登录,不等于已经登录成功。Grok 官方登录做好了,也不会自动做好 ChatGPT 登录。 +2. 扫码登录 SuperGrok 一次,就能把这颗脑子用到现在该用、也能用的地方:Claude Code、Claude Desktop、Codex,以及 WorkBuddy。每家分开改。先给你看要改什么,你点头后再改,改完再检查。Claude Desktop 不在新界面目录里,走现在的旧界面。Qoder / TRAE 按目录做不到,这轮不做。 + +必须 William 本人在 Windows 和 Mac mini 上都走完,才算做完。密码不写在这里。 + +不关 #42 / #43 整张工单。不装不升级 Grok。 diff --git a/.trellis/tasks/08-31-grok-first-class-iteration/research/hil-matrix.md b/.trellis/tasks/08-31-grok-first-class-iteration/research/hil-matrix.md new file mode 100644 index 000000000..07028b8c9 --- /dev/null +++ b/.trellis/tasks/08-31-grok-first-class-iteration/research/hil-matrix.md @@ -0,0 +1,37 @@ +# 亲测表 + +先读 [../summary.md](../summary.md)。 + +做完的意思:William 在 **Windows** 和 **Mac mini** 上把下表都走一遍。结果可以写在这里,**不要**写密码、验证码、邮箱、账号名。 + +能写的只有:屏幕上写了什么、有没有出现「已验证/已登录」、有没有先给你看要改什么、检查有没有通过、失败时指去了哪扇门。 + +## 程序自己先查 + +| 编号 | 查什么 | 谁锁 | +|---|---|---| +| AT1 | Grok 点登录后是「已交给官方认证入口」,没有「认证结果已验证」 | 登录窗口 | +| AT2 | 字里有 `grok login`,没有叫扫码去终端 | 登录窗口 | +| AT3 | Claude 原来能验证的路还在 | 登录窗口 | +| AT4 | Codex 认证区没有登录按钮,指向认证中心 | 登录窗口 | +| AT5 | 空的 Grok 模型草稿,没动手就不报错;没改草稿就标没碰 | 登录窗口 / #141 B7 | +| AT6 | 没账号时预览仍拒绝 SuperGrok;有账号时可以预览,单子里没有钥匙 | 投放窗口 | +| AT7 | 新界面没账号时指向认证中心,不搬旧表单 | 投放窗口 | +| AT8 | Claude Code 失败不谎报 Codex / WorkBuddy 已改好 | 投放窗口 | +| AT9 | WorkBuddy 预览走自己的保存,不走 Codex upsert;单子里没有刷新令牌 | WorkBuddy 窗口 | + +## 两台电脑都要走 + +| 编号 | 人怎么走 | Windows | Mac | +|---|---|---|---| +| H1 | 新界面 Grok → 登录 → 终端出现 `grok login` → 软件仍不说已登录 | | | +| H2 | 同一页退出 → 终端 `grok logout` → 仍不说已验证 | | | +| H3 | 认证中心扫码登录 SuperGrok 成功;过期指回认证中心,不是 `grok login` | | | +| H4 | 模型页 Grok 只填 API 钥匙,没有 `grok login` 说明书 | | | +| H5 | 已登录 SuperGrok → Claude Code → 先看 → 确认 → 检查通过 | | | +| H6 | 已登录 SuperGrok → Claude Desktop(旧界面即可)→ 先看 → 确认 → 检查通过 | | | +| H7 | 已登录 SuperGrok → Codex → 先看 → 确认 → 检查通过 | | | +| H8 | 已登录 SuperGrok → WorkBuddy → 先看 → 确认 → 检查通过 | | | +| H9 | 故意取消或失败一家,不说别人的工具也被改好了 | | | + +空一格就不能说做完。 diff --git a/.trellis/tasks/08-31-grok-first-class-iteration/summary.md b/.trellis/tasks/08-31-grok-first-class-iteration/summary.md new file mode 100644 index 000000000..9e5c5fe0d --- /dev/null +++ b/.trellis/tasks/08-31-grok-first-class-iteration/summary.md @@ -0,0 +1,66 @@ +# 这次迭代一句话对齐 + +先读这篇。后面的 PRD、设计和测试表都是在讲同一件事。 + +## 我们要帮用户做成什么 + +家里已经买了 Grok / SuperGrok 的人,打开 FyAgent 后: + +1. 能分清三种登录,不会走错门。 +2. 扫码登录 SuperGrok 一次,就能把这颗脑子用到**所有现在该用、也能用的地方**:Claude Code、Claude Desktop、Codex,以及 WorkBuddy。 +3. 必须 William 本人在 Windows 和 Mac mini 上各走一遍,才算做完。 + +名单上有名字,不等于已经能用。 + +## 登录有三条路,不要混 + +| 路 | 人怎么走 | 现在在哪 | 做成什么样 | +|---|---|---|---| +| 官方登录 | 终端里运行 `grok login` | 新界面 Agent 配置页 | 只帮你开门,**不说**已经登进去。 | +| SuperGrok 扫码 | 认证中心用官方网页登录 | 旧认证中心 | 路标指到认证中心。这把钥匙给后面几家工具共用。 | +| API 钥匙 | 自己填一把钥匙 | 新界面模型页 | 继续只填钥匙。不要出现 `grok login` 说明书。 | + +Grok 官方登录做好了,**不会**自动做好 ChatGPT 登录。ChatGPT 是另一把钥匙,这轮不做。 + +FyAgent 不会翻 Grok 的秘密文件来假装已经登录。 + +## 这颗脑子用到哪里 + +上游已经能用 SuperGrok 扫码的,我们都要能用: + +- **Claude Code**:新界面要能看见「先看、再改、再检查」。 +- **Claude Desktop**:目录里没有单独一页,走现在的旧界面绑定,这轮要能亲测走通。 +- **Codex**:和新界面预览绑在一起。今天新界面会拒绝 SuperGrok,这轮开一扇该开的门。 + +目录允许自己换模型的: + +- **WorkBuddy**:可以。走它自己的「保存模型」,不是 Codex 那扇门。能用已经扫码的 SuperGrok 就不要再让人填第二把钥匙。 +- **OpenCode**:上游若只是普通填钥匙,这轮不另做 SuperGrok 扫码。 +- **Qoder**:明确不能配第三方模型。不做。 +- **TRAE**:只能看,不能替它写模型。不做。 + +每一家分开改。改 Codex 失败了,不能说 Claude 或 WorkBuddy 也改好了。 + +## 怎么才算做完 + +- 程序自己的检查要过。 +- William 用真实账号,在 **Windows 和 Mac mini** 上走完:三条登录,以及 SuperGrok 进 Claude Code、Claude Desktop、Codex、WorkBuddy。 +- 少一家,或少一台电脑,都不算完。 +- 密码不要写进仓库。 + +## 这次明确不做 + +- 不装、不升级 Grok。 +- 不做还剩多少钱的看板。 +- 不做 ChatGPT 那把登录钥匙。 +- 不硬做 Qoder / TRAE 的模型写入。 +- 不做「总门卫」。 +- 不把官方登录显示成「已验证」。 +- 不关 #42 / #43 整张工单。 + +## 谁做什么 + +- 窗口一:三条登录路标。 +- 窗口二:SuperGrok 进 Claude Code、Claude Desktop、Codex。 +- 窗口三:SuperGrok 进 WorkBuddy。 +- 总控:对齐和两台电脑验收。 diff --git a/.trellis/tasks/08-31-grok-first-class-iteration/task.json b/.trellis/tasks/08-31-grok-first-class-iteration/task.json new file mode 100644 index 000000000..de88c24f7 --- /dev/null +++ b/.trellis/tasks/08-31-grok-first-class-iteration/task.json @@ -0,0 +1,30 @@ +{ + "id": "grok-first-class-iteration", + "name": "grok-first-class-iteration", + "title": "Finish Grok login and SuperGrok placement into supported tools", + "description": "", + "status": "planning", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "codex", + "assignee": "codex", + "createdAt": "2026-08-31", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [ + "08-31-grok-login-trichotomy", + "08-31-grok-supergrok-to-codex", + "08-31-grok-supergrok-to-workbuddy" + ], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/.trellis/tasks/08-31-grok-first-class-iteration/use-cases.md b/.trellis/tasks/08-31-grok-first-class-iteration/use-cases.md new file mode 100644 index 000000000..fa5bb4032 --- /dev/null +++ b/.trellis/tasks/08-31-grok-first-class-iteration/use-cases.md @@ -0,0 +1,11 @@ +# 用例总表 + +人怎么走、双机怎么勾,以 [research/hil-matrix.md](./research/hil-matrix.md) 为准。各窗口自己的 Given/When/Then 在子任务里,下游开工先读子任务那份,不要只读本页。 + +| 编号 | 谁做 | 子任务用例 | +|---|---|---| +| AT1–AT5, H1–H4 | 登录窗口 | `../08-31-grok-login-trichotomy/use-cases.md` UC-L1–L5 | +| AT6–AT8, H5–H7, H9 | 投放窗口 | `../08-31-grok-supergrok-to-codex/use-cases.md` UC-P0–P4 | +| AT9, H8, H9 | WorkBuddy 窗口 | `../08-31-grok-supergrok-to-workbuddy/use-cases.md` UC-W1–W3 | + +父任务不改产品代码。总控只做对齐和两台电脑勾选。 diff --git a/.trellis/tasks/08-31-grok-login-trichotomy/check.jsonl b/.trellis/tasks/08-31-grok-login-trichotomy/check.jsonl new file mode 100644 index 000000000..960b10605 --- /dev/null +++ b/.trellis/tasks/08-31-grok-login-trichotomy/check.jsonl @@ -0,0 +1,5 @@ +{"file": ".trellis/spec/backend/external-agent-p0.md", "reason": "No verified Grok login"} +{"file": ".trellis/spec/frontend/v2-agent-models.md", "reason": "Reject login controls on Models Quick Setup"} +{"file": ".trellis/tasks/08-31-grok-login-trichotomy/research/current-login-surfaces.md", "reason": "Do not start device-code from Agent Auth"} +{"file": ".trellis/tasks/08-31-grok-first-class-iteration/summary.md", "reason": "Reject copy that mixes the three roads"} +{"file": ".trellis/tasks/08-31-grok-first-class-iteration/research/hil-matrix.md", "reason": "Login live cases on both machines"} diff --git a/.trellis/tasks/08-31-grok-login-trichotomy/design.md b/.trellis/tasks/08-31-grok-login-trichotomy/design.md new file mode 100644 index 000000000..424f84a6e --- /dev/null +++ b/.trellis/tasks/08-31-grok-login-trichotomy/design.md @@ -0,0 +1,39 @@ +# Design — Grok login trichotomy + +先读父任务 [summary.md](../08-31-grok-first-class-iteration/summary.md)。事实和行号见 `research/current-login-surfaces.md`。用例见 [use-cases.md](./use-cases.md)。 + +## 边界 + +本子任务只立三条登录路标。不写 Claude / Desktop / Codex / WorkBuddy,不新做「查 Grok 登没登」。 + +三条路已经存在,只是散在三处。不要合成一个控件。 + +| 路 | 现有主人 | 这轮改什么 | +|---|---|---| +| 官方 `grok login` / `logout` | V2 Agent 配置页 `AgentAuthStatusPanel` → `start_agent_auth_session` → `launch_auth_action(GrokBuild)` | 文案点名终端命令;终点仍是 `handoff_complete` + `handoff_only` | +| SuperGrok 扫码 | v1 认证中心 `AuthCenterPanel` / `XaiOAuthSection` → `auth_start_login("xai_oauth")` | Agent / Codex 认证区指路到认证中心;不在 Agent 页启动扫码 | +| API 钥匙 | V2 模型页 Quick Setup `fyagent-v2-quick-setup-grokbuild` | **默认不改**。这里不要出现 `grok login` | + +## 合同(不得破) + +- Grok 官方登录:**禁止**出现「已验证」「已登录」「认证结果已验证」。权威是 `unverified`。 +- Claude 的 `claude auth status` 验证环保持原样。 +- Codex Agent 认证保持 `fyagent_managed`,没有登录按钮。 +- 禁止读/写 `~/.grok/auth.json` 来证明已登录。额度查询可以继续读,登录成功不能靠它。 +- 禁止从 Agent 配置页调用 `auth_start_login`。 +- 禁止把 v1 `AuthCenterPanel` / `XaiOAuthSection` 进口到 `src/v2`。 +- 没改模型草稿则 #141 B7 标 `not touched`。默认不要动 `ProviderPanel` / `quickSetup.ts`。 + +## 数据流 + +1. 人在 Grok Agent 配置页点登录 → 终端跑 `grok login` → 会话立刻 `handoff_complete`。 +2. 人要扫码 → 被指到 v1 设置「认证」页的 `xAI (Grok OAuth)`。 +3. 人要填钥匙 → 还在模型页,和上面两路无关。 + +## 兼容 + +ChatGPT 登录(`codex_oauth`)不动。Grok 安装/升级不动。 + +## 回滚 + +只撤文案和指路。不要动 `auth_sessions.rs` 的 handoff 短路径,除非测试证明字改了但状态机坏了。 diff --git a/.trellis/tasks/08-31-grok-login-trichotomy/implement.jsonl b/.trellis/tasks/08-31-grok-login-trichotomy/implement.jsonl new file mode 100644 index 000000000..05c9b1348 --- /dev/null +++ b/.trellis/tasks/08-31-grok-login-trichotomy/implement.jsonl @@ -0,0 +1,7 @@ +{"file": ".trellis/tasks/08-31-grok-first-class-iteration/summary.md", "reason": "Shared plain-language plan"} +{"file": ".trellis/spec/backend/external-agent-p0.md", "reason": "Grok remains handoff_only"} +{"file": ".trellis/spec/frontend/reuse.md", "reason": "Edit AgentAuthStatusPanel, do not fork"} +{"file": ".trellis/spec/frontend/v2-agent-models.md", "reason": "Models Quick Setup is API key only; do not add login"} +{"file": ".trellis/spec/guides/code-reuse-thinking-guide.md", "reason": "Reuse current auth owners"} +{"file": ".trellis/tasks/08-31-grok-login-trichotomy/research/current-login-surfaces.md", "reason": "Current login surfaces and copy"} +{"file": ".trellis/tasks/08-31-grok-first-class-iteration/research/hil-matrix.md", "reason": "AT1-AT5 H1-H4 belong to this window"} diff --git a/.trellis/tasks/08-31-grok-login-trichotomy/implement.md b/.trellis/tasks/08-31-grok-login-trichotomy/implement.md new file mode 100644 index 000000000..edb2b97c1 --- /dev/null +++ b/.trellis/tasks/08-31-grok-login-trichotomy/implement.md @@ -0,0 +1,45 @@ +# Implement — Grok login trichotomy + +先读 [design.md](./design.md) 和 [use-cases.md](./use-cases.md)。父任务 [summary.md](../08-31-grok-first-class-iteration/summary.md) 是对齐源。 + +依赖:无。投放窗口可以并行读材料,但不要在本窗口的路标立好前,把 Codex 文案改成叫人去跑 `grok login`。 + +## 开工顺序 + +1. 读 `research/current-login-surfaces.md` 全文,不要凭记忆改状态机。 +2. 改 V2 Grok 认证区文案:下一步写明终端 `grok login` / `grok logout`。锁住「已交给官方认证入口」,禁止「认证结果已验证」。 +3. 给 SuperGrok 扫码一个指向认证中心的下一步。不要在 Agent 页启动设备码。 +4. 打开模型页 Grok Quick Setup,确认没有 `grok login` 说明书。默认不改这个文件。 +5. 用下面的自动检查钉住 UC-L1–L4。没改草稿就把 #141 B7 标成没碰。 +6. 回写 #43 / #106,不关整张 #43。 + +## 会碰到的文件(先读再改) + +| 文件 | 为什么 | +|---|---| +| `src/v2/pages/agents/AgentAuthStatusPanel.tsx` | Grok / Claude / Codex 认证文案和按钮 | +| `tests/v2/pages/agents/AgentAuthStatusPanel.test.tsx` | 禁止 Grok「认证结果已验证」;Claude 仍要能验证 | +| `tests/v2-browser/agents-v3.spec.ts` | 浏览器层 Grok 不得出现「认证结果已验证」 | +| `src-tauri/src/agent_install/auth_actions.rs` | Grok `HandoffComplete`;不要改成 verified | +| `src-tauri/src/agent_install/auth_sessions.rs` | handoff 短路径;默认不改 | +| `src/components/settings/AuthCenterPanel.tsx` | 扫码主人;只指路,不重做 | + +不要进口:`src/v2` 不得 import `AuthCenterPanel` / `XaiOAuthSection`(`tests/v2/app/architecture.test.ts`)。 + +## 自动检查 + +优先跑现有认证测试,不要一上来跑整仓: + +- `tests/v2/pages/agents/AgentAuthStatusPanel.test.tsx` +- `tests/v2/features/agent-auth.test.ts` +- `tests/v2-browser/agents-v3.spec.ts` 里 Grok / Claude 认证断言 + +改完再按仓库惯例补 `mise run check` 里和本窗口相关的项。 + +## 亲测 + +父任务 `research/hil-matrix.md` 的 AT1–AT5、H1–H4。本窗口不跑 H5–H8。 + +## 回滚 + +只还原认证文案。不要整段撤 Claude 验证合同。 diff --git a/.trellis/tasks/08-31-grok-login-trichotomy/prd.md b/.trellis/tasks/08-31-grok-login-trichotomy/prd.md new file mode 100644 index 000000000..ee0097430 --- /dev/null +++ b/.trellis/tasks/08-31-grok-login-trichotomy/prd.md @@ -0,0 +1,37 @@ +# Clarify Grok login trichotomy + +先读父任务 [summary.md](../08-31-grok-first-class-iteration/summary.md)。用例:[use-cases.md](./use-cases.md)。本子任务只立登录路标,不把 SuperGrok 写进 Claude / Codex / WorkBuddy。 + +## Goal + +人能分清三条登录路。官方登录过期去终端跑 `grok login`。扫码去认证中心。API 钥匙留在模型页。打开官方入口,不等于已经登录。 + +## Confirmed facts + +见 `research/current-login-surfaces.md`。 + +- 官方登录/退出只从新界面 Agent 配置页的认证按钮开始,结果只能是「交给官方了」。 +- 扫码只在旧认证中心。新界面模型页没有扫码,也没有 `grok login`。 +- 没有可复查的 Grok 登录状态命令。不能用 `~/.grok/auth.json` 证明已登录。 +- 不改模型草稿则 #141 B7 标 `not touched`。 + +## Requirements + +- R1. 三条路的名称、下一步、失败指回不互相抢。 +- R2. 官方登录终点仍是「门打开了」,不是「已验证」。 +- R3. 复用现有 `grok login` 交接和认证中心扫码,不新做一套登录。 +- R4. 默认不改模型草稿。 + +## Acceptance Criteria + +- [ ] 三条路的招牌各说各的。 +- [ ] 官方登录不出现「认证结果已验证」。 +- [ ] Claude 能验证的路还在。 +- [ ] 回写 #43、#106,不关整张 #43。 +- [ ] William 在 Windows 和 Mac mini 上亲自走完三条路。密码不进仓库。 + +## Out of scope + +- SuperGrok 写进 Claude / Desktop / Codex / WorkBuddy +- ChatGPT 登录 +- 安装升级、额度看板 diff --git a/.trellis/tasks/08-31-grok-login-trichotomy/research/current-login-surfaces.md b/.trellis/tasks/08-31-grok-login-trichotomy/research/current-login-surfaces.md new file mode 100644 index 000000000..9781ad414 --- /dev/null +++ b/.trellis/tasks/08-31-grok-login-trichotomy/research/current-login-surfaces.md @@ -0,0 +1,260 @@ +# Research: current-login-surfaces + +- **Query**: Where V2 Agent / Models / Auth start Grok login, logout, and xAI device-code; post-#167 copy/states; trichotomy in presets/seeds/quota/i18n; official Grok CLI status + `~/.grok/auth.json` rule; #141 B7 untouched-validation if Grok Build drafts are touched. +- **Scope**: mixed (internal code + current specs + parent GitHub notes) +- **Date**: 2026-08-31 +- **Parent**: `.trellis/tasks/08-31-grok-first-class-iteration` +- **Related**: Discussion #106, Issue #43, closed #107, UAT #141 B7, PR #167 (Agent auth state machine) + +## Findings + +### 1. Where login / logout / device-code actually start + +V2 does **not** have one trichotomy control. The three roads start on three different surfaces. + +| Road | Starts on V2? | Owner surface | Start path | +|---|---|---|---| +| Official `grok login` / `grok logout` | Yes — Agent Auth panel only | V2 Agent configuration (detail), not directory compact, not Models | `AgentAuthStatusPanel` → `useAgentAuthSession.start` → `start_agent_auth_session` → `launch_auth_action(GrokBuild)` | +| xAI device-code | No V2 start | v1 Settings Auth Center + Claude/Codex/Claude Desktop provider forms | `XaiOAuthSection` → `useXaiOauth` → `useManagedAuth` → `auth_start_login("xai_oauth")` | +| API Key | V2 Models Quick Setup only | Shared Provider panel for claude/codex/grokbuild; also v1 Grok Build form | `ProviderPanel.requestSave` / fetch / probe; v1 `GrokBuildProviderForm` + third-party presets | + +#### V2 Agent — official Grok login/logout + +- Directory cards only render **compact** status. Compact mode has no buttons (`src/v2/pages/agents/AgentAuthStatusPanel.tsx:254-259`). Login is not started from the directory row. +- Configuration detail always mounts the full Auth panel above the Models/Skills/MCP/Prompts tabs (`src/v2/pages/agents/AgentConfiguration.tsx:95`). +- Detail buttons call `session.start({ agentId, intent })` (`AgentAuthStatusPanel.tsx:280-291`). Tauri port invokes `start_agent_auth_session` (`src/v2/shared/platform/tauri/feature-ports/agentAuth.ts:34-51`). +- Backend: Grok login launches closed CLI `grok login` and returns **HandoffComplete**; logout runs `grok logout` (`["logout"]`) and also **HandoffComplete** (`src-tauri/src/agent_install/auth_actions.rs:163-169`). +- Session runner immediately terminals as `handoff_complete` + `handoff_only` without a verify loop (`src-tauri/src/agent_install/auth_sessions.rs:541-550`). Claude stays in `awaiting_user` / `verifying` until `claude auth status` proves a state (`auth_sessions.rs:552-606`, `auth_actions.rs:200-224`). +- Codex cannot start a session here: observation `allowed_intents` is empty (`auth_actions.rs:519-529`); `validate_intent` returns `managed_by_auth_center` (`auth_sessions.rs:410-413`). +- Install-readiness **must not** start Auth. `start_agent_action` rejects `auth_login` / `auth_logout` / `auth_connect_provider` as `executor_not_implemented` (`src-tauri/src/agent_install/mod.rs:419-422`). The readiness UI already filters those actions out and only shows install/update/launch (`AgentInstallReadinessSection.tsx:128-131`). + +#### V2 Models — no official login, no device-code + +- Agent Models section only lists providers and links to `/models?target=…` (`AgentModelsSection.tsx:144-148`, `AgentConfiguration.tsx:47-53`). +- `ProviderPanel` is shared by `claude` / `codex` / `grokbuild` and is **API Key Quick Setup** only (`src/v2/pages/models/Page.tsx:932-941`, reserved id `fyagent-v2-quick-setup-grokbuild` in `quickSetup.ts:11-18`). +- There is no `grok login` button, no Auth Center embed, and no `xai_oauth` account picker on this page. Grep of `src/v2/pages/models` for login/oauth/xai is empty. + +#### Auth UI — xAI device-code (v1, not V2) + +- Auth Center lives on v1 Settings (`src/components/settings/SettingsPage.tsx:53,312`), section titled `xAI (Grok OAuth)` (`AuthCenterPanel.tsx:75-90`). +- Start: `XaiOAuthSection` button “使用 xAI 登录” / “添加账号或重新登录” (`XaiOAuthSection.tsx:216-232`) → `useXaiOauth()` (`hooks/useXaiOauth.ts:4-6`) → `useManagedAuth("xai_oauth")` → `authApi.authStartLogin` (`useManagedAuth.ts:61-66`). +- Backend command `auth_start_login` with provider `xai_oauth` starts the device-code flow (`src-tauri/src/commands/auth.rs:110-141`). Tokens are stored in FyAgent `xai_oauth_auth.json`, **not** `~/.grok/auth.json` (`src-tauri/src/commands/xai_oauth.rs:17-19`, `src-tauri/src/proxy/providers/xai_oauth_auth.rs:211`). +- Same device-code widget is reused on Claude / Codex / Claude Desktop provider forms when `providerType === "xai_oauth"` (`ProviderForm.tsx:1177-1178`, `ClaudeDesktopProviderForm.tsx:619-624`). **Grok Build’s own form does not mount this widget.** + +--- + +### 2. Exact copy and states after PR #167 (handoff_only vs verified vs fyagent_managed) + +Contract test: `tests/v2/pages/agents/AgentAuthStatusPanel.test.tsx`. Copy owner: `AgentAuthStatusPanel.tsx`. + +| Agent | Observation kind | Authority | Allowed intents | Idle summary | Idle description | After Login click | Terminal outcome | +|---|---|---|---|---|---|---|---| +| grokbuild | `handoff_only` | `unverified` | `login`, `logout` if CLI detected | **仅支持打开官方认证入口** (`:46-47`) | **FyAgent 只能把操作交给官方应用或 CLI,无法验证最终账号状态。** (`:63-64`) | Stage **已交给官方认证入口** (`:95-96`); reason **已完成入口交接,但没有权威状态可验证。** (`:128-129`) | `handoff_complete` + `handoff_only`. Test forbids **认证结果已验证** (`AgentAuthStatusPanel.test.tsx:161-189`) | +| claude-code | `account` | `verified` when `claude auth status` JSON parses | `login`, `logout` | **已验证登录** / **已验证退出** (`:36-38`) | **状态来自官方结构化命令的回读。** (`:58-59`) | Stage **等待你完成官方认证** (`:89-90`) then **认证结果已验证** (`:93-94`) | `verified` + `verified_logged_in` / `verified_logged_out` (`auth_sessions.rs:650-658`) | +| Codex | `fyagent_managed` | `verified` (destination `auth_center`) | none | **由 FyAgent 认证中心管理** (`:48-49`) | **Codex 托管账号继续由现有认证中心负责,不在此处复制 OAuth 流程。** (`:65-66`) | No 登录 / 连接 Provider buttons (`test:192-218`) | Session start rejected as `managed_by_auth_center`; reason copy **请在现有认证中心管理此账号。** (`:130-131`) — **no navigation button** | + +Grok observation builder (`auth_actions.rs:69-86`): + +- CLI available → `handoff_only` + intents `[Login, Logout]` + reason `handoff_only`. +- CLI missing → `unavailable` + `auth_observer_unavailable`. Summary **当前无法读取认证状态** (`AgentAuthStatusPanel.tsx:50-51`); description **认证观察器不可用;不会读取厂商凭据文件或推断登录状态。** (`:67-68`). + +Compact directory line is only `认证:{summary}` (`:257`). Grok therefore shows `认证:仅支持打开官方认证入口`, never “已登录”. + +Backend table matches spec: “Grok or desktop Auth entry opens successfully → `handoff_complete` + `handoff_only`; never verified” (`.trellis/spec/backend/external-agent-p0.md:535`). + +--- + +### 3. How the three roads are distinguished today + +They are distinguished by **app + category + `providerType`**, not by a shared V2 trichotomy enum. + +#### Presets + +**Grok Build** (`src/config/grokBuildProviderPresets.ts`): + +- Official: `grokBuildOfficialPreset` name `"Grok Official"`, `category: "official"`, empty `auth` + empty `config` (`:44-53`). File comment: official OAuth is *not* a preset; official state is this empty seed (`:8-11`, `:41-42`). +- Third-party / aggregator: `grokAuth()` = `{ OPENAI_API_KEY: "" }` (`:58`). Includes a preset literally named `"xAI (Grok)"` hitting `https://api.x.ai/v1` with **API Key**, `category: "third_party"` (`:427-437`). This is **not** device-code. +- **No `providerType: "xai_oauth"`** on any Grok Build preset. + +**Claude / Codex / Claude Desktop** (device-code lives here): + +- Claude: `"xAI (Grok)"` + `providerType: "xai_oauth"` + `requiresOAuth: true` (`src/config/claudeProviderPresets.ts:1227-1244`). +- Codex: `"xAI (Grok) OAuth"` + `providerType: "xai_oauth"` (`src/config/codexProviderPresets.ts:1415-1436`). Comment: proxy injects token; base_url/empty auth are snapshots. +- Claude Desktop: `"xAI (Grok)"` + `providerType: "xai_oauth"` (`src/config/claudeDesktopProviderPresets.ts:745+`). + +v1 Grok Official copy is only shown when `category === "official"` (`GrokBuildProviderForm.tsx:436-443`). Third-party presets do not show the `grok login` lecture (`tests/components/GrokBuildProviderForm.test.tsx:27-63`). + +#### Seed providers + +`src-tauri/src/database/dao/providers_seed.rs:74-83`: + +- id `grokbuild-official` +- name `"Grok Official"` +- website `https://x.ai/grok` +- `settings_config_json`: `{"config":""}` — empty config so Grok CLI falls back to its own login +- Seed test locks the empty-config contract (`:109-118`) +- `ensure_grokbuild_official_provider` keeps this row present (`src-tauri/src/commands/provider.rs:865`) + +No seed exists for xAI device-code on the grokbuild app. Device-code accounts live in `xai_oauth_auth.json`. + +#### Quota footers + +`ProviderCard.tsx:229-232,475-502` picks footer by type: + +1. `meta.providerType === xai_oauth` → `XaiOauthQuotaFooter` → `appIdForExpiredHint="xai_oauth"` (`XaiOauthQuotaFooter.tsx:36`) +2. else official grokbuild → `SubscriptionQuotaFooter` → remaps appId to `"grok"` so copy says `grok login` (`SubscriptionQuotaFooter.tsx:442-443`) +3. else usage-script / API Key path + +Expired-hint switch (`SubscriptionQuotaFooter.tsx:80-90`): + +- `grok` / `grokbuild` → `subscription.grokOfficialExpiredHint` +- `xai_oauth` → `subscription.xaiOauthExpiredHint` +- else generic `subscription.expiredHint` with `{tool}` + +Tests lock the split: Official Grok expiry mentions `grok login`; xAI expiry mentions Auth Center and never `grok login` (`tests/components/SubscriptionQuotaFooter.test.tsx`, `tests/components/XaiOauthQuotaFooter.test.tsx`). + +**Caveat:** official grokbuild quota **does** read `~/.grok/auth.json` to call grok.com billing (`src-tauri/src/services/subscription_grok.rs:1-14,38-40`). xAI OAuth quota uses the managed token and explicitly not that file (`xai_oauth.rs:17-19`). This is a quota owner, not an Auth-observation owner. + +#### i18n (all four locales) + +| Key | en | zh | +|---|---|---| +| `providerForm.grokOfficialHint` | Grok Official uses an empty config. After you save, run `grok login` in a terminal. FyAgent does not log in for you and does not write ~/.grok/auth.json. | Grok Official 使用空配置。保存后请在终端运行 `grok login`。FyAgent 不会代为登录,也不会写入 ~/.grok/auth.json。 | +| `subscription.grokOfficialExpiredHint` | Run `grok login` in a terminal to refresh this login. | 请在终端运行 `grok login` 以刷新此登录。 | +| `subscription.xaiOauthExpiredHint` | Re-authenticate this xAI account in Auth Center. | 请到认证中心重新登录此 xAI 账号。 | +| `settings.authCenter.xaiOauthDescription` | Manage xAI / Grok accounts | 管理 xAI / Grok 账号 | +| `xaiOauth.login` | Sign in with xAI | 使用 xAI 登录 | +| `providerForm.officialHint` (Claude/generic) | Official provider uses browser login, no API Key needed | 官方供应商使用浏览器登录,无需配置 API Key | + +ja / zh-TW have the same trichotomy split (`src/i18n/locales/{en,zh,ja,zh-TW}.json`). + +V2 Agent / Models copy is **hardcoded Chinese**, not these i18n keys. Grok’s Agent panel never says `grok login`; it only says “打开官方认证入口”. + +--- + +### 4. Official Grok CLI status surface — and the auth.json rule + +**No reviewed Grok auth-status observer exists in this repo.** + +What exists: + +- Launch only: `grok login` / `grok logout` (`auth_actions.rs:163-169`). +- Availability probe: `ensure_tool_available(GROK_TOOL_ID)` where `GROK_TOOL_ID = "grok"` (`auth_actions.rs:70`, `src-tauri/src/agent_install/cli.rs:7`). +- Archived review: official CLI docs list `grok login`, `grok logout`, and `grok inspect --json`. Inspect is **project configuration**, not an auth-status contract (`.trellis/tasks/archive/2026-08/08-29-agent-auth-verification-state-machine/research/official-auth-surfaces.md:27-37`). `grok inspect` is not called anywhere in product code. + +Claude contrast: bounded `claude auth status` JSON with allowlisted fields (`auth_actions.rs:36-44,200-224`). OpenCode contrast: `opencode auth list` (`:227-246`). Grok has neither. + +**Reading `~/.grok/auth.json` to prove login is forbidden by current spec.** + +- `.trellis/spec/backend/external-agent-p0.md:508-509`: Grok Build has no reviewed structured status, so official login/logout ends in `handoff_complete`, not `verified`. +- Same spec `:518-519`: “Never read vendor token files, Keychain, browser cookies, or credential-store entries to infer state.” +- Parent decision notes: `#43` / `#106` — do not read `~/.grok/auth.json` to fake logged-in (`.trellis/tasks/08-31-grok-first-class-iteration/research/github-decision-43.md:7-8`, `github-decision-106.md:7`). +- Product copy already tells the user FyAgent will not write that file (`providerForm.grokOfficialHint`). +- Unavailable-observation copy: will not read vendor credential files (`AgentAuthStatusPanel.tsx:67-68`). + +**Not the same rule as quota.** `subscription_grok.rs` already reads `~/.grok/auth.json` for SuperGrok credit display. That path must not be reused as Auth observation / “已登录” proof. + +--- + +### 5. #141 B7 — untouched Grok Build model-draft validation + +B7 (UAT #141): empty drafts must not show submit/validation errors on route mount; fetch / probe / save own validation; corrected paths clear it. + +Classification on 2026-08-30: **fixed (automated)** for the shared Models page (`.trellis/tasks/archive/2026-08/08-29-frontend-reliability-architecture/research/uat-current-main-mapping.md:28`). Parent iteration note: if Grok Build drafts are touched, re-verify B7 on latest main and mark `fixed` / `still applies` / `not touched` (`.trellis/tasks/08-31-grok-first-class-iteration/research/github-decision-141.md`). + +Current Grok Build draft surface is the **shared** `ProviderPanel` (`Page.tsx:932+`), not a Grok-only draft widget. + +Untouched behavior (still true on current main): + +- `errors` initializes to `{}` (`Page.tsx:956`). No `useEffect` runs `validateQuickSetup` on mount. +- `validateQuickSetup` only runs inside `requestSave` (`:1079-1102`). Empty name/url/key/model then become “请输入配置名称 / 请输入不含账号信息的 HTTP(S) 地址 / 请输入 API Key / 请输入模型 ID” (`quickSetup.ts:89-93`). +- Fetch validates URL + API Key only after 拉取模型 (`Page.tsx:1005-1018`). +- Probe validates URL + API Key only after connectivity prepare (`:1057-1076`). +- Field errors render only when the corresponding `errors.*` is set (`:1405-1413`, `:1446-1453`). +- Dirty tracking (`useModelsDraftCommit`) does not validate (`modelsShared.tsx:27-50`). + +There is **no Grok-specific B7 test**. `tests/v2/pages/models/Page.test.tsx` mentions Grok Build only in rail order (`:261-265`). Browser spec `tests/v2-browser/agents-models.spec.ts` likewise only asserts catalog order. WorkBuddy/OpenCode empty-draft errors are save-gated (`Page.tsx:299`, `OpenCodeModelsPanel.tsx:347-348`), same pattern. + +If this task only changes Agent Auth copy / Auth Center handoff and does **not** edit `ProviderPanel` / `quickSetup.ts` / Grok Build form draft fields, B7 is **not touched**. If those drafts are edited, re-check: open `/models?target=grokbuild` with empty fields and assert no `role="alert"` / `fy-control-field-error` until 保存 / 拉取模型 / probe. + +v1 `GrokBuildProviderForm` TOML editor uses `showValidation={false}` (`GrokBuildProviderForm.tsx:572`); malformed TOML error is shown only when `rawConfigError` is set (`:575-582`). Official category hides the whole config block (`:449`). + +--- + +### Files Found + +| File Path | Description | +|---|---| +| `src/v2/pages/agents/AgentAuthStatusPanel.tsx` | V2 copy + login/logout buttons | +| `src/v2/pages/agents/useAgentAuthSession.ts` | Session poll; terminals include `handoff_complete` | +| `src/v2/pages/agents/AgentConfiguration.tsx` | Detail Auth panel mount | +| `src/v2/pages/agents/AgentDirectory.tsx` | Compact auth slot only | +| `src/v2/pages/agents/AgentModelsSection.tsx` | Provider list; no login | +| `src/v2/pages/models/Page.tsx` | Shared API Key Quick Setup for grokbuild | +| `src/v2/pages/models/quickSetup.ts` | Reserved id + save-time validation | +| `src/v2/shared/platform/tauri/feature-ports/agentAuth.ts` | `start_agent_auth_session` IPC | +| `src-tauri/src/agent_install/auth_actions.rs` | Grok handoff vs Claude observe vs Codex managed | +| `src-tauri/src/agent_install/auth_sessions.rs` | HandoffComplete short-circuit | +| `src-tauri/src/commands/auth.rs` | `auth_start_login` device-code | +| `src/components/settings/AuthCenterPanel.tsx` | v1 xAI device-code section | +| `src/components/providers/forms/XaiOAuthSection.tsx` | Device-code UI | +| `src/config/grokBuildProviderPresets.ts` | Official empty vs API Key presets | +| `src-tauri/src/database/dao/providers_seed.rs` | `grokbuild-official` seed | +| `src/components/SubscriptionQuotaFooter.tsx` | Official vs xAI expiry copy | +| `src-tauri/src/services/subscription_grok.rs` | Reads `~/.grok/auth.json` for quota only | +| `.trellis/spec/backend/external-agent-p0.md` | Auth contract + no credential-file inference | + +### Related Specs + +- `.trellis/spec/backend/external-agent-p0.md` — observation kinds, Grok handoff, never read vendor tokens +- `.trellis/spec/frontend/v2-agent-models.md` — Grok Quick Setup reserved id / live `~/.grok/config.toml` (models, not login) +- `.trellis/spec/backend/windows-runtime-security.md:498-511` — auth observation/session; helper must not return device code / credential paths + +## Caveats / Not Found + +- `python ./.trellis/scripts/task.py current --source` returned no active task; this note was written to the path the caller named. +- No `grok auth status` / structured Grok login observer in product code. `grok inspect --json` is documented as non-auth and unused. +- V2 has no Auth Center route and no button from the Codex façade to Settings. +- V2 Models cannot express official Grok or xAI device-code; it only writes `fyagent-v2-quick-setup-grokbuild` with an API Key. +- Official grokbuild quota still reads `~/.grok/auth.json`. That is not license to treat the file as login proof. +- No dedicated Grok B7 automated test; B7 is the shared ProviderPanel contract. + +## Confirmed facts + +1. Official Grok login/logout on V2 starts only from Agent configuration Auth buttons and ends `handoff_only` / `handoff_complete`. Opening the CLI is not “已登录”. +2. xAI device-code starts only from v1 Auth Center / Claude·Codex·Claude Desktop `xai_oauth` forms via `auth_start_login`. Grok Build presets and V2 Models do not start it. +3. API Key is the third road: Grok Build third-party presets + V2 Quick Setup `fyagent-v2-quick-setup-grokbuild`. The preset named `"xAI (Grok)"` on Grok Build is API Key to `api.x.ai`, not device-code. +4. Current spec forbids reading `~/.grok/auth.json` to prove login. No official Grok status command is reviewed or implemented. +5. B7 is save/fetch/probe-gated on the shared Models panel. Empty Grok Build drafts stay silent on mount unless that panel is edited. + +## Reuse owners + +| Need | Reuse, do not rewrite | +|---|---| +| Official `grok login` / `logout` | `launch_auth_action` + `AgentAuthStatusPanel` + `start_agent_auth_session` | +| Device-code | `XaiOAuthSection` / `useManagedAuth("xai_oauth")` / `auth_start_login` / `XaiOAuthManager` | +| Official empty provider | `grokbuild-official` seed + `grokBuildOfficialPreset` + `providerForm.grokOfficialHint` | +| Expiry copy split | `getSubscriptionExpiredHintKey` + four-locale keys already tested | +| Codex Auth | Keep `fyagent_managed` → existing Auth Center; do not add a second OAuth on Agent | +| Claude verify loop | Keep `claude auth status`; do not copy it onto Grok | + +## Recommended MVP changes + +Stay on copy + entry wiring. Do not add a Grok status parser. + +1. On V2 Agent Grok Auth panel, name the official road: next step is terminal `grok login` / `grok logout`. Keep terminal stage `handoff_complete`. Do not say 已验证 / 已登录. +2. Point Codex (already) and Grok’s **device-code** next-step at Auth Center xAI section. Do not start device-code from Agent Auth. A deep-link/button to v1 Settings Auth Center is enough if in scope; do not reimplement OAuth. +3. Keep API Key on V2 Models / third-party presets. Do not show `grok login` on Quick Setup or on `"xAI (Grok)"` API Key presets. +4. If Agent copy mentions expiry, reuse `grokOfficialExpiredHint` vs `xaiOauthExpiredHint`; do not send xAI expiry to `grok login`. +5. If Grok Build drafts are not required to tell the three roads apart, leave `ProviderPanel` / `quickSetup.ts` alone so B7 stays **not touched**. + +## Must stay out of scope + +- SuperGrok write-into Claude / Desktop / Codex / WorkBuddy(sibling tasks; this file still owns login facts only) +- New OAuth / token relay / inventing `grok auth status` +- Reading or writing `~/.grok/auth.json` to claim verified login +- Promoting quota-file-read into an Auth observer +- Changing Claude `verified` or Codex `fyagent_managed` contracts +- Grok install/upgrade (#31 / #32), V2 quota dashboard, Claude targeting +- Closing umbrella #43 +- B7 rewrite unless Grok Build draft validation is actually edited diff --git a/.trellis/tasks/08-31-grok-login-trichotomy/task.json b/.trellis/tasks/08-31-grok-login-trichotomy/task.json new file mode 100644 index 000000000..647b5fa46 --- /dev/null +++ b/.trellis/tasks/08-31-grok-login-trichotomy/task.json @@ -0,0 +1,26 @@ +{ + "id": "grok-login-trichotomy", + "name": "grok-login-trichotomy", + "title": "Clarify Grok login trichotomy for official subscription gate", + "description": "", + "status": "in_progress", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "codex", + "assignee": "codex", + "createdAt": "2026-08-31", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": "08-31-grok-first-class-iteration", + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/.trellis/tasks/08-31-grok-login-trichotomy/use-cases.md b/.trellis/tasks/08-31-grok-login-trichotomy/use-cases.md new file mode 100644 index 000000000..2ab91c1bf --- /dev/null +++ b/.trellis/tasks/08-31-grok-login-trichotomy/use-cases.md @@ -0,0 +1,40 @@ +# 用例 — 登录三分法 + +下游按这条改、按这条测。双机勾选表在父任务 `research/hil-matrix.md`。 + +## UC-L1 官方登录只开门 + +- 对应 AT1、H1 +- 人:新界面打开 Grok → 点登录 +- 期望:终端出现 `grok login`;界面是「已交给官方认证入口」;**没有**「认证结果已验证」「已登录」 +- 锁:`AgentAuthStatusPanel` 测试 + `agents-v3.spec.ts` + +## UC-L2 官方退出也不验证 + +- 对应 H2 +- 人:同一页点退出 +- 期望:终端 `grok logout`;仍不说已验证 + +## UC-L3 扫码去认证中心 + +- 对应 AT2、AT4、H3 +- 人:要 SuperGrok 扫码,或看 Codex 认证区 +- 期望:下一步指向认证中心;不叫人去终端扫码;Agent 页不启动 `auth_start_login` +- 过期:指回认证中心,不是 `grok login` + +## UC-L4 模型页只填钥匙 + +- 对应 AT5、H4 +- 人:打开模型页 Grok Quick Setup +- 期望:只有 API 钥匙;没有 `grok login` 说明书 +- 空草稿没动手:不报错。没改草稿则 #141 B7 = `not touched` + +## UC-L5 Claude 验证还在 + +- 对应 AT3 +- 人:打开 Claude Code 认证 +- 期望:原来能「认证结果已验证」的路还在;不要被 Grok 的 handoff 文案污染 + +## 本窗口不做 + +H5–H8(投放和 WorkBuddy)。ChatGPT 登录。 diff --git a/.trellis/tasks/08-31-grok-supergrok-to-codex/check.jsonl b/.trellis/tasks/08-31-grok-supergrok-to-codex/check.jsonl new file mode 100644 index 000000000..756e616df --- /dev/null +++ b/.trellis/tasks/08-31-grok-supergrok-to-codex/check.jsonl @@ -0,0 +1,6 @@ +{"file": ".trellis/spec/backend/reuse.md", "reason": "Reject a second executor"} +{"file": ".trellis/spec/backend/change-plan-executor.md", "reason": "Reject a fourth adapter or secrets in the plan"} +{"file": ".trellis/spec/frontend/reuse.md", "reason": "Reject importing AuthCenterPanel into src/v2"} +{"file": ".trellis/tasks/08-31-grok-supergrok-to-codex/research/current-supergrok-codex-path.md", "reason": "Claude Desktop stays on V1 bind; Codex needs a narrow admission only"} +{"file": ".trellis/tasks/08-31-grok-first-class-iteration/research/hil-matrix.md", "reason": "H5-H7 and H9 on both machines; do not mark H8"} +{"file": ".trellis/tasks/08-31-grok-first-class-iteration/summary.md", "reason": "One target per plan; failure must not claim other tools"} diff --git a/.trellis/tasks/08-31-grok-supergrok-to-codex/design.md b/.trellis/tasks/08-31-grok-supergrok-to-codex/design.md new file mode 100644 index 000000000..5be5cfbca --- /dev/null +++ b/.trellis/tasks/08-31-grok-supergrok-to-codex/design.md @@ -0,0 +1,52 @@ +# Design — SuperGrok to Claude Code, Claude Desktop, and Codex + +先读父任务 [summary.md](../08-31-grok-first-class-iteration/summary.md)。事实和行号见 `research/current-supergrok-codex-path.md`。用例见 [use-cases.md](./use-cases.md)。 + +本子任务不管登录路标,不管 WorkBuddy。目录名是历史留下的,范围以标题为准。 + +## 共用前提 + +登录还在认证中心。token 在 `xai_oauth_auth.json`。Provider 行只记: + +- `meta.providerType = "xai_oauth"` +- `meta.authBinding = { source: "managed_account", authProvider: "xai_oauth", accountId }` + +禁止把 token 写进 Provider、预览单、前端。禁止新 OAuth。禁止第四个 Change Plan 执行器。禁止把 v1 表单进口 `src/v2`。 + +三家**各写各的**。不要合成一张多目标计划。 + +## Claude Code + +现有主人:`claudeProviderPresets` 里名为 `xAI (Grok)` 的 `xai_oauth` 预设 + `ProviderForm` 绑定(`ProviderForm.tsx` 约 1176–1587)。 + +- 没有 Claude Change Plan 适配器。不要新开。 +- 新界面要能看见:没账号 → 去认证中心;有账号 → 走现有绑定/预览。不要整页搬 `XaiOAuthSection`。 +- 失败不得声称 Codex / Desktop / WorkBuddy 已改好。 + +## Claude Desktop + +现有主人:`claudeDesktopProviderPresets` + `ClaudeDesktopProviderForm`(约 619–624 挂 `XaiOAuthSection`)。 + +- 不在 V2 Agent 目录里。不要硬造目录页。 +- 亲测和写入都走旧界面。新界面最多一句路标。 + +## Codex + +现有主人:Change Plan `codex_provider_switch` / `codex_provider_upsert_and_switch`。 + +今天会拒绝 SuperGrok:`prove_codex_target_credential_capability`(`service.rs` 约 1593–1646)看到 `ManagedAccount` 或任何 `provider_type` 就 `SecretDependencyUnavailable`。Quick Setup DTO 只有 API 钥匙。V2 有没有 xAI 账号,页面长得一样。 + +这轮只开窄口: + +1. 认证中心已有可用 `xai_oauth` 账号时,允许预览。计划里仍然没有钥匙。 +2. 新界面能看见:没账号先去认证中心;有账号再预览 Codex。 +3. 能切换已经绑好的旧记录就切换。不要悄悄盖掉 `fyagent-v2-quick-setup-codex` 那条 API 钥匙槽。 +4. 仍走 `apply_change_plan(planId, planDigest)`。不要第四个 adapter。 + +## 兼容 + +Claude 官方登录验证环不动。Codex Agent 认证继续 `fyagent_managed`。ChatGPT `codex_oauth` 不动。 + +## 回滚 + +三家可以单独关。关 Codex 窄口时,恢复「托管账号一律拒绝」,不要误伤 API 钥匙预览。 diff --git a/.trellis/tasks/08-31-grok-supergrok-to-codex/implement.jsonl b/.trellis/tasks/08-31-grok-supergrok-to-codex/implement.jsonl new file mode 100644 index 000000000..685106581 --- /dev/null +++ b/.trellis/tasks/08-31-grok-supergrok-to-codex/implement.jsonl @@ -0,0 +1,8 @@ +{"file": ".trellis/tasks/08-31-grok-first-class-iteration/summary.md", "reason": "Shared plain-language plan"} +{"file": ".trellis/spec/backend/reuse.md", "reason": "Reuse Change Plan and Auth Center"} +{"file": ".trellis/spec/frontend/reuse.md", "reason": "No second Codex OAuth on Agent"} +{"file": ".trellis/spec/backend/change-plan-executor.md", "reason": "Keep closed adapters; no fourth operation"} +{"file": ".trellis/spec/frontend/v2-agent-models.md", "reason": "Codex Change Plan stays the write owner; no V1 form import"} +{"file": ".trellis/spec/guides/code-reuse-thinking-guide.md", "reason": "Reuse V1 xai_oauth bind and existing Codex executor"} +{"file": ".trellis/tasks/08-31-grok-first-class-iteration/research/hil-matrix.md", "reason": "H5-H7 H9 live placement"} +{"file": ".trellis/tasks/08-31-grok-supergrok-to-codex/research/current-supergrok-codex-path.md", "reason": "Current V1 bind vs V2 Change Plan rejection"} diff --git a/.trellis/tasks/08-31-grok-supergrok-to-codex/implement.md b/.trellis/tasks/08-31-grok-supergrok-to-codex/implement.md new file mode 100644 index 000000000..de6c6688f --- /dev/null +++ b/.trellis/tasks/08-31-grok-supergrok-to-codex/implement.md @@ -0,0 +1,43 @@ +# Implement — SuperGrok to Claude / Desktop / Codex + +先读 [design.md](./design.md) 和 [use-cases.md](./use-cases.md)。对齐源:父任务 [summary.md](../08-31-grok-first-class-iteration/summary.md)。 + +依赖:登录窗口的路标不要把扫码说成 `grok login`。本窗口可以先改准入,但新界面文案要和登录窗口一致。 + +## 开工顺序 + +1. 读 `research/current-supergrok-codex-path.md` 全文。 +2. Claude Code / Desktop:确认现有 `xai_oauth` 绑定还能走通。新界面只补「没账号去认证中心」。Desktop 不要塞进 Agent 目录。 +3. Codex:改 `prove_codex_target_credential_capability` 的窄口——没账号继续拒绝,有账号可以预览,单子里没有钥匙。 +4. 补 Codex 新界面能看见的路。不 import v1 表单。 +5. 改自动检查:不要再断言「凡是 SuperGrok 一律拒绝」。三家失败互不连坐。 +6. 亲测 UC-P1–P4。回写 #42 / #106,不关整张 #42。 + +## 会碰到的文件(先读再改) + +| 文件 | 为什么 | +|---|---| +| `src-tauri/src/services/change_plan/service.rs` | Codex 凭证门;窄口只放行已有 `xai_oauth` 托管账号 | +| `src-tauri/src/commands/change_plan.rs` | 现有 create/apply;不要新命令类型 | +| `src/config/codexProviderPresets.ts` | `xAI (Grok) OAuth` 预设,不要和 API Key 那条搞混 | +| `src/config/claudeProviderPresets.ts` | Claude Code 的 `xAI (Grok)` = OAuth | +| `src/config/claudeDesktopProviderPresets.ts` | Desktop 绑定 | +| `src/components/providers/forms/ProviderForm.tsx` | V1 绑定 `authBinding` | +| `src/v2/pages/models/Page.tsx` | Codex / Claude 新界面入口 | +| `src/v2/pages/models/apply/CodexSavePlanWorkspace.tsx` | 预览/确认/检查 | +| `tests/v2/app/architecture.test.ts` | 禁止 v1 认证表单进 `src/v2` | + +## 自动检查 + +- Change Plan:`service.rs` 里现有「托管绑定被拒」的测试要改成「没账号拒绝 / 有账号放行且计划无密钥」 +- Codex 预设:`tests/config/xaiOauthProviderPresets.test.ts` 不要把 API Key 预设当成扫码 +- V2 架构:`tests/v2/app/architecture.test.ts` +- 单目标:一家失败不得出现其他 Agent 的成功 apply + +## 亲测 + +`hil-matrix.md` 的 AT6–AT8、H5–H7、H9。不要替 WorkBuddy 窗口勾 H8。 + +## 回滚 + +Codex 窄口和 Claude 绑定分开撤。 diff --git a/.trellis/tasks/08-31-grok-supergrok-to-codex/prd.md b/.trellis/tasks/08-31-grok-supergrok-to-codex/prd.md new file mode 100644 index 000000000..5d8b6c754 --- /dev/null +++ b/.trellis/tasks/08-31-grok-supergrok-to-codex/prd.md @@ -0,0 +1,42 @@ +# Place SuperGrok into Claude Code, Claude Desktop, and Codex + +先读父任务 [summary.md](../08-31-grok-first-class-iteration/summary.md)。用例:[use-cases.md](./use-cases.md)。本子任务不管三条登录路标,也不管 WorkBuddy。 + +目录名仍是 `08-31-grok-supergrok-to-codex`,名称是历史留下的,范围以标题和本文为准。 + +## Goal + +人在认证中心用 SuperGrok 登录一次,就能把这颗脑子分别用到 Claude Code、Claude Desktop、Codex。每家先看要改什么,点头后再改,改完再检查。Desktop 不在新界面 Agent 目录里,亲测走旧界面。 + +## Confirmed facts + +见 `research/current-supergrok-codex-path.md`。 + +- SuperGrok 扫码是共用认证中心。旧界面已经能绑:Claude Code / Claude Desktop 的 `xAI (Grok)`(`xai_oauth`),以及 Codex 的 `xAI (Grok) OAuth`。只记绑了哪个账号。 +- 新界面 Change Plan 只收 API 钥匙,并且会拒绝 SuperGrok 这种登录。有没有账号,页面长得一样。 +- Claude 没有 Change Plan 适配器。不要为 Claude 新开第四个执行器。Claude / Desktop 复用现有 Provider 绑定。 +- Codex 还用原来的预览/确认/检查。只多开一扇该开的门:认证中心里已经有可用账号。不要第四套保存,不要把旧表单搬进新界面。 + +## Requirements + +- R1. 不新做执行器。Codex 只在现有预览/确认上,允许已有 SuperGrok 托管账号通过。 +- R2. Claude Code、Claude Desktop、Codex 各写各的。失败了不说别人也被改好了。 +- R3. token 不进 Provider 行,不进预览单,不进前端。 +- R4. 新界面能看见「没账号先去认证中心;有账号再绑 Claude Code / Codex」。Desktop 指到旧界面完成即可。 +- R5. 不把旧认证中心整页搬进 `src/v2`。 + +## Acceptance Criteria + +- [ ] 没账号时,新界面指向认证中心;Codex Change Plan 仍然拒绝。 +- [ ] 有账号时,Claude Code、Claude Desktop、Codex 都能预览、确认、检查;预览单里没有钥匙。 +- [ ] 一家失败不谎报其他工具。 +- [ ] 回写 #42、#106,不关整张 #42。 +- [ ] William 在 Windows 和 Mac mini 上用真实账号亲自走完这三家。密码不进仓库。 + +## Out of scope + +- WorkBuddy(见 `08-31-grok-supergrok-to-workbuddy`) +- 登录路标文案 +- ChatGPT 登录 +- Qoder / TRAE 模型写入 +- 安装升级、额度看板 diff --git a/.trellis/tasks/08-31-grok-supergrok-to-codex/research/current-supergrok-codex-path.md b/.trellis/tasks/08-31-grok-supergrok-to-codex/research/current-supergrok-codex-path.md new file mode 100644 index 000000000..fd12e62f4 --- /dev/null +++ b/.trellis/tasks/08-31-grok-supergrok-to-codex/research/current-supergrok-codex-path.md @@ -0,0 +1,221 @@ +# Research: current SuperGrok → Codex path + +- **Query**: How xAI / SuperGrok device-code OAuth binds to a Codex provider; whether Change Plan / #63 can write it; what V2 shows with/without an xAI account; single-target demo path using existing owners; HIL vs real SuperGrok account. +- **Scope**: mixed (internal code + parent GitHub decisions #106 / #42 / #41 / #63) +- **Date**: 2026-08-31 + +## Findings + +### Files Found + +| File Path | Description | +|---|---| +| `src-tauri/src/commands/auth.rs` | Shared managed-auth IPC: `auth_start_login` / `auth_poll_for_account` / list / status / logout for `xai_oauth` | +| `src-tauri/src/proxy/providers/xai_oauth_auth.rs` | Device-code OAuth manager; tokens in `xai_oauth_auth.json` | +| `src-tauri/src/commands/xai_oauth.rs` | Quota + models commands; not login | +| `src/lib/api/auth.ts` | Renderer IPC for `ManagedAuthProvider` including `xai_oauth` | +| `src/components/providers/forms/hooks/useXaiOauth.ts` | Thin wrapper: `useManagedAuth("xai_oauth")` | +| `src/components/providers/forms/XaiOAuthSection.tsx` | Device-code UI (Auth Center + Codex form) | +| `src/components/settings/AuthCenterPanel.tsx` | V1 Auth Center owner for xAI / Grok | +| `src/config/codexProviderPresets.ts` | Codex presets: API-key `xAI (Grok)` vs managed `xAI (Grok) OAuth` | +| `src/components/providers/forms/ProviderForm.tsx` | Binds `meta.providerType` + `authBinding` on save | +| `src-tauri/src/commands/provider.rs` | V1 `add_provider`; V2 `ProviderQuickSetupRequest` (API-key only) | +| `src-tauri/src/commands/change_plan.rs` | `create_codex_provider_upsert_plan` / `apply_change_plan` | +| `src-tauri/src/services/change_plan/service.rs` | Credential gate + reserved upsert id | +| `.trellis/spec/backend/change-plan-executor.md` | Closed adapters: switch / upsert / WorkBuddy only | +| `src/v2/pages/agents/AgentAuthStatusPanel.tsx` | Codex always delegated to Auth Center | +| `src-tauri/src/agent_install/auth_actions.rs` | Codex observation is static `fyagent_managed` | +| `src/v2/pages/models/Page.tsx` | Codex Quick Setup + Change Plan workspaces | +| `src/v2/pages/models/quickSetup.ts` | API-key form contract | + +### 1. How xAI / SuperGrok device-code OAuth binds to a Codex provider + +Login and token storage are **not** Codex-owned. They are the shared managed-auth lane `xai_oauth`. + +**Commands (login / account):** + +- `auth_start_login` with `auth_provider = "xai_oauth"` → `XaiOAuthManager::start_device_flow` (`src-tauri/src/commands/auth.rs:109-144`). +- `auth_poll_for_account` → `poll_for_token` (`auth.rs:147-199`). +- Also: `auth_get_status`, `auth_list_accounts`, `auth_set_default_account`, `auth_remove_account`, `auth_logout`, `auth_cancel_login`. +- xAI-specific extras (not login): `get_xai_oauth_quota`, `get_xai_oauth_models` (`src-tauri/src/commands/xai_oauth.rs:68-91`). + +**Protocol owner:** `XaiOAuthManager` (`xai_oauth_auth.rs:18-22`, `220-250`). Device Authorization Grant against `https://auth.x.ai`. Refresh tokens persist in app-config `xai_oauth_auth.json` (`xai_oauth_auth.rs:211`). Provider config keeps a placeholder key only (`codex.rs:732-739`). + +**Auth Center UI owner:** V1 Settings tab `auth` → `AuthCenterPanel` (`SettingsPage.tsx:305-312`, `AuthCenterPanel.tsx:75-90`). Section title is `xAI (Grok OAuth)`; it mounts `XaiOAuthSection`. Hook is `useXaiOauth` → `useManagedAuth("xai_oauth")` (`useXaiOauth.ts:4-6`, `useManagedAuth.ts:13-41`). Frontend IPC: `src/lib/api/auth.ts:3-6,38-46`. + +**Codex presets (two, do not confuse):** + +| Preset name | `providerType` | Auth | Owner | +|---|---|---|---| +| `xAI (Grok)` | absent | empty `OPENAI_API_KEY` — API-key path | `codexProviderPresets.ts:1390-1413` | +| `xAI (Grok) OAuth` | `xai_oauth` | empty key + `requiresOAuth: true` | `codexProviderPresets.ts:1414-1436` | + +Preset contract test documents this split (`tests/config/xaiOauthProviderPresets.test.ts:58-111`). Claude Code / Claude Desktop use a single managed preset named `xAI (Grok)` with `providerType: "xai_oauth"` (`claudeProviderPresets.ts:1241`, `claudeDesktopProviderPresets.ts:751`). Parent PRD “already usable on Claude / Codex presets” refers to the **OAuth** Codex preset plus Claude presets — not the API-key Codex row. + +**Bind on save (V1 Codex form):** + +1. User picks `xAI (Grok) OAuth`. Form hides API Key and mounts `XaiOAuthSection` (`CodexFormFields.tsx:493-498`). +2. Save refuses if no usable xAI account (`ProviderForm.tsx:1176-1201`, `1234-1243`). +3. Payload writes (`ProviderForm.tsx:1552-1587`): + - `meta.providerType = "xai_oauth"` + - `meta.authBinding = { source: "managed_account", authProvider: "xai_oauth", accountId }` +4. Persist via `add_provider` / `add_provider_with_result` / update (`provider.rs:417-437`). Not Change Plan. +5. Runtime: `Provider::is_xai_oauth()` (`provider.rs:96-98`). `CodexAdapter` pins `XAI_API_BASE_URL` and `xai_oauth_placeholder` (`codex.rs:677-739`). Forwarder injects the live token from `XaiOAuthManager` (`forwarder.rs:1745`, `3284`). + +Credentials are not copied into Codex `auth.json` or the Provider row. That already satisfies R3 if this bind path is reused. + +### 2. Change Plan / apply path (#63) — can SuperGrok reuse it? + +**Yes as the executor. No as the current request/admission shape.** + +Registered operations (`change-plan-executor.md:10-31`, `adapter.rs:53-56`): + +- `codex_provider_switch` ← `create_codex_provider_switch_plan(targetProviderId)` +- `codex_provider_upsert_and_switch` ← `create_codex_provider_upsert_plan(request)` +- `workbuddy_models_save` + +Apply is one command: `apply_change_plan(planId, planDigest)` (`change_plan.rs:63-96`). Codex upsert writer is `ProviderService::apply_quick_setup_with_lock_held` (`change_plan.rs:91-95`). No second executor is required. + +**Three hard gates block SuperGrok today:** + +1. **Quick Setup DTO is API-key only.** `ProviderQuickSetupRequest` = `{ name, baseUrl, apiKey, modelId, codexFeatures? }` (`v2/shared/features/models.ts:3-12`, `provider.rs:201-211`). `into_provider` for Codex writes `OPENAI_API_KEY` + reserved id `fyagent-v2-quick-setup-codex` and never sets `providerType` / `authBinding` (`provider.rs:251-307`). Empty `apiKey` is rejected (`provider.rs:223-227`). +2. **Credential capability rejects managed OAuth.** `prove_codex_target_credential_capability` returns `SecretDependencyUnavailable` if `auth_binding.source == ManagedAccount` **or** any `provider_type` is set **or** `uses_managed_account_auth()` (`service.rs:1593-1646`). `xai_oauth` hits all three. Same function is used by switch (`service.rs:265-267`) and upsert (`service.rs:351-353`). +3. **Upsert id is reserved.** `plan_codex_upsert` requires `provider.id == fyagent-v2-quick-setup-codex` (`service.rs:332-334`, `provider/mod.rs:199`). A V1-created UUID “xAI (Grok) OAuth” provider cannot be created through upsert; it could only be **switched**, and switch is blocked by gate 2. + +#41 is the visible apply/readback/recover job model. #63 is the Codex Provider vertical already landed on that executor (preview → `{ planId, planDigest }` confirm → `getChangeJob`). SuperGrok can stay on this adapter if implement adds a **narrow admission exception** for `xai_oauth` managed accounts (token stays in `xai_oauth_auth.json`; plan stays credential-free) plus a **create input that is not the API-key Quick Setup DTO**. Do not add a fourth adapter. + +### 3. What V2 shows for Codex when an xAI OAuth account exists vs not + +**Identical. V2 does not observe xAI accounts.** + +| Surface | With xAI account | Without xAI account | +|---|---|---| +| Agent Codex auth | `kind: fyagent_managed`, copy「由 FyAgent 认证中心管理」, no 登录 button | Same | +| Agent Codex models | `get_provider_summary` names only (`id` + `name`) | Same | +| Models Codex form | API-key Quick Setup (name / URL / key / model) | Same | +| Models Change Plan | Switch other named Providers; upsert reserved slot from API key | Same | + +Evidence: + +- Codex observation is a constant: `observe_agent_auth(Codex) => fyagent_managed_observation()` (`auth_actions.rs:63-65`, `519-529`). It does not call `XaiOAuthManager`. +- Parser only allows `fyagent_managed` for `agentId === "codex"` (`agent-auth.ts:350-376`). +- UI: `AgentAuthStatusPanel.tsx:48-66`, `130-131`, test `AgentAuthStatusPanel.test.tsx:192-218`. +- Provider public summary is `{ id, name }` only (`provider.rs:20-23,195-198`). No `providerType`, no auth state. +- Models Codex save always `validateQuickSetup` (requires API key) then `createCodexProviderUpsertPlan` (`Page.tsx:1079-1140`). +- `src/v2/**` has **zero** `xai_oauth` / SuperGrok / Auth Center panel imports. V2 architecture forbids importing V1 `AuthCenterPanel` / `XaiOAuthSection` (`tests/v2/app/architecture.test.ts:157-171`). +- V2 settings control is a no-op (`ToolCluster.tsx` `onClick={noop}`). Auth Center remains V1 Settings only. + +If the user already created a V1 Codex provider named `xAI (Grok) OAuth`, V2 Models will list that **name**. Selecting it for Change Plan switch will fail with `secret_dependency_unavailable`. Existence of an xAI account **without** a Codex provider row is invisible on V2. + +### 4. Single-target SuperGrok → Codex demo using existing owners only + +#42 rule: one Codex plan; failure must not claim other Agents changed. Current Change Plan already emits one Codex-only plan. + +**Working today (V1 owners, no new executor):** + +1. **Login** — V1 Settings → Auth → `AuthCenterPanel` / `XaiOAuthSection` → `auth_start_login("xai_oauth")` → device code → `auth_poll_for_account`. +2. **Choose Codex** — V1 Codex app → Add Provider → preset `xAI (Grok) OAuth` (`codexProviderPresets.ts:1414-1436`). +3. **Bind** — `ProviderForm` writes `providerType` + `authBinding` (`ProviderForm.tsx:1552-1587`). No plaintext token in the row. +4. **Write + current** — `add_provider_with_result` then V1 `switch_provider` (not Change Plan). +5. **Runtime readback** — live `~/.codex` projection uses placeholder + local proxy; token from `XaiOAuthManager`. Quota footer can read SuperGrok via `get_xai_oauth_quota` (V1 card, not V2). + +This path already satisfies “one source, one Codex target.” It does **not** satisfy PRD R4 (visible V2 path from logged-in SuperGrok to Codex readback). + +**V2-visible demo that still reuses the same owners (recommended MVP shape):** + +1. Keep login on Auth Center (`xai_oauth` commands + `XaiOAuthSection`). Do not clone OAuth into V2 Agent Auth (Codex is already `managed_by_auth_center`). +2. On V2 Codex Models, do **not** reuse the API-key Quick Setup form. Add a thin native create that builds the existing OAuth preset + `authBinding` (account id only) and calls **existing** `ChangePlanService::plan_codex_upsert` **or** `plan_codex_switch` after V1 add. +3. Preview / confirm / poll stay `CodexSavePlanWorkspace` + `ApplyWorkspace` + `apply_change_plan` + `getChangeJob` (`CodexSavePlanWorkspace.tsx:55-107`). +4. Single target = Codex only. Do not emit Claude plans. +5. Readback = existing job phases (`precheck → snapshot → managed_write → readback → finalize`) plus `useProviderSummary("codex")` showing the named / reserved provider as current. + +Admission change required: treat `xai_oauth` + `ManagedAccount` as `NoNewCredentialMaterial` when the account exists in `XaiOAuthManager` (secret not in the plan). Without that, Change Plan cannot preview or apply. + +**Owner map (do not invent new ones):** + +| Step | Existing owner | +|---|---| +| Device-code login | `auth_*` + `XaiOAuthManager` + Auth Center / `XaiOAuthSection` | +| Preset shape | `codexProviderPresets` `xAI (Grok) OAuth` | +| Bind fields | `ProviderForm` `authBinding` / `providerType` | +| Plan + apply + readback | `changePlans` port + `CodexExecutionAdapter` | +| Codex live write | `ProviderService::apply_quick_setup_with_lock_held` or V1 `switch_provider` | +| Token at request time | `CodexAdapter` + forwarder `XaiOAuthManager` | +| V2 Agent copy | `fyagent_managed` → Auth Center (keep; do not add a second login) | + +### 5. HIL / fixture evidence vs real SuperGrok account + +**Already exists (no live SuperGrok):** + +- `xai_oauth_auth.rs` unit tests: identity, store round-trip, reauth, endpoint origin, error sanitization (`xai_oauth_auth.rs:1010-1175`). No live device-code call. +- Preset contract: `tests/config/xaiOauthProviderPresets.test.ts`. +- Locale / footer: `tests/config/xaiOauthLocales.test.ts`, `tests/components/XaiOauthQuotaFooter.test.tsx`. +- Codex adapter invariants with `provider_type: xai_oauth`: `codex.rs:1547-1602`. +- Change Plan fixtures: API-key upsert/switch only (`tests/v2/fixtures/changePlans.ts:13-28`). Capability tests prove managed binding is **rejected** (`service.rs:2590-2609`). +- V2 Agent Auth fixture: Codex → Auth Center, independent of xAI accounts (`AgentAuthStatusPanel.test.tsx:192-218`, `tests/v2-browser/support/features.ts:396-402`). +- Parent open question (`08-31-grok-first-class-iteration/prd.md:50`): accept via contract/fixture/handoff vs real SuperGrok HIL — not decided. + +**Requires a real SuperGrok account:** + +- Device-code against `auth.x.ai` (user_code, verification_uri, consent). +- Refresh-token persist / `requires_reauth` after revoke. +- `get_xai_oauth_models` / `get_xai_oauth_quota` against live grok.com / api.x.ai. +- End-to-end: bind → Change Plan preview → apply → Codex live projection → one real Codex request through the local proxy (namespace flatten + sanitizer). +- V2 “logged-in SuperGrok → Codex 已回读” UI, once it exists. + +Contract + fixture can prove admission, reserved-id, no-secret-in-plan, and single-target apply. They cannot prove the OAuth handshake or a working Grok session. + +### Related Specs + +- `.trellis/spec/backend/change-plan-executor.md` — closed adapters; no renderer-supplied write target; plans stay credential-free. +- `.trellis/spec/frontend/v2-agent-models.md` — Codex Models = Quick Setup + Change Plan; Agent Codex auth stays Auth Center. +- Parent research: `.trellis/tasks/08-31-grok-first-class-iteration/research/github-decision-106.md`, `github-decision-42.md`. + +## Confirmed facts + +- SuperGrok login is FyAgent-managed `xai_oauth` device-code, stored in `xai_oauth_auth.json`, owned by Auth Center + `auth_*` commands. +- Codex bind is V1-only: preset `xAI (Grok) OAuth` → `meta.providerType=xai_oauth` + `authBinding` → `add_provider` / `switch_provider`. +- Codex runtime already injects the managed token; no second proxy/executor needed. +- V2 Change Plan (#63) is the apply/readback owner for Codex Provider, but it currently admits **API-key Quick Setup only** and **rejects** any `xai_oauth` / managed binding. +- V2 Codex UI does not change when an xAI account appears. Auth is always “go to Auth Center.” +- #42 single-target is already how Change Plan works (one Codex plan). Claude is a separate later reuse, not this task. + +## Reuse owners + +- Login: `auth_start_login` / `auth_poll_for_account` / `XaiOAuthManager` / `XaiOAuthSection` / `AuthCenterPanel`. +- Preset + bind: `codexProviderPresets` `xAI (Grok) OAuth` + `ProviderForm` `authBinding`. +- Apply: `create_codex_provider_*_plan` + `apply_change_plan` + `CodexExecutionAdapter` + `CodexSavePlanWorkspace` / `ChangePlanWorkspace`. +- Live write: existing `ProviderService` Codex writers. +- V2 Agent: keep `fyagent_managed` → Auth Center; do not start a second OAuth UI. + +## Recommended MVP changes + +1. **Admission exception** in `prove_codex_target_credential_capability` for `provider_type == xai_oauth` + `ManagedAccount` when `XaiOAuthManager` has a usable account. Plan/job still carry no token. +2. **Create input for that preset**, not an API-key Quick Setup fork: account id + display name (or reserved-slot upsert). Still one adapter. +3. **V2 visible stitch only:** from Codex Models / Agent, a path that (a) states Auth Center login if no xAI account, (b) previews one Codex plan, (c) confirms with `{ planId, planDigest }`, (d) readback via `getChangeJob` + provider summary. Do not import V1 form components into `src/v2`. +4. Prefer **switch of a V1-created xAI Codex provider** or **reserved-slot upsert**, not a new provider-id scheme. +5. Acceptance can be contract/fixture first; mark live SuperGrok as residual HIL unless the parent decides otherwise. + +## What must stay out of scope + +- Second executor / new Change Plan operation / renderer-supplied write target. +- Same-plan multi-agent apply. Claude Code / Claude Desktop are in this child as **separate** writes, not one shared plan. Claude has no Change Plan adapter; reuse V1 bind. +- WorkBuddy save (`08-31-grok-supergrok-to-workbuddy`). +- Login trichotomy copy (`08-31-grok-login-trichotomy`). +- Grok Build install/upgrade; V2 SuperGrok quota dashboard. +- Writing or treating `~/.grok/auth.json` as login proof. +- Putting access/refresh tokens in Provider rows, Quick Setup payloads, or Change Plan ledger. +- Cloning Auth Center OAuth chrome into V2 Agent Auth. +- Closing #42 / #63 / #41 wholesale. + +## 2026-08-31 scope addendum + +Parent iteration now includes Claude Code, Claude Desktop, Codex, and WorkBuddy. This research file’s Codex facts still stand. Claude / Desktop stay on existing V1 `xai_oauth` bind. WorkBuddy is a different save path and is documented in the sibling task. + +## Caveats / Not Found + +- No V2 port or command exists to list `xai_oauth` accounts. Implement must add a small read or keep login on V1 Auth Center and only consume “already logged in” on the native side. +- `ProviderPublicSummary` cannot tell V2 that a listed name is SuperGrok vs API-key without a new sanitized field or a dedicated create path. +- Upsert overwriting `fyagent-v2-quick-setup-codex` would replace the user’s V2 API-key Quick Setup slot; switch of a separate V1 provider avoids that collision. +- No SuperGrok HIL transcript was found in this task or the parent research folder. +- `task.py current` was unset; output was written to the user-specified task dir `.trellis/tasks/08-31-grok-supergrok-to-codex/research/`. diff --git a/.trellis/tasks/08-31-grok-supergrok-to-codex/task.json b/.trellis/tasks/08-31-grok-supergrok-to-codex/task.json new file mode 100644 index 000000000..bd3ce953d --- /dev/null +++ b/.trellis/tasks/08-31-grok-supergrok-to-codex/task.json @@ -0,0 +1,26 @@ +{ + "id": "grok-supergrok-to-codex", + "name": "grok-supergrok-to-codex", + "title": "Place SuperGrok into Claude Code, Claude Desktop, and Codex", + "description": "", + "status": "in_progress", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "codex", + "assignee": "codex", + "createdAt": "2026-08-31", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": "08-31-grok-first-class-iteration", + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/.trellis/tasks/08-31-grok-supergrok-to-codex/use-cases.md b/.trellis/tasks/08-31-grok-supergrok-to-codex/use-cases.md new file mode 100644 index 000000000..3a17736d2 --- /dev/null +++ b/.trellis/tasks/08-31-grok-supergrok-to-codex/use-cases.md @@ -0,0 +1,37 @@ +# 用例 — SuperGrok → Claude Code / Desktop / Codex + +双机勾选表在父任务 `research/hil-matrix.md`。 + +## UC-P0 没账号先指路 + +- 对应 AT6、AT7 +- 人:认证中心没有 SuperGrok,却想投到 Claude Code 或 Codex +- 期望:新界面指向认证中心;Codex Change Plan 仍然 `SecretDependencyUnavailable`;不搬旧表单 + +## UC-P1 Claude Code + +- 对应 H5 +- 人:已登录 SuperGrok → 选 Claude Code → 先看 → 确认 → 检查 +- 期望:只改 Claude Code;预览/回读里没有钥匙;失败不谎报 Codex / WorkBuddy + +## UC-P2 Claude Desktop + +- 对应 H6 +- 人:已登录 SuperGrok → 旧界面选 Claude Desktop → 先看 → 确认 → 检查 +- 期望:能走通;不要要求它出现在 V2 Agent 目录里 + +## UC-P3 Codex + +- 对应 H7 +- 人:已登录 SuperGrok → 选 Codex → 先看 → 确认 → 检查 +- 期望:走现有 Change Plan;预览单没有钥匙;不盖掉原来的 API 钥匙槽除非预览里写明 + +## UC-P4 一家失败不连坐 + +- 对应 AT8、H9 +- 人:故意取消或失败其中一家 +- 期望:界面和 job 都不说另外两家或 WorkBuddy 已改好 + +## 本窗口不做 + +登录三条路(H1–H4)。WorkBuddy(H8)。ChatGPT 登录。Qoder / TRAE。 diff --git a/.trellis/tasks/08-31-grok-supergrok-to-workbuddy/check.jsonl b/.trellis/tasks/08-31-grok-supergrok-to-workbuddy/check.jsonl new file mode 100644 index 000000000..7623d6a3e --- /dev/null +++ b/.trellis/tasks/08-31-grok-supergrok-to-workbuddy/check.jsonl @@ -0,0 +1,6 @@ +{"file": ".trellis/tasks/08-31-grok-first-class-iteration/summary.md", "reason": "Reject WorkBuddy work that drifts from the shared plan"} +{"file": ".trellis/spec/backend/workbuddy-configuration.md", "reason": "Reject AppType conversion or secret echo"} +{"file": ".trellis/spec/backend/change-plan-executor.md", "reason": "Reject a Codex upsert or fourth adapter"} +{"file": ".trellis/spec/frontend/reuse.md", "reason": "Reject a forked WorkBuddy save chrome"} +{"file": ".trellis/tasks/08-31-grok-supergrok-to-workbuddy/research/current-workbuddy-save-path.md", "reason": "Reject copying refresh tokens into models.json"} +{"file": ".trellis/tasks/08-31-grok-first-class-iteration/research/hil-matrix.md", "reason": "H8 must pass on both machines"} diff --git a/.trellis/tasks/08-31-grok-supergrok-to-workbuddy/design.md b/.trellis/tasks/08-31-grok-supergrok-to-workbuddy/design.md new file mode 100644 index 000000000..fb0cdb49c --- /dev/null +++ b/.trellis/tasks/08-31-grok-supergrok-to-workbuddy/design.md @@ -0,0 +1,42 @@ +# Design — SuperGrok to WorkBuddy + +先读父任务 [summary.md](../08-31-grok-first-class-iteration/summary.md)。事实见 `research/current-workbuddy-save-path.md`。合同见 `.trellis/spec/backend/workbuddy-configuration.md`。用例见 [use-cases.md](./use-cases.md)。 + +本子任务不管登录路标,不管 Claude / Desktop / Codex 的 Provider 绑定。 + +## 边界 + +WorkBuddy 不是 `AppType`,不是 Provider。保存只有这一条: + +`create_workbuddy_save_plan` → `workbuddy_models_save` → `apply_change_plan(planId, planDigest)` + +请求形状已经定死:`base_url` + `api_key` + 模型 ID + revision / overwrite token。公开计划和日志必须没有钥匙。 + +不要走 Codex upsert。不要第四个执行器。不要把 WorkBuddy 改成 Provider。 + +## 和 SuperGrok 怎么接 + +1. 没登录:指向认证中心,不假装已经写进 WorkBuddy。 +2. 已登录:用现有 `get_xai_oauth_models` 拉模型名单,填进现有 WorkBuddy 预览。不要再扫一次码。 +3. **禁止**把 OAuth 刷新令牌抄进 `{trusted-home}/.workbuddy/models.json`。令牌会过期,也是把托管秘密复制到另一家软件的文件里。 +4. WorkBuddy 运行时读自己的文件。能少填一把钥匙就少填;文件格式做不到,就在预览/亲测里写明卡在文件格式,不要谎报「已经 OAuth 绑定」。 + +## 数据流 + +```text +认证中心 xai_oauth + →(可选)get_xai_oauth_models + → WorkBuddySavePlanWorkspace.createWorkBuddySavePlan + → apply_change_plan + → models.json 回读(get_workbuddy_status / model ids) +``` + +UI 主人:`src/v2/pages/models/Page.tsx` 的 WorkBuddy 面板 + `WorkBuddySavePlanWorkspace.tsx`。 + +## 兼容 + +修订、覆盖确认、并发修改、备份路径,继续遵守 `workbuddy-configuration.md`。不要改 MCP / Skills 那条线。 + +## 回滚 + +只撤「用已登录账号拉名单 / 生成预览」的缝合。不要拆现有 WorkBuddy 保存。 diff --git a/.trellis/tasks/08-31-grok-supergrok-to-workbuddy/implement.jsonl b/.trellis/tasks/08-31-grok-supergrok-to-workbuddy/implement.jsonl new file mode 100644 index 000000000..51ce5ffb2 --- /dev/null +++ b/.trellis/tasks/08-31-grok-supergrok-to-workbuddy/implement.jsonl @@ -0,0 +1,9 @@ +{"file": ".trellis/tasks/08-31-grok-first-class-iteration/summary.md", "reason": "Shared plain-language plan"} +{"file": ".trellis/spec/backend/workbuddy-configuration.md", "reason": "WorkBuddy save, revision, credential isolation; not AppType"} +{"file": ".trellis/spec/backend/change-plan-executor.md", "reason": "Reuse workbuddy_models_save; no fourth adapter"} +{"file": ".trellis/spec/backend/reuse.md", "reason": "Reuse existing WorkBuddy and xAI owners"} +{"file": ".trellis/spec/frontend/reuse.md", "reason": "Extend WorkBuddySavePlanWorkspace; do not fork"} +{"file": ".trellis/spec/frontend/v2-agent-models.md", "reason": "WorkBuddy models stay dedicated native contract"} +{"file": ".trellis/spec/guides/code-reuse-thinking-guide.md", "reason": "Force reuse before any new save path"} +{"file": ".trellis/tasks/08-31-grok-supergrok-to-workbuddy/research/current-workbuddy-save-path.md", "reason": "Current save request and no-token-copy rule"} +{"file": ".trellis/tasks/08-31-grok-first-class-iteration/research/hil-matrix.md", "reason": "AT9 H8 H9 live cases"} diff --git a/.trellis/tasks/08-31-grok-supergrok-to-workbuddy/implement.md b/.trellis/tasks/08-31-grok-supergrok-to-workbuddy/implement.md new file mode 100644 index 000000000..ab4c12d8f --- /dev/null +++ b/.trellis/tasks/08-31-grok-supergrok-to-workbuddy/implement.md @@ -0,0 +1,40 @@ +# Implement — SuperGrok to WorkBuddy + +先读 [design.md](./design.md) 和 [use-cases.md](./use-cases.md)。对齐源:父任务 [summary.md](../08-31-grok-first-class-iteration/summary.md)。 + +依赖:人要先能在认证中心扫码(登录窗口的路标)。写入本身不依赖 Codex 窄口。 + +## 开工顺序 + +1. 读 `research/current-workbuddy-save-path.md` 和 `.trellis/spec/backend/workbuddy-configuration.md`。 +2. 没账号:WorkBuddy 模型页指向认证中心。 +3. 有账号:用 `get_xai_oauth_models` 填模型名单,再走 `create_workbuddy_save_plan`。 +4. 钉住:预览是 `workbuddy_models_save`;单子和日志没有刷新令牌;失败不连坐 Claude / Codex。 +5. 亲测 UC-W1–W3。回写 #42 / #106。 + +## 会碰到的文件(先读再改) + +| 文件 | 为什么 | +|---|---| +| `src/v2/pages/models/Page.tsx` | WorkBuddy 面板、拉模型、生成预览 | +| `src/v2/pages/models/apply/WorkBuddySavePlanWorkspace.tsx` | 预览/确认 | +| `src/v2/shared/features/change-plans.ts` | `workbuddy_models_save` | +| `src-tauri/src/services/workbuddy/types.rs` | `SaveWorkBuddyModelsRequest` | +| `src-tauri/src/commands/xai_oauth.rs` | `get_xai_oauth_models`,不是登录 | +| `src-tauri/src/proxy/providers/xai_oauth_auth.rs` | token 只留在这里 | + +不要改:Codex `prove_codex_target_credential_capability`、Claude Provider 绑定、把 WorkBuddy 加成 `AppType`。 + +## 自动检查 + +- WorkBuddy 现有保存 / revision / 脱敏测试必须继续绿 +- 新增或改断言:预览 operation = `workbuddy_models_save`;payload 不见 refresh token +- 不要出现 Codex upsert 的 reserved id + +## 亲测 + +`hil-matrix.md` 的 AT9、H8、H9。不要替投放窗口勾 H5–H7。 + +## 回滚 + +只撤 SuperGrok 缝合。现有「自己填地址和钥匙」的 WorkBuddy 保存必须还能用。 diff --git a/.trellis/tasks/08-31-grok-supergrok-to-workbuddy/prd.md b/.trellis/tasks/08-31-grok-supergrok-to-workbuddy/prd.md new file mode 100644 index 000000000..196ba2824 --- /dev/null +++ b/.trellis/tasks/08-31-grok-supergrok-to-workbuddy/prd.md @@ -0,0 +1,38 @@ +# Place SuperGrok into WorkBuddy models + +先读父任务 [summary.md](../08-31-grok-first-class-iteration/summary.md)。用例:[use-cases.md](./use-cases.md)。本子任务不管三条登录路标,也不管 Claude / Desktop / Codex 的 Provider 绑定。 + +## Goal + +人在认证中心用 SuperGrok 登录一次,就能在 WorkBuddy 里用上这颗脑子:先看要改什么,点头后再改,改完再检查。走 WorkBuddy 自己的保存,不是 Codex 那扇门。 + +## Confirmed facts + +见 `research/current-workbuddy-save-path.md`。 + +- WorkBuddy 可以自己换模型。保存走 `create_workbuddy_save_plan` / `workbuddy_models_save`,请求是地址 + 钥匙 + 模型 ID。 +- 现在没有 `xai_oauth` 预设。钥匙会写进 WorkBuddy 自己的 `models.json`,不走 Provider 行。 +- Qoder 不能配第三方模型;TRAE 不能代写模型。这两家不在本任务。 + +## Requirements + +- R1. 不新做执行器。只走现有 WorkBuddy Change Plan。 +- R2. 已扫码 SuperGrok 的,先用这份账号拉模型名单,不要再扫一次。能少填一把钥匙就少填。 +- R3. 不把 OAuth 刷新令牌抄进 `models.json`,不进预览单,不进前端。 +- R4. 失败了不说 Claude / Codex 也被改好了。 +- R5. 不把 WorkBuddy 改成 `AppType` 或 Provider。 + +## Acceptance Criteria + +- [ ] 没账号时指向认证中心,不假装已经写进 WorkBuddy。 +- [ ] 有账号时能预览、确认、检查;预览走 `workbuddy_models_save`,单子里没有刷新令牌。 +- [ ] 回写 #42、#106,不关整张 #42。 +- [ ] William 在 Windows 和 Mac mini 上用真实账号亲自走完。密码不进仓库。 + +## Out of scope + +- Claude Code / Claude Desktop / Codex 写入 +- 登录路标文案 +- ChatGPT 登录 +- Qoder / TRAE / OpenCode 另做 SuperGrok 扫码 +- 安装升级、额度看板 diff --git a/.trellis/tasks/08-31-grok-supergrok-to-workbuddy/research/current-workbuddy-save-path.md b/.trellis/tasks/08-31-grok-supergrok-to-workbuddy/research/current-workbuddy-save-path.md new file mode 100644 index 000000000..c99ac057c --- /dev/null +++ b/.trellis/tasks/08-31-grok-supergrok-to-workbuddy/research/current-workbuddy-save-path.md @@ -0,0 +1,35 @@ +# Research: current SuperGrok → WorkBuddy path + +- **Query**: How WorkBuddy saves models today; whether SuperGrok / `xai_oauth` can bind without a second pasted key; what must not be copied into `models.json`. +- **Scope**: internal code + parent #42 / #106 +- **Date**: 2026-08-31 + +## Findings + +WorkBuddy is not a Provider app. Save goes through Change Plan operation `workbuddy_models_save`: + +- Create: `create_workbuddy_save_plan` / `ports.changePlans.createWorkBuddySavePlan` +- Request: `SaveWorkBuddyModelsRequest` = `base_url` + `api_key` + model ids + revision / overwrite token +- On disk: `{trusted-home}/.workbuddy/models.json` (and backup). The live file stores `url` and `apiKey`. +- Public plan stays credential-free; the key lives in a process-private draft keyed by `planId`. + +There is no WorkBuddy `xai_oauth` preset. Auth Center login does not change the WorkBuddy form by itself. + +Copying the SuperGrok refresh token into `models.json` is out of scope: the token rotates, and it would leak a managed secret into another app's file. + +`get_xai_oauth_models` can list models for a logged-in account. That can fill the WorkBuddy model id list without a second device-code login. WorkBuddy runtime still reads its own file; if that file only accepts a key, the save cannot honestly claim “OAuth bind” the way Codex does. + +Qoder cannot take third-party models. TRAE cannot be written. Those stay out. + +## Reuse owners + +- Login: Auth Center / `xai_oauth` +- Model list (optional): `get_xai_oauth_models` +- Save: existing WorkBuddy Change Plan + `WorkBuddySavePlanWorkspace` + +## What must stay out + +- Codex upsert / Claude Provider bind +- Fourth Change Plan adapter +- Writing refresh tokens into `models.json` +- Turning WorkBuddy into `AppType` diff --git a/.trellis/tasks/08-31-grok-supergrok-to-workbuddy/task.json b/.trellis/tasks/08-31-grok-supergrok-to-workbuddy/task.json new file mode 100644 index 000000000..8f4405c97 --- /dev/null +++ b/.trellis/tasks/08-31-grok-supergrok-to-workbuddy/task.json @@ -0,0 +1,26 @@ +{ + "id": "grok-supergrok-to-workbuddy", + "name": "grok-supergrok-to-workbuddy", + "title": "Place SuperGrok into WorkBuddy models", + "description": "", + "status": "in_progress", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "codex", + "assignee": "codex", + "createdAt": "2026-08-31", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": "08-31-grok-first-class-iteration", + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/.trellis/tasks/08-31-grok-supergrok-to-workbuddy/use-cases.md b/.trellis/tasks/08-31-grok-supergrok-to-workbuddy/use-cases.md new file mode 100644 index 000000000..ba41a4ae2 --- /dev/null +++ b/.trellis/tasks/08-31-grok-supergrok-to-workbuddy/use-cases.md @@ -0,0 +1,26 @@ +# 用例 — SuperGrok → WorkBuddy + +双机勾选表在父任务 `research/hil-matrix.md`。 + +## UC-W1 没账号不假装写入 + +- 对应 AT9 的「没账号」半边 +- 人:没登录 SuperGrok,打开 WorkBuddy 模型 +- 期望:指向认证中心;不生成一笔假装成功的 WorkBuddy 保存 + +## UC-W2 已登录能保存并回读 + +- 对应 H8 +- 人:已登录 SuperGrok → WorkBuddy → 先看 → 确认 → 检查 +- 期望:预览 operation 是 `workbuddy_models_save`;用已登录账号拉模型名单;单子里没有刷新令牌;回读看得到模型 +- 若文件格式仍要一把钥匙:预览说清楚要什么;**不要**把 OAuth 刷新令牌写进 `models.json` + +## UC-W3 失败不连坐 + +- 对应 H9 +- 人:故意取消 WorkBuddy 预览 +- 期望:不说 Claude / Codex 已改好 + +## 本窗口不做 + +登录三条路。Claude / Desktop / Codex 绑定。Qoder / TRAE。把 WorkBuddy 改成 Provider。 diff --git a/src-tauri/permissions/legacy-application-commands.toml b/src-tauri/permissions/legacy-application-commands.toml index 93b4de018..4d70841f0 100644 --- a/src-tauri/permissions/legacy-application-commands.toml +++ b/src-tauri/permissions/legacy-application-commands.toml @@ -20,6 +20,7 @@ commands.allow = [ "auth_set_default_account", "auth_start_login", "auth_cancel_login", + "bind_xai_managed_provider", "cancel_codex_desktop_restart_with_force", "check_env_conflicts", "check_provider_limits", diff --git a/src-tauri/src/commands/provider.rs b/src-tauri/src/commands/provider.rs index 5e504bb70..07984f736 100644 --- a/src-tauri/src/commands/provider.rs +++ b/src-tauri/src/commands/provider.rs @@ -460,6 +460,161 @@ pub fn add_provider_with_result( Ok(result) } +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct BindXaiManagedRequest { + pub app: String, + pub account_id: Option, +} + +#[derive(Debug, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BindXaiManagedResult { + pub provider_id: String, + pub provider_name: String, + pub app: String, + pub already_bound: bool, + pub activated: bool, +} + +fn parse_xai_bind_app(app: &str) -> Result { + match app.trim() { + "claude" => Ok(AppType::Claude), + "claude-desktop" => Ok(AppType::ClaudeDesktop), + "codex" => Ok(AppType::Codex), + _ => Err("SuperGrok bind supports only claude, claude-desktop, or codex".to_string()), + } +} + +fn xai_managed_provider_id(app_type: &AppType) -> &'static str { + match app_type { + AppType::Claude => "fyagent-v2-xai-oauth-claude", + AppType::ClaudeDesktop => "fyagent-v2-xai-oauth-claude-desktop", + AppType::Codex => "fyagent-v2-xai-oauth-codex", + _ => "fyagent-v2-xai-oauth", + } +} + +fn build_xai_managed_provider(app_type: &AppType, account_id: &str) -> Provider { + let name = match app_type { + AppType::Codex => "xAI (Grok) OAuth".to_string(), + _ => "xAI (Grok)".to_string(), + }; + let settings_config = match app_type { + AppType::Codex => serde_json::json!({ + "auth": {}, + "config": "model_provider = \"xai\"\nmodel = \"grok-4.5\"\n\n[model_providers.xai]\nname = \"xAI (Grok) OAuth\"\nbase_url = \"https://api.x.ai/v1\"\nwire_api = \"responses\"\n" + }), + _ => serde_json::json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://api.x.ai/v1", + "ANTHROPIC_MODEL": "grok-4.5", + "ANTHROPIC_DEFAULT_HAIKU_MODEL": "grok-4.5", + "ANTHROPIC_DEFAULT_SONNET_MODEL": "grok-4.5", + "ANTHROPIC_DEFAULT_OPUS_MODEL": "grok-4.5" + } + }), + }; + let mut provider = Provider::with_id( + xai_managed_provider_id(app_type).to_string(), + name, + settings_config, + Some("https://x.ai/grok".to_string()), + ); + provider.category = Some("third_party".to_string()); + provider.icon = Some("xai".to_string()); + let mut meta = crate::provider::ProviderMeta { + provider_type: Some("xai_oauth".to_string()), + auth_binding: Some(crate::provider::AuthBinding { + source: crate::provider::AuthBindingSource::ManagedAccount, + auth_provider: Some("xai_oauth".to_string()), + account_id: Some(account_id.to_string()), + }), + ..Default::default() + }; + if *app_type == AppType::ClaudeDesktop { + if let Some(routes) = suggested_claude_desktop_routes(&provider) { + meta.claude_desktop_mode = Some(crate::provider::ClaudeDesktopMode::Proxy); + meta.claude_desktop_model_routes = routes; + } + } + provider.meta = Some(meta); + provider +} + +/// Bind a logged-in SuperGrok account to Claude Code, Claude Desktop, or Codex. +/// Tokens stay in `xai_oauth_auth.json`. Codex is stored only; activation uses +/// the existing Change Plan switch. +#[tauri::command(rename_all = "camelCase")] +pub async fn bind_xai_managed_provider( + request: BindXaiManagedRequest, + app_handle: tauri::AppHandle, + xai_state: State<'_, XaiOAuthState>, +) -> Result { + let app_type = parse_xai_bind_app(&request.app)?; + let manager = xai_state.0.read().await; + let account_id = match request + .account_id + .as_deref() + .map(str::trim) + .filter(|id| !id.is_empty()) + { + Some(id) => id.to_string(), + None => manager + .default_account_id() + .await + .ok_or_else(|| "No usable xAI account available".to_string())?, + }; + if !crate::proxy::providers::xai_oauth_auth::XaiOAuthManager::stored_account_is_usable( + &account_id, + ) { + return Err("No usable xAI account available".to_string()); + } + drop(manager); + + tauri::async_runtime::spawn_blocking(move || { + let state = app_handle + .try_state::() + .ok_or_else(|| "Provider state is unavailable".to_string())?; + let provider = build_xai_managed_provider(&app_type, &account_id); + let provider_id = provider.id.clone(); + let provider_name = provider.name.clone(); + let already_bound = state + .db + .get_provider_by_id(&provider_id, app_type.as_str()) + .ok() + .flatten() + .is_some(); + let activate = !matches!(app_type, AppType::Codex); + if already_bound { + state + .db + .save_provider(app_type.as_str(), &provider) + .map_err(|error| error.to_string())?; + if activate { + ProviderService::switch(state.inner(), app_type.clone(), &provider_id) + .map_err(|error| error.to_string())?; + } + } else if activate { + ProviderService::add(state.inner(), app_type.clone(), provider, true) + .map_err(|error| error.to_string())?; + } else { + ProviderService::add_draft(state.inner(), app_type.clone(), provider) + .map_err(|error| error.to_string())?; + } + + Ok(BindXaiManagedResult { + provider_id, + provider_name, + app: app_type.as_str().to_string(), + already_bound, + activated: activate, + }) + }) + .await + .map_err(|error| error.to_string())? +} + fn parse_provider_draft_app(app: &str) -> Result { let app_type = AppType::from_str(app).map_err(|e| e.to_string())?; if !matches!( @@ -1559,6 +1714,53 @@ mod provider_draft_command_tests { .count(), 1 ); + assert_eq!( + library_source + .matches("commands::bind_xai_managed_provider") + .count(), + 1 + ); + } + + #[test] + fn xai_managed_bind_allows_only_claude_desktop_and_codex() { + use super::{build_xai_managed_provider, parse_xai_bind_app}; + + assert!(matches!(parse_xai_bind_app("claude"), Ok(AppType::Claude))); + assert!(matches!( + parse_xai_bind_app("claude-desktop"), + Ok(AppType::ClaudeDesktop) + )); + assert!(matches!(parse_xai_bind_app("codex"), Ok(AppType::Codex))); + for unsupported in ["grokbuild", "gemini", "workbuddy", "qoder"] { + assert!( + parse_xai_bind_app(unsupported).is_err(), + "{unsupported} must not enter SuperGrok bind" + ); + } + + let claude = build_xai_managed_provider(&AppType::Claude, "acct-xai"); + let desktop = build_xai_managed_provider(&AppType::ClaudeDesktop, "acct-xai"); + let codex = build_xai_managed_provider(&AppType::Codex, "acct-xai"); + assert_eq!(claude.id, "fyagent-v2-xai-oauth-claude"); + assert_eq!(desktop.id, "fyagent-v2-xai-oauth-claude-desktop"); + assert_eq!(codex.id, "fyagent-v2-xai-oauth-codex"); + assert_eq!(claude.name, "xAI (Grok)"); + assert_eq!(codex.name, "xAI (Grok) OAuth"); + let payload = serde_json::to_string(&claude).unwrap() + + &serde_json::to_string(&desktop).unwrap() + + &serde_json::to_string(&codex).unwrap(); + assert!(payload.contains("xai_oauth")); + assert!(payload.contains("acct-xai")); + assert!(!payload.contains("refresh")); + assert!(!payload.contains("ANTHROPIC_AUTH_TOKEN")); + assert!(!payload.contains("OPENAI_API_KEY")); + let desktop_meta = desktop.meta.as_ref().expect("desktop meta"); + assert_eq!( + desktop_meta.claude_desktop_mode, + Some(crate::provider::ClaudeDesktopMode::Proxy) + ); + assert!(!desktop_meta.claude_desktop_model_routes.is_empty()); } #[test] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f6b752ceb..2ecbab2d6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1873,6 +1873,7 @@ pub fn run() { commands::get_provider_summary, commands::add_provider, commands::add_provider_with_result, + commands::bind_xai_managed_provider, commands::apply_provider_quick_setup_with_result, commands::update_provider, commands::update_provider_with_result, diff --git a/src-tauri/src/proxy/providers/xai_oauth_auth.rs b/src-tauri/src/proxy/providers/xai_oauth_auth.rs index 93f7179de..ab6ef6188 100644 --- a/src-tauri/src/proxy/providers/xai_oauth_auth.rs +++ b/src-tauri/src/proxy/providers/xai_oauth_auth.rs @@ -217,6 +217,23 @@ impl XaiOAuthManager { manager } + /// Process-local, token-free admission: whether `account_id` is present + /// and not marked `requires_reauth` in the on-disk store. + pub fn stored_account_is_usable(account_id: &str) -> bool { + let trimmed = account_id.trim(); + if trimmed.is_empty() { + return false; + } + Self::new(crate::config::get_app_config_dir()).has_usable_account_sync(trimmed) + } + + fn has_usable_account_sync(&self, account_id: &str) -> bool { + self.accounts + .try_read() + .ok() + .is_some_and(|accounts| Self::is_usable_account(&accounts, account_id)) + } + pub async fn start_device_flow(&self) -> Result { let endpoints = self.discover_endpoints().await?; let response = crate::proxy::http_client::get() diff --git a/src-tauri/src/services/change_plan/service.rs b/src-tauri/src/services/change_plan/service.rs index a6d77f28c..0f3751dda 100644 --- a/src-tauri/src/services/change_plan/service.rs +++ b/src-tauri/src/services/change_plan/service.rs @@ -1594,6 +1594,9 @@ fn prove_codex_target_credential_capability( inspection: &CodexSwitchInspection, ) -> SecretCapabilityResult { let provider = &inspection.target; + if xai_oauth_managed_account_is_ready(provider) { + return prove_xai_oauth_switch_shape(provider); + } if provider .meta .as_ref() @@ -1652,6 +1655,43 @@ fn prove_codex_target_credential_capability( } } +fn xai_oauth_managed_account_is_ready(provider: &Provider) -> bool { + if !provider.is_xai_oauth() { + return false; + } + let Some(binding) = provider + .meta + .as_ref() + .and_then(|meta| meta.auth_binding.as_ref()) + else { + return false; + }; + if binding.source != crate::provider::AuthBindingSource::ManagedAccount { + return false; + } + if binding.auth_provider.as_deref() != Some("xai_oauth") { + return false; + } + binding.account_id.as_deref().is_some_and(|account_id| { + crate::proxy::providers::xai_oauth_auth::XaiOAuthManager::stored_account_is_usable( + account_id, + ) + }) +} + +fn prove_xai_oauth_switch_shape(provider: &Provider) -> SecretCapabilityResult { + let Some(settings) = provider.settings_config.as_object() else { + return SecretCapabilityResult::SecretDependencyUnavailable; + }; + let Some(config_text) = settings.get("config").and_then(Value::as_str) else { + return SecretCapabilityResult::SecretDependencyUnavailable; + }; + if config_text.parse::().is_err() { + return SecretCapabilityResult::SecretDependencyUnavailable; + } + SecretCapabilityResult::NoNewCredentialMaterial +} + fn validate_optional_provider_id( value: Option, ) -> Result, ChangePlanErrorCode> { @@ -2418,6 +2458,48 @@ mod tests { ); } + #[test] + #[serial] + fn credential_capability_admits_usable_xai_oauth_managed_account() { + let (home, _guard, db, state, _current, target) = setup_switch_state(); + let store_dir = home.path().join(".fyagent"); + std::fs::create_dir_all(&store_dir).unwrap(); + std::fs::write( + store_dir.join("xai_oauth_auth.json"), + r#"{"version":1,"default_account_id":"acct-xai","accounts":{"acct-xai":{"account_id":"acct-xai","login":"fixture","refresh_token":"fixture-refresh","authenticated_at":1,"requires_reauth":false}}}"#, + ) + .unwrap(); + + let mut xai = target.clone(); + xai.id = "xai-oauth-target".to_string(); + xai.meta = Some(crate::provider::ProviderMeta { + provider_type: Some("xai_oauth".to_string()), + auth_binding: Some(crate::provider::AuthBinding { + source: crate::provider::AuthBindingSource::ManagedAccount, + auth_provider: Some("xai_oauth".to_string()), + account_id: Some("acct-xai".to_string()), + }), + ..Default::default() + }); + db.save_provider(AppType::Codex.as_str(), &xai).unwrap(); + let plan = ChangePlanService::plan_codex_switch_at(&state, &xai.id, 300).unwrap(); + assert_eq!( + plan.secret_capability, + SecretCapabilityResult::NoNewCredentialMaterial + ); + let persisted = serde_json::to_string(&plan).unwrap(); + assert!(!persisted.contains("fixture-refresh")); + assert!(!persisted.contains("acct-xai")); + + xai.meta.as_mut().unwrap().auth_binding.as_mut().unwrap().account_id = + Some("missing-account".to_string()); + db.save_provider(AppType::Codex.as_str(), &xai).unwrap(); + assert_eq!( + ChangePlanService::plan_codex_switch_at(&state, &xai.id, 301), + Err(ChangePlanErrorCode::SecretDependencyUnavailable) + ); + } + #[test] #[serial] fn malformed_target_and_live_read_error_fail_closed_without_plan() { diff --git a/src/index.html b/src/index.html index c5ff9932f..2255443c3 100644 --- a/src/index.html +++ b/src/index.html @@ -25,6 +25,7 @@ diff --git a/src/legacy-auth-boot.ts b/src/legacy-auth-boot.ts new file mode 100644 index 000000000..0651d7641 --- /dev/null +++ b/src/legacy-auth-boot.ts @@ -0,0 +1,5 @@ +window.addEventListener("fyagent:open-auth-center", () => { + void import("./legacy-auth-host.tsx").then((module) => { + module.openLegacyAuthCenter(); + }); +}); diff --git a/src/legacy-auth-host.tsx b/src/legacy-auth-host.tsx new file mode 100644 index 000000000..888a36e72 --- /dev/null +++ b/src/legacy-auth-host.tsx @@ -0,0 +1,70 @@ +import { QueryClientProvider } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; +import { createRoot } from "react-dom/client"; + +import { AuthCenterPanel } from "@/components/settings/AuthCenterPanel"; +import { ThemeProvider } from "@/components/theme-provider"; +import { queryClient } from "@/lib/query"; + +import "./i18n"; + +const ROOT_ID = "legacy-auth-root"; + +let openAuthCenter: (() => void) | null = null; +let mounted = false; + +function LegacyAuthHost() { + const [open, setOpen] = useState(true); + + useEffect(() => { + openAuthCenter = () => setOpen(true); + return () => { + if (openAuthCenter) openAuthCenter = null; + }; + }, []); + + if (!open) return null; + + return ( +
+
+
+

认证中心

+

+ SuperGrok 扫码在下面的 xAI (Grok OAuth)。不要在这里跑 grok + login。关掉后回到新界面。 +

+
+ +
+ +
+ ); +} + +function ensureMounted(): void { + if (mounted) return; + mounted = true; + const mount = document.createElement("div"); + mount.id = ROOT_ID; + document.body.appendChild(mount); + createRoot(mount).render( + + + + + , + ); +} + +export function openLegacyAuthCenter(): void { + ensureMounted(); + openAuthCenter?.(); +} diff --git a/src/v2/app/styles/shell.css b/src/v2/app/styles/shell.css index e7b272222..9f5422516 100644 --- a/src/v2/app/styles/shell.css +++ b/src/v2/app/styles/shell.css @@ -679,3 +679,61 @@ transition: none; } } + +#legacy-auth-root { + position: fixed; + inset: 0; + z-index: 80; + display: grid; + place-items: center; + padding: 16px; + background: rgba(4, 8, 14, 0.62); +} + +#legacy-auth-root .legacy-auth-host { + display: grid; + gap: 16px; + width: min(760px, 100%); + max-height: calc(100dvh - 32px); + overflow: auto; + padding: 20px; + border: 1px solid var(--fy-border); + border-radius: 16px; + background: var(--fy-surface); + color: var(--fy-text); + box-shadow: 0 24px 64px rgba(0, 0, 0, 0.35); +} + +#legacy-auth-root .legacy-auth-host-header { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +#legacy-auth-root h1, +#legacy-auth-root h3, +#legacy-auth-root h4 { + margin: 0 0 6px; +} + +#legacy-auth-root p, +#legacy-auth-root label { + margin: 0; + color: var(--fy-text-secondary); + line-height: 1.55; +} + +#legacy-auth-root button { + min-height: 32px; + padding: 6px 12px; + border: 1px solid var(--fy-border); + border-radius: 8px; + background: rgba(226, 243, 255, 0.08); + color: var(--fy-text); +} + +#legacy-auth-root button:disabled { + opacity: 0.55; +} diff --git a/src/v2/pages/agents/AgentAuthStatusPanel.tsx b/src/v2/pages/agents/AgentAuthStatusPanel.tsx index a79db08cf..cb9b81af1 100644 --- a/src/v2/pages/agents/AgentAuthStatusPanel.tsx +++ b/src/v2/pages/agents/AgentAuthStatusPanel.tsx @@ -19,6 +19,8 @@ import type { AgentCatalogId } from "../../shared/features/types"; import { LifecycleTargetPicker } from "../../shared/ui/LifecycleTargetPicker"; import { Button, InlineNotice, Spinner } from "../../shared/ui/primitives"; +import { requestOpenAuthCenter } from "../../shared/features/auth-center-handoff"; + import { isAgentAuthSessionTerminal, useAgentAuthSession, @@ -44,7 +46,9 @@ function observationSummary(observation: AgentAuthObservation): string { ? "尚未连接 Provider" : `已连接 ${observation.providers.length} 个 Provider`; case "handoff_only": - return "仅支持打开官方认证入口"; + return observation.agentId === "grokbuild" + ? "仅支持打开官方认证入口(终端 grok login)" + : "仅支持打开官方认证入口"; case "fyagent_managed": return "由 FyAgent 认证中心管理"; case "unavailable": @@ -61,9 +65,11 @@ function observationDescription(observation: AgentAuthObservation): string { case "provider_connections": return "OpenCode 按 Provider 管理连接,不提供全局登录布尔值。"; case "handoff_only": - return "FyAgent 只能把操作交给官方应用或 CLI,无法验证最终账号状态。"; + return observation.agentId === "grokbuild" + ? "软件只能开门,不能验证是否已经登录,所以这里不会出现已登录。终端里自己运行 grok login / grok logout 也可以。这条路只给 Grok Build 自己用,不会写进 Codex。SuperGrok 扫码请去认证中心。" + : "FyAgent 只能把操作交给官方应用或 CLI,无法验证最终账号状态。"; case "fyagent_managed": - return "Codex 托管账号继续由现有认证中心负责,不在此处复制 OAuth 流程。"; + return "Codex 托管账号请去认证中心管理。SuperGrok 扫码也在认证中心,不要在这里登录,也不要去终端跑 grok login。"; case "unavailable": return "认证观察器不可用;不会读取厂商凭据文件或推断登录状态。"; } @@ -103,7 +109,10 @@ function stageCopy(snapshot: AgentAuthSessionSnapshot): string { } } -function reasonCopy(reason: AgentAuthReasonCode | null): string | null { +function reasonCopy( + reason: AgentAuthReasonCode | null, + agentId?: AgentCatalogId, +): string | null { switch (reason) { case null: return null; @@ -126,9 +135,11 @@ function reasonCopy(reason: AgentAuthReasonCode | null): string | null { case "timed_out": return "在限定时间内没有获得可验证结果。"; case "handoff_only": - return "已完成入口交接,但没有权威状态可验证。"; + return agentId === "grokbuild" + ? "已打开终端入口。请完成 grok login 或 grok logout。没有权威状态可验证,不会显示已登录。" + : "已完成入口交接,但没有权威状态可验证。"; case "managed_by_auth_center": - return "请在现有认证中心管理此账号。"; + return "请在现有认证中心管理此账号。SuperGrok 扫码也在那里,不是 grok login。"; case "target_selection_required": return "检测到多份安装,请选择认证目标。"; case "target_changed": @@ -145,16 +156,25 @@ function reasonCopy(reason: AgentAuthReasonCode | null): string | null { } } -function errorReason(error: unknown): string { +function errorReason(error: unknown, agentId?: AgentCatalogId): string { if (typeof error === "object" && error !== null && "reasonCode" in error) { const reason = (error as { reasonCode?: AgentAuthReasonCode }).reasonCode; - if (reason) return reasonCopy(reason) ?? "认证操作未完成。"; + if (reason) return reasonCopy(reason, agentId) ?? "认证操作未完成。"; } return "认证操作未完成,请刷新状态后重试。"; } function terminalTone(snapshot: AgentAuthSessionSnapshot): "info" | "warning" { - return snapshot.stage === "verified" ? "info" : "warning"; + return snapshot.stage === "verified" || snapshot.stage === "handoff_complete" + ? "info" + : "warning"; +} + +function canRefreshObservation(observation: AgentAuthObservation): boolean { + return ( + observation.kind === "account" || + observation.kind === "provider_connections" + ); } export function AgentAuthStatusPanel(props: { @@ -306,12 +326,23 @@ function AgentAuthStatusPanelInner({

认证状态

{observationDescription(observation)}

- +
+ {observation.agentId === "grokbuild" || + observation.agentId === "workbuddy" || + observation.kind === "fyagent_managed" ? ( + + ) : null} + {canRefreshObservation(observation) ? ( + + ) : null} +
{observationSummary(observation)} @@ -390,12 +421,12 @@ function AgentAuthStatusPanelInner({ ) : null} {session.snapshot?.reasonCode ? (

- {reasonCopy(session.snapshot.reasonCode)} + {reasonCopy(session.snapshot.reasonCode, agentId)}

) : null} {session.error ? ( - {errorReason(session.error)} + {errorReason(session.error, agentId)} ) : null}
diff --git a/src/v2/pages/agents/AgentConfiguration.tsx b/src/v2/pages/agents/AgentConfiguration.tsx index 30a9214af..4167b5937 100644 --- a/src/v2/pages/agents/AgentConfiguration.tsx +++ b/src/v2/pages/agents/AgentConfiguration.tsx @@ -92,13 +92,15 @@ export function AgentConfiguration({ active className="fy-agent-config-body" > - {section === "models" ? ( - + <> + + + ) : section === "skills" ? ( ) : section === "mcp" ? ( diff --git a/src/v2/pages/agents/AgentModelsSection.tsx b/src/v2/pages/agents/AgentModelsSection.tsx index 3306445a0..44bfa143e 100644 --- a/src/v2/pages/agents/AgentModelsSection.tsx +++ b/src/v2/pages/agents/AgentModelsSection.tsx @@ -1,5 +1,8 @@ import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { requestOpenAuthCenter } from "../../shared/features/auth-center-handoff"; +import { appendAgentReturnToPath } from "../../shared/features/agent-navigation"; import type { ProductDirectoryEntry } from "../../shared/features/directory"; import { useOpenCodeModelSnapshot, @@ -13,7 +16,12 @@ import type { ProviderSummaryQueryData, } from "../../shared/features/types"; import { FeatureSearch } from "../../shared/ui/FeatureSearch"; -import { EmptyState, InlineNotice, Spinner } from "../../shared/ui/primitives"; +import { + Button, + EmptyState, + InlineNotice, + Spinner, +} from "../../shared/ui/primitives"; import { AgentSectionHeader } from "./AgentSectionHeader"; @@ -52,6 +60,7 @@ export function AgentModelsSection({ catalogEntry: AgentCatalogEntry; onOpenManagement: () => void; }) { + const navigate = useNavigate(); const [search, setSearch] = useState(""); const workBuddyStatus = useWorkBuddyStatus(entry.agentId === "workbuddy"); const workBuddyModels = useWorkBuddyModelIds(entry.agentId === "workbuddy"); @@ -139,13 +148,68 @@ export function AgentModelsSection({ .includes(normalizedSearch), ); + const openSuperGrokModels = (target: "codex" | "claude" | "workbuddy") => { + navigate( + appendAgentReturnToPath(`/models?target=${target}`, { + agentId: entry.agentId, + section: "models", + }), + ); + }; + return (
+ {entry.agentId === "grokbuild" ? ( + <> + + { + "官方登录不会写进 Codex / Claude / WorkBuddy。要用 SuperGrok,先去认证中心扫码,再进对应软件的模型管理绑定。「配置 API 钥匙」是第三条路,不是登录的下一步。" + } + +
+ + + + +
+ + ) : null} + {entry.agentId === "codex" ? ( + <> + + { + "Grok Build 里的 grok login 进不了 Codex。要先在认证中心扫 SuperGrok,再进入模型管理创建 Provider。" + } + +
+ +
+ + ) : null} {mode !== "unsupported" ? ( [0]; +const XAI_WORKBUDDY_BASE_URL = "https://api.x.ai/v1"; + +const XAI_BIND_LABELS: Record = { + claude: "Claude Code", + "claude-desktop": "Claude Desktop", + codex: "Codex", +}; + +const XAI_MANAGED_PROVIDER_IDS: Record = { + claude: "fyagent-v2-xai-oauth-claude", + "claude-desktop": "fyagent-v2-xai-oauth-claude-desktop", + codex: "fyagent-v2-xai-oauth-codex", +}; + +function usableXaiAccount(summary: XaiManagedSummary | undefined) { + if (!summary?.authenticated) return null; + return ( + summary.accounts.find( + (account) => + !account.requiresReauth && account.id === summary.defaultAccountId, + ) ?? summary.accounts.find((account) => !account.requiresReauth) ?? null + ); +} + +function SuperGrokPlacementNotice({ + kind, + summary, +}: { + kind: "workbuddy" | "claude" | "codex"; + summary?: XaiManagedSummary; +}) { + const account = usableXaiAccount(summary); + const notice = ( + + {kind === "workbuddy" + ? account + ? `已看到认证中心里的 SuperGrok 账号(${account.label})。可用它拉模型名单。WorkBuddy 自己的文件不能存扫码令牌;运行时若还要钥匙,请另填 API 钥匙,不要贴刷新令牌。` + : "还没在认证中心登录 SuperGrok。登录后再用「用 SuperGrok 拉名单」。不要把刷新令牌填进钥匙框。" + : account + ? kind === "codex" + ? `已登录 SuperGrok(${account.label})。可创建 xAI (Grok) OAuth 再走现有切换计划。下面 Quick Setup 仍只收 API 钥匙。` + : `已登录 SuperGrok(${account.label})。可分别绑到 Claude Code 和 Claude Desktop。下面 Quick Setup 仍只收 API 钥匙。` + : "SuperGrok 扫码请去认证中心。这里的 Quick Setup 只收 API 钥匙,不会替你完成扫码绑定。"} + + ); + if (account) return notice; + return ( + <> + {notice} + + + ); +} + const EMPTY_MODEL_IDS: readonly string[] = []; const TARGET_LABELS: Record = { @@ -128,6 +187,7 @@ function WorkBuddyPanel({ active }: { active: boolean }) { const { ports } = useFeatures(); const statusQuery = useWorkBuddyStatus(active); const modelIdsQuery = useWorkBuddyModelIds(active); + const xaiSummaryQuery = useXaiManagedSummary(active); const [baseUrl, setBaseUrl] = useState(""); const [apiKey, setApiKeyState] = useState(""); const apiKeyRef = useRef(""); @@ -245,6 +305,45 @@ function WorkBuddyPanel({ active }: { active: boolean }) { return true; }; + const fetchXaiManagedModels = async () => { + if (writeLock.current) return; + const account = usableXaiAccount(xaiSummaryQuery.data); + writeLock.current = true; + setBusy("fetch"); + clear(); + try { + const result = await ports.workbuddy.fetchXaiManagedModels( + account?.id ?? xaiSummaryQuery.data?.defaultAccountId ?? null, + ); + if (!mountedRef.current) return; + setDraftModelIds((current) => addUniqueModelIds(current, result.models)); + draftCommit.markDirty(); + setFetchedSourceIds(new Set(result.models)); + setTruncated(result.truncated); + if (!baseUrl.trim()) setBaseUrl(XAI_WORKBUDDY_BASE_URL); + setAllowNoApiKey(true); + show("fetch", { + tone: result.models.length === 0 ? "warning" : "info", + title: + result.models.length === 0 + ? "SuperGrok 没有返回模型" + : `已用 SuperGrok 读取 ${result.models.length} 个模型`, + description: + "名单来自已登录账号。保存仍走 WorkBuddy 自己的预览;不会把刷新令牌写进 models.json。", + }); + } catch { + if (mountedRef.current) + show("fetch", { + tone: "error", + title: "无法用 SuperGrok 拉名单", + description: "请先去认证中心登录,或检查账号是否需要重新扫码。", + }); + } finally { + if (mountedRef.current) setBusy(null); + writeLock.current = false; + } + }; + const fetchModels = async () => { if (writeLock.current || !validateConnection()) return; const submittedApiKey = apiKeyRef.current.trim(); @@ -596,6 +695,10 @@ function WorkBuddyPanel({ active }: { active: boolean }) { {busy === "save" ? "保存中…" : "保存并应用"} + void fetchModels()}> {busy === "fetch" ? "读取中…" : "拉取模型"} + + {app === "claude" || app === "codex" ? ( + + ) : null} + {app === "claude" || app === "codex" ? ( +
+ {app === "claude" ? ( + <> + + + + ) : ( + + )} +
+ ) : null} + { + if (!open && !xaiBindBusy) setPendingXaiBind(null); + }} + title={ + pendingXaiBind + ? pendingXaiBind === "codex" + ? "创建 SuperGrok Provider" + : `绑定 SuperGrok 到 ${XAI_BIND_LABELS[pendingXaiBind]}` + : "绑定 SuperGrok" + } + description="先看将写入的绑定,再确认。不会复制刷新令牌。" + actions={ + <> + + + + } + > + {pendingXaiBind ? ( +
+

+ 账号:{xaiAccount?.label ?? "已登录 SuperGrok"}。写入{" "} + {XAI_BIND_LABELS[pendingXaiBind]} 的 Provider 绑定,id 为{" "} + {XAI_MANAGED_PROVIDER_IDS[pendingXaiBind]}。 +

+

+ {pendingXaiBind === "codex" + ? "创建后会生成现有切换预览;确认应用前不会改当前 Codex。" + : "确认后会激活该绑定。不会改 Codex 或 WorkBuddy。"} +

+
+ ) : null} +
{queryPending && } {queryUnavailable && ( @@ -1381,6 +1652,8 @@ function ProviderPanel({ active={active} providers={summaryQuery.data.providers} currentId={summaryQuery.data.currentId} + preferredTargetId={preferredCodexSwitchId} + autoCreatePreferred={Boolean(preferredCodexSwitchId)} /> ) : null} diff --git a/src/v2/pages/models/apply/ChangePlanWorkspace.tsx b/src/v2/pages/models/apply/ChangePlanWorkspace.tsx index 13fd6c96b..320413228 100644 --- a/src/v2/pages/models/apply/ChangePlanWorkspace.tsx +++ b/src/v2/pages/models/apply/ChangePlanWorkspace.tsx @@ -20,10 +20,14 @@ export function ChangePlanWorkspace({ active, providers, currentId, + preferredTargetId, + autoCreatePreferred = false, }: { active: boolean; providers: ProviderSummaryMap; currentId: string; + preferredTargetId?: string; + autoCreatePreferred?: boolean; }) { const { ports } = useFeatures(); const targets = useMemo( @@ -31,7 +35,7 @@ export function ChangePlanWorkspace({ Object.values(providers).filter((provider) => provider.id !== currentId), [currentId, providers], ); - const [targetId, setTargetId] = useState(""); + const [targetId, setTargetId] = useState(preferredTargetId ?? ""); const [plan, setPlan] = useState(null); const [job, setJob] = useState(null); const [error, setError] = useState<{ @@ -40,6 +44,7 @@ export function ChangePlanWorkspace({ } | null>(null); const [busy, setBusy] = useState(false); const requestRevision = useRef(0); + const autoCreatedFor = useRef(""); const recoverableJobs = useRecoverableChangeJobs(active); const effectiveTargetId = targets.some((target) => target.id === targetId) ? targetId @@ -48,6 +53,19 @@ export function ChangePlanWorkspace({ plan?.targetProviderId === effectiveTargetId ? plan : null; const visibleJob = job?.targetProviderId === effectiveTargetId ? job : null; + useEffect(() => { + if ( + preferredTargetId && + preferredTargetId !== targetId && + Object.values(providers).some( + (provider) => + provider.id === preferredTargetId && provider.id !== currentId, + ) + ) { + setTargetId(preferredTargetId); + } + }, [currentId, preferredTargetId, providers, targetId]); + const selectTarget = (nextTargetId: string) => { requestRevision.current += 1; setTargetId(nextTargetId); @@ -77,6 +95,20 @@ export function ChangePlanWorkspace({ } }; + useEffect(() => { + if ( + !autoCreatePreferred || + !preferredTargetId || + effectiveTargetId !== preferredTargetId || + autoCreatedFor.current === preferredTargetId || + busy + ) { + return; + } + autoCreatedFor.current = preferredTargetId; + void createPlan(); + }, [autoCreatePreferred, busy, effectiveTargetId, preferredTargetId]); + const applyPlan = async (input: { readonly planId: string; readonly planDigest: string; @@ -140,7 +172,9 @@ export function ChangePlanWorkspace({ >

切换已保存的 Provider

- 先生成零写入预览,再单次确认应用。当前 Provider 不会列为目标。 + 先生成零写入预览,再单次确认应用。当前 Provider 不会列为目标。SuperGrok + 扫码请先去认证中心;登录后才能切换已绑定的 xAI (Grok) OAuth + Provider。不要在这里填 grok login,也不要另开一套登录。

{(recoverableJobs.data?.length ?? 0) > 0 ? ( diff --git a/src/v2/pages/models/apply/view-model.ts b/src/v2/pages/models/apply/view-model.ts index d1380eab7..5276c44fc 100644 --- a/src/v2/pages/models/apply/view-model.ts +++ b/src/v2/pages/models/apply/view-model.ts @@ -482,7 +482,7 @@ export function createApplyViewModel( tone: "warning" as const, title: "无法安全生成变更计划", description: - "目标配置需要新的凭据材料,当前 Apply 不会接收或写入该凭据。", + "目标还缺可用凭据。如果这是 SuperGrok,请先去认证中心扫码,再生成切换计划。Apply 不会接收或写入钥匙。", statusLabel: "凭据条件不满足", } : mustRegenerate diff --git a/src/v2/shared/features/auth-center-handoff.ts b/src/v2/shared/features/auth-center-handoff.ts new file mode 100644 index 000000000..9fe68575c --- /dev/null +++ b/src/v2/shared/features/auth-center-handoff.ts @@ -0,0 +1,5 @@ +export const OPEN_AUTH_CENTER_EVENT = "fyagent:open-auth-center"; + +export function requestOpenAuthCenter(): void { + window.dispatchEvent(new CustomEvent(OPEN_AUTH_CENTER_EVENT)); +} diff --git a/src/v2/shared/features/models.ts b/src/v2/shared/features/models.ts index 74940a3ed..0e51099b1 100644 --- a/src/v2/shared/features/models.ts +++ b/src/v2/shared/features/models.ts @@ -79,6 +79,33 @@ export interface WorkBuddyFetchModelsResult { truncated: boolean; } +export interface XaiManagedAccountRef { + id: string; + label: string; + requiresReauth: boolean; +} + +export interface XaiManagedSummary { + authenticated: boolean; + defaultAccountId: string | null; + accounts: XaiManagedAccountRef[]; +} + +export type XaiManagedBindApp = "claude" | "claude-desktop" | "codex"; + +export interface BindXaiManagedRequest { + app: XaiManagedBindApp; + accountId?: string | null; +} + +export interface BindXaiManagedResult { + providerId: string; + providerName: string; + app: string; + alreadyBound: boolean; + activated: boolean; +} + export interface WorkBuddySaveModelsRequest extends WorkBuddyFetchModelsRequest { selectedModelIds: string[]; diff --git a/src/v2/shared/features/ports.ts b/src/v2/shared/features/ports.ts index 5a834ab31..4bb36c6b9 100644 --- a/src/v2/shared/features/ports.ts +++ b/src/v2/shared/features/ports.ts @@ -34,6 +34,9 @@ import type { WorkBuddySaveModelsRequest, WorkBuddySaveModelsResult, WorkBuddyStatus, + XaiManagedSummary, + BindXaiManagedRequest, + BindXaiManagedResult, ExternalAgentLaunchDestination, ExternalAgentLaunchResult, ExternalAgentRuntimeStatus, @@ -129,6 +132,7 @@ export interface ProvidersPort { fetchModels(baseUrl: string, apiKey: string): Promise; checkReachability(baseUrl: string): Promise; checkModel(request: ModelProbeRequest): Promise; + bindXaiManaged(request: BindXaiManagedRequest): Promise; } export interface WorkBuddyPort { @@ -137,6 +141,10 @@ export interface WorkBuddyPort { fetchModels( request: WorkBuddyFetchModelsRequest, ): Promise; + getXaiManagedSummary(): Promise; + fetchXaiManagedModels( + accountId?: string | null, + ): Promise; saveModels( request: WorkBuddySaveModelsRequest, ): Promise; diff --git a/src/v2/shared/features/queries.ts b/src/v2/shared/features/queries.ts index f8f74628d..27dd1652d 100644 --- a/src/v2/shared/features/queries.ts +++ b/src/v2/shared/features/queries.ts @@ -26,6 +26,7 @@ export const featureKeys = { ["v2", "providers", app, "summary"] as const, workbuddyStatus: ["v2", "workbuddy", "status"] as const, workbuddyModelIds: ["v2", "workbuddy", "model-ids"] as const, + xaiManagedSummary: ["v2", "xai-managed", "summary"] as const, traeWorkModelIds: ["v2", "trae-work", "model-ids"] as const, openCodeModelSnapshot: ["v2", "opencode", "model-snapshot"] as const, skills: ["v2", "skills", "installed"] as const, @@ -129,6 +130,16 @@ export function useWorkBuddyModelIds(enabled = true) { }); } +export function useXaiManagedSummary(enabled = true) { + const { ports } = useFeatures(); + return useQuery({ + queryKey: featureKeys.xaiManagedSummary, + queryFn: ports.workbuddy.getXaiManagedSummary, + enabled, + retry: false, + }); +} + export function useTraeWorkModelIds(enabled = true) { const { ports } = useFeatures(); return useQuery({ diff --git a/src/v2/shared/features/types.ts b/src/v2/shared/features/types.ts index 78cbcef82..33dc7406e 100644 --- a/src/v2/shared/features/types.ts +++ b/src/v2/shared/features/types.ts @@ -156,6 +156,11 @@ export type { WorkBuddySaveModelsResult, WorkBuddySaveModelsSavedResult, WorkBuddyStatus, + XaiManagedAccountRef, + XaiManagedBindApp, + XaiManagedSummary, + BindXaiManagedRequest, + BindXaiManagedResult, } from "./models"; export type { ManagedPrompt } from "./prompts"; diff --git a/src/v2/shared/platform/browser/features.ts b/src/v2/shared/platform/browser/features.ts index 915944d15..201f2c4e0 100644 --- a/src/v2/shared/platform/browser/features.ts +++ b/src/v2/shared/platform/browser/features.ts @@ -93,11 +93,14 @@ export function createBrowserFeaturePorts(): FeaturePorts { fetchModels: rejectNativeOnly, checkReachability: rejectNativeOnly, checkModel: rejectNativeOnly, + bindXaiManaged: rejectNativeOnly, }, workbuddy: { getStatus: rejectNativeOnly, getModelIds: rejectNativeOnly, fetchModels: rejectNativeOnly, + getXaiManagedSummary: rejectNativeOnly, + fetchXaiManagedModels: rejectNativeOnly, saveModels: rejectNativeOnly, checkReachability: rejectNativeOnly, checkModel: rejectNativeOnly, diff --git a/src/v2/shared/platform/tauri/feature-ports/models.ts b/src/v2/shared/platform/tauri/feature-ports/models.ts index 1937c4847..f01abf7af 100644 --- a/src/v2/shared/platform/tauri/feature-ports/models.ts +++ b/src/v2/shared/platform/tauri/feature-ports/models.ts @@ -14,6 +14,9 @@ import type { ProviderSummaryQueryData, ReachabilityResult, WorkBuddySaveModelsResult, + XaiManagedSummary, + BindXaiManagedRequest, + BindXaiManagedResult, } from "../../../features/types"; import { hasExactKeys, @@ -414,6 +417,106 @@ function assertQuickSetupRequest( return request; } +function parseXaiManagedSummary(value: unknown): XaiManagedSummary { + if (!isRecord(value)) throw new Error("xAI managed summary is unavailable"); + const authenticated = + value.authenticated === true || value.authenticated === false + ? value.authenticated + : null; + const defaultAccountId = + value.defaultAccountId === null || typeof value.defaultAccountId === "string" + ? value.defaultAccountId + : value.default_account_id === null || + typeof value.default_account_id === "string" + ? value.default_account_id + : undefined; + const rawAccounts = Array.isArray(value.accounts) ? value.accounts : null; + if (authenticated === null || defaultAccountId === undefined || !rawAccounts) + throw new Error("xAI managed summary is unavailable"); + const accounts = rawAccounts.map((account) => { + if (!isRecord(account) || typeof account.id !== "string") + throw new Error("xAI managed summary is unavailable"); + const label = + typeof account.login === "string" && account.login.trim() + ? account.login + : typeof account.label === "string" && account.label.trim() + ? account.label + : "已登录账号"; + return { + id: account.id, + label, + requiresReauth: + account.requiresReauth === true || account.requires_reauth === true, + }; + }); + return { + authenticated, + defaultAccountId, + accounts, + }; +} + +function assertBindXaiManagedRequest( + request: BindXaiManagedRequest, +): BindXaiManagedRequest { + if ( + !isRecord(request) || + !isOneOf(request.app, ["claude", "claude-desktop", "codex"]) || + (request.accountId !== undefined && + request.accountId !== null && + typeof request.accountId !== "string") + ) + throw new Error("SuperGrok bind request is invalid"); + return { + app: request.app, + accountId: request.accountId ?? null, + }; +} + +function parseBindXaiManagedResult(value: unknown): BindXaiManagedResult { + if ( + !isRecord(value) || + !hasExactKeys(value, [ + "providerId", + "providerName", + "app", + "alreadyBound", + "activated", + ]) || + typeof value.providerId !== "string" || + typeof value.providerName !== "string" || + typeof value.app !== "string" || + typeof value.alreadyBound !== "boolean" || + typeof value.activated !== "boolean" + ) + throw new Error("SuperGrok bind result is unavailable"); + return { + providerId: value.providerId, + providerName: value.providerName, + app: value.app, + alreadyBound: value.alreadyBound, + activated: value.activated, + }; +} + +function parseXaiManagedModels(value: unknown): { + models: string[]; + truncated: boolean; +} { + if (!Array.isArray(value)) throw new Error("xAI models are unavailable"); + const models: string[] = []; + for (const entry of value) { + if (typeof entry === "string" && entry.trim()) { + models.push(entry.trim()); + continue; + } + if (isRecord(entry) && typeof entry.id === "string" && entry.id.trim()) { + models.push(entry.id.trim()); + } + } + return { models, truncated: false }; +} + export function createModelFeaturePorts(): Pick< FeaturePorts, "providers" | "workbuddy" | "opencodeModels" @@ -436,11 +539,29 @@ export function createModelFeaturePorts(): Pick< ), checkReachability: invokeReachability, checkModel: invokeModelProbe, + bindXaiManaged: async (request) => + parseBindXaiManagedResult( + await invoke("bind_xai_managed_provider", { + request: assertBindXaiManagedRequest(request), + }), + ), }, workbuddy: { getStatus: () => invoke("get_workbuddy_status"), getModelIds: () => invoke("get_workbuddy_model_ids"), fetchModels: (request) => invoke("fetch_workbuddy_models", { request }), + getXaiManagedSummary: async () => + parseXaiManagedSummary( + await invoke("auth_get_status", { + authProvider: "xai_oauth", + }), + ), + fetchXaiManagedModels: async (accountId) => + parseXaiManagedModels( + await invoke("get_xai_oauth_models", { + accountId: accountId ?? null, + }), + ), saveModels: (request) => invoke("save_workbuddy_models", { request }), checkReachability: invokeReachability, checkModel: invokeModelProbe, diff --git a/tests/v2-browser/agents-v3.spec.ts b/tests/v2-browser/agents-v3.spec.ts index f762ed090..499722d65 100644 --- a/tests/v2-browser/agents-v3.spec.ts +++ b/tests/v2-browser/agents-v3.spec.ts @@ -164,6 +164,9 @@ test("Agent V3 shows the full catalog, auto-scans, and reuses existing Skill and await configuration.getByRole("tab", { name: "Skills" }).click(); await expect(page).toHaveURL(/#\/agents\?target=workbuddy§ion=skills$/); + await expect( + configuration.getByRole("region", { name: "认证状态" }), + ).toHaveCount(0); const skillSwitch = configuration .getByRole("switch", { name: "WorkBuddy Skill 分配", @@ -178,6 +181,9 @@ test("Agent V3 shows the full catalog, auto-scans, and reuses existing Skill and await configuration.getByRole("tab", { name: "MCP" }).click(); await expect(page).toHaveURL(/#\/agents\?target=workbuddy§ion=mcp$/); + await expect( + configuration.getByRole("region", { name: "认证状态" }), + ).toHaveCount(0); const mcpSwitch = configuration .getByRole("switch", { name: "WorkBuddy MCP 分配", @@ -287,16 +293,31 @@ test("Agent Auth distinguishes verified sessions, handoff-only flows, and Auth C await openV2Page(page, "/agents?target=grokbuild§ion=models"); const grok = page.getByRole("region", { name: "Grok Build 配置" }); + await expect(grok.getByText(/grok login/)).toBeVisible(); + await expect(grok.getByRole("button", { name: "刷新状态" })).toHaveCount(0); + await expect( + grok.getByRole("button", { name: "配置 API 钥匙" }), + ).toBeVisible(); + await expect( + grok.getByRole("button", { name: "打开认证中心扫 SuperGrok" }), + ).toBeVisible(); + await expect( + grok.getByRole("button", { name: "去 Codex 绑定 SuperGrok" }), + ).toBeVisible(); await grok.getByRole("button", { name: "登录", exact: true }).click(); await expect(grok.getByText("已交给官方认证入口")).toBeVisible(); await expect(grok.getByText("认证结果已验证")).toHaveCount(0); + await grok.getByRole("tab", { name: "Skills" }).click(); + await expect(grok.getByRole("region", { name: "认证状态" })).toHaveCount(0); await openV2Page(page, "/agents?target=codex§ion=models"); const codex = page.getByRole("region", { name: "Codex 配置" }); await expect(codex.getByText("由 FyAgent 认证中心管理")).toBeVisible(); + await expect(codex.getByText(/grok login 进不了 Codex/)).toBeVisible(); await expect( codex.getByRole("button", { name: "登录", exact: true }), ).toHaveCount(0); + await expect(codex.getByRole("button", { name: "刷新状态" })).toHaveCount(0); const calls = await featureFixtureCalls(page); expect( diff --git a/tests/v2/pages/agents/AgentAuthStatusPanel.test.tsx b/tests/v2/pages/agents/AgentAuthStatusPanel.test.tsx index 07dbca578..648738d01 100644 --- a/tests/v2/pages/agents/AgentAuthStatusPanel.test.tsx +++ b/tests/v2/pages/agents/AgentAuthStatusPanel.test.tsx @@ -184,9 +184,29 @@ describe("AgentAuthStatusPanel", () => { stopWaiting: vi.fn(), }); - fireEvent.click(await screen.findByRole("button", { name: "登录" })); + expect( + await screen.findByText("仅支持打开官方认证入口(终端 grok login)"), + ).toBeVisible(); + expect(screen.getByText(/终端里自己运行 grok login/)).toBeVisible(); + expect(screen.getByText(/不会写进 Codex/)).toBeVisible(); + expect(screen.getByText(/SuperGrok 扫码请去认证中心/)).toBeVisible(); + expect(screen.queryByText("认证结果已验证")).not.toBeInTheDocument(); + expect(screen.queryByText("已登录", { exact: true })).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "刷新状态" }), + ).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "打开认证中心" }), + ).toBeVisible(); + + fireEvent.click(screen.getByRole("button", { name: "登录" })); expect(await screen.findByText("已交给官方认证入口")).toBeVisible(); + expect(screen.getByText(/请完成 grok login/)).toBeVisible(); expect(screen.queryByText("认证结果已验证")).not.toBeInTheDocument(); + expect(screen.queryByText("已登录", { exact: true })).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "刷新状态" }), + ).not.toBeInTheDocument(); }); it("keeps Codex delegated to the existing Auth Center", async () => { @@ -210,6 +230,14 @@ describe("AgentAuthStatusPanel", () => { }); expect(await screen.findByText("由 FyAgent 认证中心管理")).toBeVisible(); + expect(screen.getByText(/SuperGrok 扫码也在认证中心/)).toBeVisible(); + expect(screen.getByText(/不要去终端跑 grok login/)).toBeVisible(); + expect( + screen.getByRole("button", { name: "打开认证中心" }), + ).toBeVisible(); + expect( + screen.queryByRole("button", { name: "刷新状态" }), + ).not.toBeInTheDocument(); expect( screen.queryByRole("button", { name: "登录" }), ).not.toBeInTheDocument(); diff --git a/tests/v2/pages/agents/Page.test.tsx b/tests/v2/pages/agents/Page.test.tsx index a3f3a00c5..cc5215c15 100644 --- a/tests/v2/pages/agents/Page.test.tsx +++ b/tests/v2/pages/agents/Page.test.tsx @@ -11,6 +11,7 @@ import type { AgentInstallReadiness, AgentInstallState, } from "@/v2/shared/features/agent-install-readiness"; +import type { AgentAuthObservation } from "@/v2/shared/features/agent-auth"; import type { CodexDesktopPort, FeaturePorts, @@ -927,6 +928,9 @@ describe("V3 Agent directory and configuration shell", () => { expect(screen.queryByRole("switch")).not.toBeInTheDocument(); await user.click(screen.getByRole("tab", { name: "提示词" })); expect(await screen.findByText(/当前未接入提示词管理/)).toBeVisible(); + expect( + screen.queryByRole("region", { name: "认证状态" }), + ).not.toBeInTheDocument(); expect(ports.prompts.getAll).not.toHaveBeenCalled(); qoder.unmount(); @@ -945,6 +949,65 @@ describe("V3 Agent directory and configuration shell", () => { ).toBeVisible(); }); + it("keeps Grok auth on the models section and does not treat official login as Codex setup", async () => { + const user = userEvent.setup(); + const ports = configuredPorts(); + const observation: AgentAuthObservation = { + kind: "handoff_only", + contractVersion: 1, + agentId: "grokbuild", + ownership: "agent_owned", + authority: "unverified", + allowedIntents: ["login", "logout"], + checkedAt: "2026-08-30T00:00:00Z", + reasonCodes: ["handoff_only"], + }; + ports.agentAuth.getObservation = vi.fn(async () => observation); + ports.agentAuth.getActiveSession = vi.fn(async () => null); + renderPage(ports, "/agents?target=grokbuild§ion=models"); + + const configuration = await screen.findByRole("region", { + name: "Grok Build 配置", + }); + expect(within(configuration).getByRole("region", { name: "认证状态" })).toBeVisible(); + expect( + within(configuration).getByRole("button", { name: "配置 API 钥匙" }), + ).toBeVisible(); + expect( + within(configuration).getByRole("button", { + name: "打开认证中心扫 SuperGrok", + }), + ).toBeVisible(); + expect( + within(configuration).getByRole("button", { + name: "去 Codex 绑定 SuperGrok", + }), + ).toBeVisible(); + expect( + within(configuration).queryByRole("button", { name: "进入模型管理" }), + ).not.toBeInTheDocument(); + expect( + within(configuration).queryByRole("button", { name: "刷新状态" }), + ).not.toBeInTheDocument(); + + await user.click( + within(configuration).getByRole("tab", { name: "Skills" }), + ); + expect( + within(configuration).queryByRole("region", { name: "认证状态" }), + ).not.toBeInTheDocument(); + expect( + within(configuration).queryByRole("button", { name: "刷新状态" }), + ).not.toBeInTheDocument(); + + await user.click(within(configuration).getByRole("tab", { name: "模型" })); + expect( + within(configuration).getByRole("button", { + name: "去 Codex 绑定 SuperGrok", + }), + ).toBeVisible(); + }); + it("keeps catalog failure explicit instead of inventing a static directory", async () => { const ports = configuredPorts(); ports.catalog.get = vi.fn(async () => { diff --git a/tests/v2/pages/models/Page.test.tsx b/tests/v2/pages/models/Page.test.tsx index 589c95dfa..f2adede76 100644 --- a/tests/v2/pages/models/Page.test.tsx +++ b/tests/v2/pages/models/Page.test.tsx @@ -23,6 +23,7 @@ import { changeJobWire, changeJobWorkBuddyWire, changePlanUpsertWire, + changePlanWire, changePlanWorkBuddyWire, } from "../../fixtures/changePlans"; @@ -232,6 +233,15 @@ function workBuddyPorts(): FeaturePorts { revision: "revision-1", }), ); + ports.workbuddy.getXaiManagedSummary = vi.fn(async () => ({ + authenticated: false, + defaultAccountId: null, + accounts: [], + })); + ports.workbuddy.fetchXaiManagedModels = vi.fn(async () => ({ + models: [], + truncated: false, + })); return ports; } @@ -616,6 +626,41 @@ describe("V2 Models page", () => { expect(document.body).not.toHaveTextContent("fetch-secret"); }); + it("fills WorkBuddy drafts from a logged-in SuperGrok account without copying the refresh token", async () => { + const user = userEvent.setup(); + const ports = workBuddyPorts(); + ports.workbuddy.getXaiManagedSummary = vi.fn(async () => ({ + authenticated: true, + defaultAccountId: "acct-xai", + accounts: [ + { + id: "acct-xai", + label: "fixture-login", + requiresReauth: false, + }, + ], + })); + ports.workbuddy.fetchXaiManagedModels = vi.fn(async () => ({ + models: ["grok-4.5"], + truncated: false, + })); + renderPage(ports, "workbuddy"); + + expect( + await screen.findByText(/已看到认证中心里的 SuperGrok 账号/), + ).toBeVisible(); + await user.click( + screen.getByRole("button", { name: "用 SuperGrok 拉名单" }), + ); + expect(await screen.findByText("已用 SuperGrok 读取 1 个模型")).toBeVisible(); + expect(ports.workbuddy.fetchXaiManagedModels).toHaveBeenCalledWith( + "acct-xai", + ); + expect(screen.getByText("grok-4.5")).toBeVisible(); + expect(screen.getByLabelText("服务地址")).toHaveValue("https://api.x.ai/v1"); + expect(document.body).not.toHaveTextContent("refresh"); + }); + it("redacts WorkBuddy fetch failures and keeps the submitted key", async () => { const user = userEvent.setup(); const ports = workBuddyPorts(); @@ -1691,4 +1736,172 @@ describe("V2 Models page", () => { modelId: "gpt-test", }); }); + + it("binds a logged-in SuperGrok account to Claude Code and Claude Desktop without copying the refresh token", async () => { + const user = userEvent.setup(); + const ports = createBrowserFeaturePorts(); + let currentClaudeId = ""; + ports.providers.getSummary = vi.fn(async () => ({ + providers: currentClaudeId + ? { + [currentClaudeId]: { + id: currentClaudeId, + name: "xAI (Grok)", + }, + } + : {}, + currentId: currentClaudeId, + writeTargets: [...TEST_PROVIDER_WRITE_TARGETS], + })); + ports.workbuddy.getXaiManagedSummary = vi.fn(async () => ({ + authenticated: true, + defaultAccountId: "acct-xai", + accounts: [ + { + id: "acct-xai", + label: "fixture-login", + requiresReauth: false, + }, + ], + })); + ports.providers.bindXaiManaged = vi.fn(async (request) => { + currentClaudeId = + request.app === "claude-desktop" + ? "fyagent-v2-xai-oauth-claude-desktop" + : "fyagent-v2-xai-oauth-claude"; + return { + providerId: currentClaudeId, + providerName: "xAI (Grok)", + app: request.app, + alreadyBound: false, + activated: true, + }; + }); + renderPage(ports, "claude"); + + expect( + await screen.findByText(/已登录 SuperGrok(fixture-login)/), + ).toBeVisible(); + await user.click( + screen.getByRole("button", { name: "绑定到 Claude Code" }), + ); + const codeDialog = await screen.findByRole("dialog", { + name: "绑定 SuperGrok 到 Claude Code", + }); + await user.click( + within(codeDialog).getByRole("button", { name: "确认绑定" }), + ); + expect(await screen.findByText("已绑定 SuperGrok 到 Claude Code")).toBeVisible(); + expect(screen.getByText(/没有改 Codex 或 WorkBuddy/)).toBeVisible(); + expect(ports.providers.bindXaiManaged).toHaveBeenCalledWith({ + app: "claude", + accountId: "acct-xai", + }); + + await user.click( + screen.getByRole("button", { name: "绑定到 Claude Desktop" }), + ); + const desktopDialog = await screen.findByRole("dialog", { + name: "绑定 SuperGrok 到 Claude Desktop", + }); + await user.click( + within(desktopDialog).getByRole("button", { name: "确认绑定" }), + ); + expect( + await screen.findByText("已绑定 SuperGrok 到 Claude Desktop"), + ).toBeVisible(); + expect(ports.providers.bindXaiManaged).toHaveBeenLastCalledWith({ + app: "claude-desktop", + accountId: "acct-xai", + }); + expect(document.body).not.toHaveTextContent("refresh"); + }); + + it("creates a Codex SuperGrok Provider and leaves activation to the existing switch plan", async () => { + const user = userEvent.setup(); + const ports = createBrowserFeaturePorts(); + let currentCodexProviders: Record = + {}; + ports.providers.getSummary = vi.fn(async () => ({ + providers: { ...currentCodexProviders }, + currentId: "current-codex", + writeTargets: [...TEST_PROVIDER_WRITE_TARGETS], + })); + ports.workbuddy.getXaiManagedSummary = vi.fn(async () => ({ + authenticated: true, + defaultAccountId: "acct-xai", + accounts: [ + { + id: "acct-xai", + label: "fixture-login", + requiresReauth: false, + }, + ], + })); + ports.providers.applyQuickSetupWithResult = vi.fn(); + ports.changePlans.createCodexProviderSwitchPlan = vi.fn(async () => ({ + ...changePlanWire, + targetProviderId: "fyagent-v2-xai-oauth-codex", + targetProviderName: "xAI (Grok) OAuth", + })); + ports.providers.bindXaiManaged = vi.fn(async () => { + currentCodexProviders = { + "fyagent-v2-xai-oauth-codex": { + id: "fyagent-v2-xai-oauth-codex", + name: "xAI (Grok) OAuth", + }, + }; + return { + providerId: "fyagent-v2-xai-oauth-codex", + providerName: "xAI (Grok) OAuth", + app: "codex", + alreadyBound: false, + activated: false, + }; + }); + renderPage(ports, "codex"); + + expect( + await screen.findByText(/可创建 xAI \(Grok\) OAuth/), + ).toBeVisible(); + await user.click( + screen.getByRole("button", { name: "创建 SuperGrok Provider" }), + ); + const dialog = await screen.findByRole("dialog", { + name: "创建 SuperGrok Provider", + }); + await user.click(within(dialog).getByRole("button", { name: "确认绑定" })); + expect(await screen.findByText("已创建 xAI (Grok) OAuth")).toBeVisible(); + expect(screen.getByText(/没有改 Claude 或 WorkBuddy/)).toBeVisible(); + expect(ports.providers.bindXaiManaged).toHaveBeenCalledWith({ + app: "codex", + accountId: "acct-xai", + }); + await waitFor(() => + expect(ports.changePlans.createCodexProviderSwitchPlan).toHaveBeenCalledWith( + "fyagent-v2-xai-oauth-codex", + ), + ); + expect(ports.providers.applyQuickSetupWithResult).not.toHaveBeenCalled(); + expect(document.body).not.toHaveTextContent("refresh"); + }); + + it("keeps an empty Grok Build draft silent and free of grok login copy", async () => { + const ports = createBrowserFeaturePorts(); + ports.providers.getSummary = vi.fn(async () => ({ + providers: {}, + currentId: "", + writeTargets: [...TEST_PROVIDER_WRITE_TARGETS], + })); + renderPage(ports, "grokbuild"); + + await screen.findByTestId("provider-status"); + expect(screen.getByLabelText("API Key")).toBeVisible(); + expect(screen.queryByText(/grok login/i)).not.toBeInTheDocument(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(document.querySelector(".fy-control-field-error")).toBeNull(); + expect( + screen.queryByRole("button", { name: "绑定到 Claude Code" }), + ).not.toBeInTheDocument(); + }); }); diff --git a/tests/v2/platform/featurePorts.test.ts b/tests/v2/platform/featurePorts.test.ts index 05f689a0e..e34972ecc 100644 --- a/tests/v2/platform/featurePorts.test.ts +++ b/tests/v2/platform/featurePorts.test.ts @@ -421,6 +421,12 @@ describe("V2 feature ports", () => { allowNoApiKey: false, }), ).rejects.toThrow(NATIVE_ONLY_ERROR); + await expect(ports.workbuddy.getXaiManagedSummary()).rejects.toThrow( + NATIVE_ONLY_ERROR, + ); + await expect(ports.workbuddy.fetchXaiManagedModels()).rejects.toThrow( + NATIVE_ONLY_ERROR, + ); await expect(ports.mcp.importFromApps()).rejects.toThrow(NATIVE_ONLY_ERROR); await expect( ports.providers.checkReachability("https://example.test"), @@ -439,6 +445,9 @@ describe("V2 feature ports", () => { modelId: "model", }), ).rejects.toThrow(NATIVE_ONLY_ERROR); + await expect( + ports.providers.bindXaiManaged({ app: "claude", accountId: "acct-xai" }), + ).rejects.toThrow(NATIVE_ONLY_ERROR); }); it("uses exact Agent, Provider, and WorkBuddy commands and validates Provider summaries", async () => { @@ -483,6 +492,32 @@ describe("V2 feature ports", () => { if (command === "fetch_workbuddy_models") { return { models: ["model-a"], truncated: false }; } + if (command === "auth_get_status") { + return { + provider: "xai_oauth", + authenticated: true, + default_account_id: "acct-xai", + accounts: [ + { + id: "acct-xai", + login: "fixture-login", + requires_reauth: false, + }, + ], + }; + } + if (command === "get_xai_oauth_models") { + return [{ id: "grok-4.5", owned_by: "xai" }]; + } + if (command === "bind_xai_managed_provider") { + return { + providerId: "fyagent-v2-xai-oauth-codex", + providerName: "xAI (Grok) OAuth", + app: "codex", + alreadyBound: false, + activated: false, + }; + } if (command === "save_workbuddy_models") { return { state: "saved", @@ -547,9 +582,36 @@ describe("V2 feature ports", () => { await ports.catalog.get(); const summary = await ports.providers.getSummary("codex"); await ports.providers.applyQuickSetupWithResult(request, "codex"); + await expect( + ports.providers.bindXaiManaged({ + app: "codex", + accountId: "acct-xai", + }), + ).resolves.toEqual({ + providerId: "fyagent-v2-xai-oauth-codex", + providerName: "xAI (Grok) OAuth", + app: "codex", + alreadyBound: false, + activated: false, + }); await ports.workbuddy.getStatus(); await ports.workbuddy.getModelIds(); await ports.workbuddy.fetchModels(fetchRequest); + await expect(ports.workbuddy.getXaiManagedSummary()).resolves.toEqual({ + authenticated: true, + defaultAccountId: "acct-xai", + accounts: [ + { + id: "acct-xai", + label: "fixture-login", + requiresReauth: false, + }, + ], + }); + await expect(ports.workbuddy.fetchXaiManagedModels("acct-xai")).resolves.toEqual({ + models: ["grok-4.5"], + truncated: false, + }); await ports.workbuddy.saveModels(saveRequest); await expect( ports.providers.checkReachability("https://example.test/v1"), @@ -591,9 +653,15 @@ describe("V2 feature ports", () => { ["get_agent_catalog"], ["get_provider_summary", { app: "codex" }], ["apply_provider_quick_setup_with_result", { request, app: "codex" }], + [ + "bind_xai_managed_provider", + { request: { app: "codex", accountId: "acct-xai" } }, + ], ["get_workbuddy_status"], ["get_workbuddy_model_ids"], ["fetch_workbuddy_models", { request: fetchRequest }], + ["auth_get_status", { authProvider: "xai_oauth" }], + ["get_xai_oauth_models", { accountId: "acct-xai" }], ["save_workbuddy_models", { request: saveRequest }], ["stream_check_url", { baseUrl: "https://example.test/v1" }], [ @@ -603,6 +671,7 @@ describe("V2 feature ports", () => { baseUrl: "https://example.test/v1", apiKey: "mutation-only-key", modelId: "model-a", + codexImageExtension: undefined, }, ], ]); diff --git a/tests/v2/platform/tauriAclContract.test.ts b/tests/v2/platform/tauriAclContract.test.ts index a8c5f8322..4bf75cc9e 100644 --- a/tests/v2/platform/tauriAclContract.test.ts +++ b/tests/v2/platform/tauriAclContract.test.ts @@ -124,7 +124,7 @@ describe("V2 native ACL contract", () => { const allowed = activeAclCommands(); expect(renderer.dynamicInvokes).toEqual([]); - expect(renderer.commands.size).toBe(92); + expect(renderer.commands.size).toBe(95); expect( [...renderer.commands].filter((command) => !registered.has(command)), ).toEqual([]);