diff --git a/.trellis/spec/backend/change-plan-executor.md b/.trellis/spec/backend/change-plan-executor.md index 5d41d17e6..a35c255ff 100644 --- a/.trellis/spec/backend/change-plan-executor.md +++ b/.trellis/spec/backend/change-plan-executor.md @@ -87,6 +87,23 @@ faultPoints = before_managed_write, ## 3. Contracts +### Grok managed Codex source admission + +- Codex switch admits a managed xAI source only when its explicit bound vault + record is ready, has Proxy purpose/consumer, and is FyAgent-owned. Recheck on + plan creation and apply; JSON token-file existence grants no capability. +- The closed managed shape has an empty auth object, selected `xai` provider, + local Responses wire protocol, canonical Grok CLI subscription base URL and + an explicit bounded model. The native proxy converts to the vendor's Chat + Completions protocol; the local wire declaration is not the upstream protocol. + Credentials remain in the vault. Preview uses the same local proxy projection + as the existing Provider writer; apply starts/adopts that listener and retains + target rollback and readback. No fourth adapter or new schema is introduced. +- A plan/configuration success proves native configuration, not live upstream + quota consumption. Synthetic vault + loopback upstream integration is separate + from actual subscription and Windows acceptance evidence. + + ### Wire version and phase model - `CHANGE_PLAN_CONTRACT_VERSION = fyagent-change-plan/v2`. diff --git a/.trellis/spec/backend/managed-auth.md b/.trellis/spec/backend/managed-auth.md index b57f8615a..cc62fd926 100644 --- a/.trellis/spec/backend/managed-auth.md +++ b/.trellis/spec/backend/managed-auth.md @@ -134,6 +134,38 @@ returns a refresh token or SecretRef. ## 3. Contracts +### Grok subscription binding to local Agent providers + +- `bind_xai_managed_provider` accepts exactly `{ app, accountId, modelId }`. + `accountId` is the public overview identity, not a default or caller-supplied + legacy token-store ID. Binding resolves an xAI `proxy_upstream` / + `fyagent_proxy` credential, requires `ready` and `refresh_owner=fyagent`, and + reads the matching SecretRef bundle before Provider mutation. +- Native Grok/OpenCode lineages are not eligible even when the same account + identity is ready. No upstream access/refresh token is copied into a Provider, + renderer, Change Plan, or Agent auth file. The existing proxy resolver remains + the only refresh owner and rejects credentials whose current status changed. +- The binding uses a stable target/account/model Provider identity; an existing + row with a different definition fails `provider_conflict` instead of being + overwritten. A new source name includes a short public account label and a + stable identity digest so equal model/display names remain distinguishable. + A saved name is preserved and excluded from binding identity; renaming the + source or changing an account's display name does not break idempotency. + Claude Code activates through the existing Provider transaction; + Codex saves a draft and uses Change Plan; Claude Desktop saves a draft for its + existing dedicated profile application, with `activated=false`. +- Result fields remain `providerId`, `providerName`, `app`, `alreadyBound`, + `activated`. Errors contain only a closed `code`: `invalid_request`, + `account_unavailable`, `provider_conflict`, `apply_failed_rolled_back`, or + `rollback_partial_state_unknown`. The last code never means restored. +- `get_xai_oauth_models(accountId)` checks the same explicit overview identity + and vault bundle, then returns the documented `grok-build` route suggestion. + No subscription catalog endpoint is established: do not send session tokens + to the API-key `/models` endpoint or label suggestions as account entitlement. +- New vault-created binding IDs never fall back to an in-memory legacy JSON + account when the vault account disappears or becomes unavailable. + + ### Identity versus credential session - `ManagedIdentity` is keyed by `(provider, provider_subject, provider_tenant)`, diff --git a/.trellis/spec/backend/proxy-runtime.md b/.trellis/spec/backend/proxy-runtime.md index 5f47b4356..ebe722fc5 100644 --- a/.trellis/spec/backend/proxy-runtime.md +++ b/.trellis/spec/backend/proxy-runtime.md @@ -61,6 +61,46 @@ backup body, or replacement routing implementation. ## 3. Contracts +### Managed Grok activation + +- `xai_oauth` inference is pinned to the official CLI session route + `https://cli-chat-proxy.grok.com/v1/chat/completions`. Ordinary API-key + providers retain `api.x.ai`. Claude Messages and Codex Responses reuse the + existing Chat converters, including streaming tool calls; Codex's tool + catalog uses the matching `ProxyChat` profile. +- After model mapping and header overrides, write `X-XAI-Token-Auth: + xai-grok-cli` and `x-grok-model-override` from the final outbound model. + Replace incoming copies; JSON `model` alone does not select the CLI route. + The integration fixture must assert vendor host/path before redirecting I/O + to loopback. Its streaming tests inspect tool arguments and terminal events + in both downstream protocols; synthetic success is not real quota evidence. +- Claude Code/Codex Grok subscription activation composes the existing Provider + transaction with the one `ProxyService`. Acquire the target mutation lock, + then the shared managed-activation guard, then any listener-start guard. + Hold activation ownership from snapshots through commit or compensation; + another target cannot adopt a listener whose owner is still rolling back. + Manual per-app takeover uses the same guard after its existing app lock. + Activation requires a loopback address and + a successfully bound listener before publishing local configuration. +- Capture live file preimages, Provider/current markers, existing backup and + the target proxy configuration before mutation. Keep an existing restore + backup; otherwise capture the outgoing native configuration. Do not backfill + an outgoing API key into the new managed Provider. +- Write/read back the local endpoint using the target owner. Preserve Claude + permissions/unrelated environment and Codex native auth/MCP/unrelated source + configuration. Set only the selected target enabled; disable its automatic + failover so an expired subscription cannot silently use a paid API source. +- Failure restores files, row/current selection, backup, and target proxy flags. + Stop a newly created listener only when no other takeover uses it; never stop + a listener that was already running. Report incomplete compensation as + state unknown. Read back restored target/global configuration and listener + state before confirming compensation. Port-conflict regression asserts all + original flags/files; a gated two-target test proves a failed activation + cannot stop the subsequently committed target's listener. +- Existing quit/restore/next-start behavior stays authoritative; this feature + does not add a daemon, Docker, cloud service, or system-wide proxy. + + ### Command and state ownership - `commands/proxy.rs` is transport only: parse bounded wire input, acquire diff --git a/.trellis/spec/frontend/models.md b/.trellis/spec/frontend/models.md index 4cf8598ad..2017ae2f4 100644 --- a/.trellis/spec/frontend/models.md +++ b/.trellis/spec/frontend/models.md @@ -68,6 +68,8 @@ interface ProvidersPort { fetchModels(baseUrl: string, apiKey: string): Promise; checkReachability(baseUrl: string): Promise; checkModel(request: ModelProbeRequest): Promise; + bindXaiManaged(request: BindXaiManagedRequest): Promise; + fetchXaiManagedModels(accountId: string): Promise; } interface WorkBuddyPort { @@ -210,6 +212,112 @@ an apply instruction. The page sanitizes returned warning codes against the closed `CodexProviderMutationWarning` union. +### Existing Grok subscription to a local Agent + +`pages/models/XaiSubscriptionSection.tsx` is the single account/model picker +for the existing Grok integration; it composes the current Models panels and +Managed Auth overview, not a second authentication page. Agent Grok model +entries link to the Claude/Codex Models target with the existing validated +Agent-return tuple. Login and account recovery use `/auth?view=accounts`. + +```ts +interface BindXaiManagedRequest { + app: "claude" | "claude-desktop" | "codex"; + accountId: string; // Explicit overview ma1 identity, never legacy/default ID. + modelId: string; +} +interface BindXaiManagedResult { + providerId: string; + providerName: string; + app: "claude" | "claude-desktop" | "codex"; + alreadyBound: boolean; + activated: boolean; +} +``` + +- The overview is Query-owned under `featureKeys.managedAuthOverview` and + pauses automatic reads while hidden. The user explicitly selects an xAI + account. Only `health=ready` is selectable; health does not prove the native + proxy-purpose credential exists, so native revalidates it during discovery + and binding. No default-account fallback or renderer-held credential exists. +- Account selection reads `get_xai_oauth_models` with that exact overview + identity and renders the existing selectable model chips. Native validates + the vault credential and returns documented CLI route suggestions; it does + not send the subscription token to the API-key `/models` catalog. The UI + labels these as official-example options, not an account entitlement list, + and labels this integration experimental. Changing accounts clears the old + selection/list; no model is hardcoded in the renderer or selected implicitly. + A failed/empty options read exposes manual input. Model IDs are 1–128 ASCII + characters, start with an alphanumeric character and otherwise admit only + alphanumeric, `.`, `_`, `-` and `:`. Discovery is not entitlement/use proof. +- The bind request has exactly the three keys above. Native response parsing + checks exact keys, the submitted target identity, bounded provider ID/name, + boolean fields and `activated === (app === "claude")`. Invalid or unknown + responses fail closed; no raw native diagnostic enters product copy. +- Claude Code confirmation discloses the native write targets and shares the + Provider panel's synchronous write guard. A positive result requires native + application plus provider-summary/current-ID and managed-auth rereads. + Any failed reread or unknown write result blocks further writes to that + target through the existing Models parent block. Other targets remain usable. + This target block still applies when switching targets unmounts the picker + while a bind is pending; mounted guards may suppress only local UI updates. +- Codex binding saves a draft only. The result links to the existing Auth + `consumer=codex&view=connections` source workspace, where the user selects + the named saved source, previews and confirms the existing Change Plan. + Models never mounts another source-switch workspace. This picker exposes + only Claude Code and Codex CLI binding. The native contract retains Desktop + draft compatibility, but Models provides no Desktop action until its own + authoritative saved-source readback and application path are integrated. +- Bind errors are the closed `{code}` values `invalid_request`, + `account_unavailable`, `provider_conflict`, `apply_failed_rolled_back`, and + `rollback_partial_state_unknown`. The last value and malformed failures + block writes; known preflight/confirmed-restoration failures retain a safe + retry path. Once binding returned, subsequent owner-read errors are always + unconfirmed regardless of their error shape. +- Successful binding invalidates/rereads the affected Provider summary and + managed-auth overview. Account/login changes reuse the same overview key. + The UI states that using the subscription requires FyAgent running in the + background, and separates saved config from actual calls/quota use. +- WorkBuddy has no subscription picker: CLI route suggestions do not describe + models supported by its configured API-key service. Its existing service/key + input, model discovery and save-plan behavior stay intact. + +Required regressions: `XaiSubscriptionSection.test.tsx` covers explicit +account/model selection, changed/expired accounts, native failure/readback, +per-target draft/application copy, navigation and hidden reads; Models Page +coverage verifies that WorkBuddy has no unsupported subscription picker; +`xaiSubscriptionPort.test.ts` covers exact vault identity payloads, invalid +fields, target/result agreement, closed errors and browser native-only behavior. +`tests/browser/xai-subscription.spec.ts` covers the real renderer's Claude +confirmation and Codex source preview/apply using synthetic IPC fixtures. +These are not native-file, live-subscription or Windows evidence. + +The subscription boundary adds these focused validation cases; general write, +secret and lifecycle failures remain in the matrix in section 4. + +| Condition | Required renderer behavior | +| ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| No explicit account/model, or selected account is no longer ready | Disable confirmation; never select the default account or another model. | +| Discovery fails or returns no usable IDs | Offer explicit manual input; do not manufacture a model or change the upstream source. | +| Bind result names another target, has excess fields, or contradicts activation semantics | Reject the result and mark target state unconfirmed. | +| Native rejects unavailable account/conflict, or confirms restoration | Show the closed failure and retain an explicit retry/recovery entry. | +| Native cannot confirm restoration, or either post-bind owner read fails | Block further target writes; no optimistic success or automatic write retry. | +| Codex draft is saved | State that it is a draft and expose the existing Auth source-plan continuation. | +| Claude Code picker is open | Offer only its own target; do not save a Desktop source then read the Claude Code summary. | +| WorkBuddy is selected | Keep its service/key workflow; do not offer subscription route suggestions. | + +Good: a user chooses an existing xAI account and a suggested model, confirms +Claude's native file disclosure, and sees applied copy only after native and +owner readback. Base: suggestions are unavailable, so the user enters a model ID +and native still revalidates the selected account. Bad: use the first/default +account, resurrect `auth_get_status`, or call a draft a working subscription. + +Wrong: `bindXaiManaged({ app: "codex", accountId: defaultAccountId })` followed +by immediate "已切换" copy. Correct: pass the exact selected +`{app, accountId, modelId}`, reread the saved source, then hand off to Auth's +existing preview/apply workspace. A model fetch or saved draft is never live +usage evidence. + ### WorkBuddy flow - WorkBuddy reads `getStatus()` and `getModelIds()` separately. A read is 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..558219d2a --- /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/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/archive/2026-09/08-31-grok-first-class-iteration/summary.md", "reason": "Reject copy that mixes the three roads"} +{"file": ".trellis/tasks/archive/2026-09/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..0c7c88882 --- /dev/null +++ b/.trellis/tasks/08-31-grok-login-trichotomy/design.md @@ -0,0 +1,41 @@ +> Historical subplan: the 2026-09-08 parent task artifacts supersede these execution instructions. Retained acceptance items are not evidence of completion. + +# Design — Grok login trichotomy + +先读父任务 [summary.md](../archive/2026-09/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..16c215577 --- /dev/null +++ b/.trellis/tasks/08-31-grok-login-trichotomy/implement.jsonl @@ -0,0 +1,7 @@ +{"file": ".trellis/tasks/archive/2026-09/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/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/archive/2026-09/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..07774b764 --- /dev/null +++ b/.trellis/tasks/08-31-grok-login-trichotomy/implement.md @@ -0,0 +1,47 @@ +> Historical subplan: the 2026-09-08 parent task artifacts supersede these execution instructions. Retained acceptance items are not evidence of completion. + +# Implement — Grok login trichotomy + +先读 [design.md](./design.md) 和 [use-cases.md](./use-cases.md)。父任务 [summary.md](../archive/2026-09/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..c761e5733 --- /dev/null +++ b/.trellis/tasks/08-31-grok-login-trichotomy/prd.md @@ -0,0 +1,39 @@ +> Historical subplan: the 2026-09-08 parent task artifacts supersede these execution instructions. Retained acceptance items are not evidence of completion. + +# Clarify Grok login trichotomy + +先读父任务 [summary.md](../archive/2026-09/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..36a9e839d --- /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/archive/2026-09/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/archive/2026-09/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/archive/2026-09/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..64eb09d6d --- /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": null, + "relatedFiles": [], + "notes": "Historical 2026-08-31 subplan. Execution authority is the 2026-09-08 revision of grok-first-class-iteration. Do not reactivate legacy UI/JSON requirements. Original all-target/HIL acceptance is not claimed complete.", + "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..e2b7e5d96 --- /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/archive/2026-09/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/archive/2026-09/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..99e7b39f7 --- /dev/null +++ b/.trellis/tasks/08-31-grok-supergrok-to-codex/design.md @@ -0,0 +1,54 @@ +> Historical subplan: the 2026-09-08 parent task artifacts supersede these execution instructions. Retained acceptance items are not evidence of completion. + +# Design — SuperGrok to Claude Code, Claude Desktop, and Codex + +先读父任务 [summary.md](../archive/2026-09/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..3c0a6c172 --- /dev/null +++ b/.trellis/tasks/08-31-grok-supergrok-to-codex/implement.jsonl @@ -0,0 +1,8 @@ +{"file": ".trellis/tasks/archive/2026-09/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/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/archive/2026-09/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..966e7470c --- /dev/null +++ b/.trellis/tasks/08-31-grok-supergrok-to-codex/implement.md @@ -0,0 +1,45 @@ +> Historical subplan: the 2026-09-08 parent task artifacts supersede these execution instructions. Retained acceptance items are not evidence of completion. + +# Implement — SuperGrok to Claude / Desktop / Codex + +先读 [design.md](./design.md) 和 [use-cases.md](./use-cases.md)。对齐源:父任务 [summary.md](../archive/2026-09/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..cb95217d1 --- /dev/null +++ b/.trellis/tasks/08-31-grok-supergrok-to-codex/prd.md @@ -0,0 +1,44 @@ +> Historical subplan: the 2026-09-08 parent task artifacts supersede these execution instructions. Retained acceptance items are not evidence of completion. + +# Place SuperGrok into Claude Code, Claude Desktop, and Codex + +先读父任务 [summary.md](../archive/2026-09/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..5e5dc6fcc --- /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/archive/2026-09/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..b315b3ea2 --- /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": null, + "relatedFiles": [], + "notes": "Historical 2026-08-31 subplan. Execution authority is the 2026-09-08 revision of grok-first-class-iteration. Do not reactivate legacy UI/JSON requirements. Original all-target/HIL acceptance is not claimed complete.", + "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..5913f849b --- /dev/null +++ b/.trellis/tasks/08-31-grok-supergrok-to-workbuddy/check.jsonl @@ -0,0 +1,6 @@ +{"file": ".trellis/tasks/archive/2026-09/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/archive/2026-09/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..415e5b7c2 --- /dev/null +++ b/.trellis/tasks/08-31-grok-supergrok-to-workbuddy/design.md @@ -0,0 +1,44 @@ +> Historical subplan: the 2026-09-08 parent task artifacts supersede these execution instructions. Retained acceptance items are not evidence of completion. + +# Design — SuperGrok to WorkBuddy + +先读父任务 [summary.md](../archive/2026-09/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..7820f991e --- /dev/null +++ b/.trellis/tasks/08-31-grok-supergrok-to-workbuddy/implement.jsonl @@ -0,0 +1,9 @@ +{"file": ".trellis/tasks/archive/2026-09/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/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/archive/2026-09/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..9517dc4d0 --- /dev/null +++ b/.trellis/tasks/08-31-grok-supergrok-to-workbuddy/implement.md @@ -0,0 +1,42 @@ +> Historical subplan: the 2026-09-08 parent task artifacts supersede these execution instructions. Retained acceptance items are not evidence of completion. + +# Implement — SuperGrok to WorkBuddy + +先读 [design.md](./design.md) 和 [use-cases.md](./use-cases.md)。对齐源:父任务 [summary.md](../archive/2026-09/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..5f701d330 --- /dev/null +++ b/.trellis/tasks/08-31-grok-supergrok-to-workbuddy/prd.md @@ -0,0 +1,40 @@ +> Historical subplan: the 2026-09-08 parent task artifacts supersede these execution instructions. Retained acceptance items are not evidence of completion. + +# Place SuperGrok into WorkBuddy models + +先读父任务 [summary.md](../archive/2026-09/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..d8eafa3f0 --- /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": null, + "relatedFiles": [], + "notes": "Historical 2026-08-31 subplan. Execution authority is the 2026-09-08 revision of grok-first-class-iteration. Do not reactivate legacy UI/JSON requirements. Original all-target/HIL acceptance is not claimed complete.", + "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/.trellis/tasks/archive/2026-09/08-31-grok-first-class-iteration/HANDOFF.md b/.trellis/tasks/archive/2026-09/08-31-grok-first-class-iteration/HANDOFF.md new file mode 100644 index 000000000..221b6d7c3 --- /dev/null +++ b/.trellis/tasks/archive/2026-09/08-31-grok-first-class-iteration/HANDOFF.md @@ -0,0 +1,67 @@ +# 2026-09-08 当前交接 + +续作分支为 `codex/grok-auth-reuse-completion`,基于原 `feat/grok-first-class-iteration` 并整合 0.4.4 主线。原分支和原工作目录保持独立。当前方案、检查及剩余外部验证以 [设计](./design.md)、[实施证据](./research/implementation-evidence-20260908.md) 和 [最终 review](./research/full-scope-review-20260908.md) 为准。 + +已有 Grok 账号从当前“账号与认证”页面管理。模型页的 Claude Code/Codex 面板可选择订阅账号与模型:Claude 确认后应用;Codex 保存后继续预览来源,再确认应用。使用期间保持 FyAgent 在后台运行;恢复配置走原有文件恢复/来源切换流程。 + +真实账号的后续验证应在独立可恢复环境分别确认 Claude Code 与 Codex CLI 请求成功、所选账号及模型正确、流式工具调用完成、授权失效与退出恢复行为。当前自动化使用合成授权与假上游,不能证明真实额度消费;未取得 Windows 实机结果。不要把下方历史矩阵自动标为完成,不关闭 #42/#43,也不部署本分支覆盖用户的正式安装。 + +--- + +以下为原分支历史交接,旧 UI/JSON、四目标和双机完成说明均不代表当前结果。 + +# 交接: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\\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/archive/2026-09/08-31-grok-first-class-iteration/check.jsonl b/.trellis/tasks/archive/2026-09/08-31-grok-first-class-iteration/check.jsonl new file mode 100644 index 000000000..5450dbda7 --- /dev/null +++ b/.trellis/tasks/archive/2026-09/08-31-grok-first-class-iteration/check.jsonl @@ -0,0 +1,14 @@ +{"file":".trellis/spec/backend/modular-boundaries.md","reason":"Native command and service ownership"} +{"file":".trellis/spec/backend/reuse.md","reason":"Reuse current owners"} +{"file":".trellis/spec/backend/managed-auth.md","reason":"Vault and proxy credential ownership"} +{"file":".trellis/spec/backend/managed-auth-consumers.md","reason":"Purpose and consumer isolation"} +{"file":".trellis/spec/backend/proxy-runtime.md","reason":"Single local engine lifecycle"} +{"file":".trellis/spec/backend/local-proxy-pipeline.md","reason":"Routing and protocol behavior"} +{"file":".trellis/spec/backend/change-plan-executor.md","reason":"Existing preview/apply contracts"} +{"file":".trellis/spec/backend/codex-provider-configuration.md","reason":"Codex config and restoration"} +{"file":".trellis/spec/frontend/modular-boundaries.md","reason":"Current single renderer entry"} +{"file":".trellis/spec/frontend/managed-auth.md","reason":"Current authorization UI"} +{"file":".trellis/spec/frontend/models.md","reason":"Model and provider workflow"} +{"file":".trellis/spec/frontend/change-plan-workspaces.md","reason":"Preview and apply behavior"} +{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"DTO and native evidence"} +{"file":".trellis/spec/backend/development-environment.md","reason":"Current toolchain and isolated validation"} diff --git a/.trellis/tasks/archive/2026-09/08-31-grok-first-class-iteration/design.md b/.trellis/tasks/archive/2026-09/08-31-grok-first-class-iteration/design.md new file mode 100644 index 000000000..b239b2bfb --- /dev/null +++ b/.trellis/tasks/archive/2026-09/08-31-grok-first-class-iteration/design.md @@ -0,0 +1,64 @@ +# 技术方案:复用当前授权和内置转发 + +## 基线与 review + +新分支 `codex/grok-auth-reuse-completion` 从原分支 `b8b15dba` 建立,隔离目录 `~/.codex/worktrees/grok-auth-reuse-20260908/fyagent`。整合 `origin/main` 的 0.4.4 提交 `2f264d2f89326601a33f610c72a9f0143306d066`。保留主线历史和原分支;原提交标题不符合当前 CI,提交前只在新分支创建规范标题副本并验证同树/同父提交,原远端分支不改写。旧分支落后主线 236 个提交,前端目录迁移和 Managed Auth 更新造成冲突。 + +原分支已实现绑定命令、Codex 准入和页面入口,但读取旧 JSON 账号、复活旧认证 UI、固定模型与 Provider ID 等逻辑不能直接带入新版。原始 review 见 `research/original-branch-review-20260908.md`,旧计划留档见 `research/original-plan-20260831.md`。本设计覆盖旧父任务“父任务不写代码/只能旧认证中心”的限制。 + +## 最小行为缺口与责任 + +已有账号 → 安全选择账号/模型 → 保存目标 Provider → 启动/采用本机转发 → 目标文件回读 → 实际请求解析授权。缺口位于现有 Provider、Change Plan 和 Proxy 的连接处,不靠 renderer 写秘密或再建服务填补。 + +```text +现有 Managed Auth 页面 / vault + -> 不含秘密的账号选择 + -> 当前模型/Agent 页面 + -> 受限绑定 IPC + -> Provider 服务 + 现有 Codex Change Plan + -> 本机 ProxyService / 转发器 + -> Managed Auth resolve_access_material(唯一刷新者) + -> Grok 订阅上游 +``` + +## Native 合同 + +2026-09-08 独立复核补充:原有 xAI OAuth adapter 把推理送往 `api.x.ai/v1`,不能据此认定使用订阅额度。已核对官方 Grok CLI 固定提交 `72a61251fcffb464bcc687aeb5a998e5a98ec0c9`,依据见 `research/xai-subscription-upstream-20260908.md`。本次必须改用 `https://cli-chat-proxy.grok.com/v1/chat/completions`,后端设置 `X-XAI-Token-Auth: xai-grok-cli` 及 `x-grok-model-override: <最终所选模型>`,复用已有 Chat 协议转换。模型路由实际由请求头决定,不能透传客户端任意覆盖。现有 OAuth scope 已含 `grok-cli:access`,不重做登录。测试 seam 替换网络地址前断言真实 vendor host/path,并覆盖两种下游协议的流式工具调用。 + +普通 API key 来源仍使用 `api.x.ai`;订阅选项不访问该站的 API-key 模型目录。Native 经过账号资格检查后返回官方已文档化的 `grok-build` 路由建议,页面明确它不是账号 entitlement 名单,用户仍需选择或手动输入。这是实验性兼容接入;真实额度消费仍需要真实账号和上游回执。 + +- 保留 `bind_xai_managed_provider` 这一闭合用途;命令层只做 DTO/state/error 映射,Provider 构建、合法性、保存/激活交给服务 owner。 +- 绑定依据当前 Managed Auth 元数据/凭据能力,不以旧 `xai_oauth_auth.json` 是否存在判断。仅接受可用于 `proxy_upstream` 且由 FyAgent 管理的 xAI 授权;不取消 purpose/refresh-owner 隔离。 +- 请求明确选择 `accountId` 和 `modelId`,目标限定受支持 app。新增命令尚未发布,可收紧为空值失败;renderer 与 Rust DTO 同步。后端重新验证账号可用性,不能信任页面已检查。 +- 保留原结果 `providerId/providerName/app/alreadyBound/activated`;如需警告/恢复信息,使用现有 mutation/result 合同并通知前端实施者同步。`activated` 只能来自完成的目标写入/回读,不能由数据库存在推断。 +- 不覆盖无关 Provider:账号/目标范围的稳定身份或受验证的已有绑定;身份/绑定不符拒绝,保存失败使用现有事务和补偿。不在新 command 内复制 SQL/文件/网络逻辑。 +- 多账号使用相同模型时,保存来源名称必须可辨认账号;使用 vault 公开身份的短标签及必要的稳定短标识,不显示秘密或完整凭据 ID。同一绑定的 ID、目标、元数据和设置匹配时保留已保存名称,不能因单独改名破坏幂等。 +- Claude 复用既有 Provider 配置应用/Proxy 接管,Codex 保持 draft + 既有 Change Plan,不扩建第四种执行器。确保 Codex capability 放行只针对已验证托管 xAI 形状,撤销/过期在 apply 边界重新检查。 +- 应用后必须是正确本机入口;订阅上游由现有认证/转发 owner 决定,不以普通 xAI API key 入口的成功替代。 +- 只管理目标 Agent 的配置,保留官方原生登录和无关配置。复用备份/序列化写锁/恢复。不得复制上游 token 到目标 auth.json、日志、计划或 renderer。 +- per-app 写锁外还需保护共享监听器的激活事务:统一 Proxy owner 持有固定顺序的 guard,覆盖运行态快照、启动、提交与失败补偿,避免一个目标失败时停止另一个目标正在采用的监听器。补偿必须回读 app/global 状态。 + +## Renderer 合同 + +- 延续主线 `src/` 单入口与当前 `src/pages/auth` Managed Auth 页面。删除原分支新增的 legacy 动态挂载,不恢复已退役 Settings/Provider UI。 +- 原投放能力接到现有模型页/Agent 页面;复用管理账号 overview、query invalidation、导航和 Change Plan workspace。已有账号可选择,失效/不可用状态不显示可绑定。 +- 选择模型后绑定;使用后端文档路由建议或明确输入,不把建议当作订阅 entitlement 证据。 +- Codex 绑定后的结果是草稿,继续预览/确认;Claude 应用后显示配置/运行依赖,不声称真实额度已验证。当前仅展示 Claude Code/Codex 投放入口;Claude Desktop 的后台草稿能力保留但不展示未接通的应用入口。 +- WorkBuddy 保留主线 API 服务/密钥、模型发现和保存流程;不展示订阅入口,CLI 路由建议不应用于它的 API 模型配置。不引导把 refresh token 粘贴为 API key,不降低原生 Grok 官方登录/ChatGPT/其他 Agent 的主线行为。 + +## 生命周期与测试隔离 + +优先复用现有单例 ProxyService、恢复和托盘/退出行为。固定主线实际行为:普通 Provider switch 不会启动转发,set_takeover_for_app(true) 才启动并接管;真正退出恢复 Live 配置但保留 enabled,重启依 enabled 再接管;手动 stop_with_restore 恢复并清 enabled。必须补齐绑定与接管连接,不改变已有退出恢复语义。只为本链路实际缺口做局部修改,不重建守护进程或整套生命周期。绑定需在安全本机监听前提下才能应用;监听失败不遗留成功状态。先以源码和隔离 native 测试确认当前行为,再决定是否需要补丁。 + +测试优先使用临时 DB、FYAGENT_TEST_HOME、合成 token 和本机假上游,证明绑定→目标配置→代理协议→账号解析链;真实账号仅在明确可获得且能保持唯一授权持有者/恢复配置时使用。不读取或输出 token,不复制 refresh lineage 做并行测试。真实 UI/上游与 Windows 证据分别记录,不能伪称完成。 + +## 修改边界与分工 + +- Native:`commands/provider.rs`、必要的 Provider/Change Plan/Proxy/Managed Auth owner、权限注册和对应 Rust tests;删除旧 JSON 准入,不改登录协议或 schema,除非出现必须的明确缺口。 +- Renderer:原分支触及的 `src/pages/{agents,models}`、当前 auth 导航、shared feature/ports/workspace 与对应 renderer/browser tests;跟随当前主线结构,删除残留 `src/v2`/legacy boot。 +- 主控:基线整合、任务/方案、集成环境、跨层检查、审查与 PR。两个实施者的文件责任分开,接口调整先通知对方。 +- 不改根目录 dirty worktree、其他 worktree、发布版本、无关功能和现有本机 Agent 配置。已有计划文档复制到本隔离分支提交。 + +## 取舍与恢复 + +当前直接延用内置 Rust 引擎,CLIProxyAPI 仅作明确兼容阻碍时的研究储备。本次不引入新网关依赖。所有修改在新分支,原分支和主线均不改写;产品级回滚使用原有目标配置备份和来源切换能力,不把 Git 回滚当作用户文件恢复。 diff --git a/.trellis/tasks/archive/2026-09/08-31-grok-first-class-iteration/implement.jsonl b/.trellis/tasks/archive/2026-09/08-31-grok-first-class-iteration/implement.jsonl new file mode 100644 index 000000000..5450dbda7 --- /dev/null +++ b/.trellis/tasks/archive/2026-09/08-31-grok-first-class-iteration/implement.jsonl @@ -0,0 +1,14 @@ +{"file":".trellis/spec/backend/modular-boundaries.md","reason":"Native command and service ownership"} +{"file":".trellis/spec/backend/reuse.md","reason":"Reuse current owners"} +{"file":".trellis/spec/backend/managed-auth.md","reason":"Vault and proxy credential ownership"} +{"file":".trellis/spec/backend/managed-auth-consumers.md","reason":"Purpose and consumer isolation"} +{"file":".trellis/spec/backend/proxy-runtime.md","reason":"Single local engine lifecycle"} +{"file":".trellis/spec/backend/local-proxy-pipeline.md","reason":"Routing and protocol behavior"} +{"file":".trellis/spec/backend/change-plan-executor.md","reason":"Existing preview/apply contracts"} +{"file":".trellis/spec/backend/codex-provider-configuration.md","reason":"Codex config and restoration"} +{"file":".trellis/spec/frontend/modular-boundaries.md","reason":"Current single renderer entry"} +{"file":".trellis/spec/frontend/managed-auth.md","reason":"Current authorization UI"} +{"file":".trellis/spec/frontend/models.md","reason":"Model and provider workflow"} +{"file":".trellis/spec/frontend/change-plan-workspaces.md","reason":"Preview and apply behavior"} +{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"DTO and native evidence"} +{"file":".trellis/spec/backend/development-environment.md","reason":"Current toolchain and isolated validation"} diff --git a/.trellis/tasks/archive/2026-09/08-31-grok-first-class-iteration/implement.md b/.trellis/tasks/archive/2026-09/08-31-grok-first-class-iteration/implement.md new file mode 100644 index 000000000..5ffad7d90 --- /dev/null +++ b/.trellis/tasks/archive/2026-09/08-31-grok-first-class-iteration/implement.md @@ -0,0 +1,33 @@ +# 行动计划与进度 + +## 顺序与退出条件 + +1. [x] 建立目标模式;记录根目录状态,从原分支建立隔离工作分支。 +2. [x] 读取原任务、review 原提交与 0.4.4 差异;保留旧方案并写当前 PRD/design/implement。 +3. [x] 解决主线合并冲突,保留原分支与可核验的同树来源,保持主线已交付行为。 +4. [x] Native:复用 vault/SecretRef 选择账号,安全构造绑定,接通 Claude 应用与 Codex Change Plan/本机转发;补回归测试。 +5. [x] Renderer:接当前 Managed Auth 入口,完成账号/模型选择与逐目标应用,去掉 legacy 动态挂载,补交互和 port 测试。 +6. [x] 跨层集成:本机假上游和隔离目录证明请求协议、选择账号、凭据隔离、配置回读/恢复与失败;核对现有生命周期。 +7. [x] 完成当前主机标准质量检查、生产浏览器检查与必要的性能检查;记录真实账号/Windows证据边界。 +8. [x] 独立 full-scope check agent review 并修复;同步当前 spec,不保留错误的旧活跃路径。 +9. 提交关闭动作:明确文件清单提交、归档、推送新分支并创建 PR;在最终 Git/PR 回执确认远端 SHA/PR/CI,原工作区保持原样。文档不提前填写尚未创建的 PR 编号。 + +## 实施分工 + +Native 实施者只负责 `src-tauri/**`、相关 native tests 与必要 backend spec;Renderer 实施者只负责 `src/**`、`tests/renderer/**`、`tests/browser/**`、必要 frontend spec。双方可各自解决负责范围的合并冲突并 stage 明确文件,但不提交、不 push、不操作另一个人的文件;接口先同步。主控拥有任务/方案、依赖安装、整合检查、提交与 PR。 + +## 验证命令 + +遵循仓库当前工具链和 `mise run` API,外层以 `rtk` 包装: + +- `mise run system:check`,缺失依赖时使用当前 bootstrap 的已授权安装步骤,不升级锁文件。 +- `mise run rust:test -- <相关过滤或测试目标>`;最终 `mise run check` 覆盖标准 TypeScript、lint、unit、Rust 与合同检查。 +- `mise run test:browser`;涉及界面导航/动画时串行执行 `mise run test:performance`。 +- 归档前使用 exact active-task exclusion 的预归档检查,之后校验有效 context 路径;具体命令按实时 task runner 合同。 +- 所有测试输出记录摘要与必要错误,不保存秘密。最终相关改动后生成正式结果。 + +## 证据与 PR + +`research/original-branch-review-20260908.md` 记录旧代码;`research/implementation-evidence-20260908.md` 由主控记录最终差异、检查、native/真实账号/Windows 分层结果和残余限制。若真实订阅或目标机器不可用,不冒充成功;完成所有可独立运行的实现和检查,PR 明确剩余外部验证,不合并或发布。 + +旧父任务/子任务的历史矩阵不自动变成本轮全部已验收;更新其引用/状态说明以避免旧入口被再次实施。工作提交先于归档/会话记录,推送与 PR 已由用户明确授权,不再重复询问。 diff --git a/.trellis/tasks/archive/2026-09/08-31-grok-first-class-iteration/prd.md b/.trellis/tasks/archive/2026-09/08-31-grok-first-class-iteration/prd.md new file mode 100644 index 000000000..2a292e980 --- /dev/null +++ b/.trellis/tasks/archive/2026-09/08-31-grok-first-class-iteration/prd.md @@ -0,0 +1,36 @@ +# 单机订阅跨 Agent:原分支续作 + +日期:2026-09-08。用户已授权创建隔离分支、review 原分支、实施、测试、提交 PR;本次授权取代此前“先不测试”的阶段限制。 + +## 目标 + +复用 FyAgent 当前已集成的 Grok/SuperGrok 登录态,把本机 Claude Code 与 Codex 的配置应用、内置转发和订阅调用链路接通。面向小白用户,使用一个 FyAgent 安装包,不引入云账号、远端平台、同步或 Docker。 + +## 范围与来源 + +- 延续 `feat/grok-first-class-iteration` 的 `b8b15dbaf141f7c7fbd7816914fda59a07a2208a`;在独立 worktree、新分支中整合 0.4.4 主线。保留原分支历史,不修改主工作目录或他人分支。 +- 复用当前 Managed Auth 登录、SecretRef 和内置 Proxy;不重建授权系统,不依赖已退役的旧认证页面。 +- 必须完成 Grok 订阅到 Claude Code 与 Codex;保留原分支其他目标的可用能力并纠正虚假支持文案。WorkBuddy 拉模型名单不等于订阅调用;Claude Desktop 依当前目标契约处理,不制造新的产品目录。 +- 其他已经集成的登录来源保持兼容,不把此次目标扩大成所有供应商的任意跨协议转换。 + +## 用户行为 + +1. 用户能够从现有认证入口登录或选择已有 Grok 订阅账号。 +2. 用户选择目标和模型,确认后应用到该目标;不能静默改动其他 Agent 或现有其他来源。 +3. 配置应用结果来自 native 保存/回读;页面明确本机转发运行依赖,不把“已保存”称为真实额度验证。 +4. 用户能够继续使用现有配置恢复/来源切换路径;授权失效、端口冲突和写入失败有真实错误提示。 + +## 验收 + +- [x] 原分支 review、合并取舍、技术设计和行动计划落盘;原提交来源可核验且原分支不改写;若仅规范化提交标题,新旧代码树与父提交必须相同。 +- [x] vault 中已有 Grok 账号可用于绑定,不需要旧 JSON 文件或重新登录;不把上游 token 交给 renderer/目标配置。 +- [x] Claude Code 和 Codex 分别应用到本机转发入口,保留各自协议/备份/恢复,Codex 继续通过现有 Change Plan。 +- [x] 账号/模型选择明确;无缺省账号串用、无固定 Provider ID 覆盖他人数据、无 API 计费静默兜底。 +- [x] 本机监听启动、失败和既有关闭/恢复行为可解释;无需第二个服务或系统级代理。 +- [x] 相关后端/前端/集成测试、标准检查和生产浏览器检查通过;测试使用隔离目录及合成凭据。 +- [x] 可获得的真实 native/订阅证据单独记录;缺少真实账号或 Windows 环境时明确标记,不以 mock 代替。 +- 交付关闭条件:代码复核完成后,提交并推送新分支、创建 PR、回读其分支/提交/检查状态;以最终 Git/PR 回执确认,不把提交前记录当作远端执行结果,不合并 PR、不发布版本。 + +## 非目标 + +新 OAuth 提供商、云账号/同步、Docker/外部平台、系统全局代理、跨设备 token 拷贝、复制 refresh token 到 CLI、关闭整张 #42/#43、安装升级其他 Agent。 diff --git a/.trellis/tasks/archive/2026-09/08-31-grok-first-class-iteration/research/full-scope-review-20260908.md b/.trellis/tasks/archive/2026-09/08-31-grok-first-class-iteration/research/full-scope-review-20260908.md new file mode 100644 index 000000000..38310d4f2 --- /dev/null +++ b/.trellis/tasks/archive/2026-09/08-31-grok-first-class-iteration/research/full-scope-review-20260908.md @@ -0,0 +1,133 @@ +# Grok 订阅跨 Agent 完整范围审查 + +日期:2026-09-08。审查目录为当前隔离 worktree,比较基线为 `origin/main`,另包含本次新增的生产/测试模块。原 checkout 不在写入范围。第二轮复核已完成,最终结论见文末;第一轮发现保留用于说明修复依据,其“待修”状态已被第二轮结论取代。 + +## 审查范围与证据 + +- 已读取当前任务 PRD、design、implement、check.jsonl 及列出的 Native/Renderer 契约。 +- Native 主链:`commands/provider.rs` → `services/provider/managed_xai.rs` → 当前 Provider 锁及补偿事务 → `services/proxy.rs`。Codex 保存草稿后复用 `services/change_plan/service.rs` 的计划/应用准入和写入器。 +- 授权主链:显式 overview identity → `ManagedAuthService::xai_proxy_account` → 绑定不含秘密的 legacy credential identity → `resolve_access_material`。已有 `purpose=proxy_upstream`、`consumer=fyagent_proxy`、`refresh_owner=fyagent` 边界保留。 +- Renderer 主链:当前 Managed Auth overview → `XaiSubscriptionSection` 账号/模型选择 → strict port → 确认 → native 应用/保存 → Provider summary 与 Managed Auth 双回读。Codex 续接现有 Auth source workspace。 +- 检查了新 `subscription_tests.rs` 的合成 vault、真实 Provider/Change Plan writer、真实 Proxy listener/router/转换器及假上游 HTTP 断言。这些证明隔离 native 链路,不证明真实订阅上游、额度扣除、安装 CLI 的完整行为或 Windows 运行。 + +## 第一轮 Findings(历史记录,已由第二轮结论取代) + +### 1. Desktop 草稿错误回读会误封锁 Claude Code + +- 文件:`src/pages/models/XaiSubscriptionSection.tsx`。 +- 证据:Claude 面板可发 `app=claude-desktop`,但确认回调以 `props.app=claude` 获取 summary,再要求返回的 Desktop Provider ID 出现在 Claude summary 中。真实分区数据必然不匹配;原测试复用了不按 app 分区的 summary fixture,掩盖了这个问题。 +- 主控决定:本期只暴露 Claude Code/Codex CLI,移除 Desktop 按钮和专属文案分支;native/DTO 保留明确的 Desktop draft 能力,不新增 Desktop summary port。 +- 修复 owner:原 Renderer 实施者,待最终 diff/回归复核。 + +### 2. 卸载后的异常回读绕过父目标封锁 + +- 文件:`src/pages/models/XaiSubscriptionSection.tsx`。 +- 证据:`confirmBind` 在 summary/selected-current 一致性检查之前执行 `if (!mounted.current) return`。切目标可卸载该 child;native 完成但回读不匹配时会跳过 `onUnconfirmed`,回到原目标仍可继续写。 +- 修复要求:权威匹配检查和父目标封锁先执行;mounted 仅阻止本地 state 更新。增加 pending → unmount → mismatch 的回归。 +- 修复 owner:原 Renderer 实施者,待最终 diff/回归复核。 + +### 3. 跨目标激活与失败清理可争用同一个 listener + +- 文件:`src-tauri/src/services/proxy.rs`、`services/provider/mod.rs`。 +- 证据:目标级写锁不同,两个目标都可能记录 `was_running=false`。A 启动服务、B 复用服务但尚未写 enabled 时,A 失败恢复的 `another_active=false` 可停止服务;B 后续仍可能完成配置并报告成功。只包住 `start()` 的锁不能保护整个激活/恢复事务。 +- 主控接受修复:在既有 Proxy owner 以明确锁顺序序列化 managed 激活事务,覆盖 runtime snapshot、prepare、Provider 提交/补偿;补双目标并发回归。 +- 补充:runtime 补偿必须回读 app/global 状态,不能只依据 UPDATE 返回成功报告已恢复。 +- 修复 owner:原 Native 实施者,待最终 diff/回归复核。 + +### 4. 同模型多账号生成同名 Codex 来源 + +- 文件:`src-tauri/src/services/provider/managed_xai.rs`。 +- 证据:Provider ID 按账号+模型区分,但 name 仅为 `Grok · model`;两个账号使用同模型时 Auth 来源列表无法凭名字辨认。保存后的继续操作正是按该 name 提示选择。 +- 主控决定:新来源名包含安全、短的公开账号标签与稳定短标识;不输出秘密或完整 legacy ID。既有绑定只要 ID/目标/meta/settings 匹配,应保留既有 name,让账号显示名变化或用户改名不破坏幂等。定义/绑定不匹配仍拒绝覆盖。 +- 修复 owner:原 Native 实施者,待多账号同模型和重绑定回归。 + +### 5. 订阅上游入口仍需来源核证及真实 URL 断言 + +- 文件:`src-tauri/src/proxy/providers/{mod,claude,codex}.rs` 等当前 xAI adapter owner。 +- 证据:当前 OAuth inference 仍使用 `https://api.x.ai/v1`;已有研究提示 Grok CLI 订阅可能使用独立 `cli-chat-proxy.grok.com`。由主控安排 Luna 核对官方 CLI 来源,未经核证不得把普通 API 入口等同订阅入口。 +- 当前假上游 seam 会替换最终 URL,现有成功测试本身不能识别错误的真实计费入口。修复后应在替换前断言实际生产上游 URL/协议所需的来源合同。 +- 状态:产品主链的重要待定项;本报告不判定额度复用已完成。 + +## 第一轮已核对的保护边界 + +- 新绑定请求明确账号和模型;缺失授权/错误 purpose/错误 refresh owner 失败关闭,不使用默认账号代替。 +- 公开绑定 DTO、计划与目标配置未包含上游 access/refresh token;新 vault 绑定不存在时不会退回 legacy JSON 内存账号。 +- Codex 继续 plan/digest 的既有应用路径,不新增任意执行器;预览后撤销及实际请求撤销有新增回归。 +- 同步目标使用既有 Provider 锁和文件恢复 owner;不直接修改原 checkout 或真实用户 Agent 文件。 +- WorkBuddy 仍仅能借账号读取模型名单;Desktop native 仍是草稿。这两项不能宣称订阅 inference 已集成。 + +## 第一轮 Verification(历史记录) + +- Lint / TypeCheck:等待本轮修复后的 reviewer 定点检查及主控最终 gate。 +- Native:实施者报告旧定点过滤通过;新补回归、订阅入口与并发修复仍待最终测试。报告不把实施者自述替代 final gate。 +- Renderer/browser:主控和 Renderer 实施者负责当前批次;最终数量及退出码待回读。 +- 真实订阅账号、CLI smoke、Windows:本审查未执行,不宣称通过。 + +以上是修复前审查记录;当前状态以下面的第二轮结论为准。 + +## 第二轮结论 + +代码复核通过:两轮发现的六项明确问题均已修复并核对最终实现,没有剩余已确认的本轮代码阻断项。Reviewer 独立执行的 lint、TypeScript 检查与 31 项 Renderer/Port 定点测试通过。主控仍须完成全量门禁、完整浏览器/性能检查和 PR 交付,本报告不代替这些结果。 + +### Findings(fixed) + +| 项目 | 最终修复与复核证据 | +| -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Desktop 错误回读 | `XaiSubscriptionSection` 删除 Desktop UI 入口和仅此入口使用的文案;pending request 只允许 Claude/Codex。Native/DTO 仍保留明确 draft 兼容,不新增 Desktop summary port。 | +| 卸载后异常回读 | summary/current 不一致时先调用父级 `onUnconfirmed`,再以 mounted 限制组件 state。Reviewer 执行 pending bind → unmount → mismatch 回归通过。 | +| 共享 listener 竞态及补偿读回 | `ProviderService` 在 app 锁后、所有 snapshots 前取得 managed activation guard,覆盖 prepare/commit/rollback;manual per-app takeover 也使用 app → activation → start 顺序。恢复后读回 target/global 配置及 listener,篡改不再冒充 restored。已复核实入口双目标门控测试(Change Plan 与 manual 两种入口)、端口冲突与 target/global trigger 篡改测试。 | +| 同模型多账号同名 | 新 Provider 名包含过滤后的公开账号短标签、稳定 identity digest 短标识和模型;不使用秘密或完整 legacy ID。重绑定沿用已有 name,其他定义/绑定仍完整比较。已核对多账号同模型可区分、改名幂等、实际配置变更 conflict 回归。 | +| 订阅上游/协议错误 | OAuth 固定到 `https://cli-chat-proxy.grok.com/v1/chat/completions`;API key 保留原 `api.x.ai` 路径。Claude 强制 Chat,Codex Responses→Chat 与 `ProxyChat` profile 一致。最后 native 阶段设置 CLI 标识头和最终模型头,覆盖客户端/自定义副本。测试替换 I/O 前断言实际 vendor HTTPS/host/path;双协议测试检查非流式结果、流式工具参数及结束事件,撤销后零新增上游请求。 | +| 冲突官方 metadata 误分类(第二轮新增) | `resolve_codex_catalog_tool_profile` 原先先判断固定官方 ID/category;与 xAI metadata 冲突时会返回 NativeResponses。同一个 official helper 还控制 URL 与 native-auth passthrough。现 helper 明确排除 xAI,catalog xAI pin 优先。新增回归要求冲突行仍 subscription origin、XaiOAuth strategy、Chat/非 Anthropic、ProxyChat;去掉 xAI metadata 后正常官方保持 NativeResponses。Reviewer 已检查该 helper 的所有当前调用点。 | + +来源与范围同步已核对: + +- 订阅入口来源为 `research/xai-subscription-upstream-20260908.md` 的官方 Grok Build 固定提交 `72a61251fcffb464bcc687aeb5a998e5a98ec0c9` README L510–538,由 Luna 核验、主控抽查。报告中原有“当前 FyAgent 错误 API 入口”段落描述的是修复前基线。 +- `get_xai_oauth_models` 不再向 API-key catalog 发送会话 token;验证所选 vault 凭据后提供官方示例 `grok-build`。UI 明确是模型选项、非账号 entitlement 名单,并标记实验性。 +- WorkBuddy 订阅 picker 已移除;其原有 API 服务/密钥、模型发现及保存流程保留。Desktop 只保留 native draft,不宣称本轮 inference 支持。 +- 新增保护边界已同步到 backend Managed Auth、Proxy Runtime、Change Plan 与 frontend Models spec;没有新增云服务、Docker、OAuth store 或任意执行器。 +- 主控修订了浏览器测试 locator:按精确“Claude Code 模型配置”/“Codex 模型配置”父 region 选择订阅区域,并等待 Codex 按钮出现;避免过渡期命中退出中的旧目标。原两次 bind payload 和一次 apply 断言保留,没有新增 sleep/force 或放宽业务断言。 + +以上生产修复由原 Native/Renderer 实施者和主控在各自责任文件完成;Reviewer 协调发现并核对最终 diff,未并发覆盖他人代码。 + +### Findings(not fixed / 证据边界) + +- 没有遗留已确认的本轮代码错误。 +- 真实 Grok 账号的上游请求、实际额度归属/配额消费没有在本审查中验证。官方 CLI 文档和合成 HTTP 成功不能证明本机账号 entitlement;当前为明确标注的实验性兼容。 +- 安装 CLI smoke、Windows native 未由本审查执行;不能从协议 fixture 或 macOS 编译推导通过。 +- 全量 Rust/Renderer/contracts、完整浏览器和性能门禁、远端 CI/PR 状态由主控收尾;本报告不把尚未完成的层级写为 PASS。 + +### Verification + +Reviewer 独立执行,均在最终 Renderer 修改之后: + +| 检查 | 结果 | 工具回读 | +| --------------------------------------------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------- | +| `mise run lint` | PASS | session 23558,exit 0 | +| `mise run typecheck` | PASS | session 43150,exit 0 | +| `mise run test:unit -- tests/renderer/pages/models/XaiSubscriptionSection.test.tsx tests/renderer/platform/xaiSubscriptionPort.test.ts` | PASS | session 90391,2 files / 31 tests,exit 0 | +| `git diff --check` | PASS | Reviewer 最终只读检查 exit 0 | + +Native 实施者最终报告 `subscription_` 24/24(双入口并发、trigger unknown、双 stream/非 stream)和 `xai_oauth` 18/18 通过;rustfmt 与 diff check 通过。Reviewer 已核对对应最终测试实际路径,为避免并发 Cargo 没有重复运行;最终全量 Rust 结果由主控 evidence 记录,不用实施者自述替代全量 gate。 + +### 门禁后的最终局部复核 + +主控在全量门禁中修正两处机械问题,Reviewer 已读取最终源码及相对 `origin/main` 的差异: + +- `proxy/forwarder.rs`:测试 seam 的 `is_some()` 加 `expect()` 改为 `if let Some(_fixture)`;`cfg(test)` 内直接使用匹配值。生产构建仍是 `Option<()> = None` 并进入原 AppHandle 授权路径,fixture 仍不进入生产;没有 lint allow 或行为扩展。 +- `commands/agent_catalog.rs`:本次新增一个已注册/授权命令后,测试冻结数从 367 更新为 368,并显式断言包含 `bind_xai_managed_provider`。`allowed == registered` 和远端来源限制保持,变更没有放宽权限集合;该文件相对主线仅有这两处断言变化。 + +主控报告这两项修复后的 `check:backend` 完整重跑 exit 0:合计 3518 passed / 6 ignored,Clippy、Rust fmt、Rust check 均通过;frontend 已 1614 passed / 1 原有 skip,另 7 项 desktop mock 通过。本段明确记录主控回执,Reviewer 按协调要求未重复执行测试。此前核心审查结论保持通过,完整产品/真实账号及远端 PR 证据仍以主控最终 evidence 为准。 + +最终源码抽查 SHA-256(后续生产修改需复核相关部分;除最后一项外路径相对 `src-tauri/src/`): + +```text +6d96eb33da11ab4b9bb7fd905e2d57b2537835859abb398f9f32383593f32bfd services/provider/managed_xai.rs +b5b180bd43b4c03a83f49e974e240c74b387c3e422a5ecb24d67d291610d055b services/proxy.rs +9922cb6f6f2a2f4627688b1f352f9b11bd50523e580a8374c0fd076ea031b84e proxy/providers/codex.rs +cb90c2d7bd3d9f92dab744df7c7f637c5c7d8f4621f4360bd479b206a5902b3c proxy/providers/claude.rs +04c62afbb6756839301eef3f240dfa31de22cc1eb61c0b7830ca180058382f33 proxy/forwarder.rs +80c503aa51e42ed5b3aba5f3063158fcd064e1476ea631ed41d3af3eb7982fa2 services/managed_auth/subscription_tests.rs +b7f3b1918d96288994a53dd1ec3316c00c027553c284eb29beba43e41b577623 commands/agent_catalog.rs +a1c7ee77ed415ed070615b0c64a10d5e6157eccb469ddab9215e83ffd2ea11f1 src/pages/models/XaiSubscriptionSection.tsx +``` diff --git a/.trellis/tasks/archive/2026-09/08-31-grok-first-class-iteration/research/github-decision-106.md b/.trellis/tasks/archive/2026-09/08-31-grok-first-class-iteration/research/github-decision-106.md new file mode 100644 index 000000000..c066161d6 --- /dev/null +++ b/.trellis/tasks/archive/2026-09/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/archive/2026-09/08-31-grok-first-class-iteration/research/github-decision-141.md b/.trellis/tasks/archive/2026-09/08-31-grok-first-class-iteration/research/github-decision-141.md new file mode 100644 index 000000000..9db6a79bd --- /dev/null +++ b/.trellis/tasks/archive/2026-09/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/archive/2026-09/08-31-grok-first-class-iteration/research/github-decision-31-32.md b/.trellis/tasks/archive/2026-09/08-31-grok-first-class-iteration/research/github-decision-31-32.md new file mode 100644 index 000000000..26de7a619 --- /dev/null +++ b/.trellis/tasks/archive/2026-09/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/archive/2026-09/08-31-grok-first-class-iteration/research/github-decision-42.md b/.trellis/tasks/archive/2026-09/08-31-grok-first-class-iteration/research/github-decision-42.md new file mode 100644 index 000000000..41e10ead8 --- /dev/null +++ b/.trellis/tasks/archive/2026-09/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/archive/2026-09/08-31-grok-first-class-iteration/research/github-decision-43.md b/.trellis/tasks/archive/2026-09/08-31-grok-first-class-iteration/research/github-decision-43.md new file mode 100644 index 000000000..8fd8cad03 --- /dev/null +++ b/.trellis/tasks/archive/2026-09/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/archive/2026-09/08-31-grok-first-class-iteration/research/github-dual-machine-hil.md b/.trellis/tasks/archive/2026-09/08-31-grok-first-class-iteration/research/github-dual-machine-hil.md new file mode 100644 index 000000000..d84a3ebde --- /dev/null +++ b/.trellis/tasks/archive/2026-09/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/archive/2026-09/08-31-grok-first-class-iteration/research/github-hil-required.md b/.trellis/tasks/archive/2026-09/08-31-grok-first-class-iteration/research/github-hil-required.md new file mode 100644 index 000000000..f1a9c5b86 --- /dev/null +++ b/.trellis/tasks/archive/2026-09/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/archive/2026-09/08-31-grok-first-class-iteration/research/github-plain-summary.md b/.trellis/tasks/archive/2026-09/08-31-grok-first-class-iteration/research/github-plain-summary.md new file mode 100644 index 000000000..2fa1fcefb --- /dev/null +++ b/.trellis/tasks/archive/2026-09/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/archive/2026-09/08-31-grok-first-class-iteration/research/hil-matrix.md b/.trellis/tasks/archive/2026-09/08-31-grok-first-class-iteration/research/hil-matrix.md new file mode 100644 index 000000000..07028b8c9 --- /dev/null +++ b/.trellis/tasks/archive/2026-09/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/archive/2026-09/08-31-grok-first-class-iteration/research/implementation-evidence-20260908.md b/.trellis/tasks/archive/2026-09/08-31-grok-first-class-iteration/research/implementation-evidence-20260908.md new file mode 100644 index 000000000..8c202879d --- /dev/null +++ b/.trellis/tasks/archive/2026-09/08-31-grok-first-class-iteration/research/implementation-evidence-20260908.md @@ -0,0 +1,71 @@ +# 2026-09-08 实施与验证证据 + +## 交付范围 + +本次完成 Grok 订阅到 Claude Code / Codex CLI 的配置与本机转发实现,并保留实验性标识。已有 Managed Auth 登录、vault/SecretRef 和刷新 owner 是唯一凭据来源。Claude 直接确认应用;Codex 保存来源后通过既有 Change Plan 预览、确认和回读。目标配置、renderer 与计划中没有上游 access/refresh token。 + +原代码的 legacy JSON 准入、旧认证页面挂载、固定 Provider ID/模型、普通 API 推理入口已替换。官方 CLI 订阅入口和最终模型路由头由 native 控制;普通 API key 来源保持原路径。详细来源固定在 `xai-subscription-upstream-20260908.md`,不是凭假上游成功猜测真实地址。 + +逐目标写入复用现有事务、备份和恢复;共享监听器的激活与补偿串行化,补偿回读 app/global/listener 状态。未确认恢复的结果会封锁继续写入。Codex 运行中由自身刷新或切换的原生 auth 文件保留当前字节,不把旧快照覆盖回去。订阅来源关闭该目标的自动 failover,避免静默使用另一账号或 API 计费来源。 + +## 分支与工作区保护 + +- 原 checkout `~/fyagent` 的分支、HEAD、已跟踪 diff 哈希和相同选项下的 status 与本次启动基线相同;本轮写入仅发生在隔离 worktree。 +- 续作分支:`codex/grok-auth-reuse-completion`。原分支 `feat/grok-first-class-iteration` / 原 PR #172 保持 `b8b15dbaf141f7c7fbd7816914fda59a07a2208a`。 +- 整合主线 0.4.4:`2f264d2f89326601a33f610c72a9f0143306d066`。13 个主线冲突按当前 `src/` 入口与 Managed Auth 合同解决;未恢复退役 renderer。 +- 原提交标题不符合当前 Conventional Commit CI。本续作仅规范化私有新分支上的来源标题;来源副本 `55cea0b506139892720f1ce3f82a73622a6c4d7f` 与原提交具有相同 tree `a26537cac0c97389fafc34d27c8a26c338300fef`、相同父提交 `790922210d21144d63982d8bd6e873bf4c59de1a` 和相同作者元数据,保留 Original-Commit 来源。原远端分支不改写。 +- 代码与任务文档先提交,再归档当前父任务和记录本轮 journal。三个旧子计划保留原提交的 in_progress 状态,更新归档链接,不把四目标/双机 HIL 标为已完成。 +- 不合并 PR、不发布版本、不覆盖正式安装,不更改真实用户的 Agent 配置或锁文件。 + +## 环境与执行方式 + +- `mise run bootstrap` PASS:frozen pnpm 依赖、locked uv 环境、工具版本/归属和 80 项任务元数据校验;没有依赖升级。 +- `mise run system:check` PASS:macOS Apple Silicon 原生编译与运行前置条件可用。 +- 所有命令外层使用本机 `rtk`。需要 Python 的检查使用 `mise exec -- uv run --locked --no-sync mise run ...`,不依赖系统 Python。 +- 可选 Windows MSVC cross 环境未安装;这不是 Windows 原生证据。 +- WebKit 测试缺失的锁定版本浏览器从 Playwright 官方 CDN 补齐;未改 Playwright 版本、测试阈值或全局环境。 + +## 最终检查 + +| 检查 | 命令与结果 | 证明范围 | +| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| 前端完整门禁 | `check:prearchive --exclude-active-task .trellis/tasks/08-31-grok-first-class-iteration` 中 `check:frontend` PASS:181 文件、1614 passed / 1 skipped;另 7 项 desktop mock PASS | TypeScript、lint、格式、renderer/domain、结构合同和 fake IPC;视觉 manifest 只作 preflight | +| 后端完整门禁 | `mise run check:backend` PASS,exit 0;19 个测试目标合计 3518 passed / 6 ignored,fmt/check/Clippy PASS | 当前 macOS 原生 Rust 和隔离集成链路 | +| 归档前合同 | `check:contracts:prearchive --exclude-active-task .trellis/tasks/08-31-grok-first-class-iteration` PASS,exit 0 | 任务、文档、平台结构、锁文件、版本、release contracts;无 release 发布 | +| 浏览器 | `mise run test:browser` PASS:生产启动 2 项、renderer 主矩阵 534 项;新增订阅 WebKit 专项 2/2 PASS | 主矩阵通过开发服务器测试 renderer,生产启动单独验证;Chromium 四尺寸和 WebKit,均使用 fake IPC | +| 性能 | `mise run test:performance` 首轮 34 passed / 1 failed;保持阈值不变单独复跑 `state-performance.spec.ts`,2/2 PASS,exit 0 | 首轮 1x 账号弹窗帧 P95 33.7 ms,复跑 33.4 ms 满足 33.4 ms 阈值;记录边缘波动,不冒充首轮全绿或转发器资源实测 | +| 独立 review | `full-scope-review-20260908.md`:六项问题已修复,最终小修复核后无已确认阻断项 | 独立源码与相关合同检查;reviewer 定点 31/31 PASS | + +浏览器原先切换目标后使用通用订阅 region,可能操作退出中的 Claude 区域。修为先定位精确的 Codex 父区域并等待其保存按钮后再选择账号;未增加 sleep、force 或放宽 timeout,原 payload 与应用次数断言保留。 + +完整门禁最初在 Clippy 的测试 fixture `is_some + expect` 处中止;仅改为 `if let` 后从后端门禁续跑。之后全量测试发现新增 IPC 的 handler 数量冻结仍为 367;核对新增绑定命令后更新为 368,并增加精确命令存在断言,保留权限集合相等和远端限制。最终后端与合同门禁均通过。前端内容未因此变化,不重复已通过的前端全量测试。这里分别报告各组件最终结果,不把首次聚合非零退出冒充一次全绿运行。 + +6 项 Rust ignored 为原有性能诊断、外部 S3、真实 Codex 语料和 OS 凭据库 HIL;本轮没有增加跳过。结构 hash seal 仅更新经 review 的 `lib.rs` 和 `services/provider/mod.rs` 两项,扫描器未放宽。 + +性能复跑命令为 `mise exec -- pnpm exec playwright test tests/browser/state-performance.spec.ts --config=config/playwright.performance.config.ts`;没有调整动画实现、测试次数、CPU throttle 或阈值。35 个性能场景中其余 34 项首轮通过,复跑包括 1x/4x 两档账号弹窗;该检查是浏览器补充观测,不是原生 WebView 或代理资源验收。 + +## 本机集成具体覆盖 + +新增订阅集成测试使用临时 DB、隔离配置目录、合成授权、真实 Provider/Change Plan writer 与实际 loopback listener/router/协议转换器: + +- 选择账号、purpose/consumer/refresh-owner 检查;撤销、过期、计划后变更和同模型多账号。 +- Claude Messages 和 Codex Responses 的非流式、流式工具调用和结束事件;在替换为假上游前断言真实订阅 host/path。 +- 所选模型形成最终路由头,传入的伪造认证/路由头与 Codex 自带错误模型不会覆盖它。 +- 账号不可用时不调用上游,不退回 legacy JSON/default account/API key。 +- Provider/文件/运行态回读与补偿,端口/写入失败,状态篡改报告 unknown。 +- Claude 失败与 Codex Change Plan、手动接管两种并发入口;一个失败事务不能停止另一个目标已采用的服务。 +- Codex 原生 auth 的保存、CLI 刷新/切换后当前字节及恢复边界。 + +## 真实环境与残余验证 + +只读查看已安装 FyAgent 的“账号与认证”时,账号数为 0;已请用户补充登录,当前没有取得可用的真实订阅测试身份。没有读取、复制或输出凭据。现有正式安装是 0.4.4,不代表新分支运行证据。 + +本次未验证真实 Grok 上游请求、额度扣除或具体账号 entitlement,未启动已安装的 Claude Code/Codex CLI 做端到端请求,也未在 Windows 上运行。HTTP fixture 的真实 native 链路不能替代这些结果。模型建议 `grok-build` 来源于官方文档,页面明确它不构成账号模型名单。 + +后续应使用可恢复的真实账号环境分别验证两种 CLI 的选定账号/模型、流式工具调用、刷新、额度/限额响应以及退出恢复,再决定解除实验性标识。现有 listener 只随 FyAgent 运行;真正退出恢复配置、下次启动按既有 enabled 状态接管,手动停止恢复并清除 enabled。没有引入新的常驻进程。 + +## 日志与 PR + +本机完整执行日志保存在临时目录,文件前缀为 `fyagent-grok-`,包括 `prearchive-final-20260908.log`、`backend-verified-20260908.log` 和 `contracts-prearchive-final-20260908.log`。浏览器日志为 `browser-final-pass-20260908.log` 与 `webkit-subscription-20260908.log`。标准矩阵通过后,仅将新增订阅 spec 纳入既有 WebKit 项目,再运行 `mise exec -- pnpm exec playwright test tests/browser/xai-subscription.spec.ts --config=config/playwright.config.ts --project=webkit-1232x700` 验证新增两项;其余已通过测试内容未改,不重复整套矩阵。浏览器 trace/图片由 Playwright 写入其临时 artifacts 目录,未提交用户数据。 + +本记录截至提交前全部本地检查收尾;归档后仍执行无排除的 `check:contracts` 并在 PR 中记录。最终 work SHA、归档/journal SHA 与新 PR 的 head/状态以 Git 历史和 PR 回读为准;不修改或关闭原 PR #172,不关闭 #42/#43。 diff --git a/.trellis/tasks/archive/2026-09/08-31-grok-first-class-iteration/research/local-runtime-and-test-map-20260908.md b/.trellis/tasks/archive/2026-09/08-31-grok-first-class-iteration/research/local-runtime-and-test-map-20260908.md new file mode 100644 index 000000000..addee8053 --- /dev/null +++ b/.trellis/tasks/archive/2026-09/08-31-grok-first-class-iteration/research/local-runtime-and-test-map-20260908.md @@ -0,0 +1,55 @@ +# 本地代理生命周期与测试复用地图 + +调查基线:固定主线 `2f264d2f89326601a33f610c72a9f0143306d066`(源码只读,未运行测试、未启动服务、未读取真实凭据)。以下行号均以该提交的 `git show :` 为准。 + +## 结论 + +`xai_oauth` provider 的保存/切换不会单独启动本地代理,也不会自动接管 Claude/Codex 的 Live 配置。代理启动与 Live 接管是两个动作:`set_takeover_for_app(app, true)` 才会在未运行时启动代理并改写目标客户端配置;启用后切换 provider 走热切换,当前客户端仍指向本地地址。应用退出会恢复 Live 配置并停止监听,但保留“下次启动自动恢复”的 enabled 状态;用户显式关闭则恢复并清除状态。 + +## Provider xai_oauth 到运行时 + +- `Provider::is_xai_oauth` 只按 `meta.provider_type == "xai_oauth"` 识别(`src-tauri/src/provider.rs:90-111`)。Claude 与 Codex adapter 对该类型都返回固定 xAI API origin,并写入 `xai_oauth_placeholder` + `AuthStrategy::XaiOAuth`(`src-tauri/src/proxy/providers/claude.rs:490-497,703-713,774-779`;`src-tauri/src/proxy/providers/codex.rs:672-680,732-739`)。可编辑的 base URL/placeholder 不是实际 secret。 +- 每个请求的真实 access token 在 `forwarder.rs:1740-1787` 按 `managed_account_id_for("xai_oauth")` 从 ManagedAuth 取得;失败是账号级 `AuthError`,`forwarder.rs:2692-2698` 将其标为不可通过别的 provider 掩盖的 non-retryable。实现者不能把 provider 切换当作“登录完成”或把 token 写进 Claude/Codex live 文件。 +- `commands/provider.rs` 的一般 provider switch 最终调用 `ProviderService::switch`(约 `5483-5655`),正常模式写入客户端 Live 配置;若发现 backup/placeholder 接管状态则转到 `hot_switch_provider_inner`,不会恢复外部 endpoint。`commands/proxy.rs:277-306` 的 `switch_proxy_provider` 仅切换已运行代理的路由目标,它本身没有 `start()`。 + +## 启动、接管、重启与退出 + +- `services/proxy.rs:578-633` 的 `ProxyService::start` 开启全局 proxy flag、读取配置、绑定监听器并保存 `ProxyServer`;已经运行时直接返回当前信息。`proxy/server.rs:94-223` 用 `TcpListener` + Tokio task 接受连接,监听器状态由 `shutdown_tx`/`server_handle` 持有。 +- `services/proxy.rs:791-911` 的 `set_takeover_for_app(app, true)` 是应用级入口:未运行先 `start()`(796-800),然后备份 Live、同步 token 到 DB、写入 loopback proxy URL/占位符并设置该 app 的 `enabled=true`。因此 xai_oauth 切换只有在调用这条接管路径后才会“让 Claude/Codex 使用本地代理”。 +- `services/proxy.rs:914-975` 的关闭接管会恢复 Live、删除 backup、清除 enabled;最后一个 app 关闭时调用 `stop()`。`disable_takeover_for_app_sync`(977-1015)供无 Tokio runtime 的同步 Profile 路径使用:恢复文件并清状态,但刻意不停止正在运行的服务。 +- `commands/proxy.rs:40-43` 的 `stop_proxy_with_restore` 调用 `stop_with_restore`。后者(`services/proxy.rs:1350-1394`)停止服务、恢复所有 Live、清 `live_takeover_active`/所有 app enabled、删除 backups、清健康状态;这是用户明确停止,下一次不会自动接管。 +- 正常退出由 `cleanup_before_exit`(`lib.rs:2813-2852`)处理:有 backup/placeholder 时调用 `stop_with_restore_keep_state`,恢复 Live 但保留 enabled;无接管残留时只停止监听。窗口 `CloseRequested`(`lib.rs:987-1018`)若 `minimize_to_tray_on_close` 为 true 仅隐藏窗口,进程和代理继续运行;否则调用 `app_handle.exit(0)`,进入退出清理。 +- 启动恢复在 `lib.rs:2879-2933`:读取 Claude/Codex/Gemini/GrokBuild 各 app 的 `proxy_config.enabled`,逐个调用 `set_takeover_for_app(true)`;失败时调用 false 清理状态。故“窗口关闭”可能只是隐藏,“真正退出/重启”会先恢复 Live,下一次启动又按 enabled 自动重新接管。`ProxyServer::stop`(`proxy/server.rs:225-255`)发送 oneshot 并最多等待 5 秒;超时只报告 `StopTimeout`,实现者需在验收中检查端口是否确实释放。 + +## 现有测试与可复用的合成 fixture + +### 本地文件与 DB 隔离 + +- `src-tauri/tests/support.rs:6-24` 的 `ensure_test_home` 创建进程隔离临时 HOME,并设置 `FYAGENT_TEST_HOME`、`HOME`(Windows 另设 `USERPROFILE`);`reset_test_fs`(26-53)清理 `.claude`、`.codex`、`.fyagent` 等目录;`test_mutex`(64-101)串行化全局 HOME/设置写入。 +- `create_test_state` / `create_test_state_with_config`(`support.rs:103-118`)分别用 `Database::init()` 或迁移合成 `MultiAppConfig`。纯 DAO/路由单测可直接用 `Database::memory()`(`src-tauri/src/database/mod.rs:192-199`),避免磁盘 DB;涉及 live 文件路径应使用 support 的临时 HOME。 +- provider switch/接管回归样例在 `src-tauri/tests/provider_service.rs:530-709`:合成 Codex OAuth JSON(`oauth-access`/`oauth-id`)、第三方 provider TOML、动态端口 `listen_port=0`,断言 switch、backup、loopback URL、`PROXY_MANAGED` 与 stop/restore。它证明了生命周期投影,但不是 HTTP 上游端到端测试。 + +### xAI OAuth 登录合成 issuer + +`src-tauri/src/services/managed_auth/providers/xai.rs` 的测试已经提供最有价值的无秘密 OAuth fixture: + +- `xai.rs:813-862` 的 `spawn_issuer` 在 `127.0.0.1:0` 启动 Axum issuer,合成 device-code、pending/granted token、`/oauth/token`,返回 JWT 形状 claims;`xai.rs:865-877` 用 `Database::memory()` + `MemorySecretBackend` + `tempdir` 构造 ManagedAuthService。 +- `xai.rs:897-940` 的 device-code 测试通过 `XaiLoginHooks` 注入本地 endpoints,启动 login session、轮询到 Completed,并断言 snapshot 不泄露 device auth id、refresh token、access token 等。可复用此模式测试 refresh rotation、过期、slow_down、取消,不应调用真实 xAI。 +- `xai.rs:42-91` 的 `start_xai_login` 只接受 `ManagedAuthProvider::Xai` + `DeviceCode`,对 `ConnectConsumer` 只允许 Grokbuild/FyagentProxy/Opencode;`xai.rs:254-323` 将 token 变成 credential identity,写入 secret backend(`RefreshOwner::Fyagent`),完成登录后仍不自动修改 consumer 文件(305-306)。Native 实施者应显式串联“login completed → 用户确认连接 → provider/consumer projection”。 + +### 本地 HTTP 双协议测试建议与当前缺口 + +固定主线的 proxy server 路由已经同时提供:Claude `POST /v1/messages`(`src-tauri/src/proxy/server.rs:296-298`)、OpenAI Chat/Responses(308-329)以及独立 GrokBuild Responses(330-355)。因此真实本地 HTTP 验收可按以下顺序构造: + +1. 用 `ensure_test_home`/`reset_test_fs`、`Database::memory` 和合成 `Provider { meta.provider_type: "xai_oauth" }` 建 state;用 `listen_port=0` 启动 `ProxyService`,记录实际 loopback 端口。 +2. 在不读取真实 secret 的前提下,为 forwarder 提供合成 ManagedAuth credential(access token 仅是测试字符串),或把上游请求目标注入本地 Axum `TcpListener` fixture;断言仅发 `Authorization`/provider headers 的合成值,测试日志禁止输出完整 token。 +3. 用同一端口分别发送 Claude Messages JSON(`messages`、tools、stream)和 Codex Responses JSON(`input`、tools、stream),断言路由进入同一 xai_oauth provider、请求形状/错误映射/usage 与 SSE 完整性。再调用 `set_takeover_for_app(false)`,读取 Claude/Codex live 文件确认恢复,并确认端口停止。 + +当前固定主线没有发现一个已经把“启动 ProxyServer → 发本地 Claude + Codex HTTP → 伪造 xAI 上游 → 读回响应”的完整 integration test;现有 `provider_service` 主要验证文件投影,`forwarder` 内 xAI 测试主要验证错误分类,`proxy/server` 路由本身没有该端到端夹具。Native 实施者需要补这条测试,不能把 provider switch 单测或 ManagedAuth login 单测当作双协议可用性证明。 + +## 实施注意事项 + +1. UI 的 xai_oauth provider switch 应先确认代理已运行且目标 app 已 takeover;若产品动作要“一键登录后可用”,必须显式编排 login session、credential admission、provider selection、`set_takeover_for_app(true)`,不能只调用 `switch_proxy_provider`。 +2. “窗口关闭”“真正退出”“手动 stop”三个行为必须分别验收:隐藏到托盘不释放服务;退出会恢复 Live 但保留 enabled;手动 `stop_with_restore` 恢复并清 enabled;重启随后按 enabled 再接管。 +3. 两个下游协议应共用同一合成 account,但分别检查 Claude `/v1/messages` 与 Codex `/v1/responses`;xai_oauth 的 adapter 固定 xAI origin,无法靠 editable base URL 把真实请求改到本地 fixture,测试需要在 HTTP client/issuer/forwarder 层提供受控注入点。 +4. 秘密 owner 是 ManagedAuth secret backend / credential row;consumer live 文件只放 loopback 地址与占位符。任何 stop/restore 失败都必须保留 backup 并报告恢复不确定,不能静默清理。 diff --git a/.trellis/tasks/archive/2026-09/08-31-grok-first-class-iteration/research/original-branch-review-20260908.md b/.trellis/tasks/archive/2026-09/08-31-grok-first-class-iteration/research/original-branch-review-20260908.md new file mode 100644 index 000000000..93e831a4e --- /dev/null +++ b/.trellis/tasks/archive/2026-09/08-31-grok-first-class-iteration/research/original-branch-review-20260908.md @@ -0,0 +1,77 @@ +# 原 `feat/grok-first-class-iteration` 分支静态审查 + +审查基线:固定提交 `b8b15dbaf141f7c7fbd7816914fda59a07a2208a`,对比其父提交 `b8b15dbaf141f7c7fbd7816914fda59a07a2208a^`。只看该提交的 `git show`/差异,不以当前工作区内容为依据;未运行测试、未登录、未读取凭据。 + +## 结论 + +原分支已经把主要产品链路接上了:认证中心已有 xAI OAuth 账号后,V2 模型页能按目标创建 `xai_oauth` Provider;Claude Code / Claude Desktop 直接激活,Codex 建草稿并进入既有 Change Plan;token 留在 `xai_oauth_auth.json`,Provider 只保存 managed-account binding;代理层随后按请求取 token。它不是只改文案的原型。 + +但完成度是“源码链路 + 部分受控写入”,不是完整验收: + +- Claude/Claude Desktop 绑定命令直接 `ProviderService::add(..., true)` 或 `switch`,绕过统一 Change Plan 的预览、漂移检查和 typed job;UI 的确认框只是绑定确认,不是目标 live 配置的完整计划。 +- Codex 已有特殊 gate:可用的 `xai_oauth` managed account + 合法 Responses TOML 才允许生成 `NoNewCredentialMaterial` 计划;账户不可用则 fail closed。这是本分支新增的实际修复。 +- WorkBuddy 只用 SuperGrok token 拉模型名单,保存仍要求自己的 API key/配置,明确不会把 OAuth token 写进 `models.json`;因此原分支没有完成“同一订阅直接运行 WorkBuddy”。 +- 旧 `XaiOAuthManager` 仍是实际 token authority/回退路径;新增 bind 只检查磁盘账户存在且 `requires_reauth=false`,不在绑定阶段验证 token refresh 或 entitlement。真实请求失败时才会暴露问题。 + +## 已完成的实际链路 + +### Claude Code / Claude Desktop + +`src-tauri/src/commands/provider.rs:463-616` 新增 `BindXaiManagedRequest` 与 `bind_xai_managed_provider`:只允许 `claude`、`claude-desktop`、`codex`;取认证中心默认或指定账号;生成 `provider_type=xai_oauth`、`AuthBindingSource::ManagedAccount`、`auth_provider=xai_oauth` 的 Provider。Claude 配置写入 xAI base/model 环境字段,Desktop 额外写入 Proxy mode 与模型 route。 + +对 Claude/ Desktop,`activate = true`,分支在 `:589-600` 直接保存并激活;这证明能落到已有 Provider/代理执行面,但也形成下面的 Change Plan 缺口。 + +### Codex + +同一命令在 `:588-603` 对 Codex 使用 `add_draft`,不直接切当前 Provider;前端 `src/v2/pages/models/Page.tsx:1347-1406` 收到结果后把 Provider 设为 preferred target,`ChangePlanWorkspace` 自动生成现有切换计划(`Page.tsx:1655-1656`)。 + +`src-tauri/src/services/change_plan/service.rs` 的差异在 `:1585-1680`:先识别可用 xAI managed account,再校验 Provider 的 `settings_config.config` 是合法 TOML,才返回 `NoNewCredentialMaterial`;否则返回 `SecretDependencyUnavailable`。这是原分支对 v0.4.4 既有 Codex gate 的最小放行形状。 + +### 代理与凭据 + +`src-tauri/src/proxy/providers/xai_oauth_auth.rs:220-235` 新增 token-free `stored_account_is_usable`,只检查账号存在且未标记 `requires_reauth`。实际 refresh/访问令牌仍由原 `XaiOAuthManager` 管理;Provider 不携带 refresh token。`src/v2/shared/platform/tauri/feature-ports/models.ts:542-547` 注册绑定 IPC,`src-tauri/src/lib.rs:1876` 注册命令。 + +### WorkBuddy + +`src/v2/pages/models/Page.tsx:308-345` 的 `fetchXaiManagedModels` 确实使用已登录账号读取模型名单并默认填入 xAI base URL,但提示明确说“保存仍走 WorkBuddy 自己的预览;不会把刷新令牌写进 models.json”。随后 `:463-505` 的保存请求仍由 `buildSaveRequest` 生成 API-key/无 key 配置,不能据此称为订阅额度运行链路。 + +## 具体缺陷与风险 + +| 问题 | 固定提交证据 | 影响 | +| ----------------------------------------------- | --------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Claude/ Desktop 直接 live 激活 | `src-tauri/src/commands/provider.rs:589-600` | 绕过统一 Change Plan;没有目标文件 revision/digest 预览,也没有 typed apply job 的统一回读语义。已有 Provider 写入器可能保证自身原子性,但不等于本迭代合同的先看→确认→写入→回读。 | +| “已绑定”与“已可用”混淆 | `Page.tsx:1372-1406` | 前端只 refetch Provider summary/currentId;若当前已切换但代理首次 refresh/上游 entitlement 尚未验证,仍可显示已绑定/待确认,而不是请求级可用。 | +| 绑定阶段只做弱 admission | `xai_oauth_auth.rs:220-235, 783-787` | 过期、撤销、tier 不允许的 token 只要未标 `requires_reauth` 就能生成 Provider;应由后续 refresh/目标最小请求把状态分层,不把文件存在当登录/额度成功。 | +| WorkBuddy OAuth 断链 | `Page.tsx:308-345, 463-505` | 可用 SuperGrok 拉名单,但不能让 WorkBuddy 运行时使用同一订阅;目标要求若包含 WorkBuddy,这一项仍未完成。 | +| Claude 配置依赖外部路由开关 | `provider.rs:503-516` | 生成的是 `ANTHROPIC_BASE_URL=https://api.x.ai/v1`,没有在 bind 命令中启动/确认 Claude 本地 route takeover;若用户没有打开本地路由,Claude 可能直连 xAI 或失败。目标链路依赖既有代理状态,不能只看 Provider 已保存。 | +| 主线仍需保留的 OAuth manager/forwarder 兼容路径 | `xai_oauth_auth.rs:380-430`(父分支已有)与新 bind helper | 原分支新增 API 只是 binding façade,没有替换 `XaiOAuthManager` 的 refresh/forwarder 兼容职责;合入 v0.4.4 时若只移植新命令而漏掉主线仍调用的 manager/forwarder 回退,会破坏已有 OAuth。Provider preset、JSON 文件存在性判断和旧 UI 不应取代当前 Managed Auth/SecretRef 入口。 | +| 旧认证中心以动态 legacy host 挂回 V2 | `src/index.html:25-29`、`src/legacy-auth-boot.ts:1-4`、`src/legacy-auth-host.tsx:52-69` | 这是原分支的历史兼容挂载,不是 v0.4.4 当前入口合同。当前入口是 `src/pages/auth` 的 Managed Auth 与 SecretRef vault;retired Provider forms/Settings auth tab 及 legacy mutations 应由当前入口替代。仅保留主线明确需要的 legacy manager/forwarder 只读或转发兼容职责。 | + +本分支没有发现把 access/refresh token 序列化进 Provider JSON、Change Plan 或新 UI 的证据;新增测试字符串断言也明确检查 payload 不含 `refresh`、`ANTHROPIC_AUTH_TOKEN`、`OPENAI_API_KEY`(`provider.rs` 测试约 `:1710-1755`)。因此凭据泄露风险主要在错误的后续移植/日志,而不是该提交的 Provider payload。 + +## 0.4.4 必须保留或重新接入的最小修改 + +1. 保留 `bind_xai_managed_provider` 的三目标白名单、Provider IDs、`xai_oauth` binding 和不携带 token 的 Provider 形状;它是把认证中心账号投向 Claude/Codex 的最小桥。 +2. 保留 `xai_oauth_managed_account_is_ready` + `prove_xai_oauth_switch_shape` 的 Codex gate 放行逻辑,并继续让无账号/坏 TOML/撤销账号返回 `SecretDependencyUnavailable`。 +3. 将原分支的 V2 → 认证中心 handoff(`legacy-auth-boot.ts`、`legacy-auth-host.tsx`、`auth-center-handoff.ts`)替换为 v0.4.4 `src/pages/auth` 的 Managed Auth 入口、SecretRef vault 与对应 feature port;不要把旧动态挂载或 retired Provider forms/Settings auth tab 当作当前合同,也不要以重做第二套 OAuth UI 替代现有入口。 +4. Claude/ Desktop 应接入与 Codex 等价的目标计划或明确记录“绑定后立即激活”的不同合同;最小安全要求是写入前检查目标 revision、失败不谎报、写入后回读当前 Provider/代理开关。 +5. 代理侧必须保留旧 `XaiOAuthManager` 的 refresh lock、CAS/rotation、失败 reauth 标记和 forwarder fallback;新增 bind 不得创建第二个 token store。 +6. WorkBuddy 若暂不支持 OAuth 运行时,应保留当前“只能用 SuperGrok 拉名单、保存不写 token”的明确文案,不把它标成已完成;若产品范围只收 Claude/Codex,则应在 UI/合同中显式缩小目标。 + +## 最小验证点(供主控安排,不在本轮执行) + +- 单元/静态:三目标白名单;Provider payload 无 refresh/access/API key;Codex gate 对可用/撤销/坏 TOML 三态;旧 OAuth manager/forwarder 回退仍可编译注册。 +- 目标写入:Claude、Desktop、Codex 各自独立 preview → confirm → apply/readback;一家失败不能改变另外两家状态。 +- 运行链:同一 managed account 的代理请求触发 access-token refresh/CAS,Claude Messages 与 Codex Responses 均留下 route/provider 证据;不要只以 Provider currentId 代替目标请求成功。 +- 状态层:认证成功、token 可 refresh、模型可见、quota/entitlement 可见、目标请求成功分别记录;403/invalid grant 应进入 reauth/blocked,而不是绿色成功。 +- 兼容:v0.4.4 Managed Auth/SecretRef vault 入口、主线仍承担转发兼容的 OAuth manager/forwarder、已有普通 API-key Provider、Grok native handoff 均保持可达;旧 Auth Center 动态挂载和 retired Provider forms 不作为必须保留路径。不得把 Grok native login 误写入 Codex/Claude。 + +以上是固定原分支的 source review,不是当前主分支、安装包、真实账号、双机 HIL 或生产验收结论。 + +## 主控复核补充(当前设计的裁决) + +- 上文描述的旧代码路径不意味着已形成可调用闭环。固定主线的普通 Provider switch 不自动启动 Proxy,必须补 start/takeover;详见 local-runtime-and-test-map-20260908.md。 +- Claude 未使用 Codex Change Plan 本身不构成缺陷:当前合同允许复用其现有 Provider/Proxy 保存事务,不应为追求形式一致再造执行器。实际需修复的是缺少可靠的写前校验、补偿、启动与回读。 +- 原固定 Provider ID 不能无条件保留:同名记录可能属于其他账号/来源。当前设计要求账号/目标隔离和既有绑定验证,防止覆盖。 +- 原 JSON 准入须改为当前 vault/SecretRef 能力判断;保留 manager/forwarder 仅指主线仍需的兼容职责,不恢复已禁用的授权变更接口。 +- 本次必须完成 Claude Code/Codex。Desktop 仅在其已有应用路径确认结果,草稿不称为激活;WorkBuddy 模型名单不称为订阅运行成功。 diff --git a/.trellis/tasks/archive/2026-09/08-31-grok-first-class-iteration/research/original-plan-20260831.md b/.trellis/tasks/archive/2026-09/08-31-grok-first-class-iteration/research/original-plan-20260831.md new file mode 100644 index 000000000..df8e716f2 --- /dev/null +++ b/.trellis/tasks/archive/2026-09/08-31-grok-first-class-iteration/research/original-plan-20260831.md @@ -0,0 +1,198 @@ +# 原分支计划留档 + +当前执行以父任务 2026-09-08 修订为准;本页为历史内容。 + +## prd.md + +# 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` 冒充登录 + + +## design.md + +# 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 写入可以分开撤。不要把别人已经做完的登录合同整段撤掉。 + + +## implement.md + +# 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/archive/2026-09/08-31-grok-first-class-iteration/research/xai-subscription-upstream-20260908.md b/.trellis/tasks/archive/2026-09/08-31-grok-first-class-iteration/research/xai-subscription-upstream-20260908.md new file mode 100644 index 000000000..b7f0b1630 --- /dev/null +++ b/.trellis/tasks/archive/2026-09/08-31-grok-first-class-iteration/research/xai-subscription-upstream-20260908.md @@ -0,0 +1,59 @@ +# xAI 官方订阅上游核验(2026-09-08) + +## 结论 + +官方 Grok Build CLI 的会话登录(SuperGrok/CLI 订阅路径)与 xAI API key 是两条不同的上游路径。官方 CLI 文档给出的会话令牌请求目标是: + +```text +POST https://cli-chat-proxy.grok.com/v1/chat/completions +Authorization: Bearer <~/.grok/auth.json 中的 token> +X-XAI-Token-Auth: xai-grok-cli +x-grok-model-override: grok-build +``` + +官方文档没有把该会话令牌路径描述为 `api.x.ai/v1/responses`,也没有承诺订阅 Bearer 可用于普通 API 端点。当前 FyAgent 固定把 `xai_oauth` 请求送往 `https://api.x.ai/v1`,因此若目标是复用 CLI/订阅额度,现有上游地址和协议至少存在明确阻断;不能仅凭 Bearer 格式相同推断可通用。 + +## 一手证据(官方仓库) + +核验对象为官方仓库 [xai-org/grok-build](https://github.com/xai-org/grok-build),`main` 当前提交(查询日):`72a61251fcffb464bcc687aeb5a998e5a98ec0c9`,提交时间 `2026-09-01T22:20:33Z`。本机已安装的官方 CLI 元数据为 `grok 1.0.13 (5e9a58528b76)`;未把本机二进制版本冒充为仓库 main 版本,也未读取 auth.json 内容。 + +| 要点 | 官方来源和精确证据 | 能确认什么 | 边界 | +| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| 订阅/CLI 会话上游和协议 | [xai-grok-shell README](https://github.com/xai-org/grok-build/blob/main/crates/codegen/xai-grok-shell/README.md#using-authjson-for-api-access),`Using auth.json for API Access` 小节(约 L510-L538) | `POST https://cli-chat-proxy.grok.com/v1/chat/completions`;请求体为 `model: grok-build`、`messages`、可流式;官方列出 CLI 身份头和模型覆盖头 | 该小节没有 `/v1/responses` 示例或兼容承诺;模型/能力由代理路由决定 | +| 必要 headers | 同上,表格(约 L523-L529) | `Authorization: Bearer` 携带 auth.json token;`X-XAI-Token-Auth: xai-grok-cli` 标识 Grok CLI;`x-grok-model-override: grok-build` 选择路由(默认 grok-build 时可省略) | 这是官方 CLI 请求契约,不是对第三方客户端的授权许可 | +| token 有效期/刷新 | 同上(约 L530-L538);[authentication guide](https://github.com/xai-org/grok-build/blob/main/crates/codegen/xai-grok-pager/docs/user-guide/02-authentication.md#browser-login)(约 L202-L241、L389-L423) | 浏览器登录到 grok.com/auth.x.ai 后写入 `~/.grok/auth.json`;后台自动刷新;README 说明 token 约 7 天过期并提示 `grok login` | 没有公开“把 token 发给 Claude/Codex”或跨客户端 grant;第三方必须自行承担会话失效、刷新和条款风险 | +| OAuth scopes | [auth/config.rs](https://github.com/xai-org/grok-build/blob/main/crates/codegen/xai-grok-shell/src/auth/config.rs#L4-L26) | 默认 OAuth2 scope 含 `openid profile email offline_access grok-cli:access api:access conversations:read conversations:write workspaces:read workspaces:write`;注释明确 `grok-cli:access` 用于 API proxy requests | scope 不等于 API key 权限,也不能证明 token 可打普通 API host | +| 外部 OIDC 可替换 proxy | [authentication guide](https://github.com/xai-org/grok-build/blob/main/crates/codegen/xai-grok-pager/docs/user-guide/02-authentication.md#external-oidc-provider)(约 L266-L325) | `GROK_CLI_CHAT_PROXY_BASE_URL` 可将 CLI 指向自定义 proxy;默认 OIDC scope 含 `api:access`;token 由 CLI 以 Bearer 发给 xAI API | 这是 CLI 的配置扩展点,不是官方公开的 FyAgent 集成 API;自定义 proxy 的安全、额度和维护由部署者负责 | + +官方认证指南还明确 credential precedence(约 L410-L423):per-model API key/env key 优先,其次 active session token,最后 `XAI_API_KEY`。这支持“API key 与 session token 是不同凭据来源”的判断。 + +## 与 API key / API 余额的区别 + +官方 xAI API 文档的 [REST API reference](https://docs.x.ai/developers/rest-api-reference/inference)(更新 2026-09-02)把 Inference API 的 base URL 定为 `https://api.x.ai`,认证方式为 `Authorization: Bearer `;Responses 和 Chat Completions 均属于该 API,路径分别是 `/v1/responses` 与 `/v1/chat/completions`。其 [Models reference](https://docs.x.ai/developers/rest-api-reference/inference/models) 说明 `/v1/models` 返回“对当前 API key 可用”的模型和价格信息;[Billing](https://docs.x.ai/console/billing) 说明 API consumption 从团队 prepaid credits 或月结额度扣除。 + +因此: + +- `api.x.ai/v1` + `XAI_API_KEY` 是 API 团队余额/计费路径,可用 Responses、Chat Completions 和 API models catalog。 +- `cli-chat-proxy.grok.com/v1/chat/completions` + auth.json session token 是官方 CLI 的代理路径,具备 `grok-cli:access`/CLI identity 约束;公开材料没有说明它消耗哪一项 API credit,也没有把 SuperGrok 订阅余额映射为 API key 余额。 +- 即使两个请求都使用 `Authorization: Bearer`,issuer、scope、host、附加 headers 与额度归属仍不同。FyAgent 不应把 `/v1/models` 的 API key catalog 当作订阅代理的 catalog;`grok-build` 是官方代理示例中的虚拟/路由模型名。 + +## 对固定主线与 Native 实施的影响 + +固定主线 `2f264d2f89326601a33f610c72a9f0143306d066` 中,`src-tauri/src/proxy/providers/mod.rs:48` 定义 API base;`claude.rs:709-713`、`codex.rs:677-680` 对 `xai_oauth` 返回 `https://api.x.ai/v1`。这条路径与官方 CLI 订阅请求契约不一致。 + +最小可行适配建议: + +1. 为 `xai_oauth` 单独保存 upstream origin(默认 `https://cli-chat-proxy.grok.com`),不要复用 API key 的 `api.x.ai` origin。 +2. 订阅路径先实现官方已证明的 `POST /v1/chat/completions`,固定附加 `X-XAI-Token-Auth: xai-grok-cli` 和必要的 `x-grok-model-override`;下游 Claude Messages、Codex Responses/Chat 的转换要在 FyAgent 内完成。 +3. 保留 API key provider 的 `api.x.ai/v1`;其 `/responses` 与 `/chat/completions` 由官方 API 文档证明。不要把订阅 token 直接送到 API `/responses`,也不要把官方 CLI proxy 当成稳定公开 SDK。 +4. 登录实现需复用/实现 OAuth PKCE、auth.json 等价的安全存储和 refresh;只输出一次性授权 URL/本地 callback 状态,不回显 token。官方公开文档提供 CLI 登录行为,但没有面向 FyAgent 的 token exchange API,故这是集成工作而非现成官方接口。 + +## 仍未证实的事项(阻断点) + +官方公开材料截至查询日没有给出: + +- `cli-chat-proxy.grok.com` 的公开 OpenAPI、Responses 端点、非 CLI client identity 白名单或第三方 OAuth 客户端注册流程; +- SuperGrok 订阅额度与 proxy 请求的精确计费/配额模型,或 `/v1/models` 的订阅专属 catalog; +- 可供 FyAgent 直接调用的官方 generate-auth-url/exchange-code/refresh API。 + +所以当前证据足以确认“订阅 OAuth 不能按 API key 的 `api.x.ai/v1` 路径实现”,但不足以声称官方支持任意第三方客户端复用订阅额度。若 Native 方案要继续,必须以官方 CLI proxy 契约为实验性兼容目标,并把 upstream 变更、账号条款和回归验证列为产品阻断条件。 diff --git a/.trellis/tasks/archive/2026-09/08-31-grok-first-class-iteration/summary.md b/.trellis/tasks/archive/2026-09/08-31-grok-first-class-iteration/summary.md new file mode 100644 index 000000000..f2ef1e286 --- /dev/null +++ b/.trellis/tasks/archive/2026-09/08-31-grok-first-class-iteration/summary.md @@ -0,0 +1,82 @@ +# 2026-09-08 续作结果 + +当前交付范围为已有 Grok 订阅登录态接入本机 Claude Code 和 Codex CLI。沿用 FyAgent 的 Managed Auth、密钥库和内置转发,不增加 Docker、云账号或独立常驻服务。 + +- Claude Code:在模型页选择已有账号和模型,确认后保存、启动本机转发并回读当前来源。 +- Codex:先保存订阅来源,再沿现有“账号与认证”来源预览及确认流程应用。保留 Codex 自身原生登录文件。 +- 上游使用官方 CLI 文档提供的订阅入口,模型路由头由后端根据所选模型生成;无账号替换或 API 计费兜底。 +- 页面标记实验性。模型选项是官方示例路由,不能据此判断账号权限或额度。真实订阅、真实 CLI 和 Windows 的验证边界见实施证据。 +- Claude Desktop 不展示未接通入口;WorkBuddy 保留既有 API 配置。下面历史计划中的四目标、旧认证中心和双机要求不属于本轮已验收清单。 + +当前需求见 [prd.md](./prd.md),实现合同见 [design.md](./design.md),执行与检查见 [实施证据](./research/implementation-evidence-20260908.md)。 + +--- + +以下为原分支历史说明,已由上面的续作范围取代;保留用于理解原始意图。 + +# 这次迭代一句话对齐 + +先读这篇。后面的 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/archive/2026-09/08-31-grok-first-class-iteration/task.json b/.trellis/tasks/archive/2026-09/08-31-grok-first-class-iteration/task.json new file mode 100644 index 000000000..f93d70148 --- /dev/null +++ b/.trellis/tasks/archive/2026-09/08-31-grok-first-class-iteration/task.json @@ -0,0 +1,30 @@ +{ + "id": "grok-first-class-iteration", + "name": "grok-first-class-iteration", + "title": "Complete local Grok subscription reuse in supported Agents", + "description": "", + "status": "completed", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "codex", + "assignee": "codex", + "createdAt": "2026-08-31", + "completedAt": "2026-09-08", + "branch": "codex/grok-auth-reuse-completion", + "base_branch": "main", + "worktree_path": null, + "commit": "2e61af91b1677bfe584cc4908b00b53985555a92", + "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": "2026-09-08: resume original branch; local-only existing engine; implementation/tests/PR authorized. Current parent artifacts supersede old child execution instructions.", + "meta": {} +} \ No newline at end of file diff --git a/.trellis/tasks/archive/2026-09/08-31-grok-first-class-iteration/use-cases.md b/.trellis/tasks/archive/2026-09/08-31-grok-first-class-iteration/use-cases.md new file mode 100644 index 000000000..fa5bb4032 --- /dev/null +++ b/.trellis/tasks/archive/2026-09/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/workspace/codex/index.md b/.trellis/workspace/codex/index.md index 363dfb756..c7f81e69a 100644 --- a/.trellis/workspace/codex/index.md +++ b/.trellis/workspace/codex/index.md @@ -8,8 +8,8 @@ - **Active File**: `journal-1.md` -- **Total Sessions**: 2 -- **Last Active**: 2026-08-12 +- **Total Sessions**: 6 +- **Last Active**: 2026-09-08 --- @@ -19,7 +19,7 @@ | File | Lines | Status | |------|-------|--------| -| `journal-1.md` | ~66 | Active | +| `journal-1.md` | ~88 | Active | --- @@ -29,6 +29,7 @@ | # | Date | Title | Commits | Branch | |---|------|-------|---------|--------| +| 6 | 2026-09-08 | Grok subscription reuse and local verification | `2e61af91b1677bfe584cc4908b00b53985555a92` | `codex/grok-auth-reuse-completion` | | 2 | 2026-08-12 | GitHub brand and community polish | `3b7f755a5c1c306f417d67edabe4274805859673` | `codex/github-brand-community-polish` | | 1 | 2026-08-11 | 完成文档重构与视觉资产规划 | `a12ef395`, `4e01764c` | `codex/docs-restructure-current` | diff --git a/.trellis/workspace/codex/journal-1.md b/.trellis/workspace/codex/journal-1.md index 7e0d90983..7fcf310cf 100644 --- a/.trellis/workspace/codex/journal-1.md +++ b/.trellis/workspace/codex/journal-1.md @@ -64,3 +64,25 @@ Aligned FyAgent brand positioning, repository entry copy, GitHub community routi ### Status [OK] **Completed** + + +## Session 6: Grok subscription reuse and local verification + + +**Date**: 2026-09-08 +**Task**: Grok subscription reuse and local verification +**Branch**: `codex/grok-auth-reuse-completion` + +### Summary + +完成现有 Grok 订阅登录态到 Claude Code/Codex 的本机转发、恢复与严格账号绑定;独立 review 问题修复。前端 1614+7 通过,Rust 3518 通过,浏览器 534+2 及生产启动 2 项通过;性能首轮边缘失败与原阈值复跑结果保留。真实订阅额度、已安装 CLI 与 Windows 尚未验证。旧子计划状态保留,原 checkout 和原分支未改,续作将独立提交 PR。 + +### Git Commits + +| Hash | Message | +|------|---------| +| `2e61af91b1677bfe584cc4908b00b53985555a92` | feat(auth): complete managed Grok subscription reuse | + +### Status + +[OK] **Completed** diff --git a/config/playwright.config.ts b/config/playwright.config.ts index 40a351899..15b8eeadb 100644 --- a/config/playwright.config.ts +++ b/config/playwright.config.ts @@ -55,6 +55,7 @@ export default defineConfig({ "blue-themes.spec.ts", "layout-integrity.spec.ts", "auth.spec.ts", + "xai-subscription.spec.ts", "presentation-choreography.spec.ts", ], use: { diff --git a/docs/fyagent/design/subscription-account-reuse/README.md b/docs/fyagent/design/subscription-account-reuse/README.md new file mode 100644 index 000000000..e6042b2c1 --- /dev/null +++ b/docs/fyagent/design/subscription-account-reuse/README.md @@ -0,0 +1,230 @@ +# 订阅账号跨 Agent 使用:产品技术方案与可行性复核 + +> 2026-09-08 实施更新:本次采用 FyAgent 内置本机转发,支持 Grok 订阅兼容接入 Claude Code 和 Codex CLI。当前方案与证据见同名 Trellis 任务,完成后归档至 `.trellis/tasks/archive/2026-09/08-31-grok-first-class-iteration/`。下文保留此前选型阶段的调查,不作为本次已测试或已交付清单。 + +本次实现复用现有 Managed Auth 登录、vault 和令牌刷新 owner,不复制上游令牌到目标 Agent。Claude Code 应用后启动本机连接;Codex 先保存来源,再通过现有预览和确认流程应用。失败补偿和退出恢复沿用现有配置 owner,Codex 自身登录文件的当前内容保持独立。 + +上游遵循 [官方 Grok CLI 固定版本说明](https://github.com/xai-org/grok-build/blob/72a61251fcffb464bcc687aeb5a998e5a98ec0c9/crates/codegen/xai-grok-shell/README.md#using-authjson-for-api-access):`cli-chat-proxy.grok.com/v1/chat/completions`、CLI 会话认证头和所选模型的路由头。Claude Messages 与 Codex Responses 在本机转换。普通 API key 来源仍使用自己的 API 入口。页面将此能力标为实验性,模型选项仅是官方文档建议,实际可用性与额度使用必须由账号及上游返回确认。 + +当前不展示 Claude Desktop 或 WorkBuddy 的订阅投放入口;WorkBuddy 的既有 API 配置流程保留。不安装 Docker、不部署云端服务、不引入新的系统账号或跨设备同步。 + +日期:2026-09-08。评估人:Codex。 + +本文用于后续需求和架构讨论。本轮只读取资料、核对源码并编写方案,没有改产品代码,没有部署服务,没有进行登录、请求或额度测试。FyAgent 已安装版本是 0.4.4。 + +最新产品约束:FyAgent 面向小白用户,以单台设备为使用边界,没有 FyAgent 云账号、云服务器或跨设备同步需求。**当前推荐采用 FyAgent 已有的内置轻量转发,补齐一次登录、逐目标应用和后台生命周期。** CLIProxyAPI 仅作出现明确兼容或维护阻碍时的替代候选。外部平台、远端连接与 Docker 部署从当前需求中移出,旧调研保留在比较文档和本文附录,不进入实施清单。 + +## 1. 现有方案在哪里 + +原案确实存在,不需要重新发明需求。它位于 PR #172 的 `feat/grok-first-class-iteration` 分支,核对版本为 `b8b15dbaf141f7c7fbd7816914fda59a07a2208a`。本机主工作目录仍保留旧开发分支和未提交内容,所以不能用当前目录有没有这些文件判断原案是否存在。 + +| 原材料 | 内容 | 本次判断 | +| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------- | +| [产品需求 prd.md](https://github.com/fy-agent/fyagent/blob/b8b15dbaf141f7c7fbd7816914fda59a07a2208a/.trellis/tasks/08-31-grok-first-class-iteration/prd.md) | SuperGrok 登录一次,分别用于 Claude Code、Claude Desktop、Codex、WorkBuddy;逐目标保存,双机体验 | 目标、范围和验收方向明确,可以保留 | +| [技术设计 design.md](https://github.com/fy-agent/fyagent/blob/b8b15dbaf141f7c7fbd7816914fda59a07a2208a/.trellis/tasks/08-31-grok-first-class-iteration/design.md) | 复用旧认证中心和 xAI Provider,接通新版 Codex 配置应用,WorkBuddy 走自己的保存路径 | 是既有实现的接线方案;没有完成外部方案选型与部署设计 | +| [实施计划 implement.md](https://github.com/fy-agent/fyagent/blob/b8b15dbaf141f7c7fbd7816914fda59a07a2208a/.trellis/tasks/08-31-grok-first-class-iteration/implement.md) | 登录入口、Claude/Codex 投放、WorkBuddy 投放、最后双机体验 | 任务分解存在;原案 `task.json` 仍为 `planning`,不能视为已经验收 | +| [Discussion #106](https://github.com/fy-agent/fyagent/discussions/106) | 一个账号、多处使用、不额外购买 xAI API Key;对照 CC Switch 既有实现 | 产品意图来源;提到官方原生直连,但没有证明异种 CLI 都能无转换直连 | +| [Issue #42](https://github.com/fy-agent/fyagent/issues/42)、[PR #172](https://github.com/fy-agent/fyagent/pull/172) | 同一来源投给多个 Agent;每个目标独立预览、保存、回读 | 均未关闭/合并;8 月 31 日后续决定已覆盖早期“只做 Codex”的范围 | + +另外,0.4.4 的 [Managed Auth 规范](https://github.com/fy-agent/fyagent/blob/2f264d2f89326601a33f610c72a9f0143306d066/.trellis/spec/backend/managed-auth.md) 和 [consumer 规范](https://github.com/fy-agent/fyagent/blob/2f264d2f89326601a33f610c72a9f0143306d066/.trellis/spec/backend/managed-auth-consumers.md) 是认证实现约束,不能代替上面的跨 Agent 产品方案。 + +网页版 GPT 原始对话尚未定位。已检查可见任务记录及浏览器入口,未取得可验证的对应聊天;不能据此断言那段讨论不存在,也不能把本次查到的外部项目冒认成当时选定的方案。 + +## 2. 原案成熟度判断 + +**产品目标已成形,本轮已收敛为单机内置转发;登录到实际调用的产品闭环仍需实施验证。** 原案缺口和本次处理如下。 + +1. **原案没有比较实现路线。** 本次已完成比较,并根据单机、小白用户的约束选择现有内置转发。这个选择基于已有实现和产品复杂度,尚不是性能实测胜出的结论。 +2. **登录和调用之间还缺契约。** “同一把钥匙给几家共用”没有定义目标协议、实际上游、刷新责任和额度归属。共享一个订阅来源不等于复制同一份 refresh token。 +3. **部分实现描述已过时。** 原案写到 `xai_oauth_auth.json`;0.4.4 已有 Managed Auth / SecretRef、credential purpose 和刷新所有权。后续开发必须按新认证模型适配,不能照旧文件描述实施。 +4. **目标支持程度不同。** WorkBuddy 一节仍把“地址和钥匙如何对应订阅账号”留为未解决问题;原生 Grok 登录、Claude API 协议、Codex Responses 协议不能合并成一个“已登录”状态。 +5. **验收缺少实际请求归属。** 配置保存与回读只能证明文件改了。完整目标还要证明目标 CLI 确实走所选 Grok 账号、刷新后仍可用、没有静默切到另一个账号或 API 计费来源。本轮按要求不执行这些测试。 + +## 3. 本次修订的产品目标与边界 + +用户已有一个具备相应额度的订阅账号,在 FyAgent 中发起一次连接,随后选择 Claude Code、Codex 等目标。各目标使用该来源提供的模型和额度;FyAgent 显示每个目标的连接结果、当前来源和重新登录入口。 + +本轮边界为:**一个本机应用管理当前设备的订阅连接,为本机选择的 Agent 提供模型入口。** 用户不需要注册 FyAgent 账号、租服务器、安装 Docker 或管理数据库。该需求不新增远端托管、账号同步、共享账号池、团队权限或云端用量系统,也不改动产品其他无关能力。 + +这里仍有“用户登录 Grok 的上游订阅身份”,由本机保存授权;设备边界不等于制造一个 Grok 身份,也不改变订阅额度的归属。本轮仍是方案工作,不包含实施、凭据迁移或真实订阅调用。 + +必须保留三个不同来源: + +- Grok CLI 原生登录:属于 Grok CLI 自身的认证状态。 +- Grok / SuperGrok 订阅来源:目标是复用订阅可用额度,须验证具体 entitlement 和上游路径。 +- xAI API Key:属于另一种 API 接入与计费来源,不能拿它的成功代替订阅复用成功。 + +“直接把 Token 放进目录”作为具体实现手段逐目标判断,不作为产品验收目标。凭据 helper 只提供凭据;是否还需要接口转换,由目标 CLI 和上游协议决定。 + +## 4. 引擎与部署选型 + +**选择:收完 FyAgent 现有内置转发。** 0.4.4 已有本机 Managed Auth、xAI OAuth/刷新与 Claude/Codex 转发基础。当前先补齐新版逐目标应用与运行生命周期,无需先更换引擎。另接原生进程会引入发行打包、进程通信、凭据归属和版本协同工作,目前没有足够证据证明这些替换成本能换来必要收益。 + +只有在后续发现现有模块存在明确的协议能力缺口或不可接受的维护成本时,才重新评估 CLIProxyAPI。以下保留比较结果;外部平台不再是本期备选部署分支。 + +| 路线 | 本次证据 | 部署位置 | 结论 | +| ------------------------------- | ---------------------------------------------------------------------------------------- | ---------------------- | ---------------------------------------------------------------------------------- | +| 目标 CLI 原生配置 / 凭据 helper | 官方配置可以指定兼容 endpoint 和凭据入口;helper 本身不完成 xAI OAuth 登录与协议转换 | 原生直连或连接现有入口 | 已有 API endpoint 时有用;单独不足以证明 SuperGrok 订阅跨 CLI 复用 | +| **FyAgent 内置转发** | 0.4.4 已有认证和转发源码链路,新版逐目标应用有缺口 | 现有本机应用内 | **当前采用方向:补齐现有链路** | +| CLIProxyAPI `v7.2.154` | 固定源码有 xAI 设备码授权、刷新、订阅上游、Responses executor 和协议转换体系 | 现成原生进程 | 技术替代储备;仅当现有实现出现明确阻碍时重新评估 | +| Wei-Shaw/sub2api `v0.1.176` | 主项目固定 tag 已包含 Grok OAuth、Claude Messages、Responses、Chat Completions、管理接口 | 完整服务平台 | 超出当前产品需要,不实施 | +| grok2api、Hermes、LiteLLM | 补充材料与能力差异见比较文档 | 依实现而定 | grok2api 是专用备选;Hermes 尚缺两目标完整证据;LiteLLM API Key 能力不等于订阅授权 | +| CC Switch | v3.18 发布材料明确描述 xAI 订阅用于 Claude/Codex 及相应转换;FyAgent 已有该上游谱系 | 其既有实现依赖本机路由 | 作为实现参考,避免仅换包装重复集成同一链路 | + +来源:[Sub2API 固定版本说明](https://github.com/Wei-Shaw/sub2api/blob/v0.1.176/README.md)、[CLIProxyAPI 发布页](https://github.com/router-for-me/CLIProxyAPI/releases/tag/v7.2.154)、[CC Switch 3.18 发布说明](https://github.com/farion1231/cc-switch/blob/606e7bbe75db7f8285f7a3be006fac22b5d22796/docs/release-notes/v3.18.0-en.md)、[Claude Code 网关配置](https://docs.anthropic.com/en/docs/claude-code/llm-gateway)、[Codex 配置参考](https://developers.openai.com/codex/config-reference/)。 + +## 5. 当前本机方案 + +### 5.1 请求与授权结构 + +```mermaid +flowchart LR + U[用户在 FyAgent 登录 Grok] --> M[FyAgent 本机授权管理] + C[Claude Code] -->|Messages| P[FyAgent 内置轻量转发] + X[Codex CLI] -->|Responses| P + M -. 提供所选订阅授权 .-> P + P -->|认证与协议适配| G[Grok 官方订阅上游] +``` + +本机只保存授权、配置和必要状态;模型计算仍在 Grok 上游。请求从目标 Agent 经本机入口到上游,不经过 FyAgent 自建云服务器。该模式仍需联网访问模型供应商。 + +继续由 FyAgent 的 Managed Auth 统一持有和刷新订阅授权。多个 Agent 使用同一个已选订阅来源,各目标保存本机入口和目标访问凭据;上游 refresh token 不复制到各家 CLI 的原生登录文件。没有新增 FyAgent 用户注册、跨设备身份或服务端账号体系。 + +### 5.2 小白用户看到的流程 + +1. 在 FyAgent 点击“连接 Grok”,完成供应商登录。 +2. 选择“用于 Claude Code”“用于 Codex”等已经支持的目标。 +3. FyAgent 自动管理本机入口,完成各目标的配置保存并显示当前来源。 +4. 授权失效时从同一入口重新登录;用户取消某个目标的连接时,恢复该目标由本次操作修改的配置。 + +普通流程不出现 Docker、服务器、数据库、端口或引擎项目名。界面区分“配置已应用”与实际调用结果;真实订阅可用性仍由后续运行证据确认。 + +### 5.3 现有模块的分工与需要补齐的内容 + +| 部分 | 复用与补齐 | +| -------- | -------------------------------------------------------------------------------------------------------------------- | +| 授权 | 复用 Managed Auth / SecretRef、刷新和脱敏状态;确保转发消费者引用当前有效授权,刷新责任唯一 | +| 本机转发 | 复用现有 xAI 适配与 Claude/Codex 请求处理;绑定到所选订阅来源,仅监听环回地址并校验客户端访问凭据 | +| 目标应用 | 复用已有 Provider 与 Change Plan 的适用能力;修通新版 Codex 对托管来源的应用阻断,逐目标预览、备份、保存、回读和恢复 | +| 状态 | 分清授权失效、转发未启动、目标配置未采用、模型不支持与上游限流;禁止静默切换其他账号、模型或 API Key 计费来源 | +| 生命周期 | 建立窗口关闭、托盘运行、彻底退出、重启和睡眠恢复的明确行为 | + +0.4.4 的新版 Codex 阻断见 [Change Plan 源码](https://github.com/fy-agent/fyagent/blob/2f264d2f89326601a33f610c72a9f0143306d066/src-tauri/src/services/change_plan/service.rs#L1610)。原生 Grok 凭据投放的开关见 [consumer 源码](https://github.com/fy-agent/fyagent/blob/2f264d2f89326601a33f610c72a9f0143306d066/src-tauri/src/services/managed_auth/consumers/grok.rs#L17);它与多个目标共用本机转发入口是两种消费方式,不应为此直接取消凭据 purpose 隔离。 + +先覆盖 Claude Code 和 Codex;WorkBuddy、Claude Desktop、OpenCode 逐版本确认配置能力后另行接入。当前方案不据前两家的结果直接承诺其他目标已支持。 + +### 5.4 后台运行与资源约束 + +推荐首版沿用应用内服务:连接启用时运行,关闭主窗口后保留可见的托盘状态;用户选择彻底退出时停止服务,并明确说明依赖该入口的 Agent 随之暂停使用此来源。重新启动 FyAgent 后恢复已启用的本机入口。首次连接说明一次运行依赖,避免普通关闭窗口突然中断,也避免隐藏常驻服务。 + +不默认新增独立系统守护进程或开机常驻。若后续明确要求“彻底退出 FyAgent 后 CLI 仍可用”,再评估把现有模块拆成随应用交付的轻量本机服务;这一需求本身不要求 Docker,也不必更换协议引擎。 + +实现应限制缓冲、并发、重试与日志保留,避免空闲忙轮询;仅影响用户选定的 Agent 配置。资源测量另在实施阶段进行,不能用“没有 Docker”推导零资源占用。首版不新增独立数据库或缓存服务。 + +## 6. 后续实施与可行性验证 + +本轮只收敛方案,沿用用户“先不测试”的要求。后续实施顺序为: + +1. 以 0.4.4 的 Managed Auth、转发与目标应用代码为基线,锁定实际开发版本,确认需要补齐的接口和目标配置契约。 +2. 修通一个 Grok 订阅来源投给 Claude Code 与 Codex 的应用流程,保留配置备份、回读与恢复。 +3. 补齐后台运行、彻底退出、重新启动、授权失效和睡眠恢复行为。 +4. 进入验证阶段后,确认两家 CLI 的流式对话与工具调用确实使用所选订阅,刷新和并发不串号、不争抢令牌;再测量本机增量 CPU、内存、日志与唤醒。 +5. 最后分别核对 macOS / Windows 的目标进程采用和恢复,不以文件保存替代实际使用。 + +第 4 步之前不称为订阅跨 CLI 已验证,第 5 步之前不称为双平台一键体验已交付。若出现明确协议缺口,再定点评估现成引擎;不先进行整套引擎替换。 + +## 7. 当前决策 + +**本机内置轻量转发是当前产品约束下最合适的采用方向,具体实现优先收完 FyAgent 现有模块。** 这项判断依赖单设备、小白用户、已有源码基础和无需服务器的定位;尚不等于经过性能对测证明的绝对最优。 + +产品体验收敛为“本机连接订阅,一次选择,多 Agent 使用”。当前不建设云账号、远端连接、跨设备同步、共享账号平台或 Docker 安装流程。CLIProxyAPI 的资料保留为替代储备,外部服务设计留档。本次只改方案,不改产品代码,不部署或调用真实账号。 + +## 附录 A:外部服务研究留档(当前不实施) + +以下为此前方案比较的材料和技术草案,已被当前单机范围取代。保留来源是为避免丢失调研,不代表仍需开发或用户需要配置这些服务。 + +### A.1 Sub2API 的可复用能力与限制 + +本次确认的上游是 **`Wei-Shaw/sub2api`**,`v0.1.176` 的 tag object 为 `14e6d7ee7bdb1e4cb6bc59129a7ee1dd1110c52a`,指向 commit `e803e3851c0a7e222cfadeafad7b8636ab959d11`。检索曾命中 `YuHaiA/sub2api` 同名仓库;它的安装和克隆说明仍指向 Wei-Shaw。本案不以该分叉的 README 或 SHA 代替主项目版本证据。 + +该版本区分 Grok OAuth 订阅来源与 xAI API Key 来源;前者默认走 `cli-chat-proxy.grok.com`,后者走 `api.x.ai`。它提供 Claude Messages、OpenAI Responses 和 Chat Completions 接入,转换由服务承担。对于“已有订阅给多个 CLI 用”,这比单独提供 API key helper 更完整。[固定版本说明](https://github.com/Wei-Shaw/sub2api/blob/e803e3851c0a7e222cfadeafad7b8636ab959d11/README.md) + +**一键登录存在权限与接口适配缺口。** 已有 Grok OAuth 接口属于管理员 API,普通推理 API key 不能使用。授权换码还会返回 token package,并非“只返回一个已保存的账号引用”。因此,不能仅配置服务地址就宣称 FyAgent 内登录已完成。[管理员路由](https://github.com/Wei-Shaw/sub2api/blob/e803e3851c0a7e222cfadeafad7b8636ab959d11/backend/internal/server/routes/admin.go)、[OAuth handler](https://github.com/Wei-Shaw/sub2api/blob/e803e3851c0a7e222cfadeafad7b8636ab959d11/backend/internal/handler/admin/grok_oauth_handler.go) + +外置部署还需要服务器或 Docker、数据库、缓存、服务升级和网络可用性管理。适合接入已有服务或用户自管服务;本案不把建设一个共享公共中转平台纳入 FyAgent 桌面功能。代码许可按上游 [LICENSE](https://github.com/Wei-Shaw/sub2api/blob/v0.1.176/LICENSE) 的 LGPL-3.0-or-later 保留,开源实现不能替代供应商授权与实际账号可用性证据;这两项仍需在后续采用前核实。 + +### A.2 外置路线的可行性分级 + +- **已有外部服务 → 配置 Claude Code / Codex:源码与协议材料支持,条件可行。** 首先验证流式响应、工具调用和实际订阅归属。 +- **FyAgent 内发起一次订阅登录 → 多目标配置:已有管理接口可复用,需要薄连接层。** 管理权限、授权回调、账号保存和推理 key 分发仍须实现。 +- **面向普通用户的无管理员权限内嵌登录:尚未找到可直接复用的最小权限接口。** 可先走服务自己的登录页面;完整嵌入需要服务侧窄接口,不能下发全局管理员凭据。 +- **完全不经过任何转换/路由服务:未证实。** 对特定单一目标可以继续验证原生直连,但不把它列为已解决的通用方案。 + +### A.3 外部服务连接器的历史设计 + +以下保存此前按外部服务方向编写的连接器设计。该分支已被最新单机产品约束排除,仅作为调研留档,不表示待开发任务或未来承诺。 + +```mermaid +flowchart LR + F[FyAgent:登录入口与配置管理] -. 连接与授权管理 .-> S[用户可控的现成外部服务] + F -. 独立配置 .-> C[Claude Code] + F -. 独立配置 .-> X[Codex CLI] + C -->|Messages 请求| S + X -->|Responses 请求| S + S -->|订阅认证与协议适配| G[Grok 订阅上游] +``` + +模型请求从目标 CLI 发往外部服务,不经过 FyAgent 桌面进程。FyAgent 负责连接信息、登录发起、状态展示和逐目标配置;外部服务负责对应订阅的授权维护、接口适配与请求转发。关闭 FyAgent 后,已配置的目标仍应能使用外部来源;若现成服务没有这一能力,必须在选型结果中说明。 + +#### A.3.1 两种连接入口 + +**已有服务连接**:用户提供自己控制的服务地址与客户端访问凭据。FyAgent 识别协议和可用模型,展示来源,再为每个目标产生独立配置预览。这是接入现成 endpoint,不能描述成已经完成“在 FyAgent 登录订阅账号”。 + +**从 FyAgent 发起订阅连接**:用户先选择已连接的外部服务,FyAgent 通过该服务受支持的授权接口或授权页面发起登录。完成后绑定服务侧账号引用,再取得只用于目标 CLI 推理的访问凭据。只有在授权接口、状态关联、取消、过期和账号归属均有明确契约时,才将该入口开放为“一次登录”。如果现成项目只有管理网页,第一阶段可以打开该页面完成连接,但要如实说明,不能伪装为内嵌 OAuth 已完成。 + +#### A.3.2 Sub2API 连接器的具体流程 + +固定版本已提供以下接口。它们是后续连接器的复用依据,本轮未调用。 + +| 阶段 | 已有接口与输入 | FyAgent 需要补的部分 | +| ---------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | +| 发起授权 | `POST /api/v1/admin/grok/oauth/auth-url`;可传 `redirect_uri` | 选定服务、校验权限,保存本次授权的关联信息,并打开返回的授权地址 | +| 完成授权 | `POST /api/v1/admin/grok/oauth/exchange-code`;`session_id`、`code`,以及回调的 `state` / `redirect_uri` | 只接受当前登录会话的回调;过期、取消或重复提交不继续写配置 | +| 保存账号 | 账号创建 / `create-from-oauth` 等管理接口 | 由 native 后端处理换码结果,保存到所选服务;向界面只返回脱敏账号引用 | +| 分配给目标 | 服务现有账号、分组和推理 API key 能力 | 每个目标使用独立可撤销 key,并绑定只含目标订阅账号的组;禁用跨组账号兜底 | +| 刷新 | `POST /api/v1/admin/grok/accounts/:id/refresh` 与服务侧刷新机制 | 优先按服务账号刷新,不让每个 CLI 分别持有上游 refresh token | + +接口来源:[OAuth handler](https://github.com/Wei-Shaw/sub2api/blob/e803e3851c0a7e222cfadeafad7b8636ab959d11/backend/internal/handler/admin/grok_oauth_handler.go)。换码服务已有 PKCE、state 比对和一次性 session 消费;`state` 可来自输入字段或完整回调,但缺失会被拒绝。[OAuth service](https://github.com/Wei-Shaw/sub2api/blob/e803e3851c0a7e222cfadeafad7b8636ab959d11/backend/internal/service/grok_oauth_service.go) + +个人自管场景可以由 FyAgent native 后端通过 SecretRef 使用用户配置的服务管理员认证,完成以上编排;管理员凭据和换码返回的上游 token 不进入 renderer、日志或 CLI 配置,临时 token 在服务持久化确认后释放。已有服务若不允许这种管理接入,则使用其管理页面完成登录。 + +普通用户/团队场景应由服务提供最小权限的授权和账号绑定接口,服务内部完成换码与落库,仅返回账号引用和目标专用推理 key。这个窄接口是明确的适配工作,不伪称为稳定版开箱自带。现成服务内部的 OAuth、刷新与协议转换继续复用。 + +授权回调地址必须在所选上游 client 的实际允许范围内。若采用上游支持的 localhost callback,只启动登录期间的一次性回调接收器,不承载模型请求;若不支持则使用服务已有的授权返回方式。不能自行假设 `fyagent://` 或任意远程回调已被允许。取消后清除本地会话关联并拒绝迟到回调;服务端 session 的 TTL、取消接口和跨实例持久化行为需在固定部署版本核对。 + +#### A.3.3 凭据和账号的责任 + +| 数据 | 归属和处理 | +| --------------------------- | ---------------------------------------------------------------------------------- | +| 上游订阅 refresh token | 由选定的外部服务独占刷新;不向多个 CLI 复制 | +| 目标 CLI 使用的服务访问凭据 | 应为独立、可撤销的推理凭据;按目标或连接区分,不能使用服务管理员凭据 | +| FyAgent 连接凭据 | 继续复用 SecretRef / OS 凭据存储;普通配置只存引用和非秘密元数据 | +| 账号绑定 | 绑定到具体服务、具体账号或保证仅包含该账号的路由组;不能靠显示名称或“默认账号”猜测 | +| 配额与用量 | 标明来自上游额度接口、外部服务计量还是本地估计;请求 token 数不等于订阅剩余额度 | + +外部服务账号与现有 FyAgent 本地 Managed Auth 账号是不同的认证持有者。初始方案采用在目标服务重新授权,不能暗中把本机已有 refresh token 上传或复制过去。服务暂不支持可验证账号绑定时,仍可提供普通 endpoint 接入,但不能宣称复用了指定订阅。 + +#### A.3.4 目标 Agent 的接入契约 + +- Claude Code:写入该版本公开支持的兼容服务地址、模型配置和访问凭据入口。既有原生 Claude 登录保留;模型请求应明确指向所选 Grok 来源。 +- Codex CLI:使用独立自定义 Provider / profile,匹配 Responses 协议及当前版本支持的认证配置。保留官方 `auth.json`,不能把 Grok 凭据冒充 ChatGPT 登录。 +- WorkBuddy:只有现成服务输出了其支持的 endpoint、访问凭据与模型格式,才进入原有 WorkBuddy 保存流程;它不继承 Codex 的配置写入逻辑。 +- Claude Desktop、OpenCode 与其他工具:逐产品/版本确认能力后接入,不由 Claude Code 的成功自动推断。 + +具体键名、热更新行为和凭据存储位置必须在实施时锁定目标版本。本方案不虚构一个所有 CLI 通用的认证文件。 + +#### A.3.5 配置、失败与退出 + +继续复用现有 Provider、SecretRef 和受支持的配置保存能力,不新建第二套 Auth Center 或通用执行器。每个目标独立预览变更、备份、写入、回读;文件变化与目标进程实际采用分开报告。 + +绑定账号失效、服务不可达、模型不支持或上游限流时,分别提示对应原因。禁止静默换账号、改成 API Key 计费或切换别的模型来掩盖失败。取消连接撤销该目标的服务凭据并按备份恢复本次修改,不注销其他目标仍在使用的上游订阅账号。 diff --git a/docs/fyagent/design/subscription-account-reuse/options-and-resource-comparison.md b/docs/fyagent/design/subscription-account-reuse/options-and-resource-comparison.md new file mode 100644 index 000000000..ff09a17d5 --- /dev/null +++ b/docs/fyagent/design/subscription-account-reuse/options-and-resource-comparison.md @@ -0,0 +1,130 @@ +# 订阅跨 Agent:多方案比较与本机资源影响 + +> 2026-09-08 实施更新:本次选择 FyAgent 内置本机转发。执行与结果以同名 Trellis 任务为准,完成后归档至 `.trellis/tasks/archive/2026-09/08-31-grok-first-class-iteration/`;下文未测试的表述是此前选型阶段的事实边界。当前支持范围和订阅协议见本目录 README。 + +日期:2026-09-08。衔接 [产品技术方案](./README.md)。 + +最新产品约束:面向小白用户、单台设备使用,不建设 FyAgent 云账号、服务器或外部同步,也不要求用户安装 Docker。**本轮已收敛为补齐 FyAgent 现有内置轻量转发,CLIProxyAPI 仅保留为技术替代储备。** 下文保留此前多方案比较;远端和完整平台行是排除依据,不是当前待开发功能。本轮不安装服务,不运行代理或真实订阅测试。 + +## 1. 先回答:本地运行是否会消耗电脑资源 + +会,但需要区分“代理转发”和“本地模型推理”。本案讨论的网关主要负责认证、请求转换、网络转发和统计;模型仍由上游服务计算。它不因为支持 Grok 就需要在电脑上下载模型或占用 GPU。Claude Code/Codex 执行编译、测试、索引等工具时造成的负载,也应与转发进程分开计算。 + +资源影响取决于部署了什么:复用 FyAgent 现有转发模块、增加一个原生小进程,以及在 Docker 中启动网关、数据库和缓存,是三种不同的负担。 + +| 资源或影响 | 主要来源 | 需要控制什么 | +| ---------- | ----------------------------------------------------------------- | ------------------------------------------------------------------ | +| CPU | JSON/SSE 解析、协议转换、TLS、统计、日志、授权刷新 | 避免空闲忙轮询;流式处理;限制并发与无意义重试 | +| 内存 | 服务基础占用、连接和请求缓冲、账号元数据;完整平台另有数据库/缓存 | 限制正文、队列和缓存;避免无界缓冲长上下文或重复保存完整流 | +| 磁盘 | 程序、配置、凭据存储、日志;容器镜像、数据库和虚拟磁盘 | 日志轮转、保留期、脱敏;默认不持久保存完整请求/响应 | +| 电池与休眠 | 常驻进程、频繁刷新/额度轮询、后台服务或虚拟机 | 按需运行;空闲不忙等;明确退出和睡眠后的恢复方式 | +| 网络 | 转发请求、刷新、必要元数据查询 | 本机环回连接不等于公网流量翻倍;远端网关多一个网络节点,延迟需实测 | +| 使用行为 | 目标 Agent 依赖所选 endpoint;端口、配置、进程生命周期 | 只修改用户选择的 Agent;服务停止时有可理解的提示和恢复入口 | + +以上是机制分析,不是某台电脑的实测值。本轮没有统一工作负载下的 CPU、峰值内存、耗电和延迟数据,不承诺“只有几十 MB”“完全无感”或“远程一定更快”。发布的最低机器配置也不能当作程序实际常驻内存。 + +目标只是给特定 Agent 提供模型入口:可以只监听环回地址并只修改该 Agent 的服务地址,不需要开启系统级全局代理,也不需要截获电脑其他应用的流量。 + +## 2. 什么才算核心功能成立 + +本轮比较继续以 **用户自己的 Grok / SuperGrok 订阅来源供 Claude Code 和 Codex 使用** 为主线。API Key 直连、购买第三方平台额度、换成另一个本地模型,都可以是其他产品能力,但不能替代这一目标。 + +一条路线至少需要同时覆盖: + +1. 该订阅账号可完成授权,且授权失效、刷新和取消有明确行为。 +2. Claude Messages 与 Codex Responses(含目标版本使用的流式/工具调用形式)都能正确适配。 +3. 请求明确绑定到选择的订阅账号,不靠其他账号或 API 计费来源静默兜底。 +4. 能保存目标配置、回读并恢复;保存成功不等于目标进程已采用。 +5. 多个 Agent 同时使用时,刷新责任唯一,额度不足和上游限制能正确归因。 + +本轮“源码支持”仅表示找到对应实现或固定版本材料;“真实可用”需要后续运行验证。单独支持 `xai` 模型名不证明支持 SuperGrok 订阅。 + +## 3. 架构级对比 + +这里的资源高低是基于组件构成的相对判断,不是跑分。“适合情况”记录一般适用场景;当前产品仅采用本机内置路线,不据此规划团队或远端版本。 + +| 路线 | 本机额外负担 | 用户配置难度 | 核心功能证据 | 主要代价 | 适合情况 | +| --------------------------------- | ------------------------------------------------------------ | ------------------------------ | ----------------------------------------------------------------- | -------------------------------------------- | ---------------------------------------------------- | +| 目标 CLI 原生直连 + 凭据 helper | 最少;没有额外常驻模型网关 | 协议和认证都原生支持时较低 | 对 API endpoint 有公开接入能力;两家 CLI 共同使用订阅仍缺充分证据 | helper 不自动解决协议和订阅认证差异 | 逐目标已经证明能原生直连时 | +| 复用 FyAgent 现有进程内转发 | 不新增独立网关进程或数据库;现有应用内增加请求处理 | 若产品闭环完成,可做到一次安装 | 0.4.4 已有 xAI 认证与 Claude/Codex 转发源码;一键应用有缺口 | FyAgent 承担兼容维护;关闭应用可能中断入口 | 优先收完现有产品、减少重复部署 | +| 成熟轻量引擎作为本地原生 sidecar | 新增一个服务进程;可免容器和独立数据库 | 包装好后较低,手动装配则较高 | 需核实具体引擎的订阅与协议支持 | 进程管理、更新、授权存储及配置连接需要集成 | 面向个人用户,既要本地凭据控制又要复用引擎 | +| 轻量引擎部署在已有远端主机 | 本机仅有客户端连接 | 已有服务时低;从零部署时高 | 与所选引擎有关,不能只按部署位置判断 | 网络、远端凭据保管、运维、服务器成本 | 用户已有服务器、常开设备或团队基础设施 | +| 完整网关平台部署在远端 | 本机较低;服务器承担数据库、缓存和后台 | 终端用户可低,运维者较高 | 例如 Sub2API 固定版本已有 Grok OAuth 与多协议材料 | 管理权限、租户/账号绑定、平台运维 | 团队、多用户、集中管理和用量统计 | +| 完整网关平台部署在用户本机 Docker | 通常比原生转发进程组件更多;桌面容器还需计算虚拟机等宿主开销 | 对普通用户最高 | 功能取决于平台,部署到本机不会自动补足认证能力 | 容器、数据库、缓存、后台服务和升级的整体负担 | 已在维护此类环境的专业用户;不宜默认要求普通用户安装 | + +“集成 SDK”也不天然更轻:必须核对语言与运行时。把 Go SDK 接到现有 Rust/Tauri 中,不应先承诺零额外进程;清晰的独立进程边界可能比维护跨语言桥更简单。反过来,若已有 Rust 转发模块能补齐目标,也不能仅为了采用新项目而额外运行第二套引擎。 + +## 4. 现成实现的定点比较 + +比较的是“已有项目能帮我们承担多少工作”,不是仅比较项目宣传的模型列表。以下均未进行真实账号调用或资源测量。 + +| 实现与核对版本 | Grok 订阅与目标协议 | 本机部署组件 | FyAgent 仍需完成 | 本次定位 | +| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | ------------------------------------------------------------------ | ----------------------------------------------------------- | +| FyAgent `0.4.4` | 已有 Managed Auth、xAI OAuth/刷新与 Claude/Codex 转发代码;新版配置应用仍有阻断 | 复用当前 Rust/Tauri 应用内服务 | 收完逐目标应用、状态、退出行为和真实调用闭环 | **当前采用方向**:补齐已有授权到逐目标应用的闭环 | +| CLIProxyAPI `v7.2.154` | 固定版本有 xAI device OAuth/refresh、订阅上游与 Responses executor;已找到 Claude 请求转换及响应反向转换的注册/调用路径 | 可用原生单个 Go 服务进程;基础部署不要求 PostgreSQL/Redis/Docker | 进程生命周期、管理登录连接、凭据归属、指定账号路由、逐目标应用 | **技术替代储备**:现有模块出现明确兼容或维护阻碍时再评估 | +| Sub2API `v0.1.176` | 固定版本有 Grok OAuth、Claude Messages、Responses 及账号管理接口 | 标准部署包含 Go 服务、PostgreSQL、Redis;本机容器方案还计入容器宿主 | 普通用户授权接口、指定账号/组绑定、目标 key 与配置投放 | **当前排除**:平台管理与部署范围超出本产品需求 | +| grok2api `44a390b8` | 项目材料明确列 Grok Build OAuth/Device OAuth、Messages、Responses;Web/Console SSO 为其他来源 | Go 网关和管理界面;单实例可用 SQLite/内存,多实例可加 PostgreSQL/Redis | 锁定发布产物、只选择目标订阅来源、接入授权与账号绑定、验证目标协议 | **Grok 专用备选**;不能仅凭 README 的能力表判定比前两者成熟 | +| Hermes Agent `v2026.5.28` 发布材料与 xAI 文档 | 已有订阅 OAuth/刷新和 `hermes proxy` xAI 上游;未找到同一代理覆盖 Claude Messages 的充分材料 | Hermes/Python 环境和代理进程 | Claude Messages 适配证据及两个目标的完整闭环 | 已在使用 Hermes 时有复用价值;目前只能列为部分满足 | +| LiteLLM 官方 xAI 接入文档 | 文档提供 xAI API Key 接入;本轮未找到取得/刷新 SuperGrok 订阅授权的对应能力 | 另一个通用网关及所选功能依赖 | 仍需额外的订阅接入实现 | 不能单独解决核心需求;本案不为通用路由再叠加一层 | + +### 4.1 CLIProxyAPI:确实有轻量实现,但不是复制 token 的工具 + +核对 commit 为 `ba7e55836dee959e93ec6d41395865d9ec535086`。它的 xAI 认证代码包含设备码授权和刷新,明确区分 API Key 的 `api.x.ai/v1` 与 OAuth 的 `cli-chat-proxy.grok.com/v1`。令牌保存在服务的 `auth-dir`,因此改为远端部署也意味着授权持有者移到远端;不能把部署位置当成纯性能开关。[认证实现](https://github.com/router-for-me/CLIProxyAPI/blob/ba7e55836dee959e93ec6d41395865d9ec535086/internal/auth/xai/xai.go)、[上游定义](https://github.com/router-for-me/CLIProxyAPI/blob/ba7e55836dee959e93ec6d41395865d9ec535086/internal/auth/xai/types.go)、[存储实现](https://github.com/router-for-me/CLIProxyAPI/blob/ba7e55836dee959e93ec6d41395865d9ec535086/internal/auth/xai/token.go) + +原生二进制可直接运行,不要求把完整容器平台交给用户。FyAgent 若采用它,需要管理启动、停止、端口、版本和登录入口;“现成引擎”减少的是协议核心的重复实现,并不会自动完成桌面产品体验。[固定版本发布产物](https://github.com/router-for-me/CLIProxyAPI/releases/tag/v7.2.154)、[官方快速开始](https://github.com/router-for-me/CLIProxyAPIDocs/blob/main/docs/en/introduction/quick-start.md) + +Claude → xAI 的证据不只来自兼容性宣传:固定版本将 xAI 账号注册给 `XAIAutoExecutor`,请求准备代码读取来源协议并转换为 `FormatCodex`;translator 注册了 `Claude → Codex` 请求转换和返回 Claude 的响应转换,HTTP executor 再向 xAI `/responses` 发送请求。这证明了实现路径,仍不保证所有工具类型、流式边界和目标 CLI 版本都已运行通过。[executor 注册](https://github.com/router-for-me/CLIProxyAPI/blob/ba7e55836dee959e93ec6d41395865d9ec535086/sdk/cliproxy/service_executors.go#L295)、[请求转换调用](https://github.com/router-for-me/CLIProxyAPI/blob/ba7e55836dee959e93ec6d41395865d9ec535086/internal/runtime/executor/xai_executor_request.go#L58)、[translator 注册](https://github.com/router-for-me/CLIProxyAPI/blob/ba7e55836dee959e93ec6d41395865d9ec535086/internal/translator/codex/claude/init.go#L10)、[发送与响应转换](https://github.com/router-for-me/CLIProxyAPI/blob/ba7e55836dee959e93ec6d41395865d9ec535086/internal/runtime/executor/xai_executor_execute.go#L36) + +### 4.2 Sub2API 与 grok2api:能力更多,按实际组件比较 + +Sub2API 本次核对的主项目是 `Wei-Shaw/sub2api`,commit `e803e3851c0a7e222cfadeafad7b8636ab959d11`。它已有较完整的账号和用量管理能力,但标准服务需要 PostgreSQL 和 Redis。相关 OAuth 管理权限与历史连接器缺口见主方案附录 A;当前不实施该连接器。[固定版本说明](https://github.com/Wei-Shaw/sub2api/blob/e803e3851c0a7e222cfadeafad7b8636ab959d11/README.md) + +grok2api 核对的是 `chenyme/grok2api` 的 main 快照 `44a390b890e7a3e0dd209b95b8c29a9f2b1be8dd`,不是本轮验证过的稳定发布包。其材料区分 Build OAuth、Web SSO、Console SSO,并列出两种目标协议;单实例不必采用 PostgreSQL/Redis。本需求只评估与目标订阅对应的 Build OAuth 路线,不能把其他 SSO 来源的成功混作该路线的证据。[固定快照说明](https://github.com/chenyme/grok2api/blob/44a390b890e7a3e0dd209b95b8c29a9f2b1be8dd/README.md) + +在 macOS 上选择 Docker Desktop,还要计算其虚拟机和容器宿主开销。Docker 的 CPU/内存配置是资源上限,不能当成实际使用量;空闲节能能力也不能据此推断常开数据库容器的实际耗电。[Docker 官方资源设置](https://docs.docker.com/desktop/settings-and-maintenance/settings/) + +### 4.3 为什么没有直接推荐 Hermes 或 LiteLLM + +Hermes 的材料能支撑“订阅授权 + 对外提供 OpenAI 兼容入口”,但尚不足以直接判定本案要求的 Claude Code 与 Codex 两条链路都成立。若为了补齐 Claude 再套一层转换,是否还比已有引擎简单就需要重新算账。[Hermes xAI OAuth 文档](https://github.com/NousResearch/hermes-agent/blob/main/website/docs/guides/xai-grok-oauth.md)、[代理能力发布说明](https://github.com/NousResearch/hermes-agent/releases/tag/v2026.5.28) + +LiteLLM 的 xAI 文档使用 API Key。它的通用协议能力不能补上缺失的订阅授权取得和刷新,所以本轮不把它列为独立替代方案。这是本次材料范围的判断,不是断言项目永远不会支持。[LiteLLM 官方 xAI 接入](https://docs.litellm.ai/docs/providers/xai) + +## 5. 当前取舍:收完 FyAgent 内置轻量转发 + +**在当前单机、小白用户的约束下,采用现有内置转发最合适。** 这是一项产品和工程取舍;当前没有性能对测,因此不声称其 CPU 或内存已经胜过所有第三方引擎。 + +| 决策 | 理由 | +| ------------------------------- | ------------------------------------------------------------------------------------------------------ | +| 首先补齐 FyAgent 已有模块 | 已有本机授权、刷新与协议转发基础,能继续使用现有安装包和凭据模型;需要收完逐目标应用和后台行为 | +| CLIProxyAPI 保留为替代储备 | 其现成转换能力有价值,但当前替换会增加跨进程、凭据和版本管理工作;有明确能力或维护阻碍时再比较替换收益 | +| 当前不做远端连接、云账号或同步 | 产品以单设备为边界,没有相应使用需求;服务管理和外部凭据保管会增加额外工作 | +| 当前不引入 Docker 完整平台 | 单机转发无需用户承担完整数据库和容器平台的安装维护 | +| 不把 token 文件复制作为通用方案 | 多目标仍需要匹配协议、唯一刷新责任和明确的订阅来源;本机转发可以统一处理 | + +产品中的订阅账号是供应商的 Grok 身份,其授权保存在本机;无需为了复用订阅建立另一套 FyAgent 账号体系。多个本机 Agent 共享该来源,各自的配置和取消行为独立。 + +```mermaid +flowchart LR + F[FyAgent:本机连接订阅] -. 统一持有与刷新授权 .-> L[FyAgent 内置轻量转发] + C[Claude Code] --> L + X[Codex CLI] --> L + L --> G[Grok 订阅上游] +``` + +推荐体验是“连接订阅 → 选择 Agent → 应用”。转发随 FyAgent 提供,用户无需手工安装或认识代理项目。只监听本机环回地址、校验客户端访问凭据、只修改选定目标配置,并限制日志、缓冲与后台轮询。 + +首版建议复用应用内服务:关闭窗口后可保持托盘运行;彻底退出时停止服务,明确告知连接依赖;重新打开应用恢复已启用入口。这些是待实现和验证的行为约定,不能描述成当前版本已经全部具备。若后续提出彻底退出应用后仍需调用,另评估随应用交付的原生后台服务;不自动扩大为 Docker 或云端方案。 + +因此,本次不再让用户在本地、远端和平台部署中继续选择。实现顺序、当前结构和旧方案的研究留档见 [主技术方案](./README.md)。 + +## 6. 后续用什么证据决定资源是否合格 + +等用户进入验证阶段后,以同一台目标机器、同一上游账号与同一请求负载比较,先分离原有 FyAgent/Agent 的基础负载,再记录新增部分: + +- 未连接、已连接空闲、单个流式请求、两个 Agent 并发、长上下文、授权刷新、退出等场景。 +- CPU 平均值与峰值、常驻和峰值内存、日志/数据增长、进程数量、是否有 GPU 活动和明显后台唤醒。 +- 代理自身处理时间、首次响应和完整响应时间;把上游网络/生成时间与本地处理分开。 +- 睡眠恢复、端口冲突、上游限流、服务退出、取消请求、CLI 被关闭后的连接与进程清理。 +- 当前进程内方案报告 FyAgent 的增量,不能拿整个界面 RSS 当代理开销。仅在将来有明确理由重开替代选型时,再比较其他引擎的完整组件成本;本期不为横比而搭建容器平台。 + +性能目标应先在基线机器上约定,再做采用决定。本轮不为给出漂亮数字启动服务或进行账号调用。 diff --git a/scripts/tasks/supported-platform-structure-assets.json b/scripts/tasks/supported-platform-structure-assets.json index a4e6d49a7..797dfb6b5 100644 --- a/scripts/tasks/supported-platform-structure-assets.json +++ b/scripts/tasks/supported-platform-structure-assets.json @@ -163,7 +163,7 @@ ], [ "src-tauri/src/lib.rs", - "113591d1e2496b0df2307662ad53870f6a810ff65a4554962512813712bce2a8" + "17f3b6f495b907d13f93e065c807a350c4e84515dfdcf1eea24519b0d521c0a3" ], [ "src-tauri/src/lightweight.rs", @@ -235,7 +235,7 @@ ], [ "src-tauri/src/services/provider/mod.rs", - "3b01508183ba400ab62f6ec08ed269241d792367ac99c0c2365257016f7382b6" + "ea7bfba8e2562fb7806b0c73345653e9892395bee8c8d84cb14f6f0540dc5458" ], [ "src-tauri/src/services/qoderwork.rs", 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/agent_catalog.rs b/src-tauri/src/commands/agent_catalog.rs index fd1854d90..7bf43a27c 100644 --- a/src-tauri/src/commands/agent_catalog.rs +++ b/src-tauri/src/commands/agent_catalog.rs @@ -1296,7 +1296,8 @@ mod tests { }) .collect::>(); - assert_eq!(registered.len(), 367, "review intentional handler changes"); + assert!(registered.contains("bind_xai_managed_provider")); + assert_eq!(registered.len(), 368, "review intentional handler changes"); assert_eq!(allowed, registered, "every registered application command must be granted exactly once while an app ACL manifest exists"); } } diff --git a/src-tauri/src/commands/provider.rs b/src-tauri/src/commands/provider.rs index 48278be5b..0b80e7262 100644 --- a/src-tauri/src/commands/provider.rs +++ b/src-tauri/src/commands/provider.rs @@ -467,6 +467,29 @@ pub fn add_provider_with_result( Ok(result) } +pub use crate::services::provider::{ + BindXaiManagedError, BindXaiManagedRequest, BindXaiManagedResult, +}; + +/// Bind an explicitly selected vault account. All credential and Provider +/// orchestration stays in the existing native service owners. +#[tauri::command(rename_all = "camelCase")] +pub async fn bind_xai_managed_provider( + request: BindXaiManagedRequest, + app_handle: tauri::AppHandle, + auth_state: State<'_, crate::commands::ManagedAuthState>, +) -> Result { + let auth = auth_state.0.clone(); + tauri::async_runtime::spawn_blocking(move || { + let state = app_handle + .try_state::() + .ok_or(BindXaiManagedError::ApplyFailedRolledBack)?; + ProviderService::bind_xai_managed(state.inner(), auth.as_ref(), request) + }) + .await + .map_err(|_| BindXaiManagedError::RollbackPartialStateUnknown)? +} + fn parse_provider_draft_app(app: &str) -> Result { let app_type = AppType::from_str(app).map_err(|e| e.to_string())?; if !matches!( @@ -1566,6 +1589,12 @@ mod provider_draft_command_tests { .count(), 1 ); + assert_eq!( + library_source + .matches("commands::bind_xai_managed_provider") + .count(), + 1 + ); } #[test] diff --git a/src-tauri/src/commands/xai_oauth.rs b/src-tauri/src/commands/xai_oauth.rs index 77961e4d0..03109c80f 100644 --- a/src-tauri/src/commands/xai_oauth.rs +++ b/src-tauri/src/commands/xai_oauth.rs @@ -1,12 +1,9 @@ //! xAI OAuth state and xAI-specific commands. use crate::proxy::providers::xai_oauth_auth::XaiOAuthManager; -use crate::proxy::providers::XAI_API_BASE_URL; use crate::services::model_fetch::FetchedModel; use crate::services::subscription::{CredentialStatus, SubscriptionQuota}; -use serde::Deserialize; use std::sync::Arc; -use std::time::Duration; use tauri::State; use tokio::sync::RwLock; @@ -74,62 +71,10 @@ pub async fn get_xai_oauth_quota( query_xai_oauth_quota_for(&state, account_id).await } -#[derive(Debug, Deserialize)] -struct ModelsResponse { - #[serde(default)] - data: Vec, -} - -#[derive(Debug, Deserialize)] -struct ModelEntry { - id: String, - #[serde(default)] - owned_by: Option, -} - #[tauri::command(rename_all = "camelCase")] pub async fn get_xai_oauth_models( - account_id: Option, - state: State<'_, XaiOAuthState>, + account_id: String, + state: State<'_, crate::commands::ManagedAuthState>, ) -> Result, String> { - let manager = state.0.read().await; - let resolved = match account_id - .as_deref() - .map(str::trim) - .filter(|id| !id.is_empty()) - { - Some(id) => Some(id.to_string()), - None => manager.default_account_id().await, - }; - let account_id = resolved.ok_or_else(|| "No usable xAI account available".to_string())?; - let token = manager - .get_valid_token_for_account(&account_id) - .await - .map_err(|error| format!("xAI OAuth token unavailable: {error}"))?; - - let response = crate::proxy::http_client::get() - .get(format!("{XAI_API_BASE_URL}/models")) - .bearer_auth(token) - .timeout(Duration::from_secs(15)) - .send() - .await - .map_err(|error| format!("xAI models request failed: {error}"))?; - let status = response.status(); - if !status.is_success() { - return Err(format!("xAI models request failed: HTTP {status}")); - } - let payload: ModelsResponse = response - .json() - .await - .map_err(|_| "xAI models response was not valid JSON".to_string())?; - let mut models: Vec = payload - .data - .into_iter() - .map(|model| FetchedModel { - id: model.id, - owned_by: model.owned_by, - }) - .collect(); - models.sort_by(|a, b| a.id.cmp(&b.id)); - Ok(models) + state.0.fetch_xai_models(&account_id).await } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 554f3ea49..263cb9c62 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2040,6 +2040,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/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index 72994c109..0fe0917d8 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -1410,6 +1410,17 @@ impl RequestForwarder { } else { adapter.build_url(&base_url, &effective_endpoint) }; + #[cfg(test)] + let url = match xai_integration_fixture(&provider.id) { + Some(fixture) if provider.is_xai_oauth() => { + let original = url::Url::parse(&url).expect("upstream URL"); + assert_eq!(original.scheme(), "https"); + assert_eq!(original.host_str(), Some("cli-chat-proxy.grok.com")); + assert_eq!(original.path(), "/v1/chat/completions"); + format!("{}{}", fixture.upstream, original.path()) + } + _ => url, + }; // 记录映射后的出站模型名(此时 mapped_body 已完成接管映射 / [1m] 剥离 / // Copilot 归一化)。格式转换后若 body 仍带 model 字段会在下方刷新覆盖; @@ -1741,7 +1752,32 @@ impl RequestForwarder { // sending the request. Invalid refresh credentials are persisted as // requiring re-authentication by the manager. if auth.strategy == AuthStrategy::XaiOAuth { - if let Some(app_handle) = &self.app_handle { + #[cfg(test)] + let fixture = xai_integration_fixture(&provider.id); + #[cfg(not(test))] + let fixture: Option<()> = None; + if let Some(_fixture) = fixture { + #[cfg(test)] + { + let fixture = _fixture; + let account_id = provider + .meta + .as_ref() + .and_then(|meta| meta.managed_account_id_for("xai_oauth")); + let material = fixture + .auth + .resolve_access_material( + ManagedAuthProvider::Xai, + account_id.as_deref(), + ) + .await + .map_err(|_| { + ProxyError::AuthError("Fixture vault credential unavailable".into()) + })?; + auth = + AuthInfo::new(material.access_token().into(), AuthStrategy::XaiOAuth); + } + } else if let Some(app_handle) = &self.app_handle { let account_id = provider .meta .as_ref() @@ -1758,6 +1794,11 @@ impl RequestForwarder { AuthStrategy::XaiOAuth, ); } else { + if provider.id.starts_with("fyagent-xai-") { + return Err(ProxyError::AuthError( + "Grok subscription vault account is unavailable".into(), + )); + } let xai_state = app_handle.state::(); let xai_auth: tokio::sync::RwLockReadGuard<'_, XaiOAuthManager> = xai_state.0.read().await; @@ -2192,6 +2233,27 @@ impl RequestForwarder { is_copilot, ); + if provider.is_xai_oauth() { + // The official CLI proxy routes by this header, not the JSON model + // alone. Replace all inbound/custom copies after body mapping and + // header overrides; the caller cannot choose a different route. + let model = filtered_body + .get("model") + .and_then(Value::as_str) + .filter(|model| !model.is_empty()) + .ok_or_else(|| { + ProxyError::InvalidRequest("Grok subscription model is missing".into()) + })?; + let model = http::HeaderValue::from_str(model).map_err(|_| { + ProxyError::InvalidRequest("Grok subscription model is invalid".into()) + })?; + ordered_headers.insert( + "x-xai-token-auth", + http::HeaderValue::from_static("xai-grok-cli"), + ); + ordered_headers.insert("x-grok-model-override", model); + } + reject_proxy_placeholder_for_managed_account_upstream(&url, &ordered_headers)?; // 日志目标 URL 的脱敏分两种情形: @@ -3225,6 +3287,51 @@ fn append_query_to_full_url(base_url: &str, query: Option<&str>) -> String { } } +// Test-only I/O seam: the real listener, router, translators and credential +// resolver run unchanged; only the OS vault and vendor network are synthetic. +// The map is keyed by fixture Provider ID and is absent from production builds. +#[cfg(test)] +#[derive(Clone)] +pub(crate) struct XaiIntegrationFixture { + pub auth: std::sync::Arc< + crate::services::managed_auth::ManagedAuthService< + crate::services::secret::MemorySecretBackend, + >, + >, + pub upstream: String, +} + +#[cfg(test)] +fn xai_integration_fixtures( +) -> &'static std::sync::Mutex> { + static FIXTURES: std::sync::OnceLock< + std::sync::Mutex>, + > = std::sync::OnceLock::new(); + FIXTURES.get_or_init(Default::default) +} + +#[cfg(test)] +pub(crate) fn set_xai_integration_fixture(id: &str, fixture: Option) { + let mut fixtures = xai_integration_fixtures().lock().expect("fixture lock"); + match fixture { + Some(value) => { + fixtures.insert(id.into(), value); + } + None => { + fixtures.remove(id); + } + } +} + +#[cfg(test)] +fn xai_integration_fixture(id: &str) -> Option { + xai_integration_fixtures() + .lock() + .expect("fixture lock") + .get(id) + .cloned() +} + async fn try_managed_proxy_token( app_handle: &tauri::AppHandle, provider: ManagedAuthProvider, diff --git a/src-tauri/src/proxy/mod.rs b/src-tauri/src/proxy/mod.rs index d1dc85808..82290385c 100644 --- a/src-tauri/src/proxy/mod.rs +++ b/src-tauri/src/proxy/mod.rs @@ -11,6 +11,8 @@ pub mod error; pub mod error_mapper; pub(crate) mod failover_switch; mod forwarder; +#[cfg(test)] +pub(crate) use forwarder::{set_xai_integration_fixture, XaiIntegrationFixture}; pub mod gemini_url; pub mod handler_config; pub mod handler_context; diff --git a/src-tauri/src/proxy/providers/claude.rs b/src-tauri/src/proxy/providers/claude.rs index 30086b757..5ac4baf79 100644 --- a/src-tauri/src/proxy/providers/claude.rs +++ b/src-tauri/src/proxy/providers/claude.rs @@ -36,14 +36,12 @@ const CODEX_OAUTH_CLIENT_VERSION: &str = "0.144.1"; /// 供 handler/forwarder 外部使用的公开函数。 /// 优先级:meta.apiFormat > settings_config.api_format > openrouter_compat_mode > 默认 "anthropic" pub fn get_claude_api_format(provider: &Provider) -> &'static str { - // 0) Managed Responses OAuth providers force their wire protocol. This is - // an invariant, not a preset default: editable metadata must not be able to - // send an Anthropic Messages body to a Responses-only upstream. + // Managed OAuth providers pin the vendor's protocol before editable metadata. + if provider.is_xai_oauth() { + return "openai_chat"; + } if let Some(meta) = provider.meta.as_ref() { - if matches!( - meta.provider_type.as_deref(), - Some("codex_oauth" | "xai_oauth") - ) { + if matches!(meta.provider_type.as_deref(), Some("codex_oauth")) { return "openai_responses"; } } @@ -709,7 +707,7 @@ impl ProviderAdapter for ClaudeAdapter { // xAI OAuth: ignore editable provider base URLs and always use the xAI // API origin associated with the managed token. if self.is_xai_oauth(provider) { - return Ok(super::XAI_API_BASE_URL.to_string()); + return Ok(super::XAI_SUBSCRIPTION_BASE_URL.to_string()); } // 1. 从 env 中获取 @@ -834,6 +832,14 @@ impl ProviderAdapter for ClaudeAdapter { } // Defense in depth for callers that bypass endpoint rewriting. + if base_url == super::XAI_SUBSCRIPTION_BASE_URL { + return match endpoint.split_once('?') { + Some((_, query)) if !query.is_empty() => { + format!("{base_url}/chat/completions?{query}") + } + _ => format!("{base_url}/chat/completions"), + }; + } if base_url == super::XAI_API_BASE_URL { let query = endpoint.split_once('?').map(|(_, query)| query); return match query { @@ -1455,11 +1461,11 @@ mod tests { }, ); - assert_eq!(get_claude_api_format(&provider), "openai_responses"); + assert_eq!(get_claude_api_format(&provider), "openai_chat"); assert_eq!(adapter.provider_type(&provider), ProviderType::XaiOAuth); assert_eq!( adapter.extract_base_url(&provider).unwrap(), - super::super::XAI_API_BASE_URL + super::super::XAI_SUBSCRIPTION_BASE_URL ); assert!(adapter.needs_transform(&provider)); assert_eq!( @@ -1470,8 +1476,11 @@ mod tests { AuthStrategy::XaiOAuth ); assert_eq!( - adapter.build_url(super::super::XAI_API_BASE_URL, "/v1/messages?beta=1"), - "https://api.x.ai/v1/responses?beta=1" + adapter.build_url( + super::super::XAI_SUBSCRIPTION_BASE_URL, + "/v1/messages?beta=1" + ), + "https://cli-chat-proxy.grok.com/v1/chat/completions?beta=1" ); let transformed = transform_claude_request_for_api_format( @@ -1482,17 +1491,13 @@ mod tests { "messages": [{ "role": "user", "content": "hello" }] }), &provider, - "openai_responses", + "openai_chat", None, None, ) .unwrap(); - assert_eq!(transformed["reasoning"]["effort"], json!("high")); - assert_eq!( - transformed["include"], - json!(["reasoning.encrypted_content"]) - ); - assert!(transformed.get("store").is_none()); + assert!(transformed.get("messages").is_some()); + assert!(transformed.get("input").is_none()); } #[test] diff --git a/src-tauri/src/proxy/providers/codex.rs b/src-tauri/src/proxy/providers/codex.rs index ae1301f26..0806f793f 100644 --- a/src-tauri/src/proxy/providers/codex.rs +++ b/src-tauri/src/proxy/providers/codex.rs @@ -26,6 +26,9 @@ pub struct CodexAdapter; /// OpenAI Chat Completions, even if the local Codex client is talking to CC /// Switch through the Responses API. pub fn codex_provider_uses_chat_completions(provider: &Provider) -> bool { + if provider.is_xai_oauth() { + return true; + } if let Some(api_format) = provider .meta .as_ref() @@ -166,6 +169,9 @@ pub fn inject_codex_chat_prompt_cache_key( /// Determined solely from explicit config (apiFormat / wire_api); no base_url /// guessing — Anthropic gateway addresses vary widely and guessing easily misfires. pub fn codex_provider_uses_anthropic(provider: &Provider) -> bool { + if provider.is_xai_oauth() { + return false; + } if let Some(api_format) = provider .meta .as_ref() @@ -225,6 +231,7 @@ pub fn provider_needs_responses_namespace_flatten(provider: &Provider) -> bool { pub fn is_codex_official_provider(provider: &Provider) -> bool { provider.id == crate::database::CODEX_OFFICIAL_PROVIDER_ID && provider.category.as_deref() == Some("official") + && !provider.is_xai_oauth() } /// Resolve the model-catalog tool profile for a Codex provider using the SAME @@ -238,12 +245,12 @@ pub fn resolve_codex_catalog_tool_profile( provider: &Provider, ) -> crate::codex_config::CodexCatalogToolProfile { use crate::codex_config::CodexCatalogToolProfile; - if is_codex_official_provider(provider) { - return CodexCatalogToolProfile::NativeResponses; - } - // xAI OAuth pins the native Responses profile regardless of editable + // xAI OAuth pins the Chat conversion profile regardless of editable // api_format, mirroring the Claude-side managed-provider invariant. if provider.is_xai_oauth() { + return CodexCatalogToolProfile::ProxyChat; + } + if is_codex_official_provider(provider) { return CodexCatalogToolProfile::NativeResponses; } if codex_provider_uses_anthropic(provider) { @@ -674,10 +681,9 @@ impl ProviderAdapter for CodexAdapter { return Ok(super::CHATGPT_CODEX_BASE_URL.to_string()); } - // xAI OAuth: ignore editable provider base URLs and always use the xAI - // API origin associated with the managed token. + // xAI OAuth: ignore editable URLs and use the CLI session origin. if provider.is_xai_oauth() { - return Ok(super::XAI_API_BASE_URL.to_string()); + return Ok(super::XAI_SUBSCRIPTION_BASE_URL.to_string()); } // 1. 尝试直接获取 base_url 字段 @@ -1563,10 +1569,10 @@ wire_api = "responses" }); // 可编辑字段(base_url / auth key)不得影响托管路由: - // 端点硬定向 api.x.ai,凭据是占位符(真 token 由 forwarder 注入)。 + // 端点硬定向 Grok CLI 订阅地址,凭据是占位符(真 token 由 forwarder 注入)。 assert_eq!( adapter.extract_base_url(&provider).unwrap(), - super::super::XAI_API_BASE_URL + super::super::XAI_SUBSCRIPTION_BASE_URL ); let auth = adapter .extract_auth(&provider) @@ -1576,15 +1582,42 @@ wire_api = "responses" } #[test] - fn xai_oauth_pins_native_responses_catalog_profile() { + fn xai_oauth_pins_chat_catalog_profile() { let mut provider = create_provider(json!({ "auth": {}, "config": "" })); provider.meta = Some(crate::provider::ProviderMeta { provider_type: Some("xai_oauth".to_string()), - // 即使 api_format 被改成 anthropic,catalog 画像也必须钉死原生 Responses + // 即使 api_format 被改成 anthropic,catalog 画像也必须匹配 Chat 转换 api_format: Some("anthropic".to_string()), ..Default::default() }); + assert!(matches!( + resolve_codex_catalog_tool_profile(&provider), + crate::codex_config::CodexCatalogToolProfile::ProxyChat + )); + // Even contradictory imported official metadata cannot change the + // tool profile independently from the authoritative OAuth wire route. + provider.id = crate::database::CODEX_OFFICIAL_PROVIDER_ID.into(); + provider.category = Some("official".into()); + assert!(!is_codex_official_provider(&provider)); + assert_eq!( + CodexAdapter::new().extract_base_url(&provider).unwrap(), + super::super::XAI_SUBSCRIPTION_BASE_URL + ); + assert_eq!( + CodexAdapter::new() + .extract_auth(&provider) + .unwrap() + .strategy, + AuthStrategy::XaiOAuth + ); + assert!(codex_provider_uses_chat_completions(&provider)); + assert!(!codex_provider_uses_anthropic(&provider)); + assert!(matches!( + resolve_codex_catalog_tool_profile(&provider), + crate::codex_config::CodexCatalogToolProfile::ProxyChat + )); + provider.meta = None; assert!(matches!( resolve_codex_catalog_tool_profile(&provider), crate::codex_config::CodexCatalogToolProfile::NativeResponses diff --git a/src-tauri/src/proxy/providers/mod.rs b/src-tauri/src/proxy/providers/mod.rs index 625e1d0bb..56f01c613 100644 --- a/src-tauri/src/proxy/providers/mod.rs +++ b/src-tauri/src/proxy/providers/mod.rs @@ -46,6 +46,8 @@ use serde::{Deserialize, Serialize}; pub const CHATGPT_CODEX_BASE_URL: &str = "https://chatgpt.com/backend-api/codex"; pub const XAI_API_BASE_URL: &str = "https://api.x.ai/v1"; +/// Official Grok CLI session-token route; never share the API-key origin. +pub const XAI_SUBSCRIPTION_BASE_URL: &str = "https://cli-chat-proxy.grok.com/v1"; // 公开导出 pub use adapter::ProviderAdapter; @@ -88,7 +90,7 @@ pub enum ProviderType { GitHubCopilot, /// OpenAI Codex (ChatGPT Plus/Pro OAuth,需要 Anthropic ↔ Responses API 转换) CodexOAuth, - /// xAI Grok OAuth(需要 Anthropic ↔ Responses API 转换) + /// xAI Grok CLI subscription OAuth(复用 Chat Completions 转换) XaiOAuth, } @@ -121,7 +123,7 @@ impl ProviderType { ProviderType::OpenRouter => "https://openrouter.ai/api", ProviderType::GitHubCopilot => "https://api.githubcopilot.com", ProviderType::CodexOAuth => CHATGPT_CODEX_BASE_URL, - ProviderType::XaiOAuth => XAI_API_BASE_URL, + ProviderType::XaiOAuth => XAI_SUBSCRIPTION_BASE_URL, } } diff --git a/src-tauri/src/services/change_plan/service.rs b/src-tauri/src/services/change_plan/service.rs index 9b380c25f..c952f70bb 100644 --- a/src-tauri/src/services/change_plan/service.rs +++ b/src-tauri/src/services/change_plan/service.rs @@ -262,7 +262,7 @@ impl ChangePlanService { let _provider_guard = ProviderService::lock_provider_mutation(state, &AppType::Codex); let adapter = CodexProviderSwitchAdapter::for_plan(state, target_provider_id); let inspection = adapter.inspect()?; - let secret_capability = prove_codex_target_credential_capability(&inspection); + let secret_capability = prove_codex_target_credential_capability(state, &inspection); if secret_capability != SecretCapabilityResult::NoNewCredentialMaterial { return Err(ChangePlanErrorCode::SecretDependencyUnavailable); } @@ -348,7 +348,7 @@ impl ChangePlanService { let adapter = CodexProviderUpsertAdapter::for_plan(state, provider.clone(), existing_reserved_row); let inspection = adapter.inspect()?; - let secret_capability = prove_codex_target_credential_capability(&inspection); + let secret_capability = prove_codex_target_credential_capability(state, &inspection); if secret_capability != SecretCapabilityResult::NoNewCredentialMaterial { return Err(ChangePlanErrorCode::SecretDependencyUnavailable); } @@ -724,7 +724,7 @@ impl ChangePlanService { Ok(observed) => observed, Err(error) => return Ok(ApplyChangePlanOutcome::rejected(error)), }; - if prove_codex_target_credential_capability(&observed) + if prove_codex_target_credential_capability(state, &observed) != SecretCapabilityResult::NoNewCredentialMaterial { return Ok(ApplyChangePlanOutcome::rejected( @@ -1608,9 +1608,13 @@ pub(crate) fn write_workbuddy_save_locked( } fn prove_codex_target_credential_capability( + state: &AppState, inspection: &CodexSwitchInspection, ) -> SecretCapabilityResult { let provider = &inspection.target; + if ProviderService::xai_managed_account_is_ready(state, provider) { + return prove_xai_oauth_switch_shape(provider); + } if provider .meta .as_ref() @@ -1665,6 +1669,14 @@ fn prove_codex_target_credential_capability( } } +fn prove_xai_oauth_switch_shape(provider: &Provider) -> SecretCapabilityResult { + if ProviderService::xai_managed_codex_shape_is_valid(provider) { + SecretCapabilityResult::NoNewCredentialMaterial + } else { + SecretCapabilityResult::SecretDependencyUnavailable + } +} + fn validate_optional_provider_id( value: Option, ) -> Result, ChangePlanErrorCode> { @@ -2431,6 +2443,49 @@ mod tests { ); } + #[test] + #[serial] + fn credential_capability_rejects_legacy_json_without_vault_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(); + assert_eq!( + ChangePlanService::plan_codex_switch_at(&state, &xai.id, 300), + Err(ChangePlanErrorCode::SecretDependencyUnavailable) + ); + + 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-tauri/src/services/managed_auth/mod.rs b/src-tauri/src/services/managed_auth/mod.rs index 236f7f9c2..e1b3f315a 100644 --- a/src-tauri/src/services/managed_auth/mod.rs +++ b/src-tauri/src/services/managed_auth/mod.rs @@ -19,6 +19,8 @@ pub(crate) mod providers; mod repository; mod secret_bundle; mod service; +#[cfg(test)] +mod subscription_tests; pub(crate) use core::{ stable_connection_id, stable_credential_id, stable_identity_id, stable_revision, diff --git a/src-tauri/src/services/managed_auth/service.rs b/src-tauri/src/services/managed_auth/service.rs index a6b73fa62..14fe855c1 100644 --- a/src-tauri/src/services/managed_auth/service.rs +++ b/src-tauri/src/services/managed_auth/service.rs @@ -271,6 +271,55 @@ where } } + /// Resolve the public overview identity to the one FyAgent-owned Proxy + /// credential. This is an admission/readback check, never a refresh or an + /// export of native consumer credentials. + pub(crate) fn xai_proxy_account( + &self, + account_id: &str, + ) -> Result { + let selected = self + .credentials_for_account(account_id)? + .into_iter() + .find(|row| { + row.credential.provider == ManagedAuthProvider::Xai + && row.credential.purpose == CredentialPurpose::ProxyUpstream + && row.credential.consumer == Some(ManagedAuthConsumer::FyagentProxy) + }) + .ok_or(ManagedAuthCoreError::NotFound)?; + let credential = &selected.credential; + if credential.status != CredentialStatus::Ready + || credential.refresh_owner != RefreshOwner::Fyagent + { + return Err(ManagedAuthCoreError::Conflict); + } + let bundle = self.readback_bundle(&credential.secret_handle)?; + if bundle.credential_id() != credential.credential_id + || bundle.provider() != ManagedAuthProvider::Xai + || bundle.generation() != credential.generation + || (bundle.refresh_token().is_none() + && (bundle.access_token().is_none() + || access_expired(credential.access_expires_at))) + { + return Err(ManagedAuthCoreError::SecretMissing); + } + Ok(selected) + } + + pub(crate) async fn fetch_xai_models( + &self, + account_id: &str, + ) -> Result, String> { + self.xai_proxy_account(account_id) + .map_err(|_| "Grok subscription account is unavailable".to_string())?; + // The official CLI documents this routing alias, not an entitlement + // catalog. Never send a session token to the API-key /models endpoint. + Ok(vec![crate::services::model_fetch::FetchedModel { + id: "grok-build".into(), + owned_by: Some("xai".into()), + }]) + } + pub(crate) async fn resolve_access_material( &self, provider: ManagedAuthProvider, @@ -747,7 +796,9 @@ where .repository .get_credential(&credential.credential_id)? .ok_or(ManagedAuthCoreError::NotFound)?; - if current.refresh_owner != RefreshOwner::Fyagent { + if current.refresh_owner != RefreshOwner::Fyagent + || current.status != CredentialStatus::Ready + { return Err(ManagedAuthCoreError::Conflict); } let bundle = self.readback_bundle(¤t.secret_handle)?; diff --git a/src-tauri/src/services/managed_auth/subscription_tests.rs b/src-tauri/src/services/managed_auth/subscription_tests.rs new file mode 100644 index 000000000..a7bc02cb8 --- /dev/null +++ b/src-tauri/src/services/managed_auth/subscription_tests.rs @@ -0,0 +1,747 @@ +//! Isolated subscription integration: synthetic vault -> real Provider/Change +//! Plan -> real loopback Proxy -> fake vendor HTTP. No external login or file. + +use super::migration::LegacyCredentialInput; +use super::*; +use crate::app_config::AppType; +use crate::database::Database; +use crate::services::change_plan::{ChangePlanService, WriterReceipt}; +use crate::services::provider::{BindXaiManagedError, BindXaiManagedRequest, ProviderService}; +use crate::services::secret::{MemorySecretBackend, SecretService}; +use crate::store::AppState; +use serde_json::{json, Value}; +use serial_test::serial; +use std::sync::Arc; + +struct TestHome(Option); +impl TestHome { + fn set(path: &std::path::Path) -> Self { + let previous = std::env::var_os("FYAGENT_TEST_HOME"); + std::env::set_var("FYAGENT_TEST_HOME", path); + Self(previous) + } +} +impl Drop for TestHome { + fn drop(&mut self) { + match self.0.take() { + Some(value) => std::env::set_var("FYAGENT_TEST_HOME", value), + None => std::env::remove_var("FYAGENT_TEST_HOME"), + } + } +} + +fn seed(auth: &ManagedAuthService, account: &str) -> CredentialRecord { + let credential = auth + .provision_legacy_credential(LegacyCredentialInput { + migration_id: None, + provider: ManagedAuthProvider::Xai, + purpose: CredentialPurpose::ProxyUpstream, + consumer: Some(ManagedAuthConsumer::FyagentProxy), + legacy_account_id: account.into(), + provider_subject: account.into(), + provider_tenant: String::new(), + login: format!("{account}@example.test"), + display_name: None, + avatar_url: None, + access_token: None, + refresh_token: Some(zeroize::Zeroizing::new(format!( + "synthetic-refresh-{account}" + ))), + id_token: None, + desired_status: CredentialStatus::Ready, + refresh_owner: RefreshOwner::Fyagent, + authenticated_at: 1_700_000_000, + make_default: true, + }) + .unwrap(); + let bundle = ManagedAuthSecretBundle::new(ManagedAuthSecretBundleParts { + credential_id: credential.credential_id.clone(), + provider: ManagedAuthProvider::Xai, + generation: credential.generation + 1, + access_token: Some(format!("synthetic-access-{account}")), + refresh_token: Some(format!("synthetic-refresh-{account}")), + id_token: None, + token_type: Some("Bearer".into()), + granted_scopes: vec![], + issued_at: Some(chrono::Utc::now().timestamp()), + expires_at: Some(chrono::Utc::now().timestamp() + 3600), + }) + .unwrap(); + assert!(auth + .replace_bundle_cas( + &credential.credential_id, + credential.generation, + RefreshOwner::Fyagent, + bundle + ) + .unwrap()); + auth.repository + .get_credential(&credential.credential_id) + .unwrap() + .unwrap() +} + +fn request(app: &str, identity: &str) -> BindXaiManagedRequest { + BindXaiManagedRequest { + app: app.into(), + account_id: identity.into(), + model_id: "grok-build".into(), + } +} + +fn fixture() -> ( + tempfile::TempDir, + TestHome, + Arc, + Arc>, +) { + let home = tempfile::tempdir().unwrap(); + let guard = TestHome::set(home.path()); + let db = Arc::new(Database::memory().unwrap()); + db.create_change_plan_tables_for_tests().unwrap(); + let auth = Arc::new(ManagedAuthService::new( + db.clone(), + SecretService::new(MemorySecretBackend::new()), + home.path().join("vault-meta"), + )); + let state = Arc::new(AppState::new(db)); + (home, guard, state, auth) +} + +#[test] +#[serial] +fn subscription_vault_binding_rejects_missing_purpose_and_conflicting_provider() { + let (_home, _guard, state, auth) = fixture(); + assert!(matches!( + ProviderService::bind_xai_managed(&state, &auth, request("codex", "missing")), + Err(BindXaiManagedError::AccountUnavailable) + )); + let credential = seed(&auth, "selected"); + let bound = + ProviderService::bind_xai_managed(&state, &auth, request("codex", &credential.identity_id)) + .unwrap(); + assert!(!bound.activated); + let again = + ProviderService::bind_xai_managed(&state, &auth, request("codex", &credential.identity_id)) + .unwrap(); + assert!(again.already_bound); + let mut altered = state + .db + .get_provider_by_id(&bound.provider_id, "codex") + .unwrap() + .unwrap(); + altered.settings_config["config"] = json!("model = \"changed-model\""); + state.db.save_provider("codex", &altered).unwrap(); + assert!(matches!( + ProviderService::bind_xai_managed(&state, &auth, request("codex", &credential.identity_id)), + Err(BindXaiManagedError::ProviderConflict) + )); + assert_eq!( + state + .db + .get_provider_by_id(&bound.provider_id, "codex") + .unwrap() + .unwrap() + .settings_config, + altered.settings_config + ); + auth.repository + .transfer_refresh_owner( + &credential.credential_id, + credential.generation, + RefreshOwner::Fyagent, + RefreshOwner::GrokNative, + chrono::Utc::now().timestamp(), + ) + .unwrap(); + assert!(matches!( + ProviderService::bind_xai_managed( + &state, + &auth, + request("claude", &credential.identity_id) + ), + Err(BindXaiManagedError::AccountUnavailable) + )); +} + +#[test] +#[serial] +fn subscription_account_names_are_distinct_and_saved_names_are_idempotent() { + let (_home, _guard, state, auth) = fixture(); + let first = seed(&auth, "selected"); + let second = seed(&auth, "other"); + state.db.conn.lock().unwrap().execute( + "UPDATE managed_auth_identities SET display_name = 'Shared label' WHERE provider = 'xai'", [], + ).unwrap(); + let bound = + ProviderService::bind_xai_managed(&state, &auth, request("codex", &first.identity_id)) + .unwrap(); + let other = + ProviderService::bind_xai_managed(&state, &auth, request("codex", &second.identity_id)) + .unwrap(); + assert!(bound.provider_name.contains("Shared label")); + assert!(other.provider_name.contains("Shared label")); + assert_ne!(bound.provider_name, other.provider_name); + assert_ne!(bound.provider_id, other.provider_id); + let mut renamed = state + .db + .get_provider_by_id(&bound.provider_id, "codex") + .unwrap() + .unwrap(); + renamed.name = "My preferred Grok source".into(); + state.db.save_provider("codex", &renamed).unwrap(); + state.db.conn.lock().unwrap().execute( + "UPDATE managed_auth_identities SET display_name = 'New public label' WHERE identity_id = ?1", [&first.identity_id], + ).unwrap(); + let repeated = + ProviderService::bind_xai_managed(&state, &auth, request("codex", &first.identity_id)) + .unwrap(); + assert!(repeated.already_bound); + assert_eq!(repeated.provider_name, renamed.name); + assert_eq!(repeated.provider_id, bound.provider_id); + renamed + .meta + .as_mut() + .unwrap() + .auth_binding + .as_mut() + .unwrap() + .account_id = Some(second.legacy_account_id); + state.db.save_provider("codex", &renamed).unwrap(); + assert!(matches!( + ProviderService::bind_xai_managed(&state, &auth, request("codex", &first.identity_id)), + Err(BindXaiManagedError::ProviderConflict) + )); +} + +#[test] +#[serial] +fn subscription_concurrent_targets_do_not_stop_the_committed_listener() { + check_concurrent_targets(true); + check_concurrent_targets(false); +} + +fn check_concurrent_targets(use_change_plan: bool) { + use std::sync::mpsc; + use std::time::Duration; + + let (_home, _guard, state, auth) = fixture(); + let credential = seed(&auth, "selected"); + let runtime = tokio::runtime::Runtime::new().unwrap(); + let _entered = runtime.enter(); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + drop(listener); + let mut global = runtime + .block_on(state.db.get_global_proxy_config()) + .unwrap(); + global.listen_address = "127.0.0.1".into(); + global.listen_port = port; + global.proxy_enabled = false; + runtime + .block_on(state.db.update_global_proxy_config(global)) + .unwrap(); + let claude_path = crate::config::get_claude_settings_path(); + crate::config::write_json_file(&claude_path, &json!({"env":{"CUSTOM":"preserved"}})).unwrap(); + let claude_before = std::fs::read(&claude_path).unwrap(); + let codex_path = crate::codex_config::get_codex_config_path(); + std::fs::create_dir_all(codex_path.parent().unwrap()).unwrap(); + std::fs::write(&codex_path, "model = \"original\"\n").unwrap(); + let codex = + ProviderService::bind_xai_managed(&state, &auth, request("codex", &credential.identity_id)) + .unwrap(); + let plan = ChangePlanService::plan_codex_switch(&state, &codex.provider_id).unwrap(); + if !use_change_plan { + state + .db + .set_current_provider("codex", &codex.provider_id) + .unwrap(); + crate::settings::set_current_provider(&AppType::Codex, Some(&codex.provider_id)).unwrap(); + } + + // Freeze A after it starts the shared socket and fail that preparation. + // B enters through Change Plan or the manual takeover while A is pending. + let (events_tx, events_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let release_rx = std::sync::Mutex::new(release_rx); + state + .proxy_service + .set_managed_activation_test_hook(Some(Arc::new(move |app, phase| { + events_tx.send((app.to_owned(), phase.to_owned())).unwrap(); + if app == "claude" && phase == "started" { + release_rx + .lock() + .unwrap() + .recv_timeout(Duration::from_secs(5)) + .unwrap(); + return Err("synthetic preparation failure".into()); + } + Ok(()) + }))); + std::thread::scope(|scope| { + let first = scope.spawn(|| { + let _entered = runtime.enter(); + ProviderService::bind_xai_managed( + &state, + &auth, + request("claude", &credential.identity_id), + ) + }); + assert_eq!( + events_rx.recv_timeout(Duration::from_secs(5)).unwrap(), + ("claude".into(), "waiting".into()) + ); + assert_eq!( + events_rx.recv_timeout(Duration::from_secs(5)).unwrap(), + ("claude".into(), "started".into()) + ); + let second = scope.spawn(|| { + let _entered = runtime.enter(); + if !use_change_plan { + runtime + .block_on(state.proxy_service.set_takeover_for_app("codex", true)) + .unwrap(); + return None; + } + Some( + ChangePlanService::apply_codex_switch_at_with_writer( + &state, + &plan.plan_id, + &plan.plan_digest, + chrono::Utc::now().timestamp(), + |id| { + ProviderService::with_live_config_result(AppType::Codex, || { + ProviderService::switch_with_lock_held(&state, AppType::Codex, id) + }) + .map(|result| WriterReceipt { + live_config_changed: result.live_config_changed, + }) + }, + ) + .unwrap(), + ) + }); + assert_eq!( + events_rx.recv_timeout(Duration::from_secs(5)).unwrap(), + ("codex".into(), "waiting".into()) + ); + assert!( + matches!( + events_rx.recv_timeout(Duration::from_millis(150)), + Err(mpsc::RecvTimeoutError::Timeout) + ), + "B must not adopt the listener before A completes compensation" + ); + release_tx.send(()).unwrap(); + assert!(matches!( + first.join().unwrap(), + Err(BindXaiManagedError::ApplyFailedRolledBack) + )); + if let Some(outcome) = second.join().unwrap() { + assert!(matches!( + serde_json::to_value(outcome).unwrap()["job"]["resultCode"].as_str(), + Some("applied" | "applied_restart_recommended" | "applied_with_warning") + )); + } + }); + state.proxy_service.set_managed_activation_test_hook(None); + assert_eq!(std::fs::read(&claude_path).unwrap(), claude_before); + assert!(state.db.get_all_providers("claude").unwrap().is_empty()); + assert!( + !runtime + .block_on(state.db.get_proxy_config_for_app("claude")) + .unwrap() + .enabled + ); + assert!( + runtime + .block_on(state.db.get_proxy_config_for_app("codex")) + .unwrap() + .enabled + ); + assert_eq!( + state.db.get_current_provider("codex").unwrap().as_deref(), + Some(codex.provider_id.as_str()) + ); + assert!(runtime.block_on(state.proxy_service.is_running())); + assert!(std::net::TcpStream::connect((std::net::Ipv4Addr::LOCALHOST, port)).is_ok()); + assert!(std::fs::read_to_string(codex_path) + .unwrap() + .contains(&format!("127.0.0.1:{port}"))); + runtime + .block_on(state.proxy_service.stop_with_restore()) + .unwrap(); +} + +#[test] +#[serial] +fn subscription_port_conflict_restores_current_files_flags_and_provider_rows() { + let (_home, _guard, state, auth) = fixture(); + let credential = seed(&auth, "selected"); + let runtime = tokio::runtime::Runtime::new().unwrap(); + let _entered = runtime.enter(); + let occupied = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let mut global = runtime + .block_on(state.db.get_global_proxy_config()) + .unwrap(); + global.listen_address = "127.0.0.1".into(); + global.listen_port = occupied.local_addr().unwrap().port(); + global.proxy_enabled = false; + runtime + .block_on(state.db.update_global_proxy_config(global.clone())) + .unwrap(); + let mut app = runtime + .block_on(state.db.get_proxy_config_for_app("claude")) + .unwrap(); + app.enabled = false; + app.auto_failover_enabled = true; + runtime + .block_on(state.db.update_proxy_config_for_app(app.clone())) + .unwrap(); + let original = crate::provider::Provider::with_id( + "original-source".into(), + "Original".into(), + json!({"env":{"ANTHROPIC_BASE_URL":"https://original.example", "ANTHROPIC_API_KEY":"synthetic-api-key"}}), + None, + ); + state.db.save_provider("claude", &original).unwrap(); + state + .db + .set_current_provider("claude", &original.id) + .unwrap(); + crate::settings::set_current_provider(&AppType::Claude, Some(&original.id)).unwrap(); + let settings = crate::config::get_claude_settings_path(); + crate::config::write_json_file( + &settings, + &json!({"permissions":{"deny":["Bash(rm:*)"]},"env":{"CUSTOM":"preserve"}}), + ) + .unwrap(); + let before = std::fs::read(&settings).unwrap(); + assert!(matches!( + ProviderService::bind_xai_managed( + &state, + &auth, + request("claude", &credential.identity_id) + ), + Err(BindXaiManagedError::ApplyFailedRolledBack) + )); + assert_eq!(std::fs::read(&settings).unwrap(), before); + assert_eq!(state.db.get_all_providers("claude").unwrap().len(), 1); + assert_eq!( + state.db.get_current_provider("claude").unwrap().as_deref(), + Some("original-source") + ); + assert_eq!( + crate::settings::get_current_provider(&AppType::Claude).as_deref(), + Some("original-source") + ); + assert!(runtime + .block_on(state.db.get_live_backup("claude")) + .unwrap() + .is_none()); + assert!(!runtime.block_on(state.proxy_service.is_running())); + assert_eq!( + serde_json::to_value( + runtime + .block_on(state.db.get_proxy_config_for_app("claude")) + .unwrap() + ) + .unwrap(), + serde_json::to_value(app).unwrap() + ); + assert_eq!( + serde_json::to_value( + runtime + .block_on(state.db.get_global_proxy_config()) + .unwrap() + ) + .unwrap(), + serde_json::to_value(global).unwrap() + ); +} + +#[test] +#[serial] +fn subscription_runtime_rollback_tampering_reports_unknown() { + for trigger in [ + "CREATE TRIGGER tamper_runtime AFTER UPDATE ON proxy_config WHEN NEW.app_type = 'claude' BEGIN UPDATE proxy_config SET max_retries = NEW.max_retries + 1 WHERE app_type = 'claude'; END;", + "CREATE TRIGGER tamper_runtime AFTER UPDATE OF proxy_enabled ON proxy_config WHEN NEW.app_type = 'claude' AND NEW.proxy_enabled = 0 BEGIN UPDATE proxy_config SET enable_logging = 0 WHERE app_type = 'claude'; END;", + ] { + let (_home, _guard, state, auth) = fixture(); + let credential = seed(&auth, "selected"); + let runtime = tokio::runtime::Runtime::new().unwrap(); + let _entered = runtime.enter(); + let occupied = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let mut global = runtime.block_on(state.db.get_global_proxy_config()).unwrap(); + global.listen_address = "127.0.0.1".into(); + global.listen_port = occupied.local_addr().unwrap().port(); + global.proxy_enabled = false; + global.enable_logging = true; + runtime.block_on(state.db.update_global_proxy_config(global)).unwrap(); + state.db.conn.lock().unwrap().execute_batch(trigger).unwrap(); + assert!(matches!(ProviderService::bind_xai_managed(&state, &auth, request("claude", &credential.identity_id)), Err(BindXaiManagedError::RollbackPartialStateUnknown))); + assert!(!runtime.block_on(state.proxy_service.is_running())); + assert!(state.db.get_all_providers("claude").unwrap().is_empty()); + } +} + +#[test] +#[serial] +fn subscription_vault_to_both_cli_protocols_and_restore() { + let (home, _guard, state, auth) = fixture(); + let selected = seed(&auth, "selected"); + let _other_default = seed(&auth, "other-default"); + assert!(!home.path().join("vault-meta/xai_oauth_auth.json").exists()); + let runtime = tokio::runtime::Runtime::new().unwrap(); + let _entered = runtime.enter(); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + drop(listener); + let mut global = runtime + .block_on(state.db.get_global_proxy_config()) + .unwrap(); + global.listen_address = "127.0.0.1".into(); + global.listen_port = port; + runtime + .block_on(state.db.update_global_proxy_config(global)) + .unwrap(); + let claude_path = crate::config::get_claude_settings_path(); + crate::config::write_json_file( + &claude_path, + &json!({"permissions":{"deny":["Bash(rm:*)"]},"env":{"CUSTOM":"kept"}}), + ) + .unwrap(); + let claude_before: Value = crate::config::read_json_file(&claude_path).unwrap(); + let config_path = crate::codex_config::get_codex_config_path(); + std::fs::create_dir_all(config_path.parent().unwrap()).unwrap(); + let config_before = "model = \"original\"\n[features]\nweb_search_request = false\n[mcp_servers.fixture]\ncommand = \"fixture-command\"\n"; + std::fs::write(&config_path, config_before).unwrap(); + let auth_path = crate::codex_config::get_codex_auth_path(); + std::fs::write( + &auth_path, + b"{\"auth_mode\":\"chatgpt\",\"fixture\":\"preserved-native-login\"}", + ) + .unwrap(); + let auth_before = std::fs::read(&auth_path).unwrap(); + let claude = + ProviderService::bind_xai_managed(&state, &auth, request("claude", &selected.identity_id)) + .unwrap(); + assert!(claude.activated); + let codex = + ProviderService::bind_xai_managed(&state, &auth, request("codex", &selected.identity_id)) + .unwrap(); + assert!(!codex.activated); + assert_eq!( + std::fs::read_to_string(&config_path).unwrap(), + config_before + ); + let saved = state + .db + .get_provider_by_id(&codex.provider_id, "codex") + .unwrap() + .unwrap(); + assert!(ProviderService::xai_managed_account_is_ready( + &state, &saved + )); + assert!( + ProviderService::xai_managed_codex_shape_is_valid(&saved), + "shape: {}", + saved.settings_config + ); + let environment = crate::services::provider::inspect_codex_switch_environment(&state).unwrap(); + crate::services::provider::build_codex_switch_target_live_projection( + &state, + &saved, + &environment, + ) + .unwrap(); + let plan = ChangePlanService::plan_codex_switch(&state, &codex.provider_id).unwrap(); + let outcome = ChangePlanService::apply_codex_switch_at_with_writer( + &state, + &plan.plan_id, + &plan.plan_digest, + chrono::Utc::now().timestamp(), + |id| { + ProviderService::with_live_config_result(AppType::Codex, || { + ProviderService::switch_with_lock_held(&state, AppType::Codex, id) + }) + .map(|result| WriterReceipt { + live_config_changed: result.live_config_changed, + }) + }, + ) + .unwrap(); + assert!( + matches!( + serde_json::to_value(&outcome).unwrap()["job"]["resultCode"].as_str(), + Some("applied" | "applied_restart_recommended" | "applied_with_warning") + ), + "outcome: {:?}", + serde_json::to_value(&outcome).unwrap() + ); + assert_eq!(std::fs::read(&auth_path).unwrap(), auth_before); + let codex_live = std::fs::read_to_string(&config_path).unwrap(); + assert!(codex_live.contains(&format!("127.0.0.1:{port}"))); + assert!(codex_live.contains("fixture-command")); + let claude_live: Value = crate::config::read_json_file(&claude_path).unwrap(); + assert_eq!(claude_live["permissions"], claude_before["permissions"]); + assert_eq!(claude_live["env"]["CUSTOM"], "kept"); + let captured = Arc::new(tokio::sync::Mutex::new( + Vec::<(axum::http::HeaderMap, Value)>::new(), + )); + let capture = captured.clone(); + let (upstream, upstream_task) = runtime.block_on(async move { + let server = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = server.local_addr().unwrap(); + let router = axum::Router::new().route("/v1/chat/completions", axum::routing::post(move |headers: axum::http::HeaderMap, axum::Json(body): axum::Json| { + let capture = capture.clone(); + async move { + use axum::response::IntoResponse; + let streaming = body["stream"] == true; + capture.lock().await.push((headers, body)); + if streaming { + // Same split tool-call shape used by streaming_codex_chat's + // converts_tool_call_chat_sse_to_responses_sse regression. + let chunks = [ + json!({"id":"chatcmpl_fixture","model":"grok-build","choices":[{"index":0,"delta":{"role":"assistant","content":"fixture-ok"}}]}), + json!({"id":"chatcmpl_fixture","model":"grok-build","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_fixture","type":"function","function":{"name":"get_weather","arguments":""}}]}}]}), + json!({"id":"chatcmpl_fixture","model":"grok-build","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"city\":\"Tokyo\"}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":2,"completion_tokens":3,"total_tokens":5}}), + ]; + let events = chunks.into_iter().map(|chunk| format!("data: {chunk}\n\n")).collect::() + "data: [DONE]\n\n"; + ([(axum::http::header::CONTENT_TYPE, "text/event-stream")], events).into_response() + } else { + axum::Json(json!({"id":"chatcmpl_fixture","object":"chat.completion","created":123,"model":"grok-build","choices":[{"index":0,"message":{"role":"assistant","content":"fixture-ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":2,"completion_tokens":2,"total_tokens":4}})).into_response() + } + } + })); + let task = tokio::spawn(async move { axum::serve(server, router).await.unwrap(); }); + (format!("http://{address}"), task) + }); + for id in [&claude.provider_id, &codex.provider_id] { + crate::proxy::set_xai_integration_fixture( + id, + Some(crate::proxy::XaiIntegrationFixture { + auth: auth.clone(), + upstream: upstream.clone(), + }), + ); + } + runtime.block_on(async { + let client = reqwest::Client::new(); + let messages = client.post(format!("http://127.0.0.1:{port}/v1/messages")) + .header("x-api-key", "PROXY_MANAGED").header("anthropic-version", "2023-06-01") + .json(&json!({"model":"claude-sonnet-4-6","max_tokens":100,"messages":[{"role":"user","content":"hello"}],"stream":false})) + .send().await.unwrap(); + let status = messages.status(); let body = messages.text().await.unwrap(); + assert!(status.is_success(), "Claude request: {status} {body}"); + assert!(body.contains("fixture-ok")); + let responses = client.post(format!("http://127.0.0.1:{port}/v1/responses")) + .bearer_auth("PROXY_MANAGED") + .json(&json!({"model":"grok-build","input":[{"role":"user","content":"hello"}],"stream":false})) + .send().await.unwrap(); + let status = responses.status(); let body = responses.text().await.unwrap(); + assert!(status.is_success(), "Codex request: {status} {body}"); + assert!(body.contains("fixture-ok")); + let messages_stream = client.post(format!("http://127.0.0.1:{port}/v1/messages")) + .header("x-api-key", "PROXY_MANAGED").header("anthropic-version", "2023-06-01") + .header("x-xai-token-auth", "client-spoof").header("x-grok-model-override", "wrong-route") + .json(&json!({"model":"claude-sonnet-4-6","max_tokens":100,"messages":[{"role":"user","content":"hello"}],"stream":true,"tools":[{"name":"get_weather","input_schema":{"type":"object","properties":{"city":{"type":"string"}}}}]})) + .send().await.unwrap(); + assert!(messages_stream.status().is_success()); + let body = messages_stream.text().await.unwrap(); + for expected in ["event: message_start", "event: content_block_start", "get_weather", "input_json_delta", "Tokyo", "tool_use", "event: message_stop"] { + assert!(body.contains(expected), "Missing Claude stream event {expected}: {body}"); + } + let responses_stream = client.post(format!("http://127.0.0.1:{port}/v1/responses")) + .bearer_auth("PROXY_MANAGED") + .header("x-xai-token-auth", "client-spoof").header("x-grok-model-override", "wrong-route") + .json(&json!({"model":"untrusted-client-model","input":[{"role":"user","content":"hello"}],"stream":true,"tools":[{"type":"function","name":"get_weather","parameters":{"type":"object","properties":{"city":{"type":"string"}}}}]})) + .send().await.unwrap(); + assert!(responses_stream.status().is_success()); + let body = responses_stream.text().await.unwrap(); + for expected in ["event: response.created", "event: response.function_call_arguments.delta", "event: response.function_call_arguments.done", "get_weather", "call_fixture", "Tokyo", "event: response.completed"] { + assert!(body.contains(expected), "Missing Codex stream event {expected}: {body}"); + } + let requests = captured.lock().await; + assert_eq!(requests.len(), 4); + for (headers, body) in requests.iter() { + assert_eq!(headers["authorization"], "Bearer synthetic-access-selected"); + assert_eq!(headers["x-xai-token-auth"], "xai-grok-cli"); + assert_eq!(headers["x-grok-model-override"], "grok-build"); + assert_eq!(body["model"], "grok-build"); + assert!(body.get("messages").is_some()); + assert!(body.get("input").is_none()); + } + }); + let public = serde_json::to_string(&plan).unwrap() + + &serde_json::to_string(&outcome).unwrap() + + &serde_json::to_string(&claude).unwrap() + + &serde_json::to_string(&codex).unwrap() + + &codex_live + + &serde_json::to_string(&claude_live).unwrap(); + assert!(!public.contains("synthetic-access")); + assert!(!public.contains("synthetic-refresh")); + let mut next_request = request("codex", &selected.identity_id); + next_request.model_id = "grok-next".into(); + let next = ProviderService::bind_xai_managed(&state, &auth, next_request).unwrap(); + let next_plan = ChangePlanService::plan_codex_switch(&state, &next.provider_id).unwrap(); + auth.repository + .set_status( + &selected.credential_id, + CredentialStatus::RequiresReauth, + chrono::Utc::now().timestamp(), + ) + .unwrap(); + let rejected = ChangePlanService::apply_codex_switch_at_with_writer( + &state, + &next_plan.plan_id, + &next_plan.plan_digest, + chrono::Utc::now().timestamp(), + |_| -> Result { panic!("revoked account must not reach the writer") }, + ) + .unwrap(); + assert_eq!( + rejected.error_code, + Some(crate::services::change_plan::ChangePlanErrorCode::SecretDependencyUnavailable) + ); + assert_eq!( + state.db.get_current_provider("codex").unwrap().as_deref(), + Some(codex.provider_id.as_str()) + ); + runtime.block_on(async { + let response = reqwest::Client::new() + .post(format!("http://127.0.0.1:{port}/v1/responses")) + .timeout(std::time::Duration::from_secs(5)) + .bearer_auth("PROXY_MANAGED") + .json(&json!({"model":"grok-build","input":"revoked","stream":false})) + .send() + .await + .unwrap(); + assert!(!response.status().is_success()); + assert_eq!( + captured.lock().await.len(), + 4, + "revocation must not fall back or call upstream" + ); + }); + // Native Codex login can rotate independently during proxy use. Restore + // must retain the new file instead of replaying the old login backup. + let rotated_auth = b"{\"auth_mode\":\"chatgpt\",\"fixture\":\"new-native-login\"}"; + std::fs::write(&auth_path, rotated_auth).unwrap(); + runtime + .block_on(state.proxy_service.stop_with_restore()) + .unwrap(); + assert_eq!( + crate::config::read_json_file::(&claude_path).unwrap(), + claude_before + ); + assert_eq!(std::fs::read(&auth_path).unwrap(), rotated_auth); + assert_eq!( + std::fs::read_to_string(&config_path).unwrap(), + config_before + ); + for id in [&claude.provider_id, &codex.provider_id] { + crate::proxy::set_xai_integration_fixture(id, None); + } + upstream_task.abort(); +} diff --git a/src-tauri/src/services/provider/managed_xai.rs b/src-tauri/src/services/provider/managed_xai.rs new file mode 100644 index 000000000..4cd7faf23 --- /dev/null +++ b/src-tauri/src/services/provider/managed_xai.rs @@ -0,0 +1,344 @@ +//! Grok subscription bindings reuse the Provider transaction and local Proxy. + +use super::*; +use crate::provider::{AuthBinding, AuthBindingSource, ProviderMeta}; +use crate::services::managed_auth::{ + CredentialPurpose, CredentialStatus, IdentityRecord, ManagedAuthConsumer, ManagedAuthProvider, + ManagedAuthRepository, ManagedAuthService, RefreshOwner, +}; +use crate::services::secret::SecretBackend; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct BindXaiManagedRequest { + pub app: String, + pub account_id: String, + pub model_id: String, +} + +#[derive(Debug, 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, +} + +#[derive(Debug, Clone, Copy, Serialize, thiserror::Error)] +#[serde(tag = "code", rename_all = "snake_case")] +pub enum BindXaiManagedError { + #[error("invalid_request")] + InvalidRequest, + #[error("account_unavailable")] + AccountUnavailable, + #[error("provider_conflict")] + ProviderConflict, + #[error("apply_failed_rolled_back")] + ApplyFailedRolledBack, + #[error("rollback_partial_state_unknown")] + RollbackPartialStateUnknown, +} + +impl From for BindXaiManagedError { + fn from(error: QuickSetupApplyError) -> Self { + match error.code { + QuickSetupApplyFailureCode::ApplyFailedRolledBack => Self::ApplyFailedRolledBack, + QuickSetupApplyFailureCode::RollbackPartialStateUnknown => { + Self::RollbackPartialStateUnknown + } + } + } +} + +fn parse_request(request: &BindXaiManagedRequest) -> Result { + let app = match request.app.as_str() { + "claude" => AppType::Claude, + "codex" => AppType::Codex, + "claude-desktop" => AppType::ClaudeDesktop, + _ => return Err(BindXaiManagedError::InvalidRequest), + }; + let model = request.model_id.as_bytes(); + if request.account_id.is_empty() + || request.account_id.len() > 160 + || request.account_id.trim() != request.account_id + || model.is_empty() + || model.len() > 128 + || !model[0].is_ascii_alphanumeric() + || !model + .iter() + .all(|c| c.is_ascii_alphanumeric() || b"-_.:".contains(c)) + { + return Err(BindXaiManagedError::InvalidRequest); + } + Ok(app) +} + +fn build_provider( + app: &AppType, + account: &str, + identity: &IdentityRecord, + model: &str, +) -> Provider { + // Model-specific drafts avoid mutating an already active binding when a + // second target/model is selected. No user-supplied text becomes an ID. + let digest = format!( + "{:x}", + Sha256::digest(format!("{account}\0{model}").as_bytes()) + ); + let id = format!("fyagent-xai-{}-{}", app.as_str(), &digest[..24]); + let base_url = crate::proxy::providers::XAI_SUBSCRIPTION_BASE_URL; + let settings = if *app == AppType::Codex { + let config = format!("model_provider = \"xai\"\nmodel = \"{model}\"\n\n[model_providers.xai]\nname = \"Grok subscription\"\nbase_url = \"{base_url}\"\nwire_api = \"responses\"\n"); + serde_json::json!({"auth": {}, "config": config}) + } else { + serde_json::json!({"env": { + "ANTHROPIC_BASE_URL": base_url, + "ANTHROPIC_MODEL": model, + "ANTHROPIC_DEFAULT_HAIKU_MODEL": model, + "ANTHROPIC_DEFAULT_SONNET_MODEL": model, + "ANTHROPIC_DEFAULT_OPUS_MODEL": model + }}) + }; + let label: String = identity + .display_name + .as_deref() + .filter(|name| !name.trim().is_empty()) + .unwrap_or(&identity.login) + .chars() + .filter(|c| c.is_alphanumeric() || " ._@-".contains(*c)) + .take(24) + .collect(); + let label = if label.trim().is_empty() { + "账号" + } else { + label.trim() + }; + let account_tag = format!("{:x}", Sha256::digest(identity.identity_id.as_bytes())); + let mut provider = Provider::with_id( + id, + format!("Grok · {label} ({}) · {model}", &account_tag[..6]), + settings, + Some("https://x.ai/grok".into()), + ); + provider.category = Some("third_party".into()); + provider.icon = Some("xai".into()); + provider.meta = Some(ProviderMeta { + provider_type: Some("xai_oauth".into()), + auth_binding: Some(AuthBinding { + source: AuthBindingSource::ManagedAccount, + auth_provider: Some("xai_oauth".into()), + account_id: Some(account.into()), + }), + ..Default::default() + }); + if *app == AppType::ClaudeDesktop { + let meta = provider.meta.as_mut().expect("created metadata"); + meta.claude_desktop_mode = Some(crate::provider::ClaudeDesktopMode::Proxy); + for route in crate::claude_desktop_config::DEFAULT_PROXY_ROUTES { + meta.claude_desktop_model_routes.insert( + route.route_id.into(), + crate::provider::ClaudeDesktopModelRoute { + model: model.into(), + supports_1m: Some(false), + label_override: None, + }, + ); + } + } + provider +} + +impl ProviderService { + pub(crate) fn xai_managed_codex_shape_is_valid(provider: &Provider) -> bool { + let Some(auth) = provider + .settings_config + .get("auth") + .and_then(Value::as_object) + else { + return false; + }; + let Some(config) = provider + .settings_config + .get("config") + .and_then(Value::as_str) + .and_then(|text| text.parse::().ok()) + else { + return false; + }; + let Some(model) = config.get("model").and_then(toml::Value::as_str) else { + return false; + }; + let Some(selected) = config + .get("model_providers") + .and_then(toml::Value::as_table) + .and_then(|table| table.get("xai")) + .and_then(toml::Value::as_table) + else { + return false; + }; + auth.is_empty() + && config.get("model_provider").and_then(toml::Value::as_str) == Some("xai") + && selected.get("base_url").and_then(toml::Value::as_str) + == Some(crate::proxy::providers::XAI_SUBSCRIPTION_BASE_URL) + && selected.get("wire_api").and_then(toml::Value::as_str) == Some("responses") + && selected.keys().all(|key| { + matches!( + key.as_str(), + "name" | "base_url" | "wire_api" | "http_headers" + ) + }) + && selected.get("http_headers").is_none_or(|headers| { + headers.as_table().is_some_and(|headers| { + headers.len() == 1 + && headers + .get(crate::codex_config::CODEX_IMAGE_EXTENSION_HEADER) + .and_then(toml::Value::as_str) + == Some(crate::codex_config::CODEX_IMAGE_EXTENSION_VALUE) + }) + }) + && parse_request(&BindXaiManagedRequest { + app: "codex".into(), + account_id: "validation".into(), + model_id: model.into(), + }) + .is_ok() + } + + pub(crate) fn xai_managed_account_is_ready(state: &AppState, provider: &Provider) -> bool { + let Some(binding) = provider + .meta + .as_ref() + .and_then(|meta| meta.auth_binding.as_ref()) + else { + return false; + }; + if !provider.is_xai_oauth() + || binding.source != AuthBindingSource::ManagedAccount + || binding.auth_provider.as_deref() != Some("xai_oauth") + { + return false; + } + let Some(account) = binding.account_id.as_deref().filter(|id| !id.is_empty()) else { + return false; + }; + ManagedAuthRepository::new(state.db.clone()) + .get_credential_by_legacy( + ManagedAuthProvider::Xai, + CredentialPurpose::ProxyUpstream, + Some(ManagedAuthConsumer::FyagentProxy), + account, + ) + .ok() + .flatten() + .is_some_and(|credential| { + credential.status == CredentialStatus::Ready + && credential.refresh_owner == RefreshOwner::Fyagent + }) + } + + pub(crate) fn bind_xai_managed( + state: &AppState, + auth: &ManagedAuthService, + request: BindXaiManagedRequest, + ) -> Result { + let app = parse_request(&request)?; + let _guard = Self::lock_provider_mutation(state, &app); + let credential = auth + .xai_proxy_account(&request.account_id) + .map_err(|_| BindXaiManagedError::AccountUnavailable)?; + let mut provider = build_provider( + &app, + &credential.credential.legacy_account_id, + &credential.identity, + &request.model_id, + ); + Self::normalize_provider_if_claude(&app, &mut provider); + if app == AppType::Codex { + crate::codex_config::prepare_codex_provider_features_for_save(&mut provider, true) + .map_err(|_| BindXaiManagedError::InvalidRequest)?; + } + normalize_provider_common_config_for_storage(state.db.as_ref(), &app, &mut provider) + .map_err(|_| BindXaiManagedError::InvalidRequest)?; + let existing = state + .db + .get_provider_by_id(&provider.id, app.as_str()) + .map_err(|_| BindXaiManagedError::ApplyFailedRolledBack)?; + if let Some(existing) = &existing { + // Names are presentation, not binding authority. Preserve a saved + // name when public account labels or the user's own label change. + provider.name = existing.name.clone(); + if !Self::quick_setup_persisted_provider_matches(&provider, existing).unwrap_or(false) { + return Err(BindXaiManagedError::ProviderConflict); + } + } + let result = BindXaiManagedResult { + provider_id: provider.id.clone(), + provider_name: provider.name.clone(), + app: app.as_str().into(), + already_bound: existing.is_some(), + activated: app == AppType::Claude, + }; + if app == AppType::Claude { + Self::apply_quick_setup_locked(state, app, provider)?; + } else if existing.is_none() { + // Codex keeps the existing Change Plan confirmation. Desktop keeps + // its dedicated profile application; this command only saves it. + let current_before = state + .db + .get_current_provider(app.as_str()) + .map_err(|_| BindXaiManagedError::ApplyFailedRolledBack)?; + let saved = Self::add_with_initial_activation( + state, + app.clone(), + provider.clone(), + false, + false, + ); + let verified = saved.is_ok() + && state + .db + .get_provider_by_id(&provider.id, app.as_str()) + .ok() + .flatten() + .as_ref() + .is_some_and(|row| { + Self::quick_setup_persisted_provider_matches(&provider, row) + .unwrap_or(false) + }) + && state + .db + .get_current_provider(app.as_str()) + .is_ok_and(|current| current == current_before); + if !verified { + state + .db + .delete_provider(app.as_str(), &provider.id) + .map_err(|_| BindXaiManagedError::RollbackPartialStateUnknown)?; + match current_before.as_deref() { + Some(id) => state.db.set_current_provider(app.as_str(), id), + None => state.db.clear_current_provider(app.as_str()), + } + .map_err(|_| BindXaiManagedError::RollbackPartialStateUnknown)?; + if state + .db + .get_provider_by_id(&provider.id, app.as_str()) + .map_err(|_| BindXaiManagedError::RollbackPartialStateUnknown)? + .is_some() + || state + .db + .get_current_provider(app.as_str()) + .map_err(|_| BindXaiManagedError::RollbackPartialStateUnknown)? + != current_before + { + return Err(BindXaiManagedError::RollbackPartialStateUnknown); + } + return Err(BindXaiManagedError::ApplyFailedRolledBack); + } + } + Ok(result) + } +} diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index 1f7f86894..69c3a5a31 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -6,9 +6,12 @@ mod common_config; mod endpoints; mod gemini_auth; mod live; +mod managed_xai; mod universal; mod usage; +pub use managed_xai::{BindXaiManagedError, BindXaiManagedRequest, BindXaiManagedResult}; + use indexmap::IndexMap; use regex::Regex; use serde::Deserialize; @@ -104,7 +107,7 @@ pub(crate) fn build_codex_switch_target_live_projection( provider: &Provider, environment: &CodexSwitchEnvironment, ) -> Result { - if environment.should_hot_switch() { + if environment.should_hot_switch() || provider.is_xai_oauth() { return futures::executor::block_on( state .proxy_service @@ -4695,6 +4698,9 @@ impl ProviderService { app_type: AppType, mut provider: Provider, ) -> Result, QuickSetupApplyError> { + let _managed_activation = provider.is_xai_oauth().then(|| { + futures::executor::block_on(state.proxy_service.lock_managed_activation(&app_type)) + }); let _file_scope = crate::config::file_mutation_scope(); let existing_provider = state .db @@ -4735,6 +4741,26 @@ impl ProviderService { provider.in_failover_queue = existing.in_failover_queue; } + let managed_subscription = provider.is_xai_oauth(); + if managed_subscription + && (!Self::xai_managed_account_is_ready(state, &provider) + || (app_type == AppType::Codex + && !Self::xai_managed_codex_shape_is_valid(&provider))) + { + return Err(QuickSetupApplyError::rolled_back( + "Managed subscription account is unavailable", + )); + } + let managed_runtime = managed_subscription + .then(|| { + futures::executor::block_on( + state + .proxy_service + .snapshot_managed_takeover_runtime(&app_type), + ) + }) + .transpose() + .map_err(QuickSetupApplyError::rolled_back)?; let has_live_backup = backup_before.is_some(); let live_taken_over = state .proxy_service @@ -4742,7 +4768,14 @@ impl ProviderService { let should_prepare_takeover = has_live_backup || live_taken_over; let mutation = (|| -> Result<(SwitchResult, Option>), AppError> { - if should_prepare_takeover { + if managed_subscription { + futures::executor::block_on( + state + .proxy_service + .prepare_managed_takeover(&app_type, &provider), + ) + .map_err(AppError::Message)?; + } else if should_prepare_takeover { futures::executor::block_on( state .proxy_service @@ -4789,7 +4822,7 @@ impl ProviderService { .set_current_provider(app_type.as_str(), &provider.id)?; let mut result = SwitchResult::default(); - if !should_prepare_takeover { + if !should_prepare_takeover && !managed_subscription { if let Err(error) = McpService::sync_enabled_for_app_inner(state, &app_type) { log::warn!( "quick setup 后重投影 {app_type:?} MCP 失败(将在下次同步时自愈): {error}" @@ -4874,6 +4907,15 @@ impl ProviderService { } } rollback_errors.extend(restore_quick_setup_live(&live_snapshots)); + if let Some(snapshot) = &managed_runtime { + if let Err(error) = futures::executor::block_on( + state + .proxy_service + .restore_managed_takeover_runtime(snapshot), + ) { + rollback_errors.push(format!("restore subscription runtime: {error}")); + } + } match state.db.get_provider_by_id(&provider.id, app_type.as_str()) { Ok(restored) => { @@ -5561,7 +5603,10 @@ impl ProviderService { .get(id) .ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?; - if matches!(app_type, AppType::Codex) && !is_quick_setup_provider_id(&app_type, id) { + if matches!(app_type, AppType::Codex) + && !is_quick_setup_provider_id(&app_type, id) + && !_provider.is_xai_oauth() + { let settings = build_effective_settings_with_common_config( state.db.as_ref(), &app_type, @@ -5577,6 +5622,12 @@ impl ProviderService { )?; } + if _provider.is_xai_oauth() && matches!(app_type, AppType::Claude | AppType::Codex) { + return Self::apply_quick_setup_locked(state, app_type, _provider.clone()) + .map(|result| result.value) + .map_err(|error| AppError::Message(error.to_string())); + } + // OMO providers are switched through their own exclusive path. if matches!(app_type, AppType::OpenCode) && _provider.category.as_deref() == Some("omo") { return Self::switch_normal(state, app_type, id, &providers, perform_backfill); diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index fcf725c5f..f09a0c697 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -66,20 +66,225 @@ pub struct ProxyService { /// AppHandle,用于传递给 ProxyServer 以支持故障转移时的 UI 更新 app_handle: Arc>>, switch_locks: SwitchLockManager, + start_lock: Arc>, + managed_activation_lock: Arc>, + #[cfg(test)] + managed_activation_test_hook: Arc>>, } +#[cfg(test)] +pub(crate) type ManagedActivationTestHook = + Arc Result<(), String> + Send + Sync>; + #[derive(Debug, Clone, Copy, Default)] pub struct HotSwitchOutcome { pub logical_target_changed: bool, } +pub(crate) struct ManagedTakeoverRuntimeSnapshot { + global: GlobalProxyConfig, + app: AppProxyConfig, + was_running: bool, +} + impl ProxyService { + /// Provider callers already hold their per-app switch guard. The fixed + /// app -> activation -> start order serializes ownership of the shared + /// listener until both the logical commit and any compensation finish. + pub(crate) async fn lock_managed_activation( + &self, + _app: &AppType, + ) -> tokio::sync::OwnedMutexGuard<()> { + #[cfg(test)] + self.observe_managed_activation(_app, "waiting") + .expect("waiting hook must not inject an error"); + self.managed_activation_lock.clone().lock_owned().await + } + + #[cfg(test)] + pub(crate) fn set_managed_activation_test_hook(&self, hook: Option) { + *self.managed_activation_test_hook.lock().unwrap() = hook; + } + + #[cfg(test)] + fn observe_managed_activation(&self, app: &AppType, phase: &str) -> Result<(), String> { + let hook = self.managed_activation_test_hook.lock().unwrap().clone(); + match hook { + Some(hook) => hook(app.as_str(), phase), + None => Ok(()), + } + } + + pub(crate) async fn snapshot_managed_takeover_runtime( + &self, + app: &AppType, + ) -> Result { + Ok(ManagedTakeoverRuntimeSnapshot { + global: self + .db + .get_global_proxy_config() + .await + .map_err(|e| e.to_string())?, + app: self + .db + .get_proxy_config_for_app(app.as_str()) + .await + .map_err(|e| e.to_string())?, + was_running: self.is_running().await, + }) + } + + /// Called under the Provider guard and its file/DB compensation snapshot. + /// Unlike generic takeover, this never backfills outgoing live API keys + /// into a newly selected managed Provider. + pub(crate) async fn prepare_managed_takeover( + &self, + app: &AppType, + provider: &Provider, + ) -> Result<(), String> { + let config = self + .db + .get_global_proxy_config() + .await + .map_err(|e| e.to_string())?; + let loopback = config + .listen_address + .parse::() + .is_ok_and(|address| address.is_loopback()); + if !loopback { + return Err("Managed subscriptions require a loopback listener".into()); + } + let info = self.start().await?; + #[cfg(test)] + self.observe_managed_activation(app, "started")?; + if !info + .address + .parse::() + .is_ok_and(|ip| ip.is_loopback()) + { + return Err("The active subscription listener is not loopback".into()); + } + let existing = match app { + AppType::Claude if !get_claude_settings_path().exists() => json!({}), + AppType::Claude => self.read_claude_live()?, + AppType::Codex => self.read_codex_live()?, + _ => return Err("Managed takeover target is unsupported".into()), + }; + if self + .db + .get_live_backup(app.as_str()) + .await + .map_err(|e| e.to_string())? + .is_none() + { + self.db + .save_live_backup( + app.as_str(), + &serde_json::to_string(&existing).map_err(|e| e.to_string())?, + ) + .await + .map_err(|e| e.to_string())?; + } + match app { + AppType::Claude => { + let mut projected = existing; + let (url, _) = self.build_proxy_urls().await?; + Self::apply_claude_takeover_fields_for_provider(&mut projected, &url, provider); + self.write_claude_live(&projected)?; + if self.read_claude_live()? != projected { + return Err("Claude subscription configuration readback failed".into()); + } + } + AppType::Codex => { + self.sync_codex_live_from_provider_while_proxy_active(provider) + .await? + } + _ => unreachable!(), + } + if !self.live_takeover_matches_current_proxy(app).await? { + return Err("Subscription endpoint readback failed".into()); + } + let mut app_config = self + .db + .get_proxy_config_for_app(app.as_str()) + .await + .map_err(|e| e.to_string())?; + app_config.enabled = true; + // A subscription binding must not silently fall through to a paid API + // key provider when authorization or the subscription upstream fails. + app_config.auto_failover_enabled = false; + self.db + .update_proxy_config_for_app(app_config) + .await + .map_err(|e| e.to_string())?; + let observed = self + .db + .get_proxy_config_for_app(app.as_str()) + .await + .map_err(|e| e.to_string())?; + if !observed.enabled || observed.auto_failover_enabled { + return Err("Subscription routing state readback failed".into()); + } + Ok(()) + } + + pub(crate) async fn restore_managed_takeover_runtime( + &self, + snapshot: &ManagedTakeoverRuntimeSnapshot, + ) -> Result<(), String> { + self.db + .update_proxy_config_for_app(snapshot.app.clone()) + .await + .map_err(|e| e.to_string())?; + let another_active = self + .db + .is_live_takeover_active() + .await + .map_err(|e| e.to_string())?; + if !snapshot.was_running && !another_active { + if self.is_running().await { + self.stop().await?; + } + self.db + .update_global_proxy_config(snapshot.global.clone()) + .await + .map_err(|e| e.to_string())?; + let observed = self + .db + .get_global_proxy_config() + .await + .map_err(|e| e.to_string())?; + if serde_json::to_value(observed).map_err(|e| e.to_string())? + != serde_json::to_value(&snapshot.global).map_err(|e| e.to_string())? + || self.is_running().await + { + return Err("Subscription global runtime rollback readback failed".into()); + } + } else if snapshot.was_running && !self.is_running().await { + return Err("Subscription listener rollback readback failed".into()); + } + let observed = self + .db + .get_proxy_config_for_app(&snapshot.app.app_type) + .await + .map_err(|e| e.to_string())?; + if serde_json::to_value(observed).map_err(|e| e.to_string())? + != serde_json::to_value(&snapshot.app).map_err(|e| e.to_string())? + { + return Err("Subscription target runtime rollback readback failed".into()); + } + Ok(()) + } pub fn new(db: Arc) -> Self { Self { db, server: Arc::new(RwLock::new(None)), app_handle: Arc::new(RwLock::new(None)), switch_locks: SwitchLockManager::new(), + start_lock: Arc::new(tokio::sync::Mutex::new(())), + managed_activation_lock: Arc::new(tokio::sync::Mutex::new(())), + #[cfg(test)] + managed_activation_test_hook: Arc::new(std::sync::Mutex::new(None)), } } @@ -442,6 +647,26 @@ impl ProxyService { &proxy_codex_base_url, provider, )?; + if provider.is_xai_oauth() { + if let Some(existing) = existing_live { + let patched = crate::codex_config::patch_codex_source_config( + existing + .get("config") + .and_then(Value::as_str) + .unwrap_or_default(), + provider.category.as_deref(), + effective_settings.get("auth").unwrap_or(&Value::Null), + effective_settings + .get("config") + .and_then(Value::as_str) + .unwrap_or_default(), + &crate::codex_config::get_codex_config_dir(), + crate::settings::unify_codex_session_history(), + ) + .map_err(|error| error.to_string())?; + effective_settings["config"] = Value::String(patched); + } + } Ok(effective_settings) } @@ -577,6 +802,7 @@ impl ProxyService { /// 启动代理服务器 pub async fn start(&self) -> Result { + let _start_guard = self.start_lock.lock().await; // 1. 启动时自动设置 proxy_enabled = true let mut global_config = self .db @@ -792,12 +1018,15 @@ impl ProxyService { let app = AppType::from_str(app_type).map_err(|e| format!("无效的应用类型: {e}"))?; let app_type_str = app.as_str(); let _guard = self.switch_locks.lock_for_app(app_type_str).await; + let _activation_guard = self.lock_managed_activation(&app).await; if enabled { // 1) 代理服务未运行则自动启动 if !self.is_running().await { self.start().await?; } + #[cfg(test)] + self.observe_managed_activation(&app, "started")?; // 2) 已接管则直接返回(幂等);但如果缺少备份或占位符残留,需要重建接管 let current_config = self @@ -2874,6 +3103,26 @@ impl ProxyService { } fn write_codex_live(&self, config: &Value) -> Result<(), String> { + // Subscription takeover never owns Codex's native login. On restore, + // retain its current bytes even if Codex refreshed/switched that login + // while the local gateway was active. + if self + .get_current_provider_for_app(&AppType::Codex)? + .is_some_and(|provider| provider.is_xai_oauth()) + { + if let Some(text) = config.get("config").and_then(Value::as_str) { + let prepared = + crate::codex_config::prepare_codex_live_config_text_with_optional_catalog( + config, + text, + crate::codex_config::CodexCatalogToolProfile::ProxyChat, + ) + .map_err(|error| error.to_string())?; + crate::codex_config::write_codex_live_config_atomic(Some(&prepared)) + .map_err(|error| error.to_string())?; + } + return Ok(()); + } self.write_codex_live_verbatim(config) } diff --git a/src/pages/agents/AgentConfiguration.tsx b/src/pages/agents/AgentConfiguration.tsx index 5b3e7d3c9..25d99b766 100644 --- a/src/pages/agents/AgentConfiguration.tsx +++ b/src/pages/agents/AgentConfiguration.tsx @@ -93,13 +93,15 @@ export function AgentConfiguration({ active className="fy-agent-config-body" > - {section === "models" ? ( - + <> + + + ) : section === "skills" ? ( ) : section === "mcp" ? ( diff --git a/src/pages/agents/AgentModelsSection.tsx b/src/pages/agents/AgentModelsSection.tsx index 0327d0af2..6651ecb21 100644 --- a/src/pages/agents/AgentModelsSection.tsx +++ b/src/pages/agents/AgentModelsSection.tsx @@ -1,5 +1,7 @@ import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { appendAgentReturnToPath } from "../../shared/features/agent-navigation"; import type { ProductDirectoryEntry } from "../../shared/features/directory"; import { useOpenCodeModelSnapshot, @@ -14,6 +16,7 @@ import type { } from "../../shared/features/types"; import { FeatureSearch } from "../../shared/ui/FeatureSearch"; import { EmptyState, InlineNotice, Spinner } from "../../shared/ui/primitives"; +import { Button } from "../../shared/ui/Button"; import { AgentSectionHeader } from "./AgentSectionHeader"; @@ -52,6 +55,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"); @@ -149,6 +153,33 @@ export function AgentModelsSection({ actionLabel="管理模型" onAction={onOpenManagement} /> + {entry.agentId === "grokbuild" ? ( + <> + + 要让 Claude Code 或 Codex 使用 SuperGrok,请在账号与认证保存 Grok + 账号,再到目标软件的模型管理选择账号和模型。 + +
+ {(["claude", "codex"] as const).map((target) => ( + + ))} +
+ + ) : null} {mode !== "unsupported" ? ( ({}); const [busy, setBusy] = useState(false); + const [subscriptionBusy, setSubscriptionBusy] = useState(false); const [notice, setNotice] = useState(null); const [warningCodes, setWarningCodes] = useState< CodexProviderMutationWarning[] @@ -1071,7 +1073,13 @@ function ProviderPanel({ }; const requestSave = () => { - if (writeLock.current || writesBlocked || writeConfirm.open) return; + if ( + writeLock.current || + subscriptionBusy || + writesBlocked || + writeConfirm.open + ) + return; const validated = validateQuickSetup( { name, @@ -1326,6 +1334,7 @@ function ProviderPanel({ } disabled={ busy || + subscriptionBusy || probeBusy || writesBlocked || draftCommit.pending || @@ -1345,6 +1354,7 @@ function ProviderPanel({ className="fy-control-button-primary fy-models-commit-button" disabled={ busy || + subscriptionBusy || probeBusy || writesBlocked || queryPending || @@ -1363,6 +1373,32 @@ function ProviderPanel({ + {app === "claude" || app === "codex" ? ( + { + if (writeLock.current || writesBlocked) return false; + writeLock.current = true; + setSubscriptionBusy(true); + return true; + }} + onEndWrite={() => { + writeLock.current = false; + if (mountedRef.current) setSubscriptionBusy(false); + }} + onUnconfirmed={() => onBlockWrites(app)} + /> + ) : null} + {queryPending && } {queryUnavailable && ( diff --git a/src/pages/models/XaiSubscriptionSection.tsx b/src/pages/models/XaiSubscriptionSection.tsx new file mode 100644 index 000000000..2e95b03bc --- /dev/null +++ b/src/pages/models/XaiSubscriptionSection.tsx @@ -0,0 +1,441 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { useEffect, useId, useRef, useState } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; + +import { + appendAgentReturnToPath, + agentReturnDescriptorFromManagementSearch, +} from "../../shared/features/agent-navigation"; +import type { + BindXaiManagedRequest, + BindXaiManagedResult, + ModelWriteTarget, +} from "../../shared/features/models"; +import { useFeatures } from "../../shared/features/provider"; +import { + featureKeys, + useManagedAuthOverview, +} from "../../shared/features/queries"; +import { + isXaiSubscriptionModelId, + xaiBindErrorCode, +} from "../../shared/features/xai-subscription"; +import { FileWriteDisclosure } from "../../shared/features/controls/FileWriteDisclosure"; +import { Button } from "../../shared/ui/Button"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "../../shared/ui/Collapsible"; +import { Dialog } from "../../shared/ui/Dialog"; +import { InlineNotice, Input, Spinner } from "../../shared/ui/primitives"; +import { FieldFeedback, ModelsSection, type Notice } from "./feedback"; +import { GroupedModelChips } from "./modelChips"; + +type Props = { + active: boolean; + disabled: boolean; + app: "claude" | "codex"; + writeTargets: readonly ModelWriteTarget[]; + onBeginWrite: () => boolean; + onEndWrite: () => void; + onUnconfirmed: () => void; +}; + +const TARGET_LABELS = { + claude: "Claude Code", + codex: "Codex", +}; + +type CliBindRequest = BindXaiManagedRequest & { + app: "claude" | "codex"; +}; + +export function XaiSubscriptionSection(props: Props) { + const { ports } = useFeatures(); + const queryClient = useQueryClient(); + const overview = useManagedAuthOverview(props.active); + const navigate = useNavigate(); + const { search } = useLocation(); + const id = useId(); + const [accountId, setAccountId] = useState(""); + const [modelId, setModelId] = useState(""); + const [modelIds, setModelIds] = useState([]); + const [manualModel, setManualModel] = useState(false); + const [pending, setPending] = useState(null); + const [busy, setBusy] = useState<"fetch" | "bind" | null>(null); + const [notice, setNotice] = useState(null); + const [saved, setSaved] = useState(null); + const lock = useRef(false); + const mounted = useRef(true); + const originRef = useRef(null); + const cancelRef = useRef(null); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + + const accounts = + overview.data?.accounts.filter((item) => item.provider === "xai") ?? []; + const selected = accounts.find((item) => item.accountId === accountId); + const accountReady = !overview.isError && selected?.health === "ready"; + const locked = !props.active || props.disabled || busy !== null; + const canBind = + accountReady && isXaiSubscriptionModelId(modelId.trim()) && !locked; + const pendingAccountReady = + !overview.isError && + accounts.some( + (item) => + item.accountId === pending?.accountId && item.health === "ready", + ); + + const openAuth = (connections = false) => { + const descriptor = agentReturnDescriptorFromManagementSearch(search); + const path = connections + ? "/auth?consumer=codex&view=connections" + : "/auth?view=accounts"; + navigate(descriptor ? appendAgentReturnToPath(path, descriptor) : path); + }; + + const fetchModels = async (selectedAccountId = accountId) => { + if ( + lock.current || + locked || + overview.isError || + !accounts.some( + (account) => + account.accountId === selectedAccountId && account.health === "ready", + ) + ) + return; + lock.current = true; + setBusy("fetch"); + setNotice(null); + try { + const result = + await ports.providers.fetchXaiManagedModels(selectedAccountId); + if (!mounted.current) return; + setModelIds(result.models); + setManualModel(result.models.length === 0); + setNotice({ + tone: result.models.length ? "info" : "warning", + title: result.models.length + ? `已加载 ${result.models.length} 个模型选项` + : "暂无模型选项", + description: + "这些选项来自 Grok CLI 官方示例,并非你的账号模型名单。请选择一个,或手动填写订阅支持的模型 ID;实际可用性以服务返回为准。", + }); + } catch { + if (mounted.current) { + setManualModel(true); + setNotice({ + tone: "error", + title: "无法加载模型选项", + description: "请检查账号登录状态。也可以手动填写订阅支持的模型 ID。", + }); + } + } finally { + lock.current = false; + if (mounted.current) setBusy(null); + } + }; + + const confirmBind = async () => { + if (!pending || !pendingAccountReady || locked || lock.current) return; + if (!props.onBeginWrite()) return; + lock.current = true; + setBusy("bind"); + setNotice(null); + setSaved(null); + const request = pending; + setPending(null); + let bindingReturned = false; + try { + const result = await ports.providers.bindXaiManaged(request); + bindingReturned = true; + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: featureKeys.providerSummary(props.app), + refetchType: "none", + }), + queryClient.invalidateQueries({ + queryKey: featureKeys.managedAuthOverview, + refetchType: "none", + }), + ]); + const [summary] = await Promise.all([ + queryClient.fetchQuery({ + queryKey: featureKeys.providerSummary(props.app), + queryFn: () => + ports.providers.getSummary( + props.app === "codex" ? "codex" : "claude", + ), + }), + queryClient.fetchQuery({ + queryKey: featureKeys.managedAuthOverview, + queryFn: ports.managedAuth.getOverview, + }), + ]); + if ( + !summary.providers[result.providerId] || + (request.app === "claude" && + result.activated && + summary.currentId !== result.providerId) + ) { + props.onUnconfirmed(); + if (mounted.current) + setNotice({ + tone: "warning", + title: "设置已保存,当前状态待确认", + description: "请重新读取配置后再继续设置。", + }); + return; + } + if (!mounted.current) return; + setSaved(result); + setNotice( + result.activated + ? { + tone: "info", + title: `已将 SuperGrok 应用到 ${TARGET_LABELS[request.app]}`, + description: + "本机配置已确认。请重新打开目标软件或新建会话;实际调用与额度使用以服务返回为准。", + } + : { + tone: "info", + title: "已保存 Codex 订阅配置", + description: `请前往账号与认证,选择「${result.providerName}」并预览、确认应用。当前请求来源尚未切换。`, + }, + ); + } catch (error) { + const code = xaiBindErrorCode(error); + const unconfirmed = + bindingReturned || + code === null || + code === "rollback_partial_state_unknown"; + if (unconfirmed) props.onUnconfirmed(); + if (mounted.current) + setNotice({ + tone: "error", + title: unconfirmed ? "无法确认当前设置" : "未能应用订阅配置", + description: unconfirmed + ? "已暂停继续修改此目标。请重新打开模型页面,检查当前配置。" + : code === "account_unavailable" + ? "所选账号暂时不能用于本机转发。请到账号与认证检查登录状态,必要时重新登录。" + : code === "provider_conflict" + ? "已有配置发生变化,请重新读取后再设置。" + : code === "apply_failed_rolled_back" + ? "之前的配置已恢复。请检查本机连接服务后重试。" + : "请重新选择账号和模型后再试。", + }); + await queryClient.invalidateQueries({ + queryKey: featureKeys.managedAuthOverview, + }); + } finally { + lock.current = false; + props.onEndWrite(); + if (mounted.current) setBusy(null); + } + }; + + return ( + +

+ 选择在 FyAgent 中登录的账号和模型。使用订阅时,请保持 FyAgent + 在后台运行;完全退出后会停止转发。账号是否支持调用及额度使用,以 Grok + 服务返回为准。 +

+ {overview.isPending ? : null} + {overview.isError ? ( + + 无法读取已保存的账号,请刷新后重试。 + + ) : null} + {!overview.isPending && !overview.isError && accounts.length === 0 ? ( + + 还没有保存的 Grok 账号,请先到账号与认证登录。 + + ) : null} + {accounts.length > 0 ? ( +
+ 订阅账号 + {accounts.map((account) => ( + + ))} +
+ ) : null} +
+ + + +
+ {modelIds.length > 0 ? ( + { + setModelId(value); + setSaved(null); + } + } + emptyLabel="尚未加载模型选项" + /> + ) : null} + {modelId ?

已选模型:{modelId}

: null} + + + + + +
+ + { + setModelId(event.target.value); + setSaved(null); + }} + aria-invalid={ + modelId.length > 0 && !isXaiSubscriptionModelId(modelId.trim()) + } + aria-describedby={`${id}-model-help`} + /> +

+ 从选项中选择,或输入订阅支持的模型 ID。 +

+
+
+
+
+ +
+ + {saved?.app === "codex" ? ( + + ) : null} + { + if (!open && !lock.current) setPending(null); + }} + title={ + pending + ? `确认${pending.app === "claude" ? "应用" : "保存"} ${TARGET_LABELS[pending.app]} 订阅配置` + : "确认订阅配置" + } + description={ + pending?.app === "codex" + ? "先保存账号和模型选择,再到账号与认证预览并确认切换。" + : "将目标软件连接到 FyAgent 的本机转发服务,并保留原配置备份。" + } + actions={ + <> + + + + } + > + {pending ? ( + <> +

+ 账号: + {accounts.find((item) => item.accountId === pending.accountId) + ?.displayName ?? + accounts.find((item) => item.accountId === pending.accountId) + ?.login} +

+

模型:{pending.modelId}

+ + ) : null} + {pending?.app === "claude" ? ( + + ) : null} +
+
+ ); +} diff --git a/src/shared/features/models.ts b/src/shared/features/models.ts index f8892b903..28cfb336a 100644 --- a/src/shared/features/models.ts +++ b/src/shared/features/models.ts @@ -78,6 +78,22 @@ export interface WorkBuddyFetchModelsResult { truncated: boolean; } +export type XaiManagedBindApp = "claude" | "claude-desktop" | "codex"; + +export interface BindXaiManagedRequest { + app: XaiManagedBindApp; + accountId: string; + modelId: string; +} + +export interface BindXaiManagedResult { + providerId: string; + providerName: string; + app: XaiManagedBindApp; + alreadyBound: boolean; + activated: boolean; +} + export interface WorkBuddySaveModelsRequest extends WorkBuddyFetchModelsRequest { selectedModelIds: string[]; diff --git a/src/shared/features/ports.ts b/src/shared/features/ports.ts index 47ddcee8d..001e8b8be 100644 --- a/src/shared/features/ports.ts +++ b/src/shared/features/ports.ts @@ -34,6 +34,8 @@ import type { WorkBuddySaveModelsRequest, WorkBuddySaveModelsResult, WorkBuddyStatus, + BindXaiManagedRequest, + BindXaiManagedResult, ExternalAgentLaunchDestination, ExternalAgentLaunchResult, ExternalAgentRuntimeStatus, @@ -131,6 +133,8 @@ export interface ProvidersPort { fetchModels(baseUrl: string, apiKey: string): Promise; checkReachability(baseUrl: string): Promise; checkModel(request: ModelProbeRequest): Promise; + bindXaiManaged(request: BindXaiManagedRequest): Promise; + fetchXaiManagedModels(accountId: string): Promise; } export interface WorkBuddyPort { diff --git a/src/shared/features/types.ts b/src/shared/features/types.ts index 78cbcef82..fd6ec724d 100644 --- a/src/shared/features/types.ts +++ b/src/shared/features/types.ts @@ -156,6 +156,9 @@ export type { WorkBuddySaveModelsResult, WorkBuddySaveModelsSavedResult, WorkBuddyStatus, + XaiManagedBindApp, + BindXaiManagedRequest, + BindXaiManagedResult, } from "./models"; export type { ManagedPrompt } from "./prompts"; diff --git a/src/shared/features/xai-subscription.ts b/src/shared/features/xai-subscription.ts new file mode 100644 index 000000000..a0a45d853 --- /dev/null +++ b/src/shared/features/xai-subscription.ts @@ -0,0 +1,25 @@ +export const XAI_BIND_ERROR_CODES = [ + "invalid_request", + "account_unavailable", + "provider_conflict", + "apply_failed_rolled_back", + "rollback_partial_state_unknown", +] as const; + +export type XaiBindErrorCode = (typeof XAI_BIND_ERROR_CODES)[number]; + +export function xaiBindErrorCode(value: unknown): XaiBindErrorCode | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) + return null; + const keys = Object.keys(value); + if (keys.length !== 1 || keys[0] !== "code" || !("code" in value)) + return null; + return XAI_BIND_ERROR_CODES.find((code) => code === value.code) ?? null; +} + +export function isXaiSubscriptionModelId(value: unknown): value is string { + return ( + typeof value === "string" && + /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u.test(value) + ); +} diff --git a/src/shared/platform/browser/features.ts b/src/shared/platform/browser/features.ts index c12802691..54d7d4c8a 100644 --- a/src/shared/platform/browser/features.ts +++ b/src/shared/platform/browser/features.ts @@ -107,6 +107,8 @@ export function createBrowserFeaturePorts(): FeaturePorts { fetchModels: rejectNativeOnly, checkReachability: rejectNativeOnly, checkModel: rejectNativeOnly, + bindXaiManaged: rejectNativeOnly, + fetchXaiManagedModels: rejectNativeOnly, }, workbuddy: { getStatus: rejectNativeOnly, diff --git a/src/shared/platform/tauri/feature-ports/models.ts b/src/shared/platform/tauri/feature-ports/models.ts index 409fae1ad..a55c4354a 100644 --- a/src/shared/platform/tauri/feature-ports/models.ts +++ b/src/shared/platform/tauri/feature-ports/models.ts @@ -14,7 +14,13 @@ import type { ProviderSummaryQueryData, ReachabilityResult, WorkBuddySaveModelsResult, + BindXaiManagedRequest, + BindXaiManagedResult, } from "../../../features/types"; +import { + isXaiSubscriptionModelId, + xaiBindErrorCode, +} from "../../../features/xai-subscription"; import { hasExactKeys, hasRequiredAndOptionalKeys, @@ -404,6 +410,76 @@ function assertQuickSetupRequest( return request; } +function assertBindXaiManagedRequest( + request: BindXaiManagedRequest, +): BindXaiManagedRequest { + if ( + !isRecord(request) || + !hasExactKeys(request, ["app", "accountId", "modelId"]) || + !isOneOf(request.app, ["claude", "claude-desktop", "codex"]) || + typeof request.accountId !== "string" || + !/^ma1:[0-9a-f]{32}$/u.test(request.accountId) || + !isXaiSubscriptionModelId(request.modelId) + ) + throw new Error("SuperGrok bind request is invalid"); + return { + app: request.app, + accountId: request.accountId, + modelId: request.modelId, + }; +} + +function parseBindXaiManagedResult( + value: unknown, + request: BindXaiManagedRequest, +): BindXaiManagedResult { + if ( + !isRecord(value) || + !hasExactKeys(value, [ + "providerId", + "providerName", + "app", + "alreadyBound", + "activated", + ]) || + typeof value.providerId !== "string" || + !/^[A-Za-z0-9][A-Za-z0-9:._-]{0,159}$/u.test(value.providerId) || + typeof value.providerName !== "string" || + !value.providerName.trim() || + value.providerName.length > 200 || + /[\r\n\0]/u.test(value.providerName) || + value.app !== request.app || + typeof value.alreadyBound !== "boolean" || + typeof value.activated !== "boolean" || + value.activated !== (request.app === "claude") + ) + throw new Error("SuperGrok bind result is unavailable"); + return { + providerId: value.providerId, + providerName: value.providerName, + app: request.app, + alreadyBound: value.alreadyBound, + activated: value.activated, + }; +} + +function parseXaiManagedModels(value: unknown): { + models: string[]; + truncated: boolean; +} { + if (!Array.isArray(value) || value.length > 2_000) + throw new Error("xAI models are unavailable"); + const models: string[] = []; + for (const entry of value) { + const id = + typeof entry === "string" ? entry : isRecord(entry) ? entry.id : null; + if (!isXaiSubscriptionModelId(id)) + throw new Error("xAI models are unavailable"); + if (!models.includes(id)) models.push(id); + } + return { models, truncated: false }; +} + export function createModelFeaturePorts(): Pick< FeaturePorts, "providers" | "workbuddy" | "opencodeModels" @@ -426,6 +502,31 @@ export function createModelFeaturePorts(): Pick< ), checkReachability: invokeReachability, checkModel: invokeModelProbe, + bindXaiManaged: async (request) => { + const validated = assertBindXaiManagedRequest(request); + try { + return parseBindXaiManagedResult( + await invoke("bind_xai_managed_provider", { + request: validated, + }), + validated, + ); + } catch (error) { + throw { + code: xaiBindErrorCode(error) ?? "rollback_partial_state_unknown", + }; + } + }, + fetchXaiManagedModels: async (accountId) => { + if ( + typeof accountId !== "string" || + !/^ma1:[0-9a-f]{32}$/u.test(accountId) + ) + throw new Error("xAI account selection is invalid"); + return parseXaiManagedModels( + await invoke("get_xai_oauth_models", { accountId }), + ); + }, }, workbuddy: { getStatus: () => invoke("get_workbuddy_status"), diff --git a/tests/browser/agents-models.spec.ts b/tests/browser/agents-models.spec.ts index 0328c091b..12e37e43a 100644 --- a/tests/browser/agents-models.spec.ts +++ b/tests/browser/agents-models.spec.ts @@ -610,7 +610,7 @@ test("Codex quick setup locks duplicate submission and sends exact provider payl await page.getByLabel("配置名称").fill("Browser Codex"); await page.getByLabel("服务地址").fill("https://codex.example.test/v1"); await page.getByLabel("API Key", { exact: true }).fill(apiKey); - await page.getByLabel("模型 ID").fill("gpt-browser"); + await page.getByLabel("模型 ID", { exact: true }).fill("gpt-browser"); const submit = page.getByRole("button", { name: "保存并设为当前配置" }); await submit.click(); await confirmSaveDisclosure(page); @@ -768,7 +768,7 @@ test("Claude quick setup updates its reserved row with exact settings and switch await page.getByLabel("配置名称").fill("Browser Claude"); await page.getByLabel("服务地址").fill("https://claude.example.test/v1"); await page.getByLabel("API Key", { exact: true }).fill(apiKey); - await page.getByLabel("模型 ID").fill("claude-browser"); + await page.getByLabel("模型 ID", { exact: true }).fill("claude-browser"); await page.getByRole("button", { name: "保存并设为当前配置" }).click(); await confirmSaveDisclosure(page); await expect(page.getByLabel("API Key", { exact: true })).toHaveValue(""); @@ -813,7 +813,7 @@ test("Provider atomic failure reports rollback instead of a partial result", asy await page.getByLabel("配置名称").fill("Partial Codex"); await page.getByLabel("服务地址").fill("https://partial.example.test/v1"); await page.getByLabel("API Key", { exact: true }).fill("partial-secret"); - await page.getByLabel("模型 ID").fill("partial-model"); + await page.getByLabel("模型 ID", { exact: true }).fill("partial-model"); await page.getByRole("button", { name: "保存并设为当前配置" }).click(); await confirmSaveDisclosure(page); await page diff --git a/tests/browser/blue-themes.spec.ts b/tests/browser/blue-themes.spec.ts index 38a9d3bbc..f3db1027d 100644 --- a/tests/browser/blue-themes.spec.ts +++ b/tests/browser/blue-themes.spec.ts @@ -21,8 +21,8 @@ test("restores appearance before content and preserves pages and drafts across r await openRendererPage(page, "/models?target=codex"); await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); const input = page - .locator('input:not([type="hidden"]):not(:disabled)') - .first(); + .getByRole("region", { name: "Codex 模型配置" }) + .getByRole("textbox", { name: "配置名称", exact: true }); await input.fill("theme-draft-fixture"); const theme = page.getByRole("button", { name: "切换为清亮蓝色" }); await expect(theme).toHaveCSS("border-radius", "50%"); diff --git a/tests/browser/materials-responsive.spec.ts b/tests/browser/materials-responsive.spec.ts index b7faf140a..695a36fa2 100644 --- a/tests/browser/materials-responsive.spec.ts +++ b/tests/browser/materials-responsive.spec.ts @@ -55,6 +55,11 @@ test("keeps actual text readable on blended surfaces and dialogs", async ({ }); expect.soft(samples.filter((sample) => sample.ratio < 4.5)).toEqual([]); if (id === "models") { + const configurationName = page + .getByRole("region", { name: "Codex 模型配置" }) + .getByRole("textbox", { name: "配置名称", exact: true }); + await configurationName.scrollIntoViewIfNeeded(); + await expect(configurationName).toBeInViewport(); const boundaries = await sampleControlBoundaryContrast( page, `${scope} .fy-control-input:not(:disabled)`, diff --git a/tests/browser/press-feedback.spec.ts b/tests/browser/press-feedback.spec.ts index 77b8b30ed..f03cf4e33 100644 --- a/tests/browser/press-feedback.spec.ts +++ b/tests/browser/press-feedback.spec.ts @@ -95,9 +95,13 @@ test("positioned search/reveal controls animate their child without losing cente }) => { await installRichTauriFeatureFixture(page); await openRendererPage(page, "/models?target=workbuddy"); - const control = page.locator(".fy-control-secret-toggle").first(); + const control = page + .getByRole("region", { name: "WorkBuddy 模型配置" }) + .getByRole("button", { name: "显示 API Key", exact: true }); await expect(control).toBeVisible(); await expect(control).toBeEnabled(); + await control.scrollIntoViewIfNeeded(); + await expect(control).toBeInViewport(); const before = await control.boundingBox(); const visual = control.locator(".fy-control-icon-feedback"); const visualWidth = (await visual.boundingBox())!.width; diff --git a/tests/browser/support/features.ts b/tests/browser/support/features.ts index c16110c46..5487a4908 100644 --- a/tests/browser/support/features.ts +++ b/tests/browser/support/features.ts @@ -14,6 +14,7 @@ export interface RichFeatureFixtureOptions { providerWriteDelayMs?: number; holdProviderWrite?: boolean; holdAgentAuth?: boolean; + xaiBindFailure?: boolean; workBuddySave?: | "saved" | "overwrite_then_saved" @@ -461,6 +462,7 @@ export async function installRichTauriFeatureFixture( ], }; let upsertPlan: Record | null = null; + let switchPlan: Record | null = null; let changeJob: Record | null = null; const makeUpsertPlan = (name: string) => { const createdAt = changePlanNow(); @@ -1266,6 +1268,39 @@ export async function installRichTauriFeatureFixture( updatedEntries: 0, }; } + case "get_xai_oauth_models": + if (payload.accountId !== managedAuthAccountIds.xai) + throw { code: "account_unavailable" }; + return [ + { id: "grok-subscription-fixture-1" }, + { id: "grok-subscription-fixture-2" }, + ]; + case "bind_xai_managed_provider": { + const request = payload.request as Record; + if ( + fixtureOptions.xaiBindFailure || + request.accountId !== managedAuthAccountIds.xai + ) + throw { code: "account_unavailable" }; + const app = String(request.app); + const providerApp = app === "claude-desktop" ? "claude" : app; + const providerId = `subscription-fixture-${app}`; + const alreadyBound = Boolean(providers[providerApp]?.[providerId]); + providers[providerApp] ??= {}; + providers[providerApp][providerId] = { + id: providerId, + name: `SuperGrok ${app}`, + modelId: String(request.modelId), + }; + if (app === "claude") currentProviderIds.claude = providerId; + return { + providerId, + providerName: `SuperGrok ${app}`, + app, + alreadyBound, + activated: app === "claude", + }; + } case "get_provider_summary": { const app = String(payload.app); if (fixtureOptions.observationFailure === app) { @@ -1295,11 +1330,13 @@ export async function installRichTauriFeatureFixture( return []; case "create_codex_provider_switch_plan": { const createdAt = changePlanNow(); - return { + switchPlan = { planId: "plan-codex-switch", operation: "codex_provider_switch", targetProviderId: String(payload.targetProviderId), - targetProviderName: "Fixture Codex Switch", + targetProviderName: + providers.codex?.[String(payload.targetProviderId)]?.name ?? + "Fixture Codex Switch", planDigest: digest("c"), baselineDigest: digest("d"), dbBaselineProviderId: currentProviderIds.codex ?? null, @@ -1321,6 +1358,7 @@ export async function installRichTauriFeatureFixture( ], evidenceNote: "usage_not_observed", }; + return structuredClone(switchPlan); } case "create_codex_provider_upsert_plan": { if (fixtureOptions.holdProviderWrite) await providerWriteGate; @@ -1344,6 +1382,21 @@ export async function installRichTauriFeatureFixture( return structuredClone(workBuddyPlan); } case "apply_change_plan": { + if (switchPlan && payload.planId === switchPlan.planId) { + if (payload.planDigest !== switchPlan.planDigest) + return { kind: "rejected", errorCode: "stale" }; + currentProviderIds.codex = String(switchPlan.targetProviderId); + switchPlan = { ...switchPlan, status: "consumed" }; + changeJob = { + ...makeTerminalJob(false), + jobId: "job-codex-switch", + executionId: "job-codex-switch", + planId: switchPlan.planId, + idempotencyKey: switchPlan.planId, + targetProviderId: switchPlan.targetProviderId, + }; + return { kind: "admitted", job: structuredClone(changeJob) }; + } if (workBuddyPlan && payload.planId === workBuddyPlan.planId) { if (payload.planDigest !== workBuddyPlan.planDigest) { return { kind: "rejected", errorCode: "stale" }; @@ -1480,7 +1533,8 @@ export async function installRichTauriFeatureFixture( return []; case "get_agent_install_readiness": { const agentId = String(payload.agentId); - const grokCli = agentId === "grokbuild" || agentId === "claude-code"; + const grokCli = + agentId === "grokbuild" || agentId === "claude-code"; return { contractVersion: 4, agentId, diff --git a/tests/browser/xai-subscription.spec.ts b/tests/browser/xai-subscription.spec.ts new file mode 100644 index 000000000..203cb93ec --- /dev/null +++ b/tests/browser/xai-subscription.spec.ts @@ -0,0 +1,124 @@ +import { expect, test } from "@playwright/test"; + +import { + expectHealthyPage, + expectNoHorizontalOverflow, + monitorPageHealth, + openRendererPage, +} from "./support"; +import { + featureFixtureCalls, + installRichTauriFeatureFixture, +} from "./support/features"; + +test("saved Grok subscription can be selected for Claude and continued through the Codex source plan", async ({ + page, +}) => { + await installRichTauriFeatureFixture(page); + const health = monitorPageHealth(page); + await openRendererPage(page, "/models?target=claude"); + let section = page + .getByRole("region", { name: "Claude Code 模型配置", exact: true }) + .getByRole("region", { name: "SuperGrok 订阅设置" }); + await expect( + section.getByRole("button", { name: "应用到 Claude Code" }), + ).toBeDisabled(); + await section.getByRole("radio", { name: "browser-xai@example.com" }).check(); + await section + .getByRole("button", { name: "grok-subscription-fixture-2" }) + .click(); + await section.getByRole("button", { name: "应用到 Claude Code" }).click(); + let dialog = page.getByRole("dialog", { + name: "确认应用 Claude Code 订阅配置", + }); + await expect(dialog.getByText(/grok-subscription-fixture-2/)).toBeVisible(); + await dialog.getByRole("button", { name: "确认应用" }).click(); + await expect( + section.getByText("已将 SuperGrok 应用到 Claude Code"), + ).toBeVisible(); + await expect(section.getByText(/保持 FyAgent 在后台运行/)).toBeVisible(); + await expectNoHorizontalOverflow(page); + + await page.getByRole("button", { name: "Codex", exact: true }).click(); + section = page + .getByRole("region", { name: "Codex 模型配置", exact: true }) + .getByRole("region", { name: "SuperGrok 订阅设置" }); + await expect( + section.getByRole("button", { name: "保存 Codex 订阅配置" }), + ).toBeDisabled(); + await section.getByRole("radio", { name: "browser-xai@example.com" }).check(); + await section + .getByRole("button", { name: "grok-subscription-fixture-1" }) + .click(); + await section.getByRole("button", { name: "保存 Codex 订阅配置" }).click(); + dialog = page.getByRole("dialog", { name: "确认保存 Codex 订阅配置" }); + await dialog.getByRole("button", { name: "确认保存" }).click(); + await expect(section.getByText(/当前请求来源尚未切换/)).toBeVisible(); + await section.getByRole("button", { name: "继续预览 Codex 配置" }).click(); + await expect(page).toHaveURL(/#\/auth\?consumer=codex&view=connections$/); + await page.getByRole("combobox").selectOption("subscription-fixture-codex"); + await page.getByRole("button", { name: "预览更改" }).click(); + await page.getByRole("button", { name: "应用更改" }).click(); + await expect(page.getByRole("button", { name: "应用更改" })).toHaveCount(0); + + const calls = await featureFixtureCalls(page); + expect( + calls + .filter((call) => call.command === "bind_xai_managed_provider") + .map((call) => call.payload), + ).toEqual([ + { + request: { + app: "claude", + accountId: `ma1:${"2".repeat(32)}`, + modelId: "grok-subscription-fixture-2", + }, + }, + { + request: { + app: "codex", + accountId: `ma1:${"2".repeat(32)}`, + modelId: "grok-subscription-fixture-1", + }, + }, + ]); + expect( + calls.filter((call) => call.command === "apply_change_plan"), + ).toHaveLength(1); + expect(calls.some((call) => call.command === "auth_get_status")).toBe(false); + await expectHealthyPage(page, health); +}); + +test("subscription rejection remains local to its target and exposes the account recovery entry", async ({ + page, +}) => { + await installRichTauriFeatureFixture(page, { xaiBindFailure: true }); + await openRendererPage( + page, + "/models?target=claude&agentReturn=grokbuild&agentSection=models", + ); + const section = page.getByRole("region", { name: "SuperGrok 订阅设置" }); + await section.getByRole("radio", { name: "browser-xai@example.com" }).check(); + await section + .getByRole("button", { name: "grok-subscription-fixture-1" }) + .click(); + await section.getByRole("button", { name: "应用到 Claude Code" }).click(); + await page + .getByRole("dialog") + .getByRole("button", { name: "确认应用" }) + .click(); + await expect(section.getByText("未能应用订阅配置")).toBeVisible(); + await expect(section.getByText(/所选账号暂时不能用于本机转发/)).toBeVisible(); + await section.getByRole("button", { name: "管理 Grok 账号" }).click(); + await expect(page).toHaveURL( + /#\/auth\?view=accounts&agentReturn=grokbuild&agentSection=models$/, + ); + const calls = await featureFixtureCalls(page); + expect( + calls.filter( + (call) => + call.command === "apply_provider_quick_setup_with_result" || + call.command === "apply_change_plan", + ), + ).toEqual([]); +}); diff --git a/tests/renderer/pages/models/Page.test.tsx b/tests/renderer/pages/models/Page.test.tsx index 792d00904..baac7168a 100644 --- a/tests/renderer/pages/models/Page.test.tsx +++ b/tests/renderer/pages/models/Page.test.tsx @@ -25,6 +25,7 @@ import { changePlanUpsertWire, changePlanWorkBuddyWire, } from "../../fixtures/changePlans"; +import { managedAuthOverviewFixture } from "../../fixtures/managedAuth"; function renderPage(ports: FeaturePorts, target?: string) { const initialEntry = target ? `/models?target=${target}` : "/models"; @@ -250,6 +251,43 @@ const TEST_OPENCODE_SNAPSHOT_META = { } as const; describe("Models page", () => { + it("keeps Grok subscription selections isolated when switching provider targets", async () => { + const user = userEvent.setup(); + const ports = createBrowserFeaturePorts(); + ports.managedAuth.getOverview = vi.fn(async () => + managedAuthOverviewFixture(), + ); + ports.providers.fetchXaiManagedModels = vi.fn(async () => ({ + models: ["grok-selected-fixture"], + truncated: false, + })); + ports.providers.getSummary = vi.fn(async () => ({ + providers: {}, + currentId: "", + writeTargets: [...TEST_PROVIDER_WRITE_TARGETS], + })); + renderPage(ports, "claude"); + await user.click( + await screen.findByRole("radio", { name: "xai@example.com" }), + ); + await user.click( + await screen.findByRole("button", { name: "grok-selected-fixture" }), + ); + expect( + screen.getByRole("button", { name: "应用到 Claude Code" }), + ).toBeEnabled(); + await user.click(screen.getByTestId("model-target-codex")); + expect( + await screen.findByRole("radio", { name: "xai@example.com" }), + ).not.toBeChecked(); + expect( + screen.getByRole("button", { name: "保存 Codex 订阅配置" }), + ).toBeDisabled(); + expect( + screen.queryByText("已选模型:grok-selected-fixture"), + ).not.toBeInTheDocument(); + }); + it("renders the exact selector order, local decorative icons, and QoderWork default", () => { const ports = createBrowserFeaturePorts(); renderPage(ports); @@ -515,6 +553,9 @@ describe("Models page", () => { renderPage(ports, "workbuddy"); await screen.findByText("已有第三方模型数量"); + expect( + screen.queryByRole("region", { name: "SuperGrok 订阅设置" }), + ).not.toBeInTheDocument(); const heading = screen.getByRole("heading", { name: "WorkBuddy" }); const header = heading.closest("header"); expect(header).not.toBeNull(); @@ -792,9 +833,9 @@ describe("Models page", () => { "aria-hidden", "true", ); - expect( - screen.getByText("gpt-4o").closest("[inert]"), - ).toHaveStyle({ height: "0px" }); + expect(screen.getByText("gpt-4o").closest("[inert]")).toHaveStyle({ + height: "0px", + }); await user.click( screen.getByRole("heading", { name: "当前已有的第三方模型 ID" }), diff --git a/tests/renderer/pages/models/XaiSubscriptionSection.test.tsx b/tests/renderer/pages/models/XaiSubscriptionSection.test.tsx new file mode 100644 index 000000000..1d9b86dda --- /dev/null +++ b/tests/renderer/pages/models/XaiSubscriptionSection.test.tsx @@ -0,0 +1,377 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { + fireEvent, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { StrictMode, useState } from "react"; +import { MemoryRouter, useLocation } from "react-router-dom"; +import { describe, expect, it, vi } from "vitest"; + +import { XaiSubscriptionSection } from "@/pages/models/XaiSubscriptionSection"; +import type { FeaturePorts } from "@/shared/features/ports"; +import { FeatureProvider } from "@/shared/features/provider"; +import { featureKeys } from "@/shared/features/queries"; +import { createBrowserFeaturePorts } from "@/shared/platform/browser/features"; +import { TooltipProvider } from "@/shared/ui/primitives"; +import { + managedAuthOverviewFixture, + XAI_ACCOUNT_ID, +} from "../../fixtures/managedAuth"; + +const secondAccountId = `ma1:${"9".repeat(32)}`; +const providerId = "subscription-fixture-provider"; +const modelIds = ["grok-fixture-1", "grok-fixture-2"]; +const writeTargets = [ + { + path: "~/.claude/settings.json", + backupPath: "~/.claude/settings.json.backup", + exists: true, + }, +]; + +function configuredPorts() { + const ports = createBrowserFeaturePorts(); + const overview = managedAuthOverviewFixture(); + overview.accounts.push({ + ...overview.accounts[1], + accountId: secondAccountId, + login: "second@example.com", + connectedConsumerCount: 0, + isDefault: false, + }); + ports.managedAuth.getOverview = vi.fn(async () => overview); + ports.providers.fetchXaiManagedModels = vi.fn(async () => ({ + models: modelIds, + truncated: false, + })); + ports.providers.bindXaiManaged = vi.fn(async (request) => ({ + providerId, + providerName: "My SuperGrok", + app: request.app, + activated: request.app === "claude", + alreadyBound: false, + })); + ports.providers.getSummary = vi.fn(async () => ({ + providers: { [providerId]: { id: providerId, name: "My SuperGrok" } }, + currentId: providerId, + writeTargets, + })); + return ports; +} + +function LocationAndAuthority() { + const location = useLocation(); + const client = useQueryClient(); + return ( + <> + + {location.pathname} + {location.search} + + + + ); +} + +function renderSection( + ports: FeaturePorts, + app: "claude" | "codex" = "claude", + active = true, +) { + const onBegin = vi.fn(() => true); + const onEnd = vi.fn(); + const onUnconfirmed = vi.fn(); + function Harness() { + const [disabled, setDisabled] = useState(false); + return ( + <> + { + onUnconfirmed(); + setDisabled(true); + }} + active={active} + disabled={disabled} + /> + + + ); + } + const view = render( + + + + + + + + + , + ); + return { ...view, onBegin, onEnd, onUnconfirmed }; +} + +async function selectAccountAndModel( + user: ReturnType, + account = "xai@example.com", +) { + await user.click(await screen.findByRole("radio", { name: account })); + await user.click(await screen.findByRole("button", { name: modelIds[1] })); +} + +describe("Grok subscription selection and application", () => { + it("uses an explicitly chosen account and suggested model without claiming entitlement, then rereads both owners", async () => { + const user = userEvent.setup(); + const ports = configuredPorts(); + renderSection(ports); + expect( + await screen.findByRole("button", { name: "应用到 Claude Code" }), + ).toBeDisabled(); + expect(ports.providers.fetchXaiManagedModels).not.toHaveBeenCalled(); + await selectAccountAndModel(user, "second@example.com"); + expect(screen.getByText(/并非你的账号模型名单/)).toBeVisible(); + expect( + screen.queryByRole("button", { name: /Claude Desktop/ }), + ).not.toBeInTheDocument(); + expect(ports.providers.fetchXaiManagedModels).toHaveBeenCalledWith( + secondAccountId, + ); + await user.click( + screen.getByRole("button", { name: "应用到 Claude Code" }), + ); + const dialog = await screen.findByRole("dialog"); + expect(within(dialog).getByText(/second@example.com/)).toBeVisible(); + expect(within(dialog).getByText("~/.claude/settings.json")).toBeVisible(); + const confirmation = within(dialog).getByRole("button", { + name: "确认应用", + }); + fireEvent.click(confirmation); + fireEvent.click(confirmation); + expect( + await screen.findByText("已将 SuperGrok 应用到 Claude Code"), + ).toBeVisible(); + expect(ports.providers.bindXaiManaged).toHaveBeenCalledExactlyOnceWith({ + app: "claude", + accountId: secondAccountId, + modelId: modelIds[1], + }); + expect(ports.providers.getSummary).toHaveBeenCalledWith("claude"); + expect(ports.managedAuth.getOverview).toHaveBeenCalledTimes(2); + expect(screen.getByText(/实际调用与额度使用以服务返回为准/)).toBeVisible(); + }); + + it("clears the previous account's model choice and requires a new selection", async () => { + const user = userEvent.setup(); + const ports = configuredPorts(); + renderSection(ports); + await selectAccountAndModel(user); + await user.click(screen.getByRole("radio", { name: "second@example.com" })); + await waitFor(() => + expect(ports.providers.fetchXaiManagedModels).toHaveBeenCalledTimes(2), + ); + expect( + screen.getByRole("button", { name: "应用到 Claude Code" }), + ).toBeDisabled(); + expect(screen.queryByText(/已选模型:/)).not.toBeInTheDocument(); + }); + + it("routes to the current account page with the existing Agent return context", async () => { + const user = userEvent.setup(); + renderSection(configuredPorts()); + await user.click(screen.getByRole("button", { name: "管理 Grok 账号" })); + expect(screen.getByTestId("location")).toHaveTextContent( + "/auth?view=accounts&agentReturn=grokbuild&agentSection=models", + ); + }); + + it("saves a Codex draft and hands off to the existing source workspace without applying a plan", async () => { + const user = userEvent.setup(); + const ports = configuredPorts(); + ports.changePlans.applyChangePlan = vi.fn(); + renderSection(ports, "codex"); + await selectAccountAndModel(user); + await user.click( + screen.getByRole("button", { name: "保存 Codex 订阅配置" }), + ); + await user.click( + within(await screen.findByRole("dialog")).getByRole("button", { + name: "确认保存", + }), + ); + expect(await screen.findByText(/当前请求来源尚未切换/)).toBeVisible(); + expect(ports.providers.bindXaiManaged).toHaveBeenCalledWith({ + app: "codex", + accountId: XAI_ACCOUNT_ID, + modelId: modelIds[1], + }); + expect(ports.changePlans.applyChangePlan).not.toHaveBeenCalled(); + await user.click( + screen.getByRole("button", { name: "继续预览 Codex 配置" }), + ); + expect(screen.getByTestId("location")).toHaveTextContent( + "/auth?consumer=codex&view=connections&agentReturn=grokbuild&agentSection=models", + ); + }); + + it("blocks a selected account that expires while confirmation is open", async () => { + const user = userEvent.setup(); + const ports = configuredPorts(); + renderSection(ports); + await selectAccountAndModel(user); + await user.click( + screen.getByRole("button", { name: "应用到 Claude Code" }), + ); + fireEvent.click(screen.getByTestId("expire-account")); + expect( + within(await screen.findByRole("dialog")).getByRole("button", { + name: "确认应用", + }), + ).toBeDisabled(); + expect(ports.providers.bindXaiManaged).not.toHaveBeenCalled(); + }); + + it("offers explicit manual input after discovery failure without replacing the selected account", async () => { + const user = userEvent.setup(); + const ports = configuredPorts(); + ports.providers.fetchXaiManagedModels = vi.fn(async () => { + throw new Error("SENTINEL-SECRET"); + }); + renderSection(ports); + await user.click( + await screen.findByRole("radio", { name: "xai@example.com" }), + ); + const input = await screen.findByLabelText("订阅模型 ID"); + await user.type(input, "grok-manual-fixture"); + expect( + screen.getByRole("button", { name: "应用到 Claude Code" }), + ).toBeEnabled(); + expect(screen.queryByText(/SENTINEL-SECRET/)).not.toBeInTheDocument(); + }); + + it.each(["account_unavailable", "apply_failed_rolled_back"])( + "shows %s as a safe per-target failure", + async (code) => { + const user = userEvent.setup(); + const ports = configuredPorts(); + ports.providers.bindXaiManaged = vi.fn(async () => { + throw { code }; + }); + const view = renderSection(ports); + await selectAccountAndModel(user); + await user.click( + screen.getByRole("button", { name: "应用到 Claude Code" }), + ); + await user.click( + within(await screen.findByRole("dialog")).getByRole("button", { + name: "确认应用", + }), + ); + expect(await screen.findByText("未能应用订阅配置")).toBeVisible(); + expect(view.onUnconfirmed).not.toHaveBeenCalled(); + expect( + screen.queryByText("已将 SuperGrok 应用到 Claude Code"), + ).not.toBeInTheDocument(); + }, + ); + + it("does not claim success when authoritative reread fails", async () => { + const user = userEvent.setup(); + const ports = configuredPorts(); + ports.providers.getSummary = vi.fn(async () => { + throw new Error("SENTINEL-SECRET"); + }); + const view = renderSection(ports); + await selectAccountAndModel(user); + await user.click( + screen.getByRole("button", { name: "应用到 Claude Code" }), + ); + await user.click( + within(await screen.findByRole("dialog")).getByRole("button", { + name: "确认应用", + }), + ); + expect( + await screen.findByText("无法确认当前设置", {}, { timeout: 3000 }), + ).toBeVisible(); + expect(view.onUnconfirmed).toHaveBeenCalledOnce(); + expect( + screen.getByRole("button", { name: "应用到 Claude Code" }), + ).toBeDisabled(); + expect(screen.queryByText(/SENTINEL-SECRET/)).not.toBeInTheDocument(); + }); + + it("reports a mismatched authoritative reread even after the pending target unmounts", async () => { + const user = userEvent.setup(); + const ports = configuredPorts(); + let finishBinding!: () => void; + const binding = new Promise((resolve) => { + finishBinding = resolve; + }); + const bind = ports.providers.bindXaiManaged; + ports.providers.bindXaiManaged = vi.fn(async (request) => { + await binding; + return bind(request); + }); + ports.providers.getSummary = vi.fn(async () => ({ + providers: {}, + currentId: "another-provider", + writeTargets, + })); + const view = renderSection(ports); + await selectAccountAndModel(user); + await user.click( + screen.getByRole("button", { name: "应用到 Claude Code" }), + ); + await user.click( + within(await screen.findByRole("dialog")).getByRole("button", { + name: "确认应用", + }), + ); + expect(ports.providers.bindXaiManaged).toHaveBeenCalledOnce(); + view.unmount(); + finishBinding(); + await waitFor(() => expect(view.onUnconfirmed).toHaveBeenCalledOnce()); + expect(ports.providers.getSummary).toHaveBeenCalledWith("claude"); + expect(view.onEnd).toHaveBeenCalledOnce(); + }); + + it("does not read managed accounts for an inactive panel", () => { + const ports = configuredPorts(); + renderSection(ports, "claude", false); + expect(ports.managedAuth.getOverview).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/renderer/platform/tauriAclContract.test.ts b/tests/renderer/platform/tauriAclContract.test.ts index ab1b11ecf..314590076 100644 --- a/tests/renderer/platform/tauriAclContract.test.ts +++ b/tests/renderer/platform/tauriAclContract.test.ts @@ -124,7 +124,7 @@ describe("Native ACL contract", () => { const allowed = activeAclCommands(); expect(renderer.dynamicInvokes).toEqual([]); - expect(renderer.commands.size).toBe(108); + expect(renderer.commands.size).toBe(110); expect(renderer.commands.has("set_window_theme")).toBe(true); expect( [...renderer.commands].filter((command) => !registered.has(command)), diff --git a/tests/renderer/platform/xaiSubscriptionPort.test.ts b/tests/renderer/platform/xaiSubscriptionPort.test.ts new file mode 100644 index 000000000..ca113950f --- /dev/null +++ b/tests/renderer/platform/xaiSubscriptionPort.test.ts @@ -0,0 +1,150 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { BindXaiManagedRequest } from "@/shared/features/models"; +import { + createBrowserFeaturePorts, + NATIVE_ONLY_ERROR, +} from "@/shared/platform/browser/features"; +import { XAI_ACCOUNT_ID } from "../fixtures/managedAuth"; + +const { invoke } = vi.hoisted(() => ({ invoke: vi.fn() })); +vi.mock("@tauri-apps/api/core", () => ({ invoke })); + +const request: BindXaiManagedRequest = { + app: "claude", + accountId: XAI_ACCOUNT_ID, + modelId: "grok-fixture-1", +}; +const result = { + providerId: "xai-managed-claude-account-1", + providerName: "SuperGrok fixture", + app: "claude", + alreadyBound: false, + activated: true, +}; + +async function ports() { + const { createModelFeaturePorts } = await import( + "@/shared/platform/tauri/feature-ports/models" + ); + return createModelFeaturePorts().providers; +} + +describe("Grok subscription transport", () => { + beforeEach(() => { + invoke.mockReset(); + }); + + it("sends explicit vault identity, target and selected model, without a default-account request", async () => { + invoke.mockResolvedValue(result); + await expect((await ports()).bindXaiManaged(request)).resolves.toEqual( + result, + ); + expect(invoke).toHaveBeenCalledExactlyOnceWith( + "bind_xai_managed_provider", + { request }, + ); + }); + + it.each([ + { app: "claude", accountId: XAI_ACCOUNT_ID }, + { ...request, accountId: null }, + { ...request, accountId: "legacy-xai-account" }, + { ...request, app: "workbuddy" }, + { ...request, modelId: "" }, + { ...request, modelId: "grok\nother" }, + { ...request, modelId: "x".repeat(129) }, + { ...request, modelId: "../model" }, + { ...request, token: "SENTINEL-SECRET" }, + ])( + "rejects invalid or excess request data before IPC %#", + async (invalid) => { + await expect( + (await ports()).bindXaiManaged( + invalid as unknown as BindXaiManagedRequest, + ), + ).rejects.toThrow("SuperGrok bind request is invalid"); + expect(invoke).not.toHaveBeenCalled(); + }, + ); + + it.each([ + null, + { ...result, app: "codex" }, + { ...result, activated: false }, + { ...result, providerId: "" }, + { ...result, providerName: "\n" }, + { ...result, accessToken: "SENTINEL-SECRET" }, + ])( + "fails closed on invalid, cross-target or secret-bearing results %#", + async (invalid) => { + invoke.mockResolvedValue(invalid); + await expect((await ports()).bindXaiManaged(request)).rejects.toEqual({ + code: "rollback_partial_state_unknown", + }); + }, + ); + + it("requires Codex and Desktop to remain drafts", async () => { + const providerPorts = await ports(); + for (const app of ["codex", "claude-desktop"] as const) { + invoke.mockResolvedValue({ ...result, app, activated: false }); + await expect( + providerPorts.bindXaiManaged({ ...request, app }), + ).resolves.toMatchObject({ app, activated: false }); + invoke.mockResolvedValue({ ...result, app, activated: true }); + await expect( + providerPorts.bindXaiManaged({ ...request, app }), + ).rejects.toEqual({ code: "rollback_partial_state_unknown" }); + } + }); + + it("retains only closed native failure codes", async () => { + invoke.mockRejectedValue({ code: "account_unavailable" }); + await expect((await ports()).bindXaiManaged(request)).rejects.toEqual({ + code: "account_unavailable", + }); + invoke.mockRejectedValue({ + code: "account_unavailable", + token: "SENTINEL-SECRET", + }); + await expect((await ports()).bindXaiManaged(request)).rejects.toEqual({ + code: "rollback_partial_state_unknown", + }); + }); + + it("fetches models using the chosen vault identity and rejects malformed IDs", async () => { + const providerPorts = await ports(); + invoke.mockResolvedValue([ + { id: "grok-fixture-1", owned_by: "xai" }, + "grok-fixture-2", + "grok-fixture-1", + ]); + await expect( + providerPorts.fetchXaiManagedModels(XAI_ACCOUNT_ID), + ).resolves.toEqual({ + models: ["grok-fixture-1", "grok-fixture-2"], + truncated: false, + }); + expect(invoke).toHaveBeenCalledExactlyOnceWith("get_xai_oauth_models", { + accountId: XAI_ACCOUNT_ID, + }); + invoke.mockReset(); + await expect(providerPorts.fetchXaiManagedModels("")).rejects.toThrow(); + expect(invoke).not.toHaveBeenCalled(); + invoke.mockResolvedValue([{ id: "bad\nmodel" }]); + await expect( + providerPorts.fetchXaiManagedModels(XAI_ACCOUNT_ID), + ).rejects.toThrow("xAI models are unavailable"); + }); + + it("does not invent native subscription operations in a normal browser", async () => { + const browser = createBrowserFeaturePorts(); + await expect(browser.providers.bindXaiManaged(request)).rejects.toThrow( + NATIVE_ONLY_ERROR, + ); + await expect( + browser.providers.fetchXaiManagedModels(XAI_ACCOUNT_ID), + ).rejects.toThrow(NATIVE_ONLY_ERROR); + }); +});