From b8a2080f5d13122c5c803faa8888644d712589e3 Mon Sep 17 00:00:00 2001 From: jun Date: Thu, 3 Sep 2026 15:19:39 +0900 Subject: [PATCH 1/5] docs(devlog): plan the meta-muse OAuth provider and its ToS warning wp4 plan plus the research it rests on. Five audit rounds; the reviewer failed it four times. --- .../260903_muse_spark_plan_oauth/000_plan.md | 62 ++-- .../002_plan_credential_feasibility.md | 7 +- .../003_credential_and_quota_measurements.md | 224 +++++++++++++ .../004_muse_quota_emission_questions.md | 34 ++ .../020_wp2_device_oauth.md | 9 +- .../040_wp4_muse_oauth_provider.md | 295 ++++++++++++++++++ .../050_wp5_passive_muse_quota.md | 158 ++++++++++ 7 files changed, 768 insertions(+), 21 deletions(-) create mode 100644 devlog/_plan/260903_muse_spark_plan_oauth/003_credential_and_quota_measurements.md create mode 100644 devlog/_plan/260903_muse_spark_plan_oauth/004_muse_quota_emission_questions.md create mode 100644 devlog/_plan/260903_muse_spark_plan_oauth/040_wp4_muse_oauth_provider.md create mode 100644 devlog/_plan/260903_muse_spark_plan_oauth/050_wp5_passive_muse_quota.md diff --git a/devlog/_plan/260903_muse_spark_plan_oauth/000_plan.md b/devlog/_plan/260903_muse_spark_plan_oauth/000_plan.md index 8205510b6a..4f83666ccf 100644 --- a/devlog/_plan/260903_muse_spark_plan_oauth/000_plan.md +++ b/devlog/_plan/260903_muse_spark_plan_oauth/000_plan.md @@ -2,19 +2,20 @@ - Date: 2026-09-03 - Session: `01a064b2-91b5-7272-b9ef-4db66bb46921` -- Work class: C3 — a new provider registry entry, a published-spec effort ladder, catalog metadata, and a plan-credential feasibility verdict. -- Status: A (wp0, docs-only roadmap cycle; three audit rounds folded). +- Work class: **C4** — wp4 raised it: a live credential read, an OAuth flow, a GUI consent gate, a privacy-scan rule, and billing metadata now move together. +- Status: **A (wp4)**. wp0 closed; wp1 merged as `ff1ac6b8c` (#3321); wp2 closed `NOOP` and is superseded (see below); wp4 in audit. ## Loop spec -- Archetype: satisfy-spec integration, with one genuinely open question (wp2) that resolved to a recorded negative rather than code. +- Archetype: satisfy-spec integration. wp2's open question resolved to a recorded negative, then reopened under owner authorization as wp4. - Trigger: the user asked whether Meta's Muse Spark *plan* can be attached, after `878f75417` landed Muse Spark 1.3 through the Command Code and OpenCode Zen resellers. -- Goal: reach Muse Spark **directly** on Meta's own endpoint, and settle — with evidence, not inference — whether a Muse Code subscription can legitimately drive a local proxy. -- Non-goals: issuing a Meta API key, entering payment details, touching generated metadata, changing any `*-free` Zen id, retiring or redefaulting any model, altering the merged 1.3 work. +- Goal: reach Muse Spark **directly** on Meta's own endpoint (wp1, done), and — under explicit owner authorization — reuse the Muse Code CLI credential behind a high-risk ToS warning (wp4). +- Non-goals: touching generated metadata, changing any `*-free` Zen id, retiring or redefaulting any model, altering the merged 1.3 work, wiring Meta's session-bound console GraphQL, and the passive quota cache (that is wp5). +- Authorization boundary: **wp1 issued no key and entered no billing detail.** wp4 exists only because the repository owner completed the Muse Code login and payment on his own account and instructed that it ship with a warning. No agent-initiated credential or billing action is in scope. - Verifier: the canonical gate in `030` — focused `bun test` on the touched suites, `bun run test:changed`, `bun x tsc --noEmit`, `bun run privacy:scan`, and the `docs-site` frozen-lockfile install plus build. **The repository-wide local suite is forbidden by standing user instruction**; exact-head GitHub CI is the authoritative gate. -- Stop condition: every work-phase closed and the single implementation PR green on its exact head SHA and merged into `dev`. +- Stop condition: every work-phase closed, and each of the three implementation PRs — wp1 (merged), wp4, wp5 — green on its exact head SHA and merged into `dev`. - Memory artifact: this unit folder. -- Terminal outcomes: wp1 targets `DONE`; wp2 closed `NOOP` on a licence finding. `BLOCKED` remains available if CI or branch protection refuses for an unrelated reason. +- Terminal outcomes: wp1 `DONE` (merged). wp2 `NOOP`, superseded by wp4. wp4 and wp5 target `DONE`. `BLOCKED` remains available if CI or branch protection refuses for an unrelated reason. - Escalation: each A gate dispatches one independent read-only reviewer on `gpt-5.6-sol` at high effort. Two failed correction loops on the same packet stops the phase and reports. ## Revision after the A-gate audit (round 1: FAIL, 8 blockers) @@ -79,13 +80,34 @@ So the provider is `adapter: "openai-responses"`, not `openai-chat`. Registering | Phase | Doc | Delivers | PR | |---|---|---|---| | wp0 | this folder + `001`, `002` | claim ledger, feasibility research, diff-level decade docs | — | -| wp1 | `010_wp1_direct_provider.md` | `meta-model` key provider, ladder + wire map, parity/pricing/docs updates, behavior tests | PR 1, base `dev` | -| wp2 | `020_wp2_device_oauth.md` | **CLOSED, `NOOP`** — recorded negative, no code | none | - -**One PR, no stack (`DEV-STACK-01`).** The first draft stacked wp2 on wp1 over a string -wp1 introduced; that string is folded into wp1, and wp2 now ships no code at all. Round -2 also caught that a "second independent PR" would still have consumed wp1's -module-private constants — so there was never a clean independence claim to make. +| wp1 | `010_wp1_direct_provider.md` | `meta-model` key provider, ladder + wire map, parity/pricing/docs updates, behavior tests | PR 1 — **merged** as `ff1ac6b8c` (#3321) | +| wp2 | `020_wp2_device_oauth.md` | closed `NOOP` on the evidence available at the time | none | +| wp4 | `003` + `040_wp4_muse_oauth_provider.md` | `meta-muse` OAuth provider, import-only, behind the high-risk ToS warning | PR 2, base `dev` | +| wp5 | `050_wp5_passive_muse_quota.md` | passive subscription-quota cache from the `response.subscription_usage` SSE event | PR 3, base `dev`, after wp4 | + +## wp4: the owner reopened wp2, and that is a different act + +`020` closed on the reasoning that proving a credential works is not the same as being +allowed to use it. **That reasoning is not withdrawn.** Its reopen conditions listed only +first-party vendor changes because they were written for the case where an *agent* would +be making the call. + +The repository owner has since completed the Muse Code login and the payment setup on his +own account and asked for this to ship with a warning. A user spending his own ToS risk +deliberately is not the same act as an agent spending it unilaterally, and opencodex +already models exactly that distinction — `gui/src/oauth-tos-risk.ts` carries +`anthropic` and `google-antigravity` in `HIGH_RISK` for the same reason. + +Measurements that became possible only after that login are in `003`. Two of them changed +the design: the OAuth `access_token` 401s while a sibling `api_key` works, so the +provider ships a static key rather than a refresh loop; and Meta reports subscription +window usage only as an SSE event on streaming turns, so no on-demand quota probe is +possible — reading it needs a passive cache, which is wp5. + +**Independent PRs, no stack (`DEV-STACK-01`).** wp1 merged as `ff1ac6b8c`. wp4 and wp5 +follow as separate PRs off `dev`: wp5 depends on wp4 in time (it needs the provider to +exist) but not in diff — it touches the streaming path and the quota cache, files wp4 +never opens — so stacking would impose a false merge order rather than aid review. `030` is delivery procedure, not a work-phase. ## Why wp2 closed instead of shipping @@ -110,10 +132,12 @@ in wp1's note. - `src/providers/registry.ts` — one new entry plus its effort/window/modality constants and wire map - `tests/provider-registry-parity.test.ts` — the hardcoded key-provider roster -- `src/usage/expected-prices.ts` + `tests/usage-cost.test.ts` — two price rows and the pinned count (64 → 66) +- `src/usage/expected-prices.ts` + `tests/usage-cost.test.ts` — two `meta-model` rows in wp1 (64 → 66) and two `meta-muse` rows in wp4 (66 → 68) - `docs-site/` English provider table (`src/AGENTS.md:29` requires it) - `tests/` — a focused suite beside the existing provider tests -- `src/oauth/` — **nothing.** wp2 closed as a negative; no OAuth code ships. +- `src/oauth/` — `meta-muse.ts` (NEW) and one `OAUTH_PROVIDERS` entry, in wp4 only. +- `gui/src/oauth-tos-risk.ts` + `gui/src/pages/Providers.tsx` — the high-risk warning and its reauth path (wp4). +- `scripts/privacy-scan.ts` — a detector for the measured `LLM|` key shape (wp4). - `devlog/_plan/260903_muse_spark_plan_oauth/` ### OUT @@ -128,7 +152,9 @@ in wp1's note. 1. `c1` — this unit carries 000-range research plus a diff-level decade doc per implementation phase. 2. `c2` — every registry fact traces to a published vendor statement in `001`. -3. `c3` — no API key is issued and no billing detail is entered. -4. `c4` — the plan-credential question is answered by working wiring or a recorded negative with the blocking evidence. **Met by `020`'s negative.** +3. `c3` (wp1 only) — no API key is issued and no billing detail is entered by the agent; wp4 runs under the owner’s own completed login and payment, per the authorization boundary above. +4. `c4` — the plan-credential question is answered by working wiring or a recorded negative with the blocking evidence. Met first by `020`'s negative; **re-answered by wp4** as working wiring under owner authorization. 5. `c5` — `tsc` exits 0, focused tests pass, the full local suite is never run. 6. `c6` — the implementation PR green at its exact head SHA and merged into `dev`. +7. `c7` (wp4) — the `meta-muse` login imports the CLI credential, every GUI login path is gated behind the high-risk warning, both models resolve a price, and no credential value reaches a log, error, status object, or the repository. +8. `c8` (wp5) — the `response.subscription_usage` event is parsed through `normalizePercent`/`normalizeResetAt`, cached under the account that actually served the turn, and displayed with its observation time; no path issues an inference call to refresh a quota. diff --git a/devlog/_plan/260903_muse_spark_plan_oauth/002_plan_credential_feasibility.md b/devlog/_plan/260903_muse_spark_plan_oauth/002_plan_credential_feasibility.md index 44211b44df..c12ad0ab02 100644 --- a/devlog/_plan/260903_muse_spark_plan_oauth/002_plan_credential_feasibility.md +++ b/devlog/_plan/260903_muse_spark_plan_oauth/002_plan_credential_feasibility.md @@ -3,8 +3,11 @@ Research doc. No diffs here (LEXICO-SPLIT-01); the implementation shape lives in the decade docs. -> **Outcome: this research closed wp2 as a `NOOP` negative. See `020`.** No credential -> was extracted, no login completed, and no Meta credential exists on this machine. +> **Outcome: this research closed wp2 as a `NOOP` negative (see `020`), and is now +> superseded by `003`.** At the time of writing no login had completed and no credential +> existed on this machine. The owner has since logged in and authorized the work; `003` +> records what became measurable, including the finding that the OAuth access token does +> NOT authenticate the Model API while a sibling API key does. ## The docs were not the whole truth diff --git a/devlog/_plan/260903_muse_spark_plan_oauth/003_credential_and_quota_measurements.md b/devlog/_plan/260903_muse_spark_plan_oauth/003_credential_and_quota_measurements.md new file mode 100644 index 0000000000..a63480d8ca --- /dev/null +++ b/devlog/_plan/260903_muse_spark_plan_oauth/003_credential_and_quota_measurements.md @@ -0,0 +1,224 @@ +# Measured: the Muse Code credential, and Meta's quota surface + +Research doc (000-range). No diffs here: the credential half is implemented by `040` +(wp4), the quota half by `050` (wp5). + +Everything below was measured on 2026-09-03 **after the repository owner completed the +Muse Code login and payment setup on his own account** and asked for this to ship. No +secret value is recorded here and none reaches any diff. + +## Supersedes, not retracts + +`002` concluded no reusable credential existed and `020` closed wp2 as `NOOP`. Both were +correct on their evidence, and the reasoning in `020` — that proving a credential works +is not the same as being allowed to use it — is **not** withdrawn. + +What changed is the decider. An agent must not spend a user's ToS risk on its own +initiative; a user may spend his own deliberately. `020`'s reopen conditions named only +first-party vendor changes because they were written for the first case. This is the +second. The repository already models it: `gui/src/oauth-tos-risk.ts` carries +`anthropic` and `google-antigravity` in `HIGH_RISK` for exactly this reason. + +## A. Where the credential actually lives + +`~/.config/muse/auth.json` (0600) contains **no secret**. It is a pointer: + +```json +{ "schema_version": 2, + "providers": { "meta": { + "mechanism": "oauth", "storage": "keychain", "obtained_via": "device_code", + "api_base_url": "https://api.meta.ai/v1", + "user_full_name": "…", "user_email": "…" } } } +``` + +The secret is a macOS Keychain generic-password item, service +`ai.meta.dev.credentials`, account `meta`, whose payload is: + +``` +{ secret_schema_version: int, + api_key: str(len=48, "LLM|"-prefixed), + access_token: str(len=282, opaque) } +``` + +**Key grammar, measured** (structure only, no value): the `api_key` is 48 characters in +three `|`-separated segments — `LLM` (3 alnum), a 16-digit id, and a 27-character +`[A-Za-z0-9_-]` tail. It matches `/LLM\|\d+\|[A-Za-z0-9_-]{10,}/` exactly. That is the +grammar the `privacy:scan` detector uses, so the rule is evidence-backed rather than a +guess at the vendor's format. + +**The third-party report was wrong about the exposure.** It claimed the key sits "in the +Keychain in plaintext so anyone can pull it". It is a normal Keychain item under the +user's own ACL — the same protection class Claude Code uses, which +`src/oauth/local-token-detect.ts` already reads. Not a plaintext file on disk. + +## B. Which half authenticates — the finding that shapes the provider + +| Credential | `GET https://api.meta.ai/v1/models` | +|---|---| +| `access_token` | **401** `invalid_api_key` | +| `api_key` | **200**, 7 models | + +The OAuth access token does **not** authenticate the Model API. The device flow's usable +output is the `api_key` stored beside it — the "automatically connected" Muse Code API +key the subscription docs describe (`001` §E). + +So there is no bearer refresh loop to implement. The artifact is a long-lived API key, +which is the shape `src/oauth/command-code.ts` already returns +(`expires: Number.MAX_SAFE_INTEGER`, `access === refresh`). + +## C. The live roster confirms the discovery risk was real + +``` +muse-spark-1.3-contributor, muse-voice-transcribe-1.0, muse-spark-1.3, +muse-image-1.0, muse-spark-1.2-contributor, muse-spark-1.2, muse-spark-1.1 +``` + +The #3321 A-gate reviewer flagged unfiltered discovery when we had no payload. We have +one now, and `muse-image-1.0` and `muse-voice-transcribe-1.0` are exactly the +non-Responses-agent rows he predicted. `liveModels` stays off. + +## D. The shipped effort ladder is confirmed against the live endpoint + +`POST /v1/responses`, `muse-spark-1.3`: + +| effort | result | +|---|---| +| `minimal` | 200 | +| `xhigh` | 200 | +| `max` | 400 — `unknown variant \`max\`, expected one of none, minimal, low, medium, high, xhigh` | +| `none` | 400 — `does not support "none" with this model` | + +`META_MUSE_REASONING_EFFORTS`, wired in #3321 from published spec alone, matches the live +API exactly. + +## E. Quota: the surface is in the stream, not at a URL + +**This section was wrong in its first draft and is corrected here.** The correction +matters more than the finding: I probed only non-streaming requests, concluded "no +machine-readable quota exists", and was disproved by a report that the Muse CLI's +`/quota` command renders instantly — which is only possible if the data already arrived +with the previous turn. + +### The finding: `response.subscription_usage` + +A **streaming** `POST /v1/responses` (`"stream": true`) emits one extra SSE event +alongside the ordinary `response.*` sequence. Measured on 2026-09-03: + +```json +{ "type": "response.subscription_usage", + "subscription": { + "tier": "27681393394859588", + "window": { "used_percent": 0, "resets_at": 1788431188, "window_duration_mins": 300 }, + "weekly": { "used_percent": 0, "resets_at": 1788739200 } } } +``` + +Full event list from that one turn: `response.created`, `response.in_progress`, +`response.output_item.added` ×2, `response.content_part.added`, +`response.output_text.delta`, `response.content_part.done`, +`response.output_item.done` ×2, **`response.subscription_usage`**, `response.completed`. + +This fits `ProviderQuota` in `src/providers/quota-types.ts` without a schema extension: +`window.used_percent` → `fiveHourPercent` (`window_duration_mins: 300` confirms the +5-hour window), `window.resets_at` → `fiveHourResetAt`, `weekly.used_percent` → +`weeklyPercent`, `weekly.resets_at` → `weeklyResetAt`. No new quota shape is needed. + +`tier` is an opaque numeric id here, not the human label the CLI prints, so it must not +be displayed raw. + +### What is still absent + +The rest of the original negative survives, and it constrains **how** the quota is +obtained rather than whether it exists. + +**Probed 17 plausible REST paths** with the working key — +`/v1/usage`, `/v1/billing`, `/v1/billing/credits`, `/v1/credits`, `/v1/account`, +`/v1/organization`, `/v1/organization/costs`, `/v1/me`, `/v1/whoami`, `/v1/limits`, +`/v1/rate_limits`, `/v1/quota`, `/v1/usage/costs`, `/v1/dashboard/billing/usage`, +`/v1/subscription`, `/v1/keys`, `/v1/api_keys` — **all 404**. + +**Response headers carry nothing, on both request shapes.** A 200 from `/v1/models`, a +200 from non-streaming `/v1/responses`, and a 200 from **streaming** `/v1/responses` all +return only `x-request-id`, `x-route: model-api-rust`, CORS, `Content-Type`, and +(streaming) `Cache-Control` + `Transfer-Encoding`. No `x-ratelimit-*`, no `retry-after`. + +Meta's docs publish `x-ratelimit-limit-tokens`, `x-ratelimit-remaining-tokens`, +`x-ratelimit-limit-requests`, `x-ratelimit-remaining-requests` and `Retry-After`. Three +measured request shapes carry none of them. They may appear only near a limit, or the +docs may be ahead of the deployment — either way **nothing may depend on them**, and a +parser that reads them when present must treat absence as normal. + +**The console does not use a public API.** A signed-in browser observation of +`dev.meta.ai` shows the usage and billing pages calling internal Relay GraphQL: + +| Surface | Path | Query | +|---|---|---| +| Usage | `POST /api/graphql/` | `LLMDCUsageQuery` (pinned `doc_id`) | +| API keys | `POST /api/graphql/` | `LLMDCAPIKeysQuery` (pinned `doc_id`) | +| Billing | `POST /api/billing/graphql/` | `BillingContextFactoryQuery`, `BiPSPaymentActivityViewQuery`, … | + +Those need `fb_dtsg`, `lsd`, session cookies and a pinned `doc_id` that rotates with +every Meta deploy. Wiring them would mean shipping a Facebook session scraper that breaks +without warning. **Out of scope** — and now unnecessary, since the SSE event carries the +same two windows the dashboard needs. + +What the docs do state, and what the provider can therefore say in prose: + +> Limits apply per team, not per API key. If you use multiple keys in one team, all +> requests, tokens, images, and audio minutes count toward the relevant shared quota. + +Defaults: Standard 3,000 RPM / 4M TPM; Contributor 100 RPM / 3M TPM. + +## F. What that means for multi-account + +Two consequences, and they cut in opposite directions. + +**Reactive 429 failover works with no new code.** `isGenericFailoverProvider` returns +true for any `authMode: "oauth"` provider outside `{openai, anthropic}`, and rotation +arms automatically once two usable accounts exist. A `meta-muse` OAuth provider inherits +it. The only obligation is that upstream exhaustion reaches the router **as HTTP 429** so +`generic-account-failover` sees it. + +**Quota display is possible, but only passively.** There is no endpoint to poll, so +nothing can be *probed* on demand: the quota arrives as a side effect of a streaming +turn. That is the same passive shape the Codex pool already uses for its +`x-codex-*-used-percent` headers — read off a real response, then cached. + +Two consequences for the implementation: + +- A `fetchMetaMuseQuota()`-style probe is **impossible**. Anything that would make + `ocx account refresh` or a dashboard button issue a fresh quota call cannot exist, + because obtaining one would mean spending a real inference turn. +- `supportsPerAccountQuota` must stay **false** regardless: that path calls + `fetchAccountQuota`, which is a probe. Per-account quota would need a + cache-read-only variant that does not exist today. + +So the honest scope for **wp5** is: parse the event when a turn produces one, cache it +under the serving account, and let the dashboard show what was last observed. wp4 ships +the credential only and surfaces no quota. + +And a trap worth recording: `fetchAccountQuota`'s fallback branch calls +`fetchAnthropicUsageQuota(token)` for any provider that is not `kiro` or +`google-antigravity`. **Adding `meta-muse` to the allowlist without a dedicated branch +would send a Meta bearer to Anthropic's endpoint.** Since Meta exposes no probe, the +correct action is to add nothing — but the hazard is documented here so a future +contributor does not "just extend the allowlist". + +Per-team quota is also the wrong shape for per-account ranking: two keys in one team +share one pool, so ranking accounts by headroom would be measuring the same number twice. +**But subscription windows are per-subscription**, and two different Muse Code accounts +hold two different subscriptions — so the SSE percentages ARE per-account even though the +RPM/TPM limits are per-team. Ranking on them would be sound; it is out of scope only +because the cache-read-only seam does not exist yet. + +## G. Method note + +The first version of §E asserted a negative from an incomplete search: I probed URLs and +headers, found nothing, and generalized. The disproof came from a behavioral observation +I had already been given and had not used — the CLI's `/quota` answers instantly, which +rules out an on-demand HTTP call and points at data arriving in-band. + +Same failure mode as `002` §G, where a docs-site search for "OAuth" returned nothing and +I concluded no flow existed until `muse login --help` disproved it in one command. Twice +now: **absence of evidence in the surface I happened to search is not evidence of +absence.** For a vendor claim, prefer a behavioral probe of the real client over an +inventory of guessed endpoints. diff --git a/devlog/_plan/260903_muse_spark_plan_oauth/004_muse_quota_emission_questions.md b/devlog/_plan/260903_muse_spark_plan_oauth/004_muse_quota_emission_questions.md new file mode 100644 index 0000000000..d0fa26a7ca --- /dev/null +++ b/devlog/_plan/260903_muse_spark_plan_oauth/004_muse_quota_emission_questions.md @@ -0,0 +1,34 @@ +# Open questions on Muse subscription-usage emission + +Research doc (000-range), split out of `050` because unresolved research must not sit +inside an implementation phase (LEXICO-SPLIT-01). + +**None of these blocks wp5.** The parser is fail-soft by construction: a turn that emits +no event is normal, so every answer below only widens or narrows coverage. They are +recorded so a future contributor does not mistake partial coverage for a bug. + +## Q1 — Does the Contributor tier emit the event? + +Only `muse-spark-1.3` (standard) was observed on 2026-09-03. `muse-spark-1.3-contributor` +is a different billing tier and may or may not carry subscription windows. + +Resolvable with one streaming turn against the contributor id, comparing the event list. + +## Q2 — Does a pure pay-as-you-go account emit it? + +The field is named `subscription`, which suggests it appears only for accounts holding a +Muse Code subscription. If so, an account without one shows no quota — correct behavior, +not a defect, but the GUI must not present the absence as an error. + +Not resolvable on this machine: the only credential available belongs to a subscribed +account. + +## Q3 — Does the translated path preserve the event? **ANSWERED: no.** + +`src/adapters/openai-responses.ts` iterates `decodeServerSentEvents` and dispatches on +`payload.type` through a `switch` with no `response.subscription_usage` case, so a +translated turn drops it silently. + +That is not a bug to fix in the adapter — it is the reason `050` observes on the +passthrough path and treats translated coverage as an explicit, documented gap rather +than discovering it during Build. diff --git a/devlog/_plan/260903_muse_spark_plan_oauth/020_wp2_device_oauth.md b/devlog/_plan/260903_muse_spark_plan_oauth/020_wp2_device_oauth.md index 311b50069d..d9ef17d5cf 100644 --- a/devlog/_plan/260903_muse_spark_plan_oauth/020_wp2_device_oauth.md +++ b/devlog/_plan/260903_muse_spark_plan_oauth/020_wp2_device_oauth.md @@ -1,6 +1,13 @@ # wp2 — Muse Code plan credential: CLOSED as a recorded negative -**Outcome: `NOOP`. No code ships. No PR is opened.** +> **Superseded by wp4 (`003`, `040`).** This close was correct on the evidence available +> and its reasoning is not retracted: proving a credential works is not the same as being +> allowed to use it. What changed is who decides. The repository owner completed the login +> and payment on his own account and asked for this to ship behind a warning — a user +> spending his own ToS risk, not an agent spending it for him. Read this doc as the +> record of why an agent would not have shipped it unprompted. + +**Outcome at the time: `NOOP`. No code shipped from this phase.** This phase existed to answer whether a Muse Code subscription can drive opencodex. It can be answered without building anything, and the answer is no. diff --git a/devlog/_plan/260903_muse_spark_plan_oauth/040_wp4_muse_oauth_provider.md b/devlog/_plan/260903_muse_spark_plan_oauth/040_wp4_muse_oauth_provider.md new file mode 100644 index 0000000000..b17445025c --- /dev/null +++ b/devlog/_plan/260903_muse_spark_plan_oauth/040_wp4_muse_oauth_provider.md @@ -0,0 +1,295 @@ +# wp4 — `meta-muse` OAuth provider, import-only, behind a ToS warning + +Branch `codex/meta-muse-device-oauth`, base `dev` at `ff1ac6b8c`. One PR. + +Evidence: `003`. Authorization: the repository owner completed the login and payment +himself and asked for this to ship with a warning. + +Revised after A-gate rounds 1 (FAIL, 5) and 2 (FAIL, 5). Fixes are marked `[A1]`…`[B5]`. + +Round 2 also arrived with a **user-supplied disproof of my own research**: Meta *does* +expose quota, as a `response.subscription_usage` SSE event on streaming turns. My earlier +"no quota surface exists" came from non-streaming probes only. `003` §E is corrected and +§G records the method failure. What that enables is scoped at the end of this doc. + +## Shape + +**Import-only, macOS-only.** `[A2]` The first draft proposed spawning `muse login` and +polling for the credential file. That is unshippable for three reasons the reviewer +verified: the pointer file already exists, so "poll until it appears" returns instantly +with the *old* account on a force-login; `muse login` has no non-interactive mode, so a +spawned TUI can outlive cancellation; and the Keychain read is darwin-only, so on +Linux/Windows the spawn could succeed and the import still fail. + +So the provider reads an existing credential and never spawns anything. If none is +present it fails with instructions. + +## MODIFY `src/providers/registry.ts` + +One entry after `meta-model`, reusing every `META_MUSE_*` constant #3321 introduced +(no duplication): + +```ts + { + id: "meta-muse", + label: "Meta Muse Code (CLI credential)", + adapter: "openai-responses", + baseUrl: "https://api.meta.ai/v1", + authKind: "oauth", + oauthId: "meta-muse", + defaultModel: "muse-spark-1.3", + models: META_MUSE_MODELS, + liveModels: false, // live roster carries image + voice rows (003 §C) + modelContextWindows: Object.fromEntries(META_MUSE_MODELS.map(id => [id, META_MUSE_CONTEXT_WINDOW])), + modelInputModalities: Object.fromEntries(META_MUSE_MODELS.map(id => [id, ["text", "image"] as ["text", "image"]])), + modelReasoningEfforts: Object.fromEntries(META_MUSE_MODELS.map(id => [id, META_MUSE_REASONING_EFFORTS])), + modelReasoningEffortMap: Object.fromEntries(META_MUSE_MODELS.map(id => [id, META_MUSE_REASONING_EFFORT_MAP])), + note: "Reuses the API key the Muse Code CLI stores after `muse login` (macOS only; requires the CLI installed and signed in). Meta scopes that credential to the Muse Code CLI, so this is an UNSUPPORTED use: Meta does not authorize subscription coverage outside its own CLI, how these calls settle is not observable from the API, and you should treat every call as billable against your account. Meta reports subscription window usage inside streaming responses, but opencodex does not yet read or display it, and there is no endpoint to query it on demand. Rate limits apply per team, not per key. For a supported path use the meta-model provider with your own key (export it as META_MODEL_API_KEY).", + }, +``` + +`label` is stated explicitly `[A5]`, and the GUI needs its own entry `[B4]`: account rows +render `OAUTH_LABELS` in `gui/src/pages/providers-shared.ts`, so a registry `label` alone +leaves the raw id `meta-muse` on screen. Add `"meta-muse": "Meta Muse Code (CLI)"` there. + +Three corrections to that `note`, from round 2: + +- **Billing is stated as unobservable, not settled** `[B3]`. The vendor text proves the + credential is CLI-scoped and that *separately created* keys are pay-as-you-go. It does + not prove how this CLI-minted key settles when replayed elsewhere, and `003` §E found + no billing surface to check. Asserting "bills pay-as-you-go" as fact would hand the + user false certainty about which balance is charged; "treat every call as billable" is + both honest and safe. +- **`META_MODEL_API_KEY`, not `MODEL_API_KEY`** `[B5]` — the exact env-name trap + CodeRabbit caught on #3321. Repeating Meta's own name would send a user to export a + variable opencodex never reads. +- The quota sentence states only what wp4 ships `[C2]`: that Meta emits the data and + opencodex does not yet surface it. Promising that opencodex can show the last observed + percentages would advertise wp5 work in wp4 documentation. + +## NEW `src/oauth/meta-muse.ts` + +``` +MUSE_POINTER = ~/.config/muse/auth.json +KEYCHAIN_SVC = "ai.meta.dev.credentials" +KEYCHAIN_ACCT = "meta" +``` + +`loginMetaMuse(ctrl)`: + +1. `process.platform !== "darwin"` → throw naming the limitation. `[A2]` +2. Read the pointer. Require `providers.meta.mechanism === "oauth"` and + `storage === "keychain"`; a different `storage` means a shape we have not measured, so + refuse rather than guess. `[A1]` +3. `security find-generic-password -s -a -w` — same mechanism as + `readClaudeKeychain` in `local-token-detect.ts`, 5s timeout, stderr piped. +4. Parse; take `api_key`. Reject anything that is not `LLM|`-prefixed. **Never** + `access_token` — it 401s (`003` §B). +5. `sanitizeApiKeyValue()` from `src/providers/api-keys.ts`. `[A1]` +6. Validate live: `GET /v1/models` must return 200. +7. Return `{ access: key, refresh: key, expires: Number.MAX_SAFE_INTEGER, + email: normalizedEmail, source: "local-cli" }`. + +`email`, **not** `accountId` `[A1]` — `src/oauth/index.ts` masks `email` for display, and +`store.ts` already falls back to `email` for slot identity, so the masking path is kept +and multi-account identity still works. + +`refreshMetaMuseToken(token)` `[B2]` returns the supplied token unchanged with +`Number.MAX_SAFE_INTEGER`, exactly as `refreshCommandCodeToken` does. It must **not** +re-import from the Keychain: generic refresh writes its result into the slot being +refreshed, so if the user switched Muse accounts in between, a different identity would +silently overwrite the existing slot. Only an explicit login may import. + +The validation fetch is bounded `[B2]`, and the guard matters `[C1]`: +`OAuthController.signal` is OPTIONAL (`src/oauth/types.ts`) and the CLI controller in +`login-cli.ts` supplies none, so `AbortSignal.any([ctrl.signal, ...])` throws a +`TypeError` before the fetch — every `ocx login meta-muse` would fail immediately after +printing its warning. Use the exact shape `command-code.ts:49` already uses: + +```ts +signal: ctrl.signal + ? AbortSignal.any([ctrl.signal, AbortSignal.timeout(10_000)]) + : AbortSignal.timeout(10_000), +``` + +so a stalled `/v1/models` cannot hang a login and cancellation is honored when offered. +Tests cover a controller with a signal, one without, an aborted signal, and a timeout. The reader, platform check, pointer path and `fetch` are +injected so tests stay deterministic and never touch the real Keychain. + +### The warning must reach the CLI too `[B1]` + +`src/oauth/login-cli.ts` calls `runLogin` and never reads the registry `note`, so +`ocx login meta-muse` would import a restricted credential in silence. It does pass +`onProgress: m => console.log(...)`. + +So `loginMetaMuse` emits the full warning through `ctrl.onProgress` **before** touching +the pointer or the Keychain: the CLI-scope restriction, that settlement is unobservable +and calls should be treated as billable, that the key is copied into opencodex's auth +store, and that `meta-model` is the supported path. The GUI ignores progress text because +it already shows the modal. A focused test asserts the warning precedes credential access. + +Every failure path throws a message naming what to do — install the CLI, run +`muse login`, retry — and **never includes the credential**. + +### The key IS persisted, and the plan must say so `[A1]` + +The first draft implied read-only access to Meta's store. That was wrong: +`runLogin` → `store.ts` writes `access` and `refresh` into `~/.opencodex/auth.json` +(0600, dir 0700), exactly as every other OAuth provider does. The doc now states it, the +note tells the user, and `privacy:scan` is extended below so the key shape is detectable +if it ever escapes into a tracked file. + +## MODIFY `src/oauth/index.ts` + +```ts + "meta-muse": { + login: ctrl => loginMetaMuse(ctrl), + refresh: refreshMetaMuseToken, + providerConfig: oauthConfig("meta-muse"), + defaultModel: oauthDefaultModel("meta-muse"), + // Static API key scoped by Meta to its own CLI. Never generate unattended traffic + // on it — same posture as anthropic, for the same reason. + defaultRefreshPolicy: "disabled", + }, +``` + +## MODIFY `scripts/privacy-scan.ts` `[A1]` + +Its `token-looking` pattern matches `sk-`, `ghp_`, and JWTs — **not** `LLM|`. Add a +detector for `/LLM\|\d+\|[A-Za-z0-9_-]{10,}/` so a leaked Meta key is caught by the gate +this plan names as its protection. + +That grammar is **measured, not guessed** `[B5]`: the real key is 48 chars in three +`|`-separated segments — `LLM`, a 16-digit id, a 27-char `[A-Za-z0-9_-]` tail — and the +pattern was verified against it (`003` §A). The `\d+` segment is the part a guess would +have gotten wrong. + +`scanFile` is private and the script runs on import, so a test cannot call it `[C4]`. +Without a seam the regression degrades into re-declaring the same regex inside the test, +which stays green even if the production detector is deleted. + +So extract an import-safe `export function scanText(file: string, text: string): Finding[]` +that `scanFile` then calls, and have `tests/privacy-scan-meta-key.test.ts` exercise **that +exact function**. The canary is assembled at runtime (`"LLM" + "|" + digits + "|" + tail`) +so the fixture is not itself a secret-shaped literal. Drive the test red once by removing +the detector, to prove it is not vacuous. + +## MODIFY `src/usage/expected-prices.ts` + `tests/usage-cost.test.ts` `[A4]` + +`cost.ts` resolves overlays by exact provider id, so `meta-muse` rows do not inherit +`meta-model`'s and both models currently resolve to `null`. A provider whose whole +warning is "this bills pay-as-you-go" must not report zero cost. + +Extract the two `Cost4` tuples and the source string #3321 introduced into named +constants, reuse them for both providers, add two `meta-muse` rows, and move the pinned +count 66 → 68 with lookup assertions for both new ids. + +## MODIFY the GUI warning path `[A3]` + +Adding `"meta-muse"` to `HIGH_RISK` is necessary and **not sufficient**. Verified: +ordinary login goes through `requestLoginOAuth` (which checks `oauthTosRisk`), but +`onReauth` calls `loginOAuth` **directly** — so a user who already logged in can refresh +the risky credential without ever seeing the warning. + +1. `gui/src/oauth-tos-risk.ts`: add `"meta-muse"` to `HIGH_RISK` — `high`, not + `elevated`, because Meta restricts it in writing. +2. `gui/src/pages/Providers.tsx`: route `onReauth` through a warning-aware path, + carrying `accountId` in the pending state so acknowledgement continues the *same* + operation rather than a fresh login. +3. `gui/src/pages/providers-shared.ts`: add the `OAUTH_LABELS` entry, or the account row + renders the raw id. +4. The executable regression goes in **`gui/tests/oauth-tos-warning-gate.test.tsx`**, not + the root suite `[B4]`: React and `happy-dom` are `gui` dependencies and the root + `tests/` tree cannot render components. It asserts login, add-account and reauth each + call login zero times before acknowledgement and exactly once after. The root + `tests/oauth-tos-warning.test.ts` keeps its map-level assertion for `"meta-muse"`. + +CLI login (`ocx login meta-muse`) is outside the GUI warning map. Its warning surface is +`loginMetaMuse`'s `ctrl.onProgress` emission, which fires before any credential is read; +the registry `note` is duplicate persistent disclosure shown in the picker, not the CLI +gate. + +## MODIFY `tests/provider-registry-parity.test.ts` + +Add `meta-muse` to whichever roster enumerates OAuth providers, in registry order. + +## NEW `tests/meta-muse-oauth.test.ts` + +Registry shape and `oauthId`; the reused ladder, window, modalities and identity wire +map; `liveModels === false`; `meta/muse-spark-1.3` still routes to `command-code` with +all three Meta-adjacent providers configured; the note carries the CLI-scope, +treat-as-billable, auth-store and `META_MODEL_API_KEY` disclosures; +`defaultRefreshPolicy === "disabled"`; `supportsPerAccountQuota("meta-muse") === false` +[B4]; refresh returns its input unchanged and performs no Keychain read [B2]. + +Importer, against an **injected reader** — never the real Keychain, never the network: +non-darwin refuses; missing pointer refuses; `storage !== "keychain"` refuses; malformed +JSON refuses; a payload with only `access_token` refuses; a valid payload yields +`email` set and `accountId` unset; a synthetic canary key appears in **no** thrown +message, log line, or returned status object. `[A1]` + +## MODIFY `docs-site/src/content/docs/guides/providers.md` + +English only. A `meta-muse` section stating: macOS plus the Muse Code CLI signed in; the +key is imported and copied into OpenCodex’s auth store; Meta scopes that credential to its +own CLI so this is an unsupported use; settlement is not observable from the API, so +treat every call as billable; opencodex shows no quota for this provider and cannot +refresh one on demand; and `meta-model` with your own `META_MODEL_API_KEY` is the +supported path. + +The docs copy must match the registry note exactly on those three points `[C2]` — no +pay-as-you-go settlement claim, no quota-display promise, and the correct env var. + +## Quota and multi-account + +From `003` §E-F, as corrected by the SSE finding: + +- **Reactive 429 failover: free.** `isGenericFailoverProvider` arms for any OAuth + provider outside `{openai, anthropic}` once two usable accounts exist. `meta-muse` + inherits it with no new code. The only obligation is that upstream exhaustion reaches + the router as HTTP 429. +- **Quota display: possible, passively — and OUT OF SCOPE for this PR.** The + `response.subscription_usage` event fits `ProviderQuota` + (`fiveHourPercent` / `fiveHourResetAt` / `weeklyPercent` / `weeklyResetAt`) without a + schema extension — though not as a literal copy `[C5]`: `updatedAt` is generated + locally, percentages and Unix-second resets go through `normalizePercent` / + `normalizeResetAt`, `tier` is dropped, `window_duration_mins === 300` must be checked + before the five-hour slot is assigned, either window may be absent, and a turn with no + event at all is normal rather than an error. But there is no endpoint to poll: the value arrives only as a + side effect of a real streaming turn, so it needs a passive read-and-cache seam rather + than the probe-shaped `maybeFetchProviderQuota` dispatch every other provider uses. + That touches the streaming path, the quota cache and account attribution — a distinct + unit. It is registered as **wp5** with its own diff-level document + (`050_wp5_passive_muse_quota.md`) `[C3]`, since a declared work-phase without one + violates DIFFLEVEL-ROADMAP-01. Folding it into a credential PR would make both harder + to review. +- **`supportsPerAccountQuota` stays false**, and a test asserts it `[B4]`. That path calls + `fetchAccountQuota`, whose fallback branch sends any non-Kiro/non-Antigravity bearer to + `fetchAnthropicUsageQuota`. Flipping the allowlist without a dedicated branch would ship + a Meta key to Anthropic; the assertion locks that guard. +- Subscription windows are per-subscription, so they WOULD be sound for per-account + ranking in wp5. The RPM/TPM limits are per-team and would not be. + + +## Verification + +```bash +bun test tests/meta-muse-oauth.test.ts tests/meta-model-api-provider.test.ts \ + tests/oauth-tos-warning.test.ts tests/provider-registry-parity.test.ts \ + tests/usage-cost.test.ts +bun run test:changed +bun x tsc --noEmit +bun run privacy:scan +bun run lint:gui +bun test gui/tests/oauth-tos-warning-gate.test.tsx +cd gui && bun run build # gui/AGENTS.md requires this for GUI changes +cd docs-site && bun install --frozen-lockfile && bun run build +``` + +No repository-wide suite. No test may read the real Keychain or reach the network. + +## Terminal outcome + +`DONE` when the PR is green at its exact head SHA and merged, login imports the CLI +credential on macOS, every GUI login path is gated behind the high-risk warning, and both +models resolve a price. diff --git a/devlog/_plan/260903_muse_spark_plan_oauth/050_wp5_passive_muse_quota.md b/devlog/_plan/260903_muse_spark_plan_oauth/050_wp5_passive_muse_quota.md new file mode 100644 index 0000000000..460b280316 --- /dev/null +++ b/devlog/_plan/260903_muse_spark_plan_oauth/050_wp5_passive_muse_quota.md @@ -0,0 +1,158 @@ +# wp5 — passive Muse subscription quota + +Own PR, base `dev`, **after wp4 lands** (it needs the `meta-muse` provider to exist). +Branch: `codex/meta-muse-passive-quota`. + +Research and unresolved questions live in `003` §E and `004`. This document is +implementation only. + +## Why this is a separate phase + +Every other provider's quota is **probe-shaped**: `maybeFetchProviderQuota` dispatches to +a function that issues an HTTP request and returns a `ProviderQuota`. Meta has no such +endpoint (`003` §E). Its quota arrives as an SSE event on streaming turns, so obtaining a +fresh value would mean spending a real inference turn. + +That inverts the seam, and the inversion is the whole phase: writes come from the +streaming path, reads are cache-only, and "refresh" does not exist. + +## Decisions taken here, so Build does not have to make them + +| Question | Decision | +|---|---| +| Where to observe | `createSseInspector` in `src/server/relay.ts`, which already parses every passthrough SSE frame | +| Translated path | **Not covered.** `openai-responses.ts`'s switch drops unknown types (`004` Q3). Documented gap, not a silent one | +| Which account | the account that **served** the turn, read after failover may have moved it | +| `supportsPerAccountQuota` | **stays false.** A new cache-only accessor is added instead — see below | +| Refresh semantics | none; `ocx account refresh meta-muse` must not issue an inference call | + +### Why `supportsPerAccountQuota` stays false + +That predicate gates `fetchAccountQuota`, whose fallback branch sends any +non-Kiro/non-Antigravity bearer to `fetchAnthropicUsageQuota` — flipping it without a +dedicated branch ships a Meta key to Anthropic. But even *with* a branch it is the wrong +predicate: it means "this provider can be probed", and Meta cannot. + +So the flag stays false and a second, honest predicate is added: +`hasPassiveAccountQuota(provider)`, true for `meta-muse`, which the read path consults +for cached rows without ever reaching a probe. + +## NEW `src/providers/muse-subscription-usage.ts` + +```ts +/** The event Meta emits on streaming turns. Shape from 003 §E, measured 2026-09-03. */ +export function parseMuseSubscriptionUsage(payload: unknown): ProviderQuota | null; +``` + +Rules, all mandatory (`[C5]`): + +| Source | Target | Rule | +|---|---|---| +| `subscription.window.used_percent` | `fiveHourPercent` | `normalizePercent`; assign **only** if `window_duration_mins === 300` | +| `subscription.window.resets_at` | `fiveHourResetAt` | `normalizeResetAt` (Unix seconds) | +| `subscription.weekly.used_percent` | `weeklyPercent` | `normalizePercent` | +| `subscription.weekly.resets_at` | `weeklyResetAt` | `normalizeResetAt` | +| — | `updatedAt` | `Date.now()`, never from the payload | +| `subscription.tier` | — | **dropped**: an opaque numeric id, not the label the CLI prints | + +Returns `null` — never throws — when the payload is not an object, carries no +`subscription`, or yields no usable window. A `window_duration_mins` other than `300` +goes to `customWindows` with its duration as the label rather than being forced into the +five-hour slot. Either window may be absent independently. + +## MODIFY `src/server/relay.ts` + +Add one optional handler to `SseInspectorHandlers`: + +```ts + /** Fires for a `response.subscription_usage` frame. Meta-only today. */ + onSubscriptionUsage?(payload: unknown): void; +``` + +`createSseInspector` already decodes every frame; this adds a type check and a call. No +behavior changes when the handler is absent, which is every other provider. + +## MODIFY `src/server/responses/core.ts` + +At the passthrough inspector construction, pass `onSubscriptionUsage` **only** when the +resolved provider is `meta-muse`. The handler: + +1. `parseMuseSubscriptionUsage(payload)`; bail on `null`. +2. Resolve the serving account: `genericFailoverAccountId` if failover moved it, else the + account resolved at dispatch. Attribution to the dispatch-time account would be wrong + precisely when it matters most. +3. `recordPassiveAccountQuota("meta-muse", accountId, quota)`. + +## MODIFY `src/providers/quota.ts` + +```ts +/** Providers whose per-account quota is observed passively, never probed. */ +export function hasPassiveAccountQuota(provider: string): boolean { + return provider === "meta-muse"; +} + +/** Write a quota observed in-band. Generation-fenced, like the probe writers. */ +export function recordPassiveAccountQuota(provider: string, accountId: string, quota: ProviderQuota): void; +``` + +`recordPassiveAccountQuota` mirrors the existing probe writers at `quota.ts:1380`, with +one correction the A-gate caught: capturing the generation immediately before the write +cannot see a config or account change that happened EARLIER in the turn, which is exactly +the case that matters. So the CALLER captures `captureConfigGeneration()` when it resolves +the serving credential and passes it in, and the writer discards if the generation moved +since. Then write +`accountQuotaCache.set(accountCacheKey(provider, accountId), { ts: Date.now(), quota })`, +then `persistAccountQuotaCache()` so a restart keeps the last observation. + +The read path gains `hasPassiveAccountQuota` alongside `supportsPerAccountQuota` so +cached Meta rows are served, and **no** dispatch branch is added to +`maybeFetchProviderQuota` — there is nothing to fetch. + +## MODIFY `src/server/management/oauth-account-routes.ts` + +The `quota=1` enrichment returns cached rows for a passive provider and never triggers a +probe. When no observation exists yet, the row is absent rather than an error: a user who +has not run a streaming turn has no quota, which is correct. + +## MODIFY `gui/src/hooks/useProviderAccountPools.ts` + the account row + +Render the observation time with the percentages — "5h 12% · observed 14m ago". A passive +value can be arbitrarily old and must not be presented as live. Absent quota renders +nothing, not a zero bar. + +## Tests + +`tests/muse-subscription-usage.test.ts` — parser, fixture-driven: +the measured payload; `window_duration_mins: 600` → `customWindows`, not +`fiveHourPercent`; weekly-only; window-only; `used_percent: 150` CLAMPED to 100 by +`normalizePercent` (quota-wire clamps rather than rejects - assert the clamp); missing `subscription`; non-object; `tier` never surfaced; +`updatedAt` local. + +`tests/muse-passive-quota-cache.test.ts` — `recordPassiveAccountQuota` writes under the +serving account key; a generation bump discards the write; the row persists and rehydrates; +`hasPassiveAccountQuota("meta-muse")` true while `supportsPerAccountQuota("meta-muse")` +stays **false** (the exfiltration guard from wp4 must survive this phase). + +`tests/relay-sse-subscription-usage.test.ts` — the inspector invokes the handler for a +recorded transcript containing the event, does not invoke it for one without, and is +unaffected when the handler is absent. + +No live call, no real Keychain, in any test. + +## Verification + +```bash +bun test tests/muse-subscription-usage.test.ts tests/muse-passive-quota-cache.test.ts \ + tests/relay-sse-subscription-usage.test.ts tests/meta-muse-oauth.test.ts +bun run test:changed +bun x tsc --noEmit +bun run privacy:scan +bun run lint:gui +cd gui && bun run build +``` + +## Terminal outcome + +`DONE` when a streaming `meta-muse` turn populates the account's 5-hour and weekly +percentages, the dashboard shows them with their observation age, a restart preserves the +last observation, and no code path issues an inference call to refresh a quota. From 2aba7075dd860efa89ef52c89e73c3575661ab91 Mon Sep 17 00:00:00 2001 From: jun Date: Thu, 3 Sep 2026 15:28:10 +0900 Subject: [PATCH 2/5] feat(oauth): import the Muse Code CLI credential behind a ToS warning Adds `meta-muse`, an OAuth provider that reuses the API key the Muse Code CLI already holds, for operators who signed that CLI in and would rather not provision a second key. This ships because the repository owner authorized it for his own account. An earlier phase closed the same idea as a NOOP, and that reasoning stands: proving a credential works is not the same as being allowed to use it, so an agent must not spend a user's ToS risk on its own initiative. A user spending his own deliberately is a different act, and the repository already models it - anthropic and google-antigravity sit in the same HIGH_RISK map. Two measurements shaped the design. The credential file at ~/.config/muse/auth.json holds no secret; it is a pointer to a macOS Keychain item. That item carries both an access_token and an api_key, and only the api_key authenticates: the OAuth access token returns 401 invalid_api_key on /v1/models while the sibling key returns 200. So this is a static-key credential with nothing to refresh, the shape command-code already uses. Import-only, and macOS-only. `muse login` has no non-interactive mode, so a spawned child could outlive cancellation, and polling for the pointer file is satisfied instantly by the one already on disk - which would reimport the OLD account on a force-login. When no credential is present the provider says what to run instead of running it. The warning reaches both surfaces, which took two fixes: - The GUI map alone was not enough. Reauthentication called loginOAuth directly, so a user who had already logged in could refresh a high-risk credential without ever seeing the modal. onReauth now routes through the warning-aware path, carrying accountId so acknowledgement continues the same operation rather than a plain login against the active account. - login-cli.ts never reads the registry note, so `ocx login meta-muse` had no warning at all. loginMetaMuse emits it through ctrl.onProgress before it touches the pointer or the Keychain. The disclosures say what is actually known. Meta scopes this credential to its own CLI and how these calls settle is not observable from the API, so the note says treat every call as billable rather than asserting pay-as-you-go as fact. It also states that the key is copied into OpenCodex's auth store, because it is - runLogin persists it like every other OAuth credential. Also: two price overlays (overlays resolve by exact provider id, so a provider whose warning is 'treat every call as billable' would otherwise report no cost), a privacy-scan detector for the measured LLM|| key shape exercised through a new exported scanText seam, and a GUI test that asserts login, add-account and reauth each reach login zero times before acknowledgement and once after. supportsPerAccountQuota stays false, with a test. That predicate gates fetchAccountQuota, whose fallback sends any non-Kiro/non-Antigravity bearer to Anthropic's usage endpoint - flipping it without a dedicated branch would ship a Meta key to Anthropic. Quota is deferred to wp5: Meta does report subscription windows, but only as an SSE event on streaming turns, which needs a passive cache rather than a probe. Plan and five-round audit trail: devlog/_plan/260903_muse_spark_plan_oauth/. --- .../src/content/docs/guides/providers.md | 21 ++ gui/src/oauth-tos-risk.ts | 2 +- gui/src/pages/Providers.tsx | 24 +- gui/src/pages/providers-shared.ts | 1 + gui/tests/oauth-tos-warning-gate.test.tsx | 121 ++++++++++ scripts/privacy-scan.ts | 27 ++- src/oauth/index.ts | 11 + src/oauth/meta-muse.ts | 213 ++++++++++++++++++ src/providers/registry.ts | 27 +++ src/usage/expected-prices.ts | 18 +- tests/meta-muse-oauth.test.ts | 202 +++++++++++++++++ tests/oauth-tos-warning.test.ts | 5 + tests/privacy-scan-meta-key.test.ts | 40 ++++ tests/usage-cost.test.ts | 8 +- 14 files changed, 707 insertions(+), 13 deletions(-) create mode 100644 gui/tests/oauth-tos-warning-gate.test.tsx create mode 100644 src/oauth/meta-muse.ts create mode 100644 tests/meta-muse-oauth.test.ts create mode 100644 tests/privacy-scan-meta-key.test.ts diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index eaea71d0bc..73f7f20620 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -344,6 +344,7 @@ free-experimentation model. | Baseten Model APIs | `https://inference.baseten.co/v1` | | Command Code | `https://api.commandcode.ai/provider/v1` | | Meta Model API | `https://api.meta.ai/v1` | +| Meta Muse Code (CLI credential) | `https://api.meta.ai/v1` | | SambaNova Cloud | `https://api.sambanova.ai/v1` | | Nebius Token Factory | `https://api.tokenfactory.nebius.com/v1` | | DigitalOcean Serverless Inference | `https://inference.do-ai.run/v1` | @@ -457,6 +458,26 @@ material off it. Muse Spark is also reachable through resellers, with a narrower `command-code` carries both tiers, while `opencode-go` serves only `muse-spark-1.3-contributor`. +**Meta Muse Code (`meta-muse`).** If you already use the Muse Code CLI, this imports the +API key it stored after `muse login` instead of asking you to provision a second one. +macOS only — the CLI keeps that key in the macOS Keychain, and no other platform's +storage has been verified. OpenCodex never launches the CLI: if no credential is present +it tells you to run `muse login` yourself. + +**Read this before enabling it.** Meta scopes that credential to the Muse Code CLI, so +using it here is an *unsupported* path. Meta does not authorize subscription coverage +outside its own client, how these calls settle is not observable from the API, and you +should treat every call as billable against your account. The imported key is copied into +OpenCodex's auth store (`~/.opencodex/auth.json`, mode 0600) like every other OAuth +credential. The dashboard shows a Terms-of-Service warning before the first login and +before any reauthentication — the same treatment Anthropic and Google Antigravity get. + +Meta reports subscription window usage inside streaming responses, but OpenCodex does not +yet read or display it, and there is no endpoint to query it on demand, so this provider +shows no quota. Rate limits apply per team, not per key. + +For a supported setup, use `meta-model` above with your own key. + **Command Code quota.** The dashboard and `ocx account refresh` probe Command Code's `/alpha/billing/credits` windows (5-hour and weekly) on the canonical `https://api.commandcode.ai` host. The OAuth preset (`command-code`) uses the stored diff --git a/gui/src/oauth-tos-risk.ts b/gui/src/oauth-tos-risk.ts index d894d1f121..8d87db55d5 100644 --- a/gui/src/oauth-tos-risk.ts +++ b/gui/src/oauth-tos-risk.ts @@ -7,7 +7,7 @@ */ export type OAuthTosRiskLevel = "high" | "elevated"; -const HIGH_RISK = new Set(["anthropic", "google-antigravity"]); +const HIGH_RISK = new Set(["anthropic", "google-antigravity", "meta-muse"]); const ELEVATED_RISK = new Set(["github-copilot", "cursor"]); export function oauthTosRisk(providerId: string): OAuthTosRiskLevel | null { diff --git a/gui/src/pages/Providers.tsx b/gui/src/pages/Providers.tsx index 158497a36c..7bb9009418 100644 --- a/gui/src/pages/Providers.tsx +++ b/gui/src/pages/Providers.tsx @@ -43,7 +43,12 @@ export default function Providers({ apiBase }: { apiBase: string }) { /** ChatGPT/Codex login from Add Provider → Accounts (uses /api/codex-auth, not /api/oauth). */ const [codexLoginOpen, setCodexLoginOpen] = useState(false); const [modelsRefreshToken, setModelsRefreshToken] = useState(0); - const [oauthTosPending, setOauthTosPending] = useState<{ provider: string; addAccount: boolean } | null>(null); + // `accountId` rides along so acknowledging the warning continues the SAME operation. + // Without it, a reauth that reached the modal would resume as a plain login and target + // the active account instead of the one the user clicked. + const [oauthTosPending, setOauthTosPending] = useState< + { provider: string; addAccount: boolean; accountId?: string } | null + >(null); /** Bumped after OAuth login so ProviderDetails switches to the Accounts tab. */ const [accountsFocus, setAccountsFocus] = useState<{ token: number; provider: string | null }>({ token: 0, @@ -227,13 +232,20 @@ export default function Providers({ apiBase }: { apiBase: string }) { refreshCodexAccount: () => codexPool.load(true), }); - const requestLoginOAuth = (provider: string, addAccount = false) => { + /** + * The single warning-aware entry point for every OAuth login. + * + * Reauthentication used to call `loginOAuth` directly, so a user who had already logged + * in could refresh a high-risk credential without ever seeing the ToS modal — the map + * gated the first login and nothing after it. + */ + const requestLoginOAuth = (provider: string, addAccount = false, accountId?: string) => { if (busy === provider) return; if (oauthTosRisk(provider)) { - setOauthTosPending({ provider, addAccount }); + setOauthTosPending({ provider, addAccount, ...(accountId ? { accountId } : {}) }); return; } - void loginOAuth(provider, addAccount); + void loginOAuth(provider, addAccount, accountId); }; if (!config) { @@ -367,7 +379,7 @@ export default function Providers({ apiBase }: { apiBase: string }) { onLogin: requestLoginOAuth, onCancelLogin: cancelLoginOAuth, onLogout: logoutOAuth, - onReauth: (provider, accountId) => loginOAuth(provider, true, accountId), + onReauth: (provider, accountId) => requestLoginOAuth(provider, true, accountId), onSwitchAccount: switchAccount, onRemoveAccount: removeAccount, onRetryAccounts: async provider => { await fetchAccountSets([provider]); }, @@ -441,7 +453,7 @@ export default function Providers({ apiBase }: { apiBase: string }) { const pending = oauthTosPending; if (!pending) return; setOauthTosPending(null); - void loginOAuth(pending.provider, pending.addAccount); + void loginOAuth(pending.provider, pending.addAccount, pending.accountId); }} /> diff --git a/gui/src/pages/providers-shared.ts b/gui/src/pages/providers-shared.ts index 3b8607dc75..2efc654c40 100644 --- a/gui/src/pages/providers-shared.ts +++ b/gui/src/pages/providers-shared.ts @@ -49,6 +49,7 @@ const OAUTH_LABELS: Record = { xai: "xAI (Grok)", anthropic: "Anthropic (Claude)", kimi: "Kimi (Moonshot)", + "meta-muse": "Meta Muse Code (CLI)", "google-antigravity": "Google Antigravity", "github-copilot": "GitHub Copilot", cursor: "Cursor", diff --git a/gui/tests/oauth-tos-warning-gate.test.tsx b/gui/tests/oauth-tos-warning-gate.test.tsx new file mode 100644 index 0000000000..e2dca17ac5 --- /dev/null +++ b/gui/tests/oauth-tos-warning-gate.test.tsx @@ -0,0 +1,121 @@ +/** + * The ToS warning must gate EVERY OAuth login path, not just the first one. + * + * The root suite's seam test greps source text, and it passed for months while + * reauthentication called `loginOAuth` directly — so a user who had already logged in + * could refresh a high-risk credential without ever seeing the modal. Source-string + * assertions cannot catch that; this exercises the real decision function instead. + * + * It mirrors `requestLoginOAuth` in `Providers.tsx`: same risk lookup, same pending + * state, same continuation. If that function stops consulting `oauthTosRisk`, or drops + * `accountId` from the pending state, the corresponding case here fails. + */ +import { describe, expect, test } from "bun:test"; +import { join } from "node:path"; +import { oauthTosRisk } from "../src/oauth-tos-risk"; + +/** GUI tests run with `gui/` as cwd, so resolve page paths relative to this file. */ +const PROVIDERS_PAGE = join(import.meta.dir, "..", "src", "pages", "Providers.tsx"); + +interface Pending { + provider: string; + addAccount: boolean; + accountId?: string; +} + +/** A standalone model of the component's gate, exercised without mounting the page. */ +function createGate() { + const logins: Array<{ provider: string; addAccount: boolean; accountId?: string }> = []; + let pending: Pending | null = null; + + const loginOAuth = (provider: string, addAccount = false, accountId?: string) => { + logins.push({ provider, addAccount, ...(accountId ? { accountId } : {}) }); + }; + + const requestLoginOAuth = (provider: string, addAccount = false, accountId?: string) => { + if (oauthTosRisk(provider)) { + pending = { provider, addAccount, ...(accountId ? { accountId } : {}) }; + return; + } + loginOAuth(provider, addAccount, accountId); + }; + + const acknowledge = () => { + const p = pending; + if (!p) return; + pending = null; + loginOAuth(p.provider, p.addAccount, p.accountId); + }; + + return { logins, requestLoginOAuth, acknowledge, cancel: () => { pending = null; }, pending: () => pending }; +} + +describe("meta-muse sits in the high-risk map", () => { + test("is flagged high, like the other vendor-restricted subscription logins", () => { + expect(oauthTosRisk("meta-muse")).toBe("high"); + expect(oauthTosRisk("META-MUSE")).toBe("high"); + }); + + test("the supported key provider is NOT flagged", () => { + // meta-model uses the user's own key on a documented endpoint: no ToS risk to warn about. + expect(oauthTosRisk("meta-model")).toBeNull(); + }); +}); + +describe("every login path is gated for a high-risk provider", () => { + for (const [label, invoke] of [ + ["plain login", (g: ReturnType) => g.requestLoginOAuth("meta-muse")], + ["add account", (g: ReturnType) => g.requestLoginOAuth("meta-muse", true)], + ["reauthentication", (g: ReturnType) => g.requestLoginOAuth("meta-muse", true, "acct-1")], + ] as const) { + test(`${label}: no login before acknowledgement, exactly one after`, () => { + const gate = createGate(); + invoke(gate); + expect(gate.logins).toHaveLength(0); + expect(gate.pending()).not.toBeNull(); + + gate.acknowledge(); + expect(gate.logins).toHaveLength(1); + }); + + test(`${label}: cancelling never logs in`, () => { + const gate = createGate(); + invoke(gate); + gate.cancel(); + gate.acknowledge(); + expect(gate.logins).toHaveLength(0); + }); + } + + /* + * Without accountId in the pending state, acknowledging a reauth resumes as a plain + * add-account login and targets the wrong account. + */ + test("reauthentication continues the SAME operation after acknowledgement", () => { + const gate = createGate(); + gate.requestLoginOAuth("meta-muse", true, "acct-42"); + gate.acknowledge(); + expect(gate.logins[0]).toEqual({ provider: "meta-muse", addAccount: true, accountId: "acct-42" }); + }); + + test("an unflagged provider is not gated at all", () => { + const gate = createGate(); + gate.requestLoginOAuth("kimi"); + expect(gate.logins).toHaveLength(1); + expect(gate.pending()).toBeNull(); + }); +}); + +describe("the page wires reauthentication through the gate", () => { + test("onReauth calls requestLoginOAuth, not loginOAuth", async () => { + const page = await Bun.file(PROVIDERS_PAGE).text(); + const onReauth = page.slice(page.indexOf("onReauth:"), page.indexOf("onReauth:") + 120); + expect(onReauth).toContain("requestLoginOAuth"); + expect(onReauth).not.toContain("loginOAuth(provider"); + }); + + test("the pending state carries accountId through to the continuation", async () => { + const page = await Bun.file(PROVIDERS_PAGE).text(); + expect(page).toContain("pending.accountId"); + }); +}); diff --git a/scripts/privacy-scan.ts b/scripts/privacy-scan.ts index f6dbcc6618..c684d38ff9 100644 --- a/scripts/privacy-scan.ts +++ b/scripts/privacy-scan.ts @@ -184,8 +184,14 @@ function addFindingsForPattern( } } -function scanFile(file: string): Finding[] { - const text = readFileSync(file, "utf-8"); +/** + * Scan already-read text. + * + * Split out of `scanFile` so a test can exercise the REAL detectors. This module runs its + * scan on import, so a test that cannot call a function ends up re-declaring the patterns + * instead — and then stays green even if a detector here is deleted. + */ +export function scanText(file: string, text: string): Finding[] { const findings: Finding[] = []; addFindingsForPattern( findings, @@ -221,9 +227,26 @@ function scanFile(file: string): Finding[] { /\b(?:sk-[A-Za-z0-9_-]{20,}|ghp_[A-Za-z0-9_]{20,}|eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,})\b/g, match => isAllowedTokenLooking(file, match[0]), ); + /* + * Meta Model API keys. The pattern above does not match them: the measured shape is + * `LLM|<16 digits>|<27 chars>`, verified against a real key's grammar (never its value). + * The `meta-muse` provider imports one of these, so a leak has to be detectable here. + */ + addFindingsForPattern( + findings, + file, + text, + "meta-api-key", + /\bLLM\|\d+\|[A-Za-z0-9_-]{10,}\b/g, + match => isAllowedTokenLooking(file, match[0]), + ); return findings; } +function scanFile(file: string): Finding[] { + return scanText(file, readFileSync(file, "utf-8")); +} + const findings = gitLsFiles() .filter(existsSync) .filter(shouldScan) diff --git a/src/oauth/index.ts b/src/oauth/index.ts index 92bc74b3fa..868c4f9b99 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -39,6 +39,7 @@ import { loginAntigravity, refreshAntigravityToken } from "./google-antigravity" import { loginCursor, refreshCursorToken } from "./cursor"; import { loginGithubCopilot, refreshGithubCopilotToken, validateCopilotApiBaseUrl } from "./github-copilot"; import { loginCommandCode, refreshCommandCodeToken } from "./command-code"; +import { loginMetaMuse, refreshMetaMuseToken } from "./meta-muse"; import { ANTIGRAVITY_REQUEST_UA } from "../adapters/google-antigravity-wire"; import { deriveOAuthDefaultModel, deriveOAuthProviderConfig } from "../providers/derive"; import { apiKeyPoolEntryId, sanitizeApiKeyValue } from "../providers/api-keys"; @@ -228,6 +229,16 @@ export const OAUTH_PROVIDERS: Record = { providerConfig: oauthConfig("kimi"), defaultModel: oauthDefaultModel("kimi"), }, + "meta-muse": { + login: ctrl => loginMetaMuse(ctrl), + refresh: refreshMetaMuseToken, + providerConfig: oauthConfig("meta-muse"), + defaultModel: oauthDefaultModel("meta-muse"), + // Static API key that Meta scopes to its own CLI. Never generate unattended traffic + // on it — same posture as anthropic, for the same reason: the vendor restricts use + // outside its own client, so every exchange stays attributable to a user action. + defaultRefreshPolicy: "disabled", + }, nous: { // Nous Portal device-grant login (RFC 8628) against portal.nousresearch.com. // The access token is the per-request inference JWT (scope inference:invoke). diff --git a/src/oauth/meta-muse.ts b/src/oauth/meta-muse.ts new file mode 100644 index 0000000000..b64b0dfd15 --- /dev/null +++ b/src/oauth/meta-muse.ts @@ -0,0 +1,213 @@ +/** + * Meta Muse Code credential import. + * + * The Muse Code CLI signs in through a browser device-approval flow and stores the + * result in two places: `~/.config/muse/auth.json` is a POINTER carrying no secret, and + * the secret itself lives in the macOS Keychain under service + * `ai.meta.dev.credentials`, account `meta`. + * + * Two measured facts shape this module (devlog/_plan/260903_muse_spark_plan_oauth/003): + * + * 1. The Keychain payload holds BOTH an `access_token` and an `api_key`, and only the + * `api_key` authenticates the Model API — the OAuth access token returns 401 + * `invalid_api_key`. So this is a static-key credential, not a refreshable one. + * 2. Meta scopes that credential to the Muse Code CLI in writing. Reusing it here is an + * UNSUPPORTED path the repository owner opted into deliberately, which is why the + * warning below fires before anything is read and why the provider sits in the GUI's + * HIGH_RISK ToS map. + * + * This module never spawns the CLI. A login that finds no credential explains what to + * run rather than running it: `muse login` is interactive with no machine-readable mode, + * so a spawned child could outlive cancellation, and polling for the pointer file would + * be satisfied instantly by the one already on disk — reimporting the OLD account on a + * force-login. + */ +import { homedir } from "node:os"; +import { join } from "node:path"; +import { sanitizeApiKeyValue } from "../providers/api-keys"; +import type { OAuthController, OAuthCredentials } from "./types"; + +const MUSE_POINTER_PATH = join(homedir(), ".config", "muse", "auth.json"); +const KEYCHAIN_SERVICE = "ai.meta.dev.credentials"; +const KEYCHAIN_ACCOUNT = "meta"; +const MODELS_URL = "https://api.meta.ai/v1/models"; +const VALIDATE_TIMEOUT_MS = 10_000; + +/** + * Shown BEFORE any credential is read. + * + * `login-cli.ts` passes `onProgress` straight to `console.log` and never reads the + * registry note, so this is the CLI's only warning surface. The GUI ignores it because + * `OAuthTosWarningModal` has already been acknowledged by then. + */ +const CONSENT_WARNING = [ + "Meta scopes the Muse Code credential to the Muse Code CLI.", + "Using it here is UNSUPPORTED: Meta does not authorize subscription coverage outside its own CLI,", + "how these calls settle is not observable from the API, and you should treat every call as billable.", + "The imported key is copied into OpenCodex's auth store (~/.opencodex/auth.json, 0600).", + "Supported alternative: the meta-model provider with your own key (META_MODEL_API_KEY).", +].join(" "); + +/** The Keychain payload. `access_token` is deliberately unused — it 401s (003 §B). */ +interface MuseKeychainSecret { + api_key?: unknown; + access_token?: unknown; +} + +interface MusePointer { + providers?: { meta?: { mechanism?: unknown; storage?: unknown; user_email?: unknown } }; +} + +/** Injected so tests never touch the real Keychain, filesystem, platform, or network. */ +export interface MuseImportDeps { + platform?: string; + readPointer?: () => Promise; + readKeychain?: () => Promise; + fetchImpl?: typeof fetch; +} + +async function defaultReadPointer(): Promise { + try { + return await Bun.file(MUSE_POINTER_PATH).text(); + } catch { + return null; + } +} + +async function defaultReadKeychain(): Promise { + try { + const proc = Bun.spawn( + ["security", "find-generic-password", "-s", KEYCHAIN_SERVICE, "-a", KEYCHAIN_ACCOUNT, "-w"], + { stdout: "pipe", stderr: "pipe" }, + ); + const [out, code] = await Promise.all([new Response(proc.stdout).text(), proc.exited]); + if (code !== 0) return null; + const trimmed = out.trim(); + return trimmed.length > 0 ? trimmed : null; + } catch { + return null; + } +} + +const INSTALL_HINT = + "Install it from https://dev.meta.ai/install.sh, run `muse login`, then retry."; + +function normalizedEmail(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim().toLowerCase(); + return trimmed.length > 0 ? trimmed : undefined; +} + +/** + * Import the credential the Muse Code CLI already holds. + * + * Every refusal names what the user should do. None of them includes the credential. + */ +export async function loginMetaMuse( + ctrl: OAuthController = {}, + deps: MuseImportDeps = {}, +): Promise { + // Before ANY read: the CLI has no other warning surface. + ctrl.onProgress?.(CONSENT_WARNING); + + const platform = deps.platform ?? process.platform; + if (platform !== "darwin") { + throw new Error( + "Meta Muse Code login is macOS-only: the CLI stores its credential in the macOS Keychain, " + + "and no other platform's storage has been verified. Use the meta-model provider with your own key instead.", + ); + } + + const pointerRaw = await (deps.readPointer ?? defaultReadPointer)(); + if (pointerRaw === null) { + throw new Error(`Muse Code CLI credential not found at ${MUSE_POINTER_PATH}. ${INSTALL_HINT}`); + } + + let pointer: MusePointer; + try { + pointer = JSON.parse(pointerRaw) as MusePointer; + } catch { + throw new Error(`Muse Code credential file at ${MUSE_POINTER_PATH} is not valid JSON. Run \`muse login\` to rewrite it.`); + } + + const meta = pointer.providers?.meta; + if (!meta || meta.mechanism !== "oauth") { + throw new Error("The Muse Code credential file has no signed-in Meta account. Run `muse login`, then retry."); + } + // A different storage backend is a shape we have not measured; refuse rather than guess. + if (meta.storage !== "keychain") { + throw new Error( + `Muse Code stored its credential with an unsupported backend (${String(meta.storage)}); only the macOS Keychain is verified.`, + ); + } + + const secretRaw = await (deps.readKeychain ?? defaultReadKeychain)(); + if (secretRaw === null) { + throw new Error( + "Could not read the Muse Code credential from the macOS Keychain. Approve the Keychain prompt, or run `muse login` again.", + ); + } + + let secret: MuseKeychainSecret; + try { + secret = JSON.parse(secretRaw) as MuseKeychainSecret; + } catch { + throw new Error("The Muse Code Keychain entry is not valid JSON. Run `muse login` to rewrite it."); + } + + // access_token is present but 401s against the Model API (003 §B) — never fall back to it. + const apiKey = sanitizeApiKeyValue(secret.api_key); + if (!apiKey) { + throw new Error("The Muse Code Keychain entry carries no usable API key. Run `muse login` again."); + } + if (!/^LLM\|\d+\|[A-Za-z0-9_-]{10,}$/.test(apiKey)) { + throw new Error("The Muse Code credential is not in the expected Meta API key format. Run `muse login` again."); + } + + ctrl.onProgress?.("Validating the imported Meta credential…"); + const fetchImpl = deps.fetchImpl ?? fetch; + // ctrl.signal is OPTIONAL and the CLI controller supplies none: AbortSignal.any([undefined]) + // throws a TypeError, which would fail every CLI login right after the warning printed. + const signal = ctrl.signal + ? AbortSignal.any([ctrl.signal, AbortSignal.timeout(VALIDATE_TIMEOUT_MS)]) + : AbortSignal.timeout(VALIDATE_TIMEOUT_MS); + let response: Response; + try { + response = await fetchImpl(MODELS_URL, { + headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" }, + signal, + }); + } catch (error) { + if (ctrl.signal?.aborted) throw ctrl.signal.reason ?? new DOMException("Meta Muse login aborted", "AbortError"); + throw new Error(`Could not reach the Meta Model API to validate the credential: ${(error as Error).message}`); + } + if (!response.ok) { + throw new Error( + `The Muse Code credential was rejected by the Meta Model API (HTTP ${response.status}). Run \`muse login\` again.`, + ); + } + + return { + access: apiKey, + // Static key: there is nothing to exchange, so refresh carries the same value. + refresh: apiKey, + expires: Number.MAX_SAFE_INTEGER, + // `email`, not `accountId`: the account list masks email for display, and store.ts + // already falls back to it for slot identity, so multi-account still works. + ...(normalizedEmail(meta.user_email) ? { email: normalizedEmail(meta.user_email) } : {}), + source: "local-cli", + }; +} + +/** + * Static-key refresh, exactly like Command Code's. + * + * This deliberately does NOT re-read the Keychain. Generic refresh writes its result into + * the slot being refreshed, so if the user ran `muse login` with a DIFFERENT account in + * between, a re-import would silently overwrite one stored identity with another. Only an + * explicit login may import. + */ +export async function refreshMetaMuseToken(apiKey: string): Promise { + if (!apiKey) throw new Error("Meta Muse Code API key missing; run `ocx login meta-muse`"); + return { access: apiKey, refresh: apiKey, expires: Number.MAX_SAFE_INTEGER, source: "local-cli" }; +} diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 20c1befd76..cc692902d3 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -1515,6 +1515,33 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // user to export a variable this proxy never reads. note: "Pay-as-you-go Meta Model API. Get a key at https://dev.meta.ai (Meta calls it MODEL_API_KEY; export it here as META_MODEL_API_KEY) — a Meta developer account needs a payment method before it can serve requests, and every call is metered per token. A Muse Code subscription does NOT work here: Meta scopes that credential to the Muse Code CLI and bills any other key pay-as-you-go (dev.meta.ai/docs/muse-code/subscriptions). The Contributor tier (muse-spark-1.3-contributor) is cheap because Meta trains on your prompts — about 92% off input, 95% off output, 99% off cached input; do not send confidential material through it. Muse Spark is also reachable through resellers: command-code carries both tiers, opencode-go serves only muse-spark-1.3-contributor.", }, + /* [Decision Log] + - 목적과 의도: Let an operator who already signed the Muse Code CLI in reach Muse Spark with that credential, instead of provisioning a second key. + - 기존 구현 및 제약 조건: The CLI stores a pointer at ~/.config/muse/auth.json and the secret in the macOS Keychain (ai.meta.dev.credentials/meta). Measured: the OAuth access_token 401s on /v1/models while the sibling api_key returns 200, so the usable artifact is a static key, not a refreshable token. + - 검토한 주요 대안: spawn `muse login` and poll; reimplement Meta's device grant; treat it as a second key preset; ship nothing. + - 선택한 방식: an import-only, macOS-only OAuth provider that reads the existing credential, validates it once, and never spawns or reimplements anything. + - 다른 대안 대신 이 방식을 선택한 이유: `muse login` has no non-interactive mode, so a spawned child could outlive cancellation, and polling for the pointer file is satisfied instantly by the one already on disk — reimporting the OLD account on a force-login. Reimplementing the grant would mean guessing a client id the vendor does not publish. + - 장점, 단점 및 영향: no new credential to provision, and the id is distinct from meta-model so neither pool contaminates the other. Meta scopes this credential to its own CLI, so the provider carries a HIGH_RISK ToS warning, a CLI-side warning before any read, and a note that says plainly what is unsupported. + */ + { + id: "meta-muse", + label: "Meta Muse Code (CLI credential)", + adapter: "openai-responses", + baseUrl: "https://api.meta.ai/v1", + authKind: "oauth", + oauthId: "meta-muse", + dashboardUrl: "https://dev.meta.ai", + defaultModel: "muse-spark-1.3", + models: META_MUSE_MODELS, + // Same reason as meta-model: the authenticated roster carries muse-image-1.0 and + // muse-voice-transcribe-1.0, which this Responses-agent provider cannot drive. + liveModels: false, + modelContextWindows: Object.fromEntries(META_MUSE_MODELS.map(id => [id, META_MUSE_CONTEXT_WINDOW])), + modelInputModalities: Object.fromEntries(META_MUSE_MODELS.map(id => [id, ["text", "image"] as ["text", "image"]])), + modelReasoningEfforts: Object.fromEntries(META_MUSE_MODELS.map(id => [id, META_MUSE_REASONING_EFFORTS])), + modelReasoningEffortMap: Object.fromEntries(META_MUSE_MODELS.map(id => [id, META_MUSE_REASONING_EFFORT_MAP])), + note: "Reuses the API key the Muse Code CLI stores after `muse login` (macOS only; requires the CLI installed and signed in). Meta scopes that credential to the Muse Code CLI, so this is an UNSUPPORTED use: Meta does not authorize subscription coverage outside its own CLI, how these calls settle is not observable from the API, and you should treat every call as billable against your account. The imported key is copied into OpenCodex's auth store. Meta reports subscription window usage inside streaming responses, but OpenCodex does not yet read or display it, and there is no endpoint to query it on demand. Rate limits apply per team, not per key. For a supported path use the meta-model provider with your own key (export it as META_MODEL_API_KEY).", + }, { id: "umans", label: "Umans AI Coding Plan", diff --git a/src/usage/expected-prices.ts b/src/usage/expected-prices.ts index 20ba598569..5d71b6ff4c 100644 --- a/src/usage/expected-prices.ts +++ b/src/usage/expected-prices.ts @@ -89,6 +89,15 @@ const GEMINI_38_PRICING = "https://ai.google.dev/gemini-api/docs/pricing (2026-0 const MINIMAX_PRICING = "https://platform.minimax.io/docs/guides/pricing-paygo"; const OPENAI_GPT56_PRICING = "https://developers.openai.com/api/docs/pricing"; const META_MODEL_PRICING = "https://dev.meta.ai/docs/pricing-rate-limits"; +/* + * Shared by both Meta providers. Overlays resolve by EXACT provider id, so `meta-muse` + * cannot inherit `meta-model`'s rows — and an unpriced provider whose whole warning is + * "treat every call as billable" would report no cost at all. + */ +const META_MUSE_SPARK_13: Cost4 = { input: 1.25, output: 4.25, cacheRead: 0.15, cacheWrite: 0 }; +const META_MUSE_SPARK_13_CONTRIBUTOR: Cost4 = { input: 0.1, output: 0.2, cacheRead: 0.002, cacheWrite: 0 }; +const META_SPARK_SOURCE = `Meta Model API published price ${META_MODEL_PRICING}`; +const META_SPARK_CONTRIBUTOR_SOURCE = `Meta Model API published Contributor-tier price ${META_MODEL_PRICING}; data-sharing discount tier`; const DEEPSEEK_PRICING = "https://api-docs.deepseek.com/quick_start/pricing-details-usd; V4 Flash alias transition scheduled 2026-07-24 — re-verify after"; // Kimi official tables publish input/output/cache-hit only; cacheWrite is mapped to the // cache-miss input price (Kimi auto-caches with no separate write billing). 2026-07-20 re-verified. @@ -152,8 +161,13 @@ export const EXPECTED_PRICE_OVERLAYS: readonly ExpectedPriceOverlay[] = [ // published list prices for Meta's own endpoint (hence "verified", not derived), and // they match the figures Command Code republishes for the same two models. // cacheWrite=0: Meta publishes a cached-input price but no cache-write charge. - { provider: "meta-model", modelId: "muse-spark-1.3", cost4: { input: 1.25, output: 4.25, cacheRead: 0.15, cacheWrite: 0 }, source: `Meta Model API published price ${META_MODEL_PRICING}`, verifiedAt: "2026-09-03", status: "verified" }, - { provider: "meta-model", modelId: "muse-spark-1.3-contributor", cost4: { input: 0.1, output: 0.2, cacheRead: 0.002, cacheWrite: 0 }, source: `Meta Model API published Contributor-tier price ${META_MODEL_PRICING}; data-sharing discount tier`, verifiedAt: "2026-09-03", status: "verified" }, + { provider: "meta-model", modelId: "muse-spark-1.3", cost4: META_MUSE_SPARK_13, source: META_SPARK_SOURCE, verifiedAt: "2026-09-03", status: "verified" }, + { provider: "meta-model", modelId: "muse-spark-1.3-contributor", cost4: META_MUSE_SPARK_13_CONTRIBUTOR, source: META_SPARK_CONTRIBUTOR_SOURCE, verifiedAt: "2026-09-03", status: "verified" }, + // Same endpoint, same list price, different credential. Meta does not authorize this + // reuse and settlement is not observable, so these are the public Model API rates as a + // conservative estimate — not evidence of how the call is actually billed. + { provider: "meta-muse", modelId: "muse-spark-1.3", cost4: META_MUSE_SPARK_13, source: META_SPARK_SOURCE, verifiedAt: "2026-09-03", status: "verified-derived" }, + { provider: "meta-muse", modelId: "muse-spark-1.3-contributor", cost4: META_MUSE_SPARK_13_CONTRIBUTOR, source: META_SPARK_CONTRIBUTOR_SOURCE, verifiedAt: "2026-09-03", status: "verified-derived" }, // Daybreak aliases: priced as their current snapshots (red -> gpt-5.6-cyber, // blue -> gpt-5.6-sol). The alias ids carry no rows of their own upstream, hence // verified-derived. Blue deliberately reuses GPT56_SOL rather than duplicating the tuple. diff --git a/tests/meta-muse-oauth.test.ts b/tests/meta-muse-oauth.test.ts new file mode 100644 index 0000000000..662403ca16 --- /dev/null +++ b/tests/meta-muse-oauth.test.ts @@ -0,0 +1,202 @@ +/** + * Meta Muse Code credential provider (`meta-muse`). + * + * This provider reuses the API key the Muse Code CLI stores — a credential Meta scopes + * to its own CLI. It exists because the repository owner authorized it explicitly, and + * the tests below pin the guards that make that choice informed rather than silent: + * the warning fires before any read, the note discloses what is unsupported, the login + * cannot be spawned or refreshed into a different identity, and the credential never + * reaches a message or a status object. + */ +import { describe, expect, test } from "bun:test"; +import { OAUTH_PROVIDERS } from "../src/oauth"; +import { loginMetaMuse, refreshMetaMuseToken } from "../src/oauth/meta-muse"; +import { getProviderRegistryEntry } from "../src/providers/registry"; +import { supportsPerAccountQuota } from "../src/providers/quota"; +import { routeModel } from "../src/router"; +import type { OcxConfig } from "../src/types"; + +const MODELS = ["muse-spark-1.3", "muse-spark-1.3-contributor"] as const; + +/** A synthetic key of the measured grammar. Assembled at runtime, never a real value. */ +const CANARY = `LLM|${"1".repeat(16)}|${"c".repeat(27)}`; + +function entry() { + const found = getProviderRegistryEntry("meta-muse"); + if (!found) throw new Error("missing meta-muse registry entry"); + return found; +} + +function pointer(overrides: Record = {}): string { + return JSON.stringify({ + schema_version: 2, + providers: { meta: { mechanism: "oauth", storage: "keychain", user_email: "Someone@Example.COM", ...overrides } }, + }); +} + +const okFetch = (async () => new Response(JSON.stringify({ data: [] }), { status: 200 })) as unknown as typeof fetch; + +function deps(over: Partial[1]> = {}) { + return { + platform: "darwin", + readPointer: async () => pointer(), + readKeychain: async () => JSON.stringify({ secret_schema_version: 1, api_key: CANARY, access_token: "x".repeat(280) }), + fetchImpl: okFetch, + ...over, + }; +} + +describe("meta-muse registry entry", () => { + test("routes to Meta's Responses endpoint as an OAuth provider", () => { + expect(entry().baseUrl).toBe("https://api.meta.ai/v1"); + expect(entry().adapter).toBe("openai-responses"); + expect(entry().authKind).toBe("oauth"); + expect(entry().oauthId).toBe("meta-muse"); + }); + + test("reuses the ladder, window, modalities and identity wire map from the key provider", () => { + for (const id of MODELS) { + expect(entry().modelReasoningEfforts?.[id]).toEqual(["minimal", "low", "medium", "high", "xhigh"]); + expect(entry().modelReasoningEffortMap?.[id]?.minimal).toBe("minimal"); + expect(entry().modelContextWindows?.[id]).toBe(1_048_576); + expect(entry().modelInputModalities?.[id]).toEqual(["text", "image"]); + } + }); + + test("keeps live discovery off — the real roster carries image and voice models", () => { + expect(entry().liveModels).toBeFalsy(); + expect(entry().models).toEqual([...MODELS]); + }); + + test("the note discloses every unsupported-use fact a user needs before opting in", () => { + const note = entry().note ?? ""; + expect(note).toContain("UNSUPPORTED"); + expect(note).toContain("treat every call as billable"); + expect(note).toContain("auth store"); + // The env-var trap: Meta calls it MODEL_API_KEY, opencodex reads META_MODEL_API_KEY. + expect(note).toContain("META_MODEL_API_KEY"); + // Must not promise quota display that only wp5 delivers. + expect(note).toContain("does not yet read or display it"); + }); + + test("never generates unattended traffic on a vendor-restricted credential", () => { + expect(OAUTH_PROVIDERS["meta-muse"]?.defaultRefreshPolicy).toBe("disabled"); + }); + + /* + * supportsPerAccountQuota gates fetchAccountQuota, whose fallback sends any + * non-Kiro/non-Antigravity bearer to Anthropic's usage endpoint. Flipping this without + * a dedicated branch would ship a Meta key to Anthropic. + */ + test("stays out of the per-account quota probe path", () => { + expect(supportsPerAccountQuota("meta-muse")).toBe(false); + }); + + test("does not capture the live command-code meta/ model namespace", () => { + const config: OcxConfig = { + port: 10100, + defaultProvider: "command-code", + providers: { + "command-code": { adapter: "command-code", baseUrl: "https://api.commandcode.ai", apiKey: "cc", authMode: "key" }, + "meta-muse": { adapter: "openai-responses", baseUrl: "https://api.meta.ai/v1", apiKey: "mm", authMode: "oauth" }, + }, + }; + expect(routeModel(config, "meta/muse-spark-1.3").providerName).toBe("command-code"); + expect(routeModel(config, "meta-muse/muse-spark-1.3").providerName).toBe("meta-muse"); + }); +}); + +describe("meta-muse credential import", () => { + test("warns before it reads anything", async () => { + const seen: string[] = []; + let readPointerCalled = false; + await loginMetaMuse( + { onProgress: m => seen.push(m) }, + deps({ readPointer: async () => { readPointerCalled = true; expect(seen.length).toBeGreaterThan(0); return pointer(); } }), + ); + expect(readPointerCalled).toBe(true); + const warning = seen[0] ?? ""; + expect(warning).toContain("UNSUPPORTED"); + expect(warning).toContain("billable"); + expect(warning).toContain("meta-model"); + }); + + test("imports the api_key, not the access_token that 401s", async () => { + const creds = await loginMetaMuse({}, deps()); + expect(creds.access).toBe(CANARY); + expect(creds.refresh).toBe(CANARY); + expect(creds.expires).toBe(Number.MAX_SAFE_INTEGER); + expect(creds.source).toBe("local-cli"); + }); + + test("carries a normalized email, and no accountId, so the display mask applies", async () => { + const creds = await loginMetaMuse({}, deps()); + expect(creds.email).toBe("someone@example.com"); + expect(creds.accountId).toBeUndefined(); + }); + + test("a controller without a signal still logs in", async () => { + // AbortSignal.any([undefined, ...]) throws; the CLI controller supplies no signal. + await expect(loginMetaMuse({}, deps())).resolves.toBeDefined(); + }); + + test("an aborted controller signal aborts the login", async () => { + const ac = new AbortController(); + ac.abort(); + const failing = (async () => { throw new DOMException("aborted", "AbortError"); }) as unknown as typeof fetch; + await expect(loginMetaMuse({ signal: ac.signal }, deps({ fetchImpl: failing }))).rejects.toThrow(); + }); + + for (const [label, over] of [ + ["a non-darwin platform", { platform: "linux" }], + ["no credential file", { readPointer: async () => null }], + ["a malformed credential file", { readPointer: async () => "{not json" }], + ["no signed-in Meta account", { readPointer: async () => JSON.stringify({ providers: {} }) }], + ["an unverified storage backend", { readPointer: async () => pointer({ storage: "file" }) }], + ["an unreadable keychain", { readKeychain: async () => null }], + ["a malformed keychain payload", { readKeychain: async () => "{not json" }], + ["a payload with only an access_token", { readKeychain: async () => JSON.stringify({ access_token: "x".repeat(280) }) }], + ["a key of the wrong shape", { readKeychain: async () => JSON.stringify({ api_key: "not-a-meta-key" }) }], + ] as const) { + test(`refuses ${label} with an actionable message`, async () => { + await expect(loginMetaMuse({}, deps(over as never))).rejects.toThrow(); + }); + } + + test("a rejected credential fails without echoing it", async () => { + const denied = (async () => new Response("nope", { status: 401 })) as unknown as typeof fetch; + await expect(loginMetaMuse({}, deps({ fetchImpl: denied }))).rejects.toThrow(/401/); + }); + + /* + * The canary must never appear anywhere a human or a log can read it. This is the + * assertion that would catch a well-meaning "include the key in the error for + * debugging" change. + */ + test("no failure path echoes the credential", async () => { + const denied = (async () => new Response("nope", { status: 401 })) as unknown as typeof fetch; + const progress: string[] = []; + let message = ""; + try { + await loginMetaMuse({ onProgress: m => progress.push(m) }, deps({ fetchImpl: denied })); + } catch (error) { + message = String((error as Error).message) + String((error as Error).stack ?? ""); + } + expect(message).not.toContain(CANARY); + for (const line of progress) expect(line).not.toContain(CANARY); + }); +}); + +describe("meta-muse refresh", () => { + test("returns the same static key and reads no credential store", async () => { + // Re-importing here would let a DIFFERENT Muse account silently overwrite this slot. + const creds = await refreshMetaMuseToken(CANARY); + expect(creds.access).toBe(CANARY); + expect(creds.refresh).toBe(CANARY); + expect(creds.expires).toBe(Number.MAX_SAFE_INTEGER); + }); + + test("an empty key is refused rather than replayed", async () => { + await expect(refreshMetaMuseToken("")).rejects.toThrow(/ocx login meta-muse/); + }); +}); diff --git a/tests/oauth-tos-warning.test.ts b/tests/oauth-tos-warning.test.ts index 699eaddb15..2859b14711 100644 --- a/tests/oauth-tos-warning.test.ts +++ b/tests/oauth-tos-warning.test.ts @@ -9,6 +9,9 @@ describe("oauth ToS risk map", () => { test("flags high-risk subscription OAuth providers", () => { expect(oauthTosRisk("anthropic")).toBe("high"); expect(oauthTosRisk("google-antigravity")).toBe("high"); + // Meta restricts the Muse Code credential to its own CLI in writing, which is the + // documented difference between "high" and "elevated". + expect(oauthTosRisk("meta-muse")).toBe("high"); expect(oauthTosRisk("Anthropic")).toBe("high"); expect(oauthTosRisk(" anthropic ")).toBe("high"); }); @@ -22,6 +25,8 @@ describe("oauth ToS risk map", () => { expect(oauthTosRisk("xai")).toBeNull(); expect(oauthTosRisk("kimi")).toBeNull(); expect(oauthTosRisk("kiro")).toBeNull(); + // The supported Meta path: the user's own key on a documented endpoint. + expect(oauthTosRisk("meta-model")).toBeNull(); expect(oauthTosRisk("")).toBeNull(); expect(oauthTosRisk(" ")).toBeNull(); }); diff --git a/tests/privacy-scan-meta-key.test.ts b/tests/privacy-scan-meta-key.test.ts new file mode 100644 index 0000000000..c8259e966f --- /dev/null +++ b/tests/privacy-scan-meta-key.test.ts @@ -0,0 +1,40 @@ +/** + * The privacy scanner must recognize a Meta Model API key. + * + * The `meta-muse` provider imports one of these from the Muse Code CLI, and the plan + * names `privacy:scan` as the gate that would catch it if it ever escaped into a tracked + * file. The pre-existing `token-looking` pattern matches `sk-`, `ghp_` and JWTs — none + * of which resemble Meta's `LLM||` shape. + * + * This exercises the REAL `scanText` used by `bun run privacy:scan`, not a copy of its + * regex: a test that re-declared the pattern would keep passing after the production + * detector was deleted. + */ +import { describe, expect, test } from "bun:test"; +import { scanText } from "../scripts/privacy-scan"; + +/** Assembled at runtime so this file contains no secret-shaped literal of its own. */ +const canary = ["LLM", "1".repeat(16), "c".repeat(27)].join("|"); + +describe("privacy scan: Meta API keys", () => { + test("flags a Meta-shaped key in a tracked file", () => { + const findings = scanText("src/example.ts", `const key = "${canary}";`); + expect(findings.some(f => f.kind === "meta-api-key")).toBe(true); + }); + + test("the pre-existing token patterns would have missed it", () => { + const findings = scanText("src/example.ts", `const key = "${canary}";`); + // Proves the new detector is doing the work, not an incidental match. + expect(findings.some(f => f.kind === "token-looking")).toBe(false); + }); + + test("ordinary prose mentioning the prefix is not a finding", () => { + const findings = scanText("docs/example.md", "Meta keys start with an LLM| prefix."); + expect(findings.some(f => f.kind === "meta-api-key")).toBe(false); + }); + + test("a Bearer header carrying one is still caught", () => { + const findings = scanText("src/example.ts", `Authorization: Bearer ${canary}`); + expect(findings.length).toBeGreaterThan(0); + }); +}); diff --git a/tests/usage-cost.test.ts b/tests/usage-cost.test.ts index d93ae813c4..d166434a3d 100644 --- a/tests/usage-cost.test.ts +++ b/tests/usage-cost.test.ts @@ -297,8 +297,8 @@ describe("resolveMatchedPrice", () => { expect(resolveMatchedPrice("openrouter", "anthropic-claude-3.5-sonnet")).toBeNull(); }); - test("16. shipped overlay membership: 66 keys, including canonical Fable 5.1, Opus 5 and compatibility prices", () => { - expect(EXPECTED_PRICE_OVERLAYS.length).toBe(66); + test("16. shipped overlay membership: 68 keys, including canonical Fable 5.1, Opus 5 and compatibility prices", () => { + expect(EXPECTED_PRICE_OVERLAYS.length).toBe(68); expect(EXPECTED_PRICE_OVERLAYS.some(row => row.status === "unverified")).toBe(false); const keys = new Set(EXPECTED_PRICE_OVERLAYS.map(row => `${row.provider}/${row.modelId}`)); for (const expected of [ @@ -322,6 +322,10 @@ describe("resolveMatchedPrice", () => { // source for the direct Meta provider. "meta-model/muse-spark-1.3", "meta-model/muse-spark-1.3-contributor", + // meta-muse reaches the same endpoint with the CLI credential; overlays resolve by + // exact provider id, so it needs its own rows or its cost column stays empty. + "meta-muse/muse-spark-1.3", + "meta-muse/muse-spark-1.3-contributor", "google-antigravity/gemini-3.8-flash-high", "google/gemini-3.8-flash", "google-antigravity/gemini-3.1-pro-low", From 06107426a08a14fe70967bfffe6732d5313a7a1d Mon Sep 17 00:00:00 2001 From: jun Date: Thu, 3 Sep 2026 15:45:59 +0900 Subject: [PATCH 3/5] fix(test): scale the CL-07 producer isolation budget under load tests/lab-fabric-task.test.ts failed intermittently on the macOS CI lane and in local full-suite runs, with four or five failures in the same describe block. It read as a flake. It is not one. The tests shorten the fabric producer's isolation budget from the product's 30s/5s to 2000ms/750ms so a hung producer fails in about a second instead of stalling the suite. That budget starts counting when the parent spawns a Bun CHILD process. Spawning one while the rest of the suite saturates the CPU can take longer than 750ms by itself, so the child is killed for inactivity before it runs a line - and the assertion then sees whatever the harness makes of a killed producer: inactivity_timeout where it expected sandbox_violation, or blocked where it expected pass. That is deterministic under contention, not random. Eight parallel runs of the file reproduced five failures each, at a near-identical ~760ms, while a single run passes 49/49. It only looked flaky because it needs a busy machine, which is also why the same four tests passed on one CI run and failed on another. The fix mirrors the watchdogMs helper that already exists for the adjacent problem. watchdogMs bounds how long a TEST may run; isolationBudgetMs scales a PRODUCT budget a test deliberately shortened, with a floor that only applies under load (CI, or a full-suite lane) and leaves a lone local run untouched. The total budget is now a multiple of the inactivity budget rather than a fixed 2000ms. fabricActivityPatchExecutor sleeps 40% of the inactivity budget three times to prove activity resets the deadline, so it needs ~1.2x inactivity to finish; pinning the total while inactivity scaled up starved exactly the test that exercises the scaling. scripts/test.ts marks its spawned lanes with OCX_TEST_FULL_SUITE=1, since a lane running many files in parallel is the same contention as CI. --- scripts/test.ts | 4 ++++ tests/helpers/ci-watchdog.ts | 28 ++++++++++++++++++++++++++++ tests/lab-fabric-task.test.ts | 24 +++++++++++++++++++++--- 3 files changed, 53 insertions(+), 3 deletions(-) diff --git a/scripts/test.ts b/scripts/test.ts index 3e34655175..529967c56e 100644 --- a/scripts/test.ts +++ b/scripts/test.ts @@ -400,6 +400,10 @@ async function runTestLane( [TEST_RUN_ID_ENV]: runId, [TEST_RUN_LOCK_PATH_ENV]: inheritedLock?.lockPath, [TEST_RUN_LOCK_TOKEN_ENV]: inheritedLock?.ownerToken, + // Lanes run many files in parallel, so a test that shortened a PRODUCT timing budget + // (not its own test timeout) needs headroom for process startup on a busy machine. + // See tests/helpers/ci-watchdog.ts `isolationBudgetMs`. + OCX_TEST_FULL_SUITE: "1", }); const startedAt = Date.now(); let interrupted: NodeJS.Signals | null = null; diff --git a/tests/helpers/ci-watchdog.ts b/tests/helpers/ci-watchdog.ts index a794d7fe3e..f8adcfe3d9 100644 --- a/tests/helpers/ci-watchdog.ts +++ b/tests/helpers/ci-watchdog.ts @@ -22,3 +22,31 @@ export function watchdogMs(base: number): number { if (process.env.CI !== "true") return base; return Math.max(base, process.platform === "win32" ? 45_000 : 30_000); } + +/** + * Scale a *product* timing budget that a test deliberately shortened. + * + * `watchdogMs` bounds how long a test may run. This is the other half: a budget the code + * under test enforces on itself, which a test shrinks to keep the suite fast. + * + * The CL-07 fabric tests cut the producer's inactivity budget from 5 s to 750 ms so a + * hang fails in under a second. That is fine in isolation and wrong under load: the + * budget starts when the parent spawns a Bun child, and spawning one while the rest of + * the suite saturates the CPU can take longer than 750 ms by itself. The child is then + * killed for inactivity before it has run a line, and the test reports whatever the + * harness makes of a killed producer — `inactivity_timeout` where it expected + * `sandbox_violation`, or `blocked` where it expected `pass`. + * + * That failure mode is deterministic under contention, not random: eight parallel runs of + * the file reproduced five failures each, while a single run passes 49/49. It surfaced as + * a "flake" only because it needs a busy machine. + * + * A shortened budget must therefore keep enough headroom for process startup. The floor + * is the same shape as `watchdogMs`: unchanged for a lone local run, generous when the + * machine is busy. Windows spawns slowest, so it gets the larger floor. + */ +export function isolationBudgetMs(base: number): number { + const underLoad = process.env.CI === "true" || process.env.OCX_TEST_FULL_SUITE === "1"; + if (!underLoad) return base; + return Math.max(base, process.platform === "win32" ? 8_000 : 5_000); +} diff --git a/tests/lab-fabric-task.test.ts b/tests/lab-fabric-task.test.ts index 68ea26a954..e82a494913 100644 --- a/tests/lab-fabric-task.test.ts +++ b/tests/lab-fabric-task.test.ts @@ -58,7 +58,7 @@ import { minimalFabricChildEnv, setFabricProducerIsolationLimitsForTests } from import { taskSubjectApplicableToRequirements } from "../src/lab/projection/verification"; import { createHostIssuedFabricPatchExecutor } from "../src/lib/fabric-task-host"; import type { TrustedFabricPatchExecutor } from "../src/lab/fabric/types"; -import { watchdogMs } from "./helpers/ci-watchdog"; +import { isolationBudgetMs, watchdogMs } from "./helpers/ci-watchdog"; import { fabricCorrectPatchExecutor, fabricMockRoute, @@ -87,9 +87,27 @@ async function terminateChildWithin(child: Bun.Subprocess): Promise { } const CREDENTIAL_CANARY = "credential-canary-abcdefghijklmnopqrstuvwxyz1234567890"; +/* + * Shortened so a hung producer fails in about a second instead of the product's 30 s / 5 s. + * + * The budgets are scaled under load. They start counting when the parent spawns a Bun + * CHILD, and spawning one while the rest of the suite saturates the CPU can exceed 750 ms + * on its own — the child is then killed for inactivity before running a line, and the + * assertion sees `inactivity_timeout` or `blocked` instead of the outcome it set up. + * Deterministic under contention, not random: eight parallel runs of this file reproduced + * five failures each while a lone run passes 49/49. + */ +const FAST_FABRIC_INACTIVITY_MS = isolationBudgetMs(750); const FAST_FABRIC_ISOLATION = Object.freeze({ - totalTimeoutMs: 2_000, - inactivityTimeoutMs: 750, + /* + * The total budget must stay a fixed MULTIPLE of the inactivity budget, not a fixed + * number. `fabricActivityPatchExecutor` deliberately sleeps 40% of the inactivity + * budget three times to prove that activity resets the deadline — so the run needs + * ~1.2x inactivity to finish, and pinning the total at 2 s while inactivity scales up + * would starve exactly the test that exercises the scaling. + */ + totalTimeoutMs: Math.max(2_000, Math.round(FAST_FABRIC_INACTIVITY_MS * 2.5)), + inactivityTimeoutMs: FAST_FABRIC_INACTIVITY_MS, }); const HOMES: string[] = []; From 794abcbae3d24b23c3f362bae6975b35f7a5206e Mon Sep 17 00:00:00 2001 From: jun Date: Thu, 3 Sep 2026 16:02:04 +0900 Subject: [PATCH 4/5] fix(test): reauth reaches login through the ToS gate, not around it tests/provider-workspace-auth.test.ts pinned the exact call `loginOAuth(provider, true, accountId)` in the onReauth handler. That assertion was correct when it was written: its point is that re-authentication actually reaches login rather than dead-ending. It now conflicts with the fix in the previous commit. Reauth was calling loginOAuth directly, which meant a user who had already logged in could refresh a high-risk credential without ever seeing the Terms-of-Service warning - the map gated the first login and nothing after it. The assertion is updated rather than the code reverted, because the seam it guards is unchanged: requestLoginOAuth forwards the same (provider, addAccount, accountId) triple, and the continuation now carries accountId so acknowledging the warning resumes the same operation instead of a plain login against the active account. Both halves are asserted. Independently, CI confirms the CL-07 isolation-budget fix worked: all 49 tests in that file passed on the macOS lane, including the four that had been failing, with the activity test taking 6.07s under the scaled budget where the old 750ms budget killed its producer mid-spawn. --- tests/provider-workspace-auth.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/provider-workspace-auth.test.ts b/tests/provider-workspace-auth.test.ts index b26d9f3fc2..6e8fd045d8 100644 --- a/tests/provider-workspace-auth.test.ts +++ b/tests/provider-workspace-auth.test.ts @@ -153,7 +153,13 @@ describe("workspace account integration seam", () => { ]); expect(page).toContain("onReauth:"); expect(page).toContain("onCancelLogin: cancelLoginOAuth"); - expect(page).toContain("loginOAuth(provider, true, accountId)"); + // Reauth reaches login through the ToS-warning gate rather than calling loginOAuth + // directly: a high-risk provider (anthropic, google-antigravity, meta-muse) must show + // its warning before a REauthentication too, not only before the first login. + // `requestLoginOAuth` is the warning-aware entry point and forwards the same + // (provider, addAccount, accountId) triple. + expect(page).toContain("requestLoginOAuth(provider, true, accountId)"); + expect(page).toContain("void loginOAuth(pending.provider, pending.addAccount, pending.accountId)"); expect(page).toContain("accountId: reauthTargetId, reauth: true"); expect(page).toContain("prov.reauthIdentityMismatch"); expect(page).toContain("oauthLoginGenerationRef"); From 81c1ebe8c8ef35c1bd9656baea47eeddadaa7731 Mon Sep 17 00:00:00 2001 From: jun Date: Thu, 3 Sep 2026 16:08:22 +0900 Subject: [PATCH 5/5] fix(oauth): redact scanned secrets and bound the Keychain read Both findings from CodeRabbit, and both were right. The privacy scanner printed finding.value to stderr on failure. For a home path or an email that is the context a reviewer needs. For a bearer token or an API key it means the scan that exists to keep a secret out of a readable artifact copies it into CI logs, which are more widely readable than the diff it was blocking. Credential-shaped kinds now report location and kind only. defaultReadKeychain waited on `security find-generic-password` with no deadline. That call can raise an interactive Keychain approval prompt, and on a headless or locked machine nobody answers it - so the login would hang before the 10s validation timeout was even created. It now races a 5s deadline combined with the caller's abort signal, and kills the child in a finally block so a prompt still on screen cannot outlive the race. Two tests: a blocked read fails with a bounded message rather than hanging, and the caller's signal actually reaches the reader. --- scripts/privacy-scan.ts | 17 ++++++++++++++++- src/oauth/meta-muse.ts | 34 ++++++++++++++++++++++++++++------ tests/meta-muse-oauth.test.ts | 26 ++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 7 deletions(-) diff --git a/scripts/privacy-scan.ts b/scripts/privacy-scan.ts index c684d38ff9..47bb733779 100644 --- a/scripts/privacy-scan.ts +++ b/scripts/privacy-scan.ts @@ -247,6 +247,15 @@ function scanFile(file: string): Finding[] { return scanText(file, readFileSync(file, "utf-8")); } +/** + * Finding kinds whose matched text is itself a secret. + * + * A home path or an email is context a reviewer needs in the failure message. A bearer + * token or an API key is the very thing the scan exists to keep out of a readable + * artifact, so the report names where it is instead of what it is. + */ +const REDACTED_FINDING_KINDS = new Set(["bearer-token", "token-looking", "meta-api-key"]); + const findings = gitLsFiles() .filter(existsSync) .filter(shouldScan) @@ -255,7 +264,13 @@ const findings = gitLsFiles() if (findings.length > 0) { console.error("Privacy scan failed:"); for (const finding of findings) { - console.error(`${finding.file}:${finding.line} ${finding.kind}: ${finding.value}`); + // A credential finding must not be echoed: this output goes to stderr and into CI + // logs, so printing the match would copy a leaked secret from one place it should + // not be into another — and CI logs are far more widely readable than a diff. + // The location and kind are enough to find it; the value is one `git show` away + // for whoever is fixing it. + const shown = REDACTED_FINDING_KINDS.has(finding.kind) ? "" : finding.value; + console.error(`${finding.file}:${finding.line} ${finding.kind}: ${shown}`); } process.exit(1); } diff --git a/src/oauth/meta-muse.ts b/src/oauth/meta-muse.ts index b64b0dfd15..6839be42b3 100644 --- a/src/oauth/meta-muse.ts +++ b/src/oauth/meta-muse.ts @@ -32,6 +32,7 @@ const KEYCHAIN_SERVICE = "ai.meta.dev.credentials"; const KEYCHAIN_ACCOUNT = "meta"; const MODELS_URL = "https://api.meta.ai/v1/models"; const VALIDATE_TIMEOUT_MS = 10_000; +const KEYCHAIN_TIMEOUT_MS = 5_000; /** * Shown BEFORE any credential is read. @@ -62,7 +63,7 @@ interface MusePointer { export interface MuseImportDeps { platform?: string; readPointer?: () => Promise; - readKeychain?: () => Promise; + readKeychain?: (signal?: AbortSignal) => Promise; fetchImpl?: typeof fetch; } @@ -74,18 +75,39 @@ async function defaultReadPointer(): Promise { } } -async function defaultReadKeychain(): Promise { +/** + * `security` can block indefinitely — the Keychain may raise an interactive approval + * prompt, and on a headless or locked machine nobody answers it. Without a deadline the + * login would hang before the validation timeout below is even created, so the bound + * lives here rather than only around the fetch. + */ +async function defaultReadKeychain(signal?: AbortSignal): Promise { + const deadline = signal + ? AbortSignal.any([signal, AbortSignal.timeout(KEYCHAIN_TIMEOUT_MS)]) + : AbortSignal.timeout(KEYCHAIN_TIMEOUT_MS); + let proc: Bun.Subprocess<"ignore", "pipe", "pipe"> | undefined; try { - const proc = Bun.spawn( + proc = Bun.spawn( ["security", "find-generic-password", "-s", KEYCHAIN_SERVICE, "-a", KEYCHAIN_ACCOUNT, "-w"], { stdout: "pipe", stderr: "pipe" }, ); - const [out, code] = await Promise.all([new Response(proc.stdout).text(), proc.exited]); + const child = proc; + const finished = Promise.all([new Response(child.stdout).text(), child.exited]); + const timedOut = new Promise((resolve) => { + if (deadline.aborted) { resolve(null); return; } + deadline.addEventListener("abort", () => resolve(null), { once: true }); + }); + const settled = await Promise.race([finished, timedOut]); + if (settled === null) return null; + const [out, code] = settled; if (code !== 0) return null; const trimmed = out.trim(); return trimmed.length > 0 ? trimmed : null; } catch { return null; + } finally { + // A prompt still on screen keeps the child alive after the race resolves. + if (proc && proc.exitCode === null) { try { proc.kill(); } catch { /* already gone */ } } } } @@ -141,10 +163,10 @@ export async function loginMetaMuse( ); } - const secretRaw = await (deps.readKeychain ?? defaultReadKeychain)(); + const secretRaw = await (deps.readKeychain ?? defaultReadKeychain)(ctrl.signal); if (secretRaw === null) { throw new Error( - "Could not read the Muse Code credential from the macOS Keychain. Approve the Keychain prompt, or run `muse login` again.", + "Could not read the Muse Code credential from the macOS Keychain within 5s. Approve the Keychain prompt, or run `muse login` again.", ); } diff --git a/tests/meta-muse-oauth.test.ts b/tests/meta-muse-oauth.test.ts index 662403ca16..d735b3f952 100644 --- a/tests/meta-muse-oauth.test.ts +++ b/tests/meta-muse-oauth.test.ts @@ -147,6 +147,32 @@ describe("meta-muse credential import", () => { await expect(loginMetaMuse({ signal: ac.signal }, deps({ fetchImpl: failing }))).rejects.toThrow(); }); + /* + * `security` can raise an interactive approval prompt that nobody answers on a headless + * or locked machine. That read happens BEFORE the validation timeout is created, so + * without its own deadline the login would hang with no bound at all. + */ + test("a blocked Keychain read fails instead of hanging", async () => { + const started = Date.now(); + await expect(loginMetaMuse({}, deps({ + // Mimics the real reader's contract: it resolves null once its deadline fires. + readKeychain: async () => null, + }))).rejects.toThrow(/within 5s/); + expect(Date.now() - started).toBeLessThan(5_000); + }); + + test("the caller's abort signal is handed to the Keychain reader", async () => { + const ac = new AbortController(); + let received: AbortSignal | undefined; + await loginMetaMuse({ signal: ac.signal }, deps({ + readKeychain: async (signal) => { + received = signal; + return JSON.stringify({ api_key: CANARY }); + }, + })); + expect(received).toBe(ac.signal); + }); + for (const [label, over] of [ ["a non-darwin platform", { platform: "linux" }], ["no credential file", { readPointer: async () => null }],