From 2251d3fd90db2c8ff5b44b2bb1ee95cc5d3b38f4 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Sun, 20 Sep 2026 10:18:14 +0900 Subject: [PATCH 1/4] fix(oauth): require dashboard consent for Muse import (cherry picked from commit f7817373d8a26a6029a533ca55b923b9ff73fac3) --- src/server/management/oauth-account-routes.ts | 13 ++++++++- tests/oauth/oauth-public-surface.test.ts | 29 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/server/management/oauth-account-routes.ts b/src/server/management/oauth-account-routes.ts index 8fcf66eba10..d7be7f11e5b 100644 --- a/src/server/management/oauth-account-routes.ts +++ b/src/server/management/oauth-account-routes.ts @@ -148,7 +148,7 @@ function validateKeyName( } export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise { - const { req, url, config, deps, syncClaudeAgentDefsBestEffort } = ctx; + const { req, url, config, deps, principal, syncClaudeAgentDefsBestEffort } = ctx; if (url.pathname === "/api/accounts/events" && req.method === "GET") { const { accountSelectionStream } = await import("./account-selection-stream"); @@ -172,6 +172,17 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< const body = await readManagementJsonBodyOr(req, {}) as { provider?: string; addAccount?: boolean; accountId?: string; reauth?: boolean; openBrowser?: unknown }; const provider = (body.provider ?? "").trim().toLowerCase(); if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400); + // Meta Muse login imports a credential from the user's macOS Keychain and + // persists it in OpenCodex. A raw management token proves administrative + // access, not that a person acknowledged that credential move and its ToS + // risk. The dashboard warning therefore needs this matching server-side gate; + // headers are not evidence because an admin-token holder can forge them. + if (provider === "meta-muse" && principal !== "gui-session") { + return jsonResponse({ + error: "Meta Muse import requires acknowledgement in the OpenCodex dashboard.", + code: "oauth_consent_required", + }, 403); + } const namespaceCollision = codexAccountNamespaceProviderCollisionError(config.codexAccountNamespaces, provider); if (namespaceCollision) return jsonResponse({ error: namespaceCollision }, 409); const accountId = body.accountId?.trim(); diff --git a/tests/oauth/oauth-public-surface.test.ts b/tests/oauth/oauth-public-surface.test.ts index fd9bb74a9eb..43938027af3 100644 --- a/tests/oauth/oauth-public-surface.test.ts +++ b/tests/oauth/oauth-public-surface.test.ts @@ -84,6 +84,35 @@ describe("legacy ChatGPT OAuth public-surface exclusion", () => { expect(isPublicOAuthProvider("github-copilot")).toBe(true); }); + test("Meta Muse import requires a consent-bearing GUI session", async () => { + const cfg = config(); + const request = () => new Request("http://localhost/api/oauth/login", { + method: "POST", + headers: { + "content-type": "application/json", + origin: "http://localhost", + "x-opencodex-gui-origin": "http://localhost", + "x-opencodex-csrf-token": "forgeable-without-a-session", + }, + // A missing account makes a correctly admitted request stop before the + // platform-specific import, while still proving it passed the consent gate. + body: JSON.stringify({ provider: "meta-muse", accountId: "missing-slot" }), + }); + + for (const principal of [undefined, "admin-token", "gui-pair-capability"] as const) { + const response = await handleManagementAPI(request(), new URL(request().url), cfg, {}, principal); + expect(response?.status).toBe(403); + expect(await response?.json()).toEqual({ + error: "Meta Muse import requires acknowledgement in the OpenCodex dashboard.", + code: "oauth_consent_required", + }); + } + + const admitted = await handleManagementAPI(request(), new URL(request().url), cfg, {}, "gui-session"); + expect(admitted?.status).toBe(404); + expect(await admitted?.json()).toEqual({ error: "Unknown account for reauth" }); + }); + test("generic management OAuth endpoints reject chatgpt before touching login state", async () => { const cfg = config(); const requests = [ From 1e53c59735e85774da371c009166a49b491b9d95 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:39:53 +0900 Subject: [PATCH 2/4] fix(oauth): bound Meta Muse device response bodies (cherry picked from commit b1d06a28334ccec12a57dc99705a395cb48f74dd) --- src/oauth/meta-muse-device.ts | 48 +++++++++++++++++++++--- structure/providers-and-adapters.md | 2 +- tests/providers/meta-muse-device.test.ts | 28 ++++++++++++++ 3 files changed, 71 insertions(+), 7 deletions(-) diff --git a/src/oauth/meta-muse-device.ts b/src/oauth/meta-muse-device.ts index 70ec7257821..8ed273f52c4 100644 --- a/src/oauth/meta-muse-device.ts +++ b/src/oauth/meta-muse-device.ts @@ -23,6 +23,7 @@ */ import type { OAuthController, OAuthCredentials } from "./types"; import { sanitizeApiKeyValue } from "../providers/api-keys"; +import { BOUNDED_BODY_MAX_BYTES, readBoundedResponseBytes } from "../lib/bounded-body"; /** Meta's own Muse Code client. Public in its device-approval URL; not a secret. */ const CLIENT_ID = "1031625952748946"; @@ -173,12 +174,45 @@ function requestSignal(signal: AbortSignal | undefined): AbortSignal { return signal ? AbortSignal.any([signal, timeout]) : timeout; } +async function readMuseJson( + response: Response, + signal: AbortSignal, + kind: "device-authorization" | "device-token" | "mint-invalid", +): Promise | undefined> { + const declaredLength = response.headers.get("content-length"); + if (declaredLength && /^\d+$/.test(declaredLength) && Number(declaredLength) > BOUNDED_BODY_MAX_BYTES) { + void response.body?.cancel().catch(() => undefined); + throw new MuseDeviceLoginError( + kind, + `Muse Code response exceeded the ${BOUNDED_BODY_MAX_BYTES}-byte limit`, + { status: response.status }, + ); + } + const { bytes, oversized } = await readBoundedResponseBytes(response, { + maxBytes: BOUNDED_BODY_MAX_BYTES, + signal, + }); + if (oversized) { + throw new MuseDeviceLoginError( + kind, + `Muse Code response exceeded the ${BOUNDED_BODY_MAX_BYTES}-byte limit`, + { status: response.status }, + ); + } + try { + return record(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes))); + } catch { + return undefined; + } +} + /** Step 1: ask Meta for a user code. */ export async function requestMuseDeviceAuthorization( deps: MuseDeviceDeps = {}, signal?: AbortSignal, ): Promise { const now = deps.now ?? Date.now; + const request = requestSignal(signal); const response = await (deps.fetchImpl ?? fetch)(DEVICE_AUTHORIZATION_URL, { method: "POST", headers: { @@ -188,7 +222,7 @@ export async function requestMuseDeviceAuthorization( }, body: new URLSearchParams({ client_id: CLIENT_ID }).toString(), redirect: "error", - signal: requestSignal(signal), + signal: request, }); if (!response.ok) { throw new MuseDeviceLoginError( @@ -197,7 +231,7 @@ export async function requestMuseDeviceAuthorization( { status: response.status }, ); } - const payload = record(await response.json().catch(() => undefined)); + const payload = await readMuseJson(response, request, "device-authorization"); const deviceCode = text(payload?.device_code); const userCode = text(payload?.user_code); if (!deviceCode || !userCode) { @@ -241,6 +275,7 @@ export async function pollMuseDeviceToken( // shape checked the deadline at the top, so a sleep ending exactly at the deadline // skipped the final poll and discarded an approval the user had already completed // inside that window. + const request = requestSignal(signal); const response = await (deps.fetchImpl ?? fetch)(DEVICE_TOKEN_URL, { method: "POST", headers: { @@ -254,9 +289,9 @@ export async function pollMuseDeviceToken( grant_type: DEVICE_GRANT_TYPE, }).toString(), redirect: "error", - signal: requestSignal(signal), + signal: request, }); - const payload = record(await response.json().catch(() => undefined)); + const payload = await readMuseJson(response, request, "device-token"); if (response.ok) { // [W3] No deadline re-check here. If Meta answered 200 with a token, Meta accepted // the device code; its clock is authoritative and ours is not. Discarding an issued @@ -322,6 +357,7 @@ export async function mintMuseApiKey( signal?: AbortSignal, ): Promise { const now = deps.now ?? Date.now; + const request = requestSignal(signal); const response = await (deps.fetchImpl ?? fetch)(MUSE_KEY_URL, { method: "POST", headers: { @@ -332,7 +368,7 @@ export async function mintMuseApiKey( }, body: JSON.stringify(options.onboard ? { onboard: true } : {}), redirect: "error", - signal: requestSignal(signal), + signal: request, }); if (response.status === 429) { const wait = retryAfterMs(response.headers.get("retry-after"), now()); @@ -352,7 +388,7 @@ export async function mintMuseApiKey( { status: response.status }, ); } - const payload = record(await response.json().catch(() => undefined)); + const payload = await readMuseJson(response, request, "mint-invalid"); if (!payload) { throw new MuseDeviceLoginError("mint-invalid", "Muse Code key exchange returned an unreadable response", { status: response.status, diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index bf4271d54fe..6b4efe1fa30 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -15,7 +15,7 @@ only canonical Fable, Opus, or Sonnet labels after removing terminal controls; u | `src/providers/model-rename-fields.ts`, `src/providers/model-rename-migration.ts` | Classifies every provider config field for a declared model rename. Exact-model records, lists and nested request-pacing keys follow the replacement; an already saved replacement entry wins. Provider-wide settings and credential fields are not model identities. | | `src/providers/resolved-model-policy.ts`, `src/providers/resolved-model-policy-merge.ts` | Static provider/model policy resolution for the final upstream wire model, plus its pure clone/merge/URL/family helpers. The resolver detaches and freezes registry defaults, operator overrides, exact explicit input-modality declarations, hard wire pins, aliases, and explicit false/empty values with field-level provenance. Provider derivation, routing, catalog hints, gather admission, and adapter selection consume its detached frozen result. Callers supply transport match, the exact capability row, and a credential-free effective auth decision; credential bytes, usability evidence, account/quota/health state, and observed limits remain outside the result. | -| `src/oauth/` | OAuth providers, token storage, refresh, and auth-token resolution. The login callback listener binds a per-provider FIXED loopback port, so consecutive logins reuse the same number; every response it sends ends its connection (`Connection: close`, including non-callback paths such as a stray `/favicon.ico` 404). Stopping the listener does not close an established socket, so without that a pooled client would deliver the next login's callback to the retired flow, which rejects the unknown state as a CSRF mismatch while the live flow waits. Kiro add-account identity prefers same-session `whoami` over a leftover SQLite state profile, and never persists the Builder ID service profile ARN as `accountId`. | +| `src/oauth/` | OAuth providers, token storage, refresh, and auth-token resolution. Meta Muse device authorization, polling, and key-mint JSON responses share the 64 KiB bounded-body ceiling and the request's deadline; oversized declared or streamed bodies are rejected before JSON parsing. The login callback listener binds a per-provider FIXED loopback port, so consecutive logins reuse the same number; every response it sends ends its connection (`Connection: close`, including non-callback paths such as a stray `/favicon.ico` 404). Stopping the listener does not close an established socket, so without that a pooled client would deliver the next login's callback to the retired flow, which rejects the unknown state as a CSRF mismatch while the live flow waits. Kiro add-account identity prefers same-session `whoami` over a leftover SQLite state profile, and never persists the Builder ID service profile ARN as `accountId`. | | `src/combos/request.ts` | Clones each selected combo target request and applies the existing target capability ladder: adaptive unknown targets and explicit empty ladders receive no unsupported reasoning/thinking controls, while known ladders retain per-target resolution. | | `src/adapters/openai-responses.ts` | Native OpenAI/ChatGPT Responses passthrough. | | `src/responses/muse-tool-name-alias.ts` | Host-gated Meta Muse 64-char tool-name alias/restore used by the Responses passthrough. | diff --git a/tests/providers/meta-muse-device.test.ts b/tests/providers/meta-muse-device.test.ts index 6341efe050b..6223e0d4626 100644 --- a/tests/providers/meta-muse-device.test.ts +++ b/tests/providers/meta-muse-device.test.ts @@ -27,6 +27,7 @@ const KEY = `LLM|${"1".repeat(16)}|${"c".repeat(27)}`; const ACCOUNT_TOKEN = "meta-account-" + "z".repeat(48); /** If this string ever reaches an error message, a response body leaked into one. */ const BODY_CANARY = "canary-body-must-never-appear-in-an-error"; +const OVERSIZED_JSON = JSON.stringify({ value: "x".repeat(65_536) }); interface Reply { status?: number; body?: unknown; text?: string; headers?: Record } interface Scenario { @@ -132,6 +133,13 @@ describe("muse device authorization", () => { expect(error.message).toContain("500"); expect(error.message).not.toContain(BODY_CANARY); }); + + test("rejects an oversized streamed authorization response", async () => { + const h = harness({ auth: { text: OVERSIZED_JSON } }); + const error = await caught(() => requestMuseDeviceAuthorization(h.deps)); + expect(error.kind).toBe("device-authorization"); + expect(error.message).toContain("65536-byte limit"); + }); }); describe("muse device poll", () => { @@ -217,6 +225,14 @@ describe("muse device poll", () => { expect(error.kind).toBe("device-token"); }); + test("rejects an oversized token response instead of polling again", async () => { + const h = harness({ tokens: [{ text: OVERSIZED_JSON }] }); + const auth = await requestMuseDeviceAuthorization(h.deps); + const error = await caught(() => pollMuseDeviceToken(auth, h.deps)); + expect(error.kind).toBe("device-token"); + expect(h.calls.token).toBe(1); + }); + // W4 and W3 together: the last seconds of a grant must still be polled, and a token // the server issued in that window must not be thrown away by a local clock. test("polls once more inside the final seconds and accepts a late token", async () => { @@ -284,6 +300,18 @@ describe("muse key mint", () => { expect(error.message).not.toContain(BODY_CANARY); }); + test("rejects an oversized declared mint response before consuming it", async () => { + let cancelled = false; + const fetchImpl = (async () => new Response(new ReadableStream({ + pull() {}, + cancel() { cancelled = true; }, + }), { headers: { "content-length": "65537" } })) as typeof fetch; + const error = await caught(() => mintMuseApiKey(ACCOUNT_TOKEN, {}, { fetchImpl })); + expect(error.kind).toBe("mint-invalid"); + expect(error.message).toContain("65536-byte limit"); + expect(cancelled).toBeTrue(); + }); + test("lowercases the email and keeps the usage object", async () => { const h = harness({ mint: { body: { ...MINT_OK, subs_usage: { weekly: { used_percent: 4 } } } } }); const payload = await mintMuseApiKey(ACCOUNT_TOKEN, {}, h.deps); From 1564e6e495173c5d33e7b8beff16bfd3fdcbeaea Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Tue, 22 Sep 2026 07:00:46 +0900 Subject: [PATCH 3/4] fix(oauth): cancel unparsed error bodies and pin the token bound in the poll test (cherry picked from commit ecfd3f0805eca6e5bad3cd00785265c015a77048) --- src/oauth/meta-muse-device.ts | 7 ++++++- tests/providers/meta-muse-device.test.ts | 3 +++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/oauth/meta-muse-device.ts b/src/oauth/meta-muse-device.ts index 8ed273f52c4..985f8157eb5 100644 --- a/src/oauth/meta-muse-device.ts +++ b/src/oauth/meta-muse-device.ts @@ -225,6 +225,9 @@ export async function requestMuseDeviceAuthorization( signal: request, }); if (!response.ok) { + // The error body is never parsed, but it still has to be released: an unread + // response body keeps the underlying connection occupied. + void response.body?.cancel().catch(() => undefined); throw new MuseDeviceLoginError( "device-authorization", `Muse Code device authorization request failed: HTTP ${response.status}`, @@ -381,7 +384,9 @@ export async function mintMuseApiKey( ); } if (!response.ok) { - // Status only. The body of this endpoint can carry the key itself. + // Status only. The body of this endpoint can carry the key itself, so it is + // never parsed — but it is still cancelled so the connection is released. + void response.body?.cancel().catch(() => undefined); throw new MuseDeviceLoginError( "mint-http", `Muse Code key exchange failed: HTTP ${response.status}`, diff --git a/tests/providers/meta-muse-device.test.ts b/tests/providers/meta-muse-device.test.ts index 6223e0d4626..7113371b781 100644 --- a/tests/providers/meta-muse-device.test.ts +++ b/tests/providers/meta-muse-device.test.ts @@ -230,6 +230,9 @@ describe("muse device poll", () => { const auth = await requestMuseDeviceAuthorization(h.deps); const error = await caught(() => pollMuseDeviceToken(auth, h.deps)); expect(error.kind).toBe("device-token"); + // A 200 without access_token also ends as device-token, so kind alone cannot tell + // the limit fired; the message names the bound. + expect(error.message).toContain("65536-byte limit"); expect(h.calls.token).toBe(1); }); From c3a2f379cefba4aad998492a619b058003dc1667 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Wed, 23 Sep 2026 04:26:25 +0900 Subject: [PATCH 4/4] fix(meta): preserve consent and bounded login failure cleanup --- .../src/content/docs/guides/providers.md | 24 +++---- .../docs/reference/platform-support.md | 17 ++--- src/oauth/meta-muse-device.ts | 1 + src/server/management/oauth-account-routes.ts | 11 ++-- structure/gui-and-management-api.md | 2 +- structure/providers-and-adapters.md | 7 ++ tests/oauth/oauth-public-surface.test.ts | 65 ++++++++++++++++++- tests/providers/meta-muse-device.test.ts | 17 +++++ 8 files changed, 114 insertions(+), 30 deletions(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index e8cba6c928d..6258b8df496 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -135,7 +135,7 @@ Two exceptions are worth knowing because you can hit them: | Command Code | `ocx login command-code` — opencodex reads five-hour and weekly windows plus a credit balance | `commandcode` — the same service on `/provider/v1` with a key | | GitHub Copilot | `ocx login github-copilot` — requires an active Copilot subscription | the same `github-copilot` provider with `authMode: "key"`. The device flow above is the supported path, and either credential is a Copilot one, so the subscription still pays | | OrcaRouter | `ocx login orcarouter-oauth` — consent mints a user-owned, long-lived `sk-orca-…` key, and the request then carries a key | `orcarouter` — the same key pasted by hand | -| Meta Muse | `ocx login meta-muse` imports the Muse Code CLI key. Meta scopes that credential to its own CLI, so this is an unsupported use: how the calls settle is not observable from the API, and you should treat every call as billable against your account | `meta-model` is the supported path — every call is metered per token, and a Muse Code subscription does not work there | +| Meta Muse | `ocx login meta-muse` can import a local Muse Code CLI key or start device login. Meta scopes that credential to its own CLI, so this is an unsupported use: how the calls settle is not observable from the API, and you should treat every call as billable against your account | `meta-model` is the supported path — every call is metered per token, and a Muse Code subscription does not work there | Cursor, Kiro and Nous Portal are login-only and have no API-key equivalent. Google Antigravity is login-only too: `ocx login google-antigravity` signs in with your Google account over the Cloud Code @@ -697,18 +697,20 @@ 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`).** On macOS, 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. OpenCodex never launches the CLI: if no credential is present it tells you to -run `muse login` yourself. - -Elsewhere it asks you to paste the key. Meta ships no native Windows CLI, and on Linux the -CLI exists but where it stores its credential has not been verified, so OpenCodex refuses -to guess at a credential store and points you at [dev.meta.ai](https://dev.meta.ai) -instead, where the same key is visible. A pasted key faces the same format check and the -same live validation against the Model API as an imported one. See +**Meta Muse Code (`meta-muse`).** A plain macOS login first tries the API key already +stored by `muse login`. With no local credential, or on another platform, it starts the +browser device-approval flow. Add-account and reauthentication skip local import to avoid +reusing the account being replaced. OpenCodex never launches the Muse CLI. If device login +fails without cancellation, an available manual-input surface can accept a pasted key; +that key faces the same format and Model API validation as an imported key. See [Platform support](/reference/platform-support/) for the full per-platform picture. +Starting any Meta Muse login through the management API requires a dashboard session, +including add-account and reauthentication. A raw admin token or forged GUI headers receive +`403 oauth_consent_required` before a credential is read or a grant starts. This gate uses +the server-resolved session principal, not a separately recorded warning-checkbox receipt. +Direct `ocx login meta-muse` and other OAuth providers keep their existing login policies. + Both seeded `meta-muse` models expose `minimal`/`low`/`medium`/`high`/`xhigh`/`max` to routed clients, including Grok's effort picker. Requests use `User-Agent: muse-build/1.3.0 (opencodex compatibility)` so Meta accepts the Muse Code diff --git a/docs-site/src/content/docs/reference/platform-support.md b/docs-site/src/content/docs/reference/platform-support.md index a0d484551dc..29beee473a9 100644 --- a/docs-site/src/content/docs/reference/platform-support.md +++ b/docs-site/src/content/docs/reference/platform-support.md @@ -51,15 +51,13 @@ directly. ### Meta Muse Code -On macOS, OpenCodex imports the API key the Muse Code CLI already stored after -`muse login`, so you are not asked to provision a second one. - -Elsewhere it asks you to paste the key instead. Meta ships no native Windows -CLI, and on Linux the CLI exists but where it keeps its credential has not been -verified, so OpenCodex declines to guess at a credential store. The same key is -visible in [Meta's developer console](https://dev.meta.ai), and a pasted key -faces the same format check and the same live validation against the Model API -as an imported one. +On macOS, a plain login first tries the API key already stored by `muse login`. +If none is available, or on another platform, OpenCodex starts device approval +without launching the Muse CLI. Add-account and reauthentication skip import. +A failed, uncancelled device login can fall back to a manual key when the caller +offers an input surface. Pasted keys use the same format and Model API validation +as imported ones. Management login requires a dashboard session before either +credential-acquisition path; see the [provider guide](/guides/providers/). ## Windows notes @@ -78,4 +76,3 @@ OpenCodex states the actual reason rather than disabling a control silently. If a capability is unavailable on your platform, the error or the dashboard says which mechanism is missing and what the supported alternative is. If you hit one that does not, that is a bug worth reporting. - diff --git a/src/oauth/meta-muse-device.ts b/src/oauth/meta-muse-device.ts index 985f8157eb5..fbff8b06124 100644 --- a/src/oauth/meta-muse-device.ts +++ b/src/oauth/meta-muse-device.ts @@ -374,6 +374,7 @@ export async function mintMuseApiKey( signal: request, }); if (response.status === 429) { + void response.body?.cancel().catch(() => undefined); const wait = retryAfterMs(response.headers.get("retry-after"), now()); throw new MuseDeviceLoginError( "mint-rate-limited", diff --git a/src/server/management/oauth-account-routes.ts b/src/server/management/oauth-account-routes.ts index d7be7f11e5b..1d6379335fe 100644 --- a/src/server/management/oauth-account-routes.ts +++ b/src/server/management/oauth-account-routes.ts @@ -172,14 +172,13 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< const body = await readManagementJsonBodyOr(req, {}) as { provider?: string; addAccount?: boolean; accountId?: string; reauth?: boolean; openBrowser?: unknown }; const provider = (body.provider ?? "").trim().toLowerCase(); if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400); - // Meta Muse login imports a credential from the user's macOS Keychain and - // persists it in OpenCodex. A raw management token proves administrative - // access, not that a person acknowledged that credential move and its ToS - // risk. The dashboard warning therefore needs this matching server-side gate; - // headers are not evidence because an admin-token holder can forge them. + // Muse may import a local Keychain credential or start a device grant; add-account + // and reauth skip the import. All management login paths require the dashboard + // principal before credential acquisition. A raw token proves administration, + // not acknowledgement; caller-supplied headers are not consent evidence. if (provider === "meta-muse" && principal !== "gui-session") { return jsonResponse({ - error: "Meta Muse import requires acknowledgement in the OpenCodex dashboard.", + error: "Meta Muse login requires acknowledgement in the OpenCodex dashboard.", code: "oauth_consent_required", }, 403); } diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 5be71c0bfbc..daa808e6c1f 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -161,7 +161,7 @@ this document owns is which module holds which area and what invariant that area | Updates | `GET /api/update/check`, `POST /api/update/run`, and `GET /api/update/status` own dashboard self-update state. A launched worker PID is persisted in `update-job.json`; dead PIDs recover immediately, while legacy active records without a PID recover only after ten minutes. Live PIDs remain exclusive regardless of record age. `GET /api/update/badge` backs the sidebar badge: it reports that an update exists and links to the update surface rather than gating other actions. | | Providers | Create/update/delete ordinary provider configs and enrich registry metadata. The reserved `openai` card exposes Pool(default)/Direct account mode; `openai-apikey` remains the separate API route. | | Models | Fetch routed model lists, disabled model visibility, and catalog-facing ids. New non-OAuth registration holds exposure until authoritative discovery; 20 or more distinct switch rows start OFF without disabling the provider. Pending rows cannot accept visibility changes. | -| OAuth | Login/status/logout for OAuth-backed providers, plus multiauth account management: `GET /api/oauth/accounts`, `PUT /api/oauth/accounts/active`, `PUT /api/oauth/accounts/alias`, `DELETE /api/oauth/accounts` list masked accounts per provider, switch the active one, edit its display-only alias, and remove one. The login flow itself is `GET /api/oauth/providers`, `POST /api/oauth/login`, `POST /api/oauth/login/code`, `POST /api/oauth/login/cancel`, `POST /api/oauth/logout`, and `GET /api/oauth/status`; pool controls are `GET/PUT/PATCH /api/oauth/accounts/pool` and `POST /api/oauth/accounts/clear-cooldown`. Login accepts `addAccount: true` to force a fresh browser identity. Device flows return a structured `deviceCode`; the GUI highlights and copies it before the user opens the verification page. | +| OAuth | Login/status/logout for OAuth-backed providers, plus multiauth account management: `GET /api/oauth/accounts`, `PUT /api/oauth/accounts/active`, `PUT /api/oauth/accounts/alias`, `DELETE /api/oauth/accounts` list masked accounts per provider, switch the active one, edit its display-only alias, and remove one. The login flow itself is `GET /api/oauth/providers`, `POST /api/oauth/login`, `POST /api/oauth/login/code`, `POST /api/oauth/login/cancel`, `POST /api/oauth/logout`, and `GET /api/oauth/status`; pool controls are `GET/PUT/PATCH /api/oauth/accounts/pool` and `POST /api/oauth/accounts/clear-cooldown`. Login accepts `addAccount: true` to force a fresh browser identity. Meta Muse login also requires the server-resolved `gui-session` principal before local import or device login (including reauth); see the [provider contract](providers-and-adapters.md). Device flows return a structured `deviceCode`; the GUI highlights and copies it before the user opens the verification page. | | Key providers | `GET /api/key-providers` exposes API-key provider presets for setup and dashboard flows, and `GET/POST/DELETE /api/keys` owns the proxy's own admission keys. Multi-key pool per key-auth provider: `GET /api/providers/keys`, `POST /api/providers/keys`, `PUT /api/providers/keys/active`, `PUT /api/providers/keys/alias`, `DELETE /api/providers/keys` masked list, add (upsert + activate), switch, rename, and remove keys. `provider.apiKey` always mirrors the active pool entry so routing stays single-key. | | OpenAI account mode | Report one OpenAI Codex card with Pool/Direct controls and one API-key card. Mode PATCH persists live without restart or catalog identity changes; Pool owns account/quota controls and Direct uses caller/main login only. Main-account DTOs report real credential presence and terminal `needsReauth` state instead of treating missing/invalid native auth as an unknown quota. Selection order has its own route: `PUT /api/codex-auth/accounts/priority` takes `{ id, priority }`, where `priority` is an integer -100..100 or `null` to restore the default, accepts `__main__`, 404s an unknown id, and echoes the stored value. Re-ordering never clears thread affinity, so the response carries no `appliesImmediately`, but it does release any pin — see [`openai-tiers.md`](providers/openai-tiers.md) for why. `PUT /api/codex-auth/active` with a null id releases one too, but that drops the operator's account selection along with it, so this route is the only operator-facing way to clear a pin while leaving the selected account in place. `GET /api/codex-auth/active` reports `pinned`, true only while the manually selected account is still the effective active one, plus `pinnedAccountId`, which names the pinned account whether or not it is the active one. Surfaces should render `pinnedAccountId`: under round-robin and fill-first the pin caps the tier ceiling at its own tier while the strategy cursor moves freely inside that tier, so `pinned` goes false on a sibling's turn even though the pin is still suppressing every higher tier — which is why the dashboard badges `pinnedAccountId` and the GUI controller tracks only the id. `pinned` answers the narrower question of whether routing is *currently* on the operator's choice; no surface in this repo asks it, and a new one almost certainly wants the id instead. | | Subagents | Read/write the featured `subagentModels` list capped at five ids. `GET/PUT /api/injection-model` manages the shared delegation model/effort selection, the independent OpenCodex guidance switch, and the default-off `syncCodexSubagentDefaults` opt-in for native Codex subagent defaults. When OpenCodex owns the active Codex routing, native `[agents]` defaults apply to newly created Codex tasks after sync/restart; external user-managed provider configs remain untouched. The defaults do not cause delegation and preserve existing user-owned defaults rather than overwriting them. PUT is partial-update: absent keys are unchanged, `null` clears, and non-object bodies are rejected with 400 before field validation. `syncCodexSubagentDefaults: true` requires a nonblank `model` and a supported Codex reasoning effort when effort is set; clearing `model` (null/empty) always clears effort and disables native-default sync even when the stored effort was invalid. | diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 6b4efe1fa30..321e82ccfcd 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -1,5 +1,12 @@ # Providers And Adapters +Meta Muse management login in `src/server/management/oauth-account-routes.ts` requires a +server-resolved `gui-session` before starting credential acquisition, including local import, +device login, add-account and reauthentication. This principal is not a checkbox receipt; +forged GUI headers and raw management credentials do not substitute for it. Direct CLI login +and other OAuth providers retain their existing policies. `src/oauth/meta-muse-device.ts` +cancels unparsed authorization/mint failures, including mint429, without reflecting their bodies. + OrcaRouter key exchange uses the shared raw-byte reader before returning a durable key. Its 64 KiB response ceiling, single 30-second header/body deadline, and cancellation behavior follow the [bounded ingestion contract](transports/inventory.md#bounded-response-ingestion-and-orcarouter-login). diff --git a/tests/oauth/oauth-public-surface.test.ts b/tests/oauth/oauth-public-surface.test.ts index 43938027af3..4937d4cddbc 100644 --- a/tests/oauth/oauth-public-surface.test.ts +++ b/tests/oauth/oauth-public-surface.test.ts @@ -18,6 +18,8 @@ import type { OcxConfig } from "../../src/types"; import type { OAuthController } from "../../src/oauth/types"; import { getCredential } from "../../src/oauth/store"; import * as oauthStore from "../../src/oauth/store"; +import * as oauth from "../../src/oauth"; +import { requestMuseDeviceAuthorization } from "../../src/oauth/meta-muse-device"; import { flushConfigDirHardeningForTests } from "../../src/config/paths"; import { setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; @@ -84,7 +86,7 @@ describe("legacy ChatGPT OAuth public-surface exclusion", () => { expect(isPublicOAuthProvider("github-copilot")).toBe(true); }); - test("Meta Muse import requires a consent-bearing GUI session", async () => { + test("Meta Muse login requires a consent-bearing GUI session", async () => { const cfg = config(); const request = () => new Request("http://localhost/api/oauth/login", { method: "POST", @@ -103,7 +105,7 @@ describe("legacy ChatGPT OAuth public-surface exclusion", () => { const response = await handleManagementAPI(request(), new URL(request().url), cfg, {}, principal); expect(response?.status).toBe(403); expect(await response?.json()).toEqual({ - error: "Meta Muse import requires acknowledgement in the OpenCodex dashboard.", + error: "Meta Muse login requires acknowledgement in the OpenCodex dashboard.", code: "oauth_consent_required", }); } @@ -113,6 +115,65 @@ describe("legacy ChatGPT OAuth public-surface exclusion", () => { expect(await admitted?.json()).toEqual({ error: "Unknown account for reauth" }); }); + test.each([ + ["plain", {}, false], + ["add-account", { addAccount: true }, true], + ["reauth", { reauth: true }, true], + ] as const)("Muse %s admission precedes either credential-acquisition path", async (_mode, flags, forceLogin) => { + const cfg = config(); + saveConfig(cfg); + const login = spyOn(oauth, "startLoginFlow").mockResolvedValue({ url: "" }); + const request = (provider = "meta-muse") => new Request("http://localhost/api/oauth/login", { + method: "POST", + headers: { "content-type": "application/json", origin: "http://localhost", + "x-opencodex-gui-origin": "http://localhost", "x-opencodex-csrf-token": "forged" }, + body: JSON.stringify({ provider, ...flags, openBrowser: false }), + }); + try { + for (const principal of [undefined, "admin-token", "gui-pair-capability"] as const) { + const req = request(); + const response = await handleManagementAPI(req, new URL(req.url), cfg, {}, principal); + expect(response?.status).toBe(403); + expect((await response?.json())?.code).toBe("oauth_consent_required"); + } + expect(login).not.toHaveBeenCalled(); + const admitted = request(); + expect((await handleManagementAPI(admitted, new URL(admitted.url), cfg, {}, "gui-session"))?.status).toBe(200); + expect(login).toHaveBeenCalledWith("meta-muse", { forceLogin }, { onSettled: expect.any(Function) }); + const other = request("xai"); + expect((await handleManagementAPI(other, new URL(other.url), cfg, {}, "admin-token"))?.status).toBe(200); + expect(login).toHaveBeenLastCalledWith("xai", { forceLogin }, { onSettled: expect.any(Function) }); + } finally { login.mockRestore(); } + }); + + test("admitted Muse device overflow stays behind the public OAuth error boundary", async () => { + const cfg = config(); + saveConfig(cfg); + let fetches = 0; + const login = spyOn(oauth, "startLoginFlow").mockImplementation(async () => { + await requestMuseDeviceAuthorization({ fetchImpl: (async () => { + fetches++; + return new Response(JSON.stringify({ device_code: "private-device-canary", filler: "x".repeat(65_536) })); + }) as typeof fetch }); + throw new Error("oversized authorization must not succeed"); + }); + const request = () => new Request("http://localhost/api/oauth/login", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ provider: "meta-muse", addAccount: true, openBrowser: false }), + }); + try { + const denied = request(); + expect((await handleManagementAPI(denied, new URL(denied.url), cfg, {}, "admin-token"))?.status).toBe(403); + expect(fetches).toBe(0); + const admitted = request(); + const response = await handleManagementAPI(admitted, new URL(admitted.url), cfg, {}, "gui-session"); + expect(response?.status).toBe(409); + expect(await response?.json()).toEqual({ error: PUBLIC_OAUTH_ERROR }); + expect(fetches).toBe(1); + expect(getCredential("meta-muse")).toBeNull(); + } finally { login.mockRestore(); } + }); + test("generic management OAuth endpoints reject chatgpt before touching login state", async () => { const cfg = config(); const requests = [ diff --git a/tests/providers/meta-muse-device.test.ts b/tests/providers/meta-muse-device.test.ts index 7113371b781..6d6fac8df3f 100644 --- a/tests/providers/meta-muse-device.test.ts +++ b/tests/providers/meta-muse-device.test.ts @@ -273,6 +273,23 @@ describe("muse device poll", () => { }); describe("muse key mint", () => { + test("cancels a rate-limited mint body without reading or reflecting it", async () => { + let cancelled = false; + let reads = 0; + const fetchImpl = (async () => new Response(new ReadableStream({ + pull(controller) { reads++; controller.enqueue(new TextEncoder().encode(BODY_CANARY)); }, + cancel() { cancelled = true; }, + }, { highWaterMark: 0 }), { status: 429, headers: { "retry-after": "30" } })) as typeof fetch; + const error = await caught(() => mintMuseApiKey(ACCOUNT_TOKEN, {}, { fetchImpl })); + expect(error.kind).toBe("mint-rate-limited"); + expect(error.status).toBe(429); + expect(error.retryAfterMs).toBe(30_000); + expect(error.message).toContain("30s"); + expect(error.message).not.toContain(BODY_CANARY); + expect(reads).toBe(0); + expect(cancelled).toBe(true); + }); + test("asks Meta to onboard during a login and sends the account bearer", async () => { const h = harness(); await mintMuseApiKey(ACCOUNT_TOKEN, { onboard: true }, h.deps);