Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 13 additions & 11 deletions docs-site/src/content/docs/guides/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
17 changes: 7 additions & 10 deletions docs-site/src/content/docs/reference/platform-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.

56 changes: 49 additions & 7 deletions src/oauth/meta-muse-device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<Record<string, unknown> | 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<MuseDeviceAuthorization> {
const now = deps.now ?? Date.now;
const request = requestSignal(signal);
const response = await (deps.fetchImpl ?? fetch)(DEVICE_AUTHORIZATION_URL, {
method: "POST",
headers: {
Expand All @@ -188,16 +222,19 @@ export async function requestMuseDeviceAuthorization(
},
body: new URLSearchParams({ client_id: CLIENT_ID }).toString(),
redirect: "error",
signal: requestSignal(signal),
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}`,
{ 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) {
Expand Down Expand Up @@ -241,6 +278,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: {
Expand All @@ -254,9 +292,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
Expand Down Expand Up @@ -322,6 +360,7 @@ export async function mintMuseApiKey(
signal?: AbortSignal,
): Promise<MuseKeyPayload> {
const now = deps.now ?? Date.now;
const request = requestSignal(signal);
const response = await (deps.fetchImpl ?? fetch)(MUSE_KEY_URL, {
method: "POST",
headers: {
Expand All @@ -332,9 +371,10 @@ export async function mintMuseApiKey(
},
body: JSON.stringify(options.onboard ? { onboard: true } : {}),
redirect: "error",
signal: requestSignal(signal),
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",
Expand All @@ -345,14 +385,16 @@ 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}`,
{ 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,
Expand Down
12 changes: 11 additions & 1 deletion src/server/management/oauth-account-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ function validateKeyName(
}

export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<Response | null> {
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");
Expand All @@ -172,6 +172,16 @@ 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);
// 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 login 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();
Expand Down
2 changes: 1 addition & 1 deletion structure/gui-and-management-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
Loading
Loading