diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 92089466d8d..af1854528e0 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -1014,7 +1014,10 @@ stored by `muse login`. With no local credential, or on another platform, it sta 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 +that key faces the same format and Model API validation as an imported key. On macOS, +approve the Keychain prompt on the proxy host within 120 seconds. Import refusals +show the failure reason and offer an existing-key paste field without starting another +login. Add account on an already configured provider bypasses local import. 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, diff --git a/docs-site/src/content/docs/reference/platform-support.md b/docs-site/src/content/docs/reference/platform-support.md index 5309bf7badc..edbb09b4d43 100644 --- a/docs-site/src/content/docs/reference/platform-support.md +++ b/docs-site/src/content/docs/reference/platform-support.md @@ -59,6 +59,25 @@ offers an input surface. Pasted keys use the same format and Model API validatio as imported ones. Management login requires a dashboard session before either credential-acquisition path; see the [provider guide](/guides/providers/). +The macOS Keychain prompt appears on the machine running the proxy, which may be +separate from your dashboard browser. OpenCodex allows up to 120 seconds to approve +it. Cancel the dashboard login to stop the pending read. Missing entries, denied +access, unavailable Keychain interaction, timeout, malformed credentials, and +Model API validation failures have separate recovery messages. If import is +blocked, the dashboard offers a paste field for an existing Muse Code API key; it +does not automatically start another device grant. To use browser sign-in instead, +choose **Add account** on an already configured provider. OpenCodex does not change +Keychain access controls. + +If login still fails, report the exact displayed Muse error (including any HTTP +status), OpenCodex version, `muse --version`, whether the dashboard is remote from +the proxy, and how long the prompt was visible. In browser developer tools โ†’ +Network, copy only the `error` field from `/api/oauth/login` or +`/api/oauth/status?provider=meta-muse`. Do not share a HAR, request headers, +auth files, Keychain output, account details, or the rest of the status response. +These safe diagnostics distinguish local import from Meta validation failures; +no credential dump is needed. + For Responses requests to Meta, omitted or `auto` tool selection is supported. Explicit `none` sends `tools: []` and removes `additional_tools` items from the input. Forced, named, and `allowed_tools` selections return HTTP 400 before the diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index bc12111f4f8..be93494a382 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1183,6 +1183,8 @@ "memory-watchdog.test.ts": "server", "meta-model-api-provider.test.ts": "providers", "meta-muse-wonder.test.ts": "providers", "meta-muse-oauth.test.ts": "providers", + "meta-muse-keychain.test.ts": "providers", + "oauth-meta-muse-login-diagnostics.test.ts": "oauth", "mimo-effort.test.ts": "providers", "mimo-free-provider.test.ts": "providers", "mimo-token-plan-provider.test.ts": "providers", diff --git a/src/oauth/index.ts b/src/oauth/index.ts index 04c63e7e26f..63809facab0 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -1,3 +1,4 @@ +import { MuseLoginError, museLoginErrorMessage } from "./meta-muse-errors"; import type { KiroOAuthMetadata, OAuthController, OAuthCredentials } from "./types"; import { mapFill } from "../providers/resolved-model-policy-merge"; import { initializeProviderModelSelection } from "../providers/initial-model-selection"; @@ -502,6 +503,7 @@ class OAuthLoginSupersededError extends Error { /** Project arbitrary OAuth failures onto the small, stable public error vocabulary. */ export function publicOAuthAuthenticationErrorMessage(error: unknown): string { + if (error instanceof MuseLoginError) return museLoginErrorMessage(error.kind, error.status); if (error instanceof OAuthMutationBusyError) { return error.message === "OAuth mutation queue wait timed out" ? "OAuth mutation queue wait timed out" diff --git a/src/oauth/login-cli.ts b/src/oauth/login-cli.ts index 5d458dc41a0..1d79b04ed6c 100644 --- a/src/oauth/login-cli.ts +++ b/src/oauth/login-cli.ts @@ -202,6 +202,10 @@ export async function handleOAuthLogin(name: string, deps: LoginCliDeps = {}, op await withPrompt(deps.ask, async (ask) => { await login(name, { onAuth: ({ url, instructions }) => { + if (!url) { + if (instructions) console.log(instructions); + return; + } console.log(`\n๐Ÿ” Opening browser for ${name} login...\n${url}\n`); if (instructions) console.log(instructions); // The controller does not await onAuth, so the launcher's answer cannot be reported from diff --git a/src/oauth/meta-muse-errors.ts b/src/oauth/meta-muse-errors.ts new file mode 100644 index 00000000000..d973d56f5f3 --- /dev/null +++ b/src/oauth/meta-muse-errors.ts @@ -0,0 +1,41 @@ +/** Closed, account-free diagnostics: never project a provider's raw error text. */ +const messages = { + "cancelled": "Muse Code login cancelled.", + "pointer-unreadable": "Could not read the Muse Code credential file. Check its permissions or run `muse login` again.", + "pointer-invalid": "The Muse Code credential file is not valid JSON or has an invalid shape. Run `muse login` to rewrite it.", + "storage-unsupported": "Muse Code stored its credential with an unsupported backend; only the macOS Keychain import is verified. Retry import or paste an existing Muse Code key when prompted.", + "keychain-timeout": "The Muse Code Keychain approval timed out after 120s. Unlock the login keychain, approve the prompt and retry, or paste an existing Muse Code key when prompted.", + "keychain-denied": "Access to the Muse Code Keychain entry was denied or cancelled. Approve the Keychain prompt and retry, or paste an existing Muse Code key when prompted.", + "keychain-locked": "The Muse Code Keychain entry requires user interaction. Unlock the login keychain and retry from an interactive macOS session, or paste an existing Muse Code key when prompted.", + "keychain-missing": "The Muse Code Keychain entry was not found. Run `muse login` again, or paste an existing Muse Code key when prompted.", + "keychain-unreadable": "Could not read the Muse Code credential from the macOS Keychain. Run `muse login` again, or paste an existing Muse Code key when prompted.", + "keychain-invalid": "The Muse Code Keychain entry is not valid JSON or has an invalid shape. Run `muse login` to rewrite it.", + "key-missing": "The Muse Code Keychain entry carries no usable API key. Run `muse login` again, or paste an existing Muse Code key when prompted.", + "key-format": "The Muse Code credential is not in the expected Meta API key format. Run `muse login` again, or paste an existing Muse Code key when prompted.", + "manual-key-missing": "The pasted Muse Code credential carries no usable API key. Copy it again from https://dev.meta.ai.", + "manual-key-format": "The pasted Muse Code credential is not in the expected Meta API key format. Copy it again from https://dev.meta.ai.", + "validation-network": "Could not reach the Meta Model API to validate the Muse Code credential. Check the proxy host's network and retry.", + "validation-timeout": "The Meta Model API credential validation timed out after 10s. Check the proxy host's network and retry.", + "validation-http": "The Muse Code credential was rejected by the Meta Model API. Renew it with `muse login` or copy it again from https://dev.meta.ai, then retry.", +} as const; + +export type MuseLoginErrorKind = keyof typeof messages; + +export class MuseLoginError extends Error { + constructor(readonly kind: MuseLoginErrorKind, readonly status?: number) { + super(museLoginErrorMessage(kind, status)); + this.name = "MuseLoginError"; + } +} + +export function museLoginErrorMessage(kind: MuseLoginErrorKind, status?: number): string { + const message = Object.hasOwn(messages, kind) ? messages[kind] : messages["keychain-unreadable"]; + return kind === "validation-http" && Number.isInteger(status) && status! >= 100 && status! <= 599 + ? `${message} (HTTP ${status})` + : message; +} + +export function publicMuseLoginErrorMessage(error: unknown): string | undefined { + // Reconstruct from the closed kind; even a mutated Error.message cannot leak a key. + return error instanceof MuseLoginError ? museLoginErrorMessage(error.kind, error.status) : undefined; +} diff --git a/src/oauth/meta-muse-keychain.ts b/src/oauth/meta-muse-keychain.ts new file mode 100644 index 00000000000..3cfd925b26f --- /dev/null +++ b/src/oauth/meta-muse-keychain.ts @@ -0,0 +1,52 @@ +import { MuseLoginError } from "./meta-muse-errors"; + +// Approval includes typing a password. Five seconds kills security while the user types. +export const MUSE_KEYCHAIN_TIMEOUT_MS = 120_000; +export interface MuseKeychainProcess { + stdout: ReadableStream; + exited: Promise; + kill(): void; +} +interface KeychainDeps { + spawn?: (argv: string[]) => MuseKeychainProcess; + timeoutMs?: number; +} + +export async function readMuseKeychain(signal?: AbortSignal, deps: KeychainDeps = {}): Promise { + if (signal?.aborted) throw new MuseLoginError("cancelled"); + const timeout = AbortSignal.timeout(deps.timeoutMs ?? MUSE_KEYCHAIN_TIMEOUT_MS); + const deadline = signal ? AbortSignal.any([signal, timeout]) : timeout; + let child: MuseKeychainProcess | undefined; + let exited = false; + let onAbort: (() => void) | undefined; + try { + child = (deps.spawn ?? (argv => Bun.spawn(argv, { stdin: "ignore", stdout: "pipe", stderr: "ignore" })))( + ["/usr/bin/security", "find-generic-password", "-s", "ai.meta.dev.credentials", "-a", "meta", "-w"], + ); + const finished = Promise.all([new Response(child.stdout).text(), child.exited.then(code => { exited = true; return code; })]); + const aborted = new Promise((_, reject) => { + onAbort = () => reject(new MuseLoginError(signal?.aborted ? "cancelled" : "keychain-timeout")); + if (deadline.aborted) onAbort(); + else deadline.addEventListener("abort", onAbort, { once: true }); + }); + const [out, code] = await Promise.race([finished, aborted]); + if (signal?.aborted) throw new MuseLoginError("cancelled"); + if (code !== 0) { + // security returns OSStatus truncated to an 8-bit process exit code (SecBase.h). + // Unknown failures stay unknown; no stderr or credential output is retained. + if (code === 44) throw new MuseLoginError("keychain-missing"); // errSecItemNotFound -25300 + if (code === 36) throw new MuseLoginError("keychain-locked"); // errSecInteractionNotAllowed -25308 + if (code === 51 || code === 128) throw new MuseLoginError("keychain-denied"); // -25293 / -128 + throw new MuseLoginError("keychain-unreadable"); + } + if (!out.trim()) throw new MuseLoginError("keychain-unreadable"); + return out.trim(); + } catch (error) { + if (signal?.aborted) throw new MuseLoginError("cancelled"); + if (error instanceof MuseLoginError) throw error; + throw new MuseLoginError("keychain-unreadable"); + } finally { + if (onAbort) deadline.removeEventListener("abort", onAbort); + if (child && !exited) { try { child.kill(); } catch { /* already exited */ } } + } +} diff --git a/src/oauth/meta-muse.ts b/src/oauth/meta-muse.ts index 4c93fed3ce4..91dee6d78b6 100644 --- a/src/oauth/meta-muse.ts +++ b/src/oauth/meta-muse.ts @@ -26,14 +26,13 @@ import { homedir } from "node:os"; import { join } from "node:path"; import { sanitizeApiKeyValue } from "../providers/api-keys"; import { MuseDeviceLoginError, loginMetaMuseDevice } from "./meta-muse-device"; +import { MuseLoginError, publicMuseLoginErrorMessage } from "./meta-muse-errors"; +import { readMuseKeychain } from "./meta-muse-keychain"; 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; -const KEYCHAIN_TIMEOUT_MS = 5_000; /** * Shown BEFORE any credential is read. @@ -82,44 +81,9 @@ export interface MuseLoginOptions { async function defaultReadPointer(): Promise { try { return await Bun.file(MUSE_POINTER_PATH).text(); - } catch { - return null; - } -} - -/** - * `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 { - proc = Bun.spawn( - ["security", "find-generic-password", "-s", KEYCHAIN_SERVICE, "-a", KEYCHAIN_ACCOUNT, "-w"], - { stdout: "pipe", stderr: "pipe" }, - ); - 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 */ } } + } catch (error) { + if ((error as { code?: string })?.code === "ENOENT") return null; + throw new MuseLoginError("pointer-unreadable"); } } @@ -160,6 +124,10 @@ async function manualKeyCredential( return pasted.length > 0 ? pasted : null; } +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + function normalizedEmail(value: unknown): string | undefined { if (typeof value !== "string") return undefined; const trimmed = value.trim().toLowerCase(); @@ -179,6 +147,7 @@ export async function loginMetaMuse( // Before ANY read: the CLI has no other warning surface. ctrl.onProgress?.(CONSENT_WARNING); + if (ctrl.signal?.aborted) throw new MuseLoginError("cancelled"); const platform = deps.platform ?? process.platform; // Import first on darwin, and only for a plain login. A user who already ran `muse @@ -187,8 +156,19 @@ export async function loginMetaMuse( // a request to arrive at the same key. `forceLogin` maps to "off" upstream because // reimporting is how an add-account silently re-adds the account the user already has. if (platform === "darwin" && options.importLocal !== "off") { - const imported = await importFromKeychain(ctrl, deps); - if (imported) return imported; + try { + const imported = await importFromKeychain(ctrl, deps); + if (imported) return imported; + } catch (error) { + if (isCancellation(error, ctrl)) throw error; + const reason = publicMuseLoginErrorMessage(error); + // Recovery requires an explicit paste, never an automatic grant or key mint. + if (!reason || !ctrl.onManualCodeInput) throw error; + const pasted = await manualKeyCredential(ctrl, reason); + if (ctrl.signal?.aborted) throw new MuseLoginError("cancelled"); + if (pasted === null) throw error; + return await validatedMetaMuseCredential(pasted, ctrl, deps, undefined, "manual"); + } } // fetchImpl/sleep/now are FORWARDED. Without this the device module would carry its own @@ -221,46 +201,46 @@ export async function loginMetaMuse( * caller can offer a device grant instead of a dead end. Everything else still throws: a * corrupt file, an unmeasured storage backend, a Keychain read that times out, and a * Keychain entry that is unreadable or carries no usable key are all cases where a - * credential probably EXISTS, and silently starting a browser grant would create a second - * login to work around a permissions dialog. + * credential probably EXISTS. The caller can offer explicit key paste recovery, but never + * silently starts a second login to work around a permissions dialog. */ async function importFromKeychain( ctrl: OAuthController, deps: MuseImportDeps, ): Promise { const pointerRaw = await (deps.readPointer ?? defaultReadPointer)(); + if (ctrl.signal?.aborted) throw new MuseLoginError("cancelled"); if (pointerRaw === null) return null; let pointer: MusePointer; try { pointer = JSON.parse(pointerRaw) as MusePointer; + if (!isRecord(pointer) || (pointer.providers !== undefined && !isRecord(pointer.providers)) + || (pointer.providers?.meta !== undefined && !isRecord(pointer.providers.meta))) throw new Error(); } catch { - throw new Error(`Muse Code credential file at ${MUSE_POINTER_PATH} is not valid JSON. Run \`muse login\` to rewrite it.`); + throw new MuseLoginError("pointer-invalid"); } const meta = pointer.providers?.meta; if (!meta || meta.mechanism !== "oauth") return null; // 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.`, - ); + throw new MuseLoginError("storage-unsupported"); } - // ctrl.signal is handed to the reader, not dropped: the caller can cancel a Keychain - // prompt nobody is going to answer. - 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 within 5s. Approve the Keychain prompt, or run `muse login` again, or add the account again to sign in with a browser code instead.", - ); - } + if (ctrl.signal?.aborted) throw new MuseLoginError("cancelled"); + // Publish a hint before the OS dialog so the dashboard can poll and cancel it. + ctrl.onAuth?.({ url: "", instructions: "Approve the Muse Code Keychain prompt on the proxy host. Waiting up to 120 seconds." }); + const secretRaw = await (deps.readKeychain ?? readMuseKeychain)(ctrl.signal); + if (ctrl.signal?.aborted) throw new MuseLoginError("cancelled"); + if (secretRaw === null) throw new MuseLoginError("keychain-unreadable"); let secret: MuseKeychainSecret; try { secret = JSON.parse(secretRaw) as MuseKeychainSecret; + if (!isRecord(secret)) throw new Error(); } catch { - throw new Error("The Muse Code Keychain entry is not valid JSON. Run `muse login` to rewrite it."); + throw new MuseLoginError("keychain-invalid"); } // access_token is present but 401s against the Model API (003 ยงB) โ€” never fall back to it. @@ -287,7 +267,7 @@ function deviceFailureReason(error: unknown): string { case "subscription-inactive": return "This Meta account has no active Muse Code subscription."; case "entitlement-required": - return error.message; + return "Complete the Muse Code subscription setup at https://dev.meta.ai, then retry."; case "mint-rate-limited": return "Meta rate-limited the Muse Code key request."; case "device-denied": @@ -314,7 +294,7 @@ function noPasteSurfaceError(platform: string, reason: string): Error { const platformClause = platform === "darwin" ? "" : platform === "win32" - ? " Meta ships no native Windows Muse Code CLI, so there is no credential to import." + ? " The Windows Muse Code credential store has not been verified, so there is no credential to import through this reader." : " The Muse Code CLI runs here, but where it stores its credential has not been measured, " + "so importing one is refused rather than guessed."; return new Error( @@ -338,22 +318,10 @@ async function validatedMetaMuseCredential( email: string | undefined, source: "local-cli" | "manual", ): Promise { - const retry = source === "manual" - ? `Copy it again from ${MANUAL_KEY_URL}.` - : "Run `muse login` again."; + if (ctrl.signal?.aborted) throw new MuseLoginError("cancelled"); const apiKey = sanitizeApiKeyValue(candidate); - if (!apiKey) { - // The macOS wording is preserved verbatim. Extraction is a refactor, and a refactor - // that quietly rewrites a user-facing error is a behavior change in disguise. - throw new Error(source === "manual" - ? `The pasted Muse Code credential carries no usable API key. ${retry}` - : "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(source === "manual" - ? `The pasted Muse Code credential is not in the expected Meta API key format. ${retry}` - : "The Muse Code credential is not in the expected Meta API key format. Run `muse login` again."); - } + if (!apiKey) throw new MuseLoginError(source === "manual" ? "manual-key-missing" : "key-missing"); + if (!/^LLM\|\d+\|[A-Za-z0-9_-]{10,}$/.test(apiKey)) throw new MuseLoginError(source === "manual" ? "manual-key-format" : "key-format"); ctrl.onProgress?.(source === "manual" ? "Validating the pasted Meta credential..." : "Validating the imported Meta credential..."); @@ -366,18 +334,17 @@ async function validatedMetaMuseCredential( let response: Response; try { response = await fetchImpl(MODELS_URL, { - headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" }, + headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json", "x-api-version": "1.0.0" }, + redirect: "error", 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}). ${retry}`, - ); + } catch { + if (ctrl.signal?.aborted) throw new MuseLoginError("cancelled"); + throw new MuseLoginError(signal.aborted ? "validation-timeout" : "validation-network"); } + if (ctrl.signal?.aborted) throw new MuseLoginError("cancelled"); + void response.body?.cancel().catch(() => {}); + if (!response.ok) throw new MuseLoginError("validation-http", response.status); return { access: apiKey, diff --git a/structure/INDEX.md b/structure/INDEX.md index ac904b7e52f..4fcb9aa0dcc 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -65,6 +65,7 @@ Per-vendor contracts and the adapter authority that constructs them. | --- | --- | | [`providers/anthropic-progress.md`](providers/anthropic-progress.md) | Opus 5.5 progress display, visible commentary, and exact signed-block replay. | | [`providers/anthropic-account-thresholds.md`](providers/anthropic-account-thresholds.md) | Account-owned usage thresholds, inheritance, routing boundaries and durable policy changes. | +| [`providers/meta-muse.md`](providers/meta-muse.md) | CLI credential import, approval lifecycle, explicit recovery and account-free login diagnostics. | | [`providers/oauth-reconciliation.md`](providers/oauth-reconciliation.md) | Operator limits and per-model declarations across startup reconciliation and OAuth upsert. | | [`providers/claude-cli-replay.md`](providers/claude-cli-replay.md) | Claude CLI stable replay, canonical tool schemas, capture-only client tool ownership and subscription usage admission. | | [`providers-and-adapters.md`](providers-and-adapters.md) | Provider and adapter selection, the adapter inventory, live model discovery, and the hosted-search continuation bridge. | @@ -191,6 +192,10 @@ A source area can be described by more than one doc, because these docs are orga | `src/messaging/` | [`local-messaging.md`](local-messaging.md) | | `src/oauth/` | [`runtime.md`](runtime.md)
[`transports/inventory.md`](transports/inventory.md)
[`providers/anthropic-account-thresholds.md`](providers/anthropic-account-thresholds.md)
[`providers/oauth-reconciliation.md`](providers/oauth-reconciliation.md)
[`providers-and-adapters.md`](providers-and-adapters.md)
[`providers/anthropic-account-pool.md`](providers/anthropic-account-pool.md)
[`providers/oauth-code-display-login.md`](providers/oauth-code-display-login.md)
[`providers/xai-grok.md`](providers/xai-grok.md) | | `src/oauth/dsh.ts` | [`providers/dsh-account.md`](providers/dsh-account.md) | +| `src/oauth/login-cli.ts` | [`providers/meta-muse.md`](providers/meta-muse.md) | +| `src/oauth/meta-muse-errors.ts` | [`providers/meta-muse.md`](providers/meta-muse.md) | +| `src/oauth/meta-muse-keychain.ts` | [`providers/meta-muse.md`](providers/meta-muse.md) | +| `src/oauth/meta-muse.ts` | [`providers/meta-muse.md`](providers/meta-muse.md) | | `src/plugins/` | [`ops/plugins.md`](ops/plugins.md) | | `src/protocols/` | [`data-planes/protocol-paths.md`](data-planes/protocol-paths.md) | | `src/providers/` | [`runtime.md`](runtime.md)
[`subagents.md`](subagents.md)
[`transports/inventory.md`](transports/inventory.md)
[`providers-and-adapters.md`](providers-and-adapters.md)
[`providers/anthropic-account-pool.md`](providers/anthropic-account-pool.md)
[`providers/xai-grok.md`](providers/xai-grok.md) | diff --git a/structure/manifest.json b/structure/manifest.json index 708cc826810..cabf58a45bf 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -352,6 +352,13 @@ "scope": "Account-owned usage thresholds, inheritance, routing boundaries and durable policy changes.", "documents": ["src/oauth/"] }, + { + "path": "providers/meta-muse.md", + "tier": 4, + "title": "Meta Muse Login", + "scope": "CLI credential import, approval lifecycle, explicit recovery and account-free login diagnostics.", + "documents": ["src/oauth/meta-muse.ts", "src/oauth/meta-muse-keychain.ts", "src/oauth/meta-muse-errors.ts", "src/oauth/login-cli.ts"] + }, { "path": "providers/oauth-reconciliation.md", "tier": 4, diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 96d384c3caf..526af469736 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -64,15 +64,8 @@ Combo preflight allows the private search tool only while a search plan is activ The opt-in `inlineThinkTagModels` list follows static-policy override and model-rename rules; shared Kiro/Chat splitting and raw display follow [Chat compatibility](providers/chat-compat.md#inline-think-tag-recovery). -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. -Management discovery and changing device/manual instructions follow the -[OAuth continuation contract](gui-and-management-api.md#oauth-login-continuations); discovery -filters the current principal without changing the direct CLI roster or relaxing login admission. +Meta Muse import, device login, explicit paste recovery, consent and safe diagnostics follow +the [Muse login contract](providers/meta-muse.md). Other providers retain their existing login policies. The capture-only bridge in `src/adapters/coding-agent/turn.ts` reports staging failures with the fixed `tool_bridge_setup_failed` error, never an OS error carrying private file paths. diff --git a/structure/providers/meta-muse.md b/structure/providers/meta-muse.md new file mode 100644 index 00000000000..b22877c4594 --- /dev/null +++ b/structure/providers/meta-muse.md @@ -0,0 +1,31 @@ +# Meta Muse login + +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. +`src/oauth/meta-muse-keychain.ts` reads the measured macOS service/account through `/usr/bin/security` +with a cancellable 120-second approval deadline. The importer publishes a dashboard hint before waiting, +reaps the prompt on timeout/cancellation, and distinguishes missing, denied, interaction-disallowed +and unknown failures without retaining stderr. `src/oauth/meta-muse-errors.ts` provides fixed +reason-based import/validation diagnostics; public error projection reconstructs those messages +from their kind and bounded HTTP status, never mutable messages, OS errors or credential text. +Invalid pointer/secret JSON shapes are refused before property access. Import failures keep +the existing credential and offer explicit key paste recovery when available, without silently +initiating a second login. Import and paste validation use `x-api-version: 1.0.0` and reject redirects. +Management discovery and changing device/manual instructions follow the +[OAuth continuation contract](../gui-and-management-api.md#oauth-login-continuations); discovery +filters the current principal without changing the direct CLI roster or relaxing login admission. + +`src/oauth/meta-muse.ts` imports only the verified macOS pointer/Keychain shape. +Successful import takes precedence over device login; add-account and reauthentication +skip import. Missing local identity falls through to device login. Existing but unreadable +credentials never trigger an automatic grant: an explicit paste may recover the login. +Neither the OAuth account token nor an imported email is reused by a manual-key recovery. +`src/oauth/login-cli.ts` prints instruction-only hints without announcing or launching a browser. + +`tests/providers/meta-muse-keychain.test.ts` exercises the subprocess deadline and cleanup +without invoking the real Keychain. `tests/oauth/oauth-meta-muse-login-diagnostics.test.ts` +covers public projection, shape refusals, cancellation and explicit paste recovery. diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 37b16875436..ad5be8b56fd 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -807,7 +807,9 @@ "protocol-settings-route.test.ts": "server", "protocol-provider-summary.test.ts": "server", "management-workflow-budget-routes.test.ts": "server", "managing-cli.test.ts": "service", "memory-watchdog.test.ts": "server", "meta-model-api-provider.test.ts": "providers", "meta-muse-wonder.test.ts": "providers", - "meta-muse-oauth.test.ts": "providers", "mimo-effort.test.ts": "providers", + "meta-muse-oauth.test.ts": "providers", + "meta-muse-keychain.test.ts": "providers", + "oauth-meta-muse-login-diagnostics.test.ts": "oauth", "mimo-effort.test.ts": "providers", "mimo-free-provider.test.ts": "providers", "mimo-token-plan-provider.test.ts": "providers", "mimo-v26-catalog.test.ts": "providers", "minimax-clients.test.ts": "providers", "minimax-quota.test.ts": "providers", "minimax-reasoning-split.test.ts": "providers", diff --git a/tests/oauth/oauth-login-cli-browser-launch.test.ts b/tests/oauth/oauth-login-cli-browser-launch.test.ts index 1efad4b987b..c273fc64808 100644 --- a/tests/oauth/oauth-login-cli-browser-launch.test.ts +++ b/tests/oauth/oauth-login-cli-browser-launch.test.ts @@ -91,6 +91,24 @@ afterEach(() => { }); describe("CLI OAuth login reports the browser launch (#5261)", () => { + test("an instruction-only Keychain hint never launches an empty browser URL", async () => { + let launches = 0; + const lines: string[] = []; + const capture = spyOn(console, "log").mockImplementation(line => { lines.push(String(line)); }); + try { + await handleOAuthLogin(anyOAuthProvider(), { + runLogin: async (_provider, ctrl) => { + ctrl.onAuth?.({ url: "", instructions: "Approve the Muse Code Keychain prompt." }); + return {} as OAuthCredentials; + }, + openUrl: async () => { launches++; return { status: "started" }; }, + }); + expect(launches).toBe(0); + expect(lines.join(" ")).toContain("Approve the Muse Code Keychain prompt."); + expect(lines.join(" ")).not.toContain("Opening browser"); + } finally { capture.mockRestore(); } + }); + test("a browser that never opened is on screen before the paste prompt", async () => { const events: string[] = []; let settleLaunch: (result: OpenUrlResult) => void = () => {}; diff --git a/tests/oauth/oauth-meta-muse-login-diagnostics.test.ts b/tests/oauth/oauth-meta-muse-login-diagnostics.test.ts new file mode 100644 index 00000000000..60486984d57 --- /dev/null +++ b/tests/oauth/oauth-meta-muse-login-diagnostics.test.ts @@ -0,0 +1,174 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { saveConfig } from "../../src/config"; +import { cancelLoginFlow, clearLoginState, getLoginStatus, OAUTH_PROVIDERS, publicOAuthAuthenticationErrorMessage } from "../../src/oauth"; +import { loginMetaMuse, type MuseImportDeps } from "../../src/oauth/meta-muse"; +import { MuseLoginError } from "../../src/oauth/meta-muse-errors"; +import { handleManagementAPI } from "../../src/server/management-api"; +import { ManagementRequest } from "../helpers/management-auth"; +import type { OcxConfig } from "../../src/types"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { removeTreeAfterBackgroundWork } from "../helpers/remove-tree"; + +const original = OAUTH_PROVIDERS["meta-muse"].login; +let testDir = ""; +let previousHome: string | undefined; +let codexHome: IsolatedCodexHome | undefined; +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + testDir = mkdtempSync(join(tmpdir(), "ocx-muse-diagnostics-")); + process.env.OPENCODEX_HOME = testDir; + codexHome = installIsolatedCodexHome("ocx-muse-diagnostics-codex-"); + saveConfig({ port: 0, defaultProvider: "stub", providers: { stub: { adapter: "openai-chat", baseUrl: "https://stub.invalid/v1", apiKey: "stub-key" } } } as OcxConfig); +}); +afterEach(async () => { + cancelLoginFlow("meta-muse"); + OAUTH_PROVIDERS["meta-muse"].login = original; + clearLoginState("meta-muse"); + try { + await removeTreeAfterBackgroundWork(testDir); + if (codexHome) await removeTreeAfterBackgroundWork(codexHome.path); + } finally { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + codexHome?.restoreEnvironment(); + } +}); +const pointer = JSON.stringify({ providers: { meta: { mechanism: "oauth", storage: "keychain" } } }); +const deps: MuseImportDeps = { + platform: "darwin", readPointer: async () => pointer, + readKeychain: async () => { throw new MuseLoginError("keychain-denied"); }, + loginDevice: async () => { throw new Error("must not start a second login"); }, +}; +const cfg = { port: 0, providers: {} } as OcxConfig; +const request = () => new ManagementRequest("http://localhost/api/oauth/login", { + method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ provider: "meta-muse" }), +}); + +describe("Muse login diagnostics across management boundaries", () => { + test("fixed diagnostics survive projection even if message and cause contain private data", () => { + const error = new MuseLoginError("keychain-denied"); + error.message = "private-credential-and-path"; + error.cause = new Error("private-provider-body"); + expect(publicOAuthAuthenticationErrorMessage(error)).toBe(new MuseLoginError("keychain-denied").message); + expect(publicOAuthAuthenticationErrorMessage(new Error("private-provider-body"))).toStartWith("OAuth authentication failed."); + expect(publicOAuthAuthenticationErrorMessage(new MuseLoginError("validation-http", 401))).toContain("HTTP 401"); + expect(publicOAuthAuthenticationErrorMessage(new MuseLoginError("validation-http", NaN))).not.toContain("NaN"); + }); + + test("early import failures return a safe actionable login response", async () => { + OAUTH_PROVIDERS["meta-muse"].login = async ctrl => loginMetaMuse({ ...ctrl, onManualCodeInput: undefined }, { ...deps, readPointer: async () => "null" }); + const req = request(); + const res = await handleManagementAPI(req, new URL(req.url), cfg, {}, "gui-session"); + expect(res?.status).toBe(409); + expect((await res!.json()).error).toBe(new MuseLoginError("pointer-invalid").message); + }); + + test("Keychain hint returns before approval; late refusal remains actionable in status", async () => { + let finish!: () => void; + const waiting = new Promise(resolve => { finish = resolve; }); + OAUTH_PROVIDERS["meta-muse"].login = async ctrl => loginMetaMuse({ ...ctrl, onManualCodeInput: undefined }, { + ...deps, readKeychain: async () => { await waiting; throw new MuseLoginError("keychain-denied"); }, + }); + const req = request(); + const res = await handleManagementAPI(req, new URL(req.url), cfg, {}, "gui-session"); + expect(res?.status).toBe(200); + expect(await res!.json()).toMatchObject({ url: "", instructions: expect.stringContaining("120 seconds") }); + expect(getLoginStatus("meta-muse").done).toBe(false); + finish(); + for (let i = 0; i < 100 && !getLoginStatus("meta-muse").done; i++) await Bun.sleep(1); + const statusReq = new ManagementRequest("http://localhost/api/oauth/status?provider=meta-muse"); + const statusRes = await handleManagementAPI(statusReq, new URL(statusReq.url), cfg, {}, "gui-session"); + expect((await statusRes!.json()).error).toBe(new MuseLoginError("keychain-denied").message); + }); + + test.each(["null", "[]", '"string"', '{"providers":null}', '{"providers":{"meta":null}}'])("invalid pointer %s is contained", async raw => { + await expect(loginMetaMuse({}, { ...deps, readPointer: async () => raw })).rejects.toThrow(/invalid shape/); + }); + + test("unsupported storage text is never reflected", async () => { + const raw = JSON.stringify({ providers: { meta: { mechanism: "oauth", storage: "private-storage-value" } } }); + const error = await loginMetaMuse({}, { ...deps, readPointer: async () => raw }).catch(e => e); + expect(error.kind).toBe("storage-unsupported"); + expect(error.message).not.toContain("private-storage-value"); + }); + + test("import refusal offers explicit paste recovery without starting another grant", async () => { + let grants = 0; + const hints: string[] = []; + const key = `LLM|${"1".repeat(16)}|${"c".repeat(27)}`; + const credential = await loginMetaMuse({ + onAuth: info => hints.push(info.instructions ?? ""), + onManualCodeInput: async () => key, + }, { + ...deps, + loginDevice: async () => { grants++; throw new Error("unexpected grant"); }, + fetchImpl: (async (url, init) => { + expect(url).toBe("https://api.meta.ai/v1/models"); + expect(new Headers(init?.headers).get("x-api-version")).toBe("1.0.0"); + expect(init?.redirect).toBe("error"); + return new Response("{}", { status: 200 }); + }) as typeof fetch, + }); + expect(credential.source).toBe("manual"); + expect(credential.access === key).toBe(true); + expect(credential.email).toBeUndefined(); + expect(grants).toBe(0); + expect(hints.join(" ")).toContain("denied"); + expect(hints.join(" ")).not.toContain(key); + }); + + test("empty recovery preserves the import diagnosis; cancellation never validates", async () => { + await expect(loginMetaMuse({ onManualCodeInput: async () => "" }, deps)).rejects.toThrow(/denied/); + const ac = new AbortController(); + let fetched = false; + await expect(loginMetaMuse({ signal: ac.signal, onManualCodeInput: async () => { ac.abort(); return "ignored"; } }, { + ...deps, fetchImpl: (async () => { fetched = true; return new Response(); }) as typeof fetch, + })).rejects.toThrow(/cancelled/); + expect(fetched).toBe(false); + }); + + test("dashboard import failure continues through the existing manual-code endpoint", async () => { + const key = `LLM|${"2".repeat(16)}|${"d".repeat(27)}`; + OAUTH_PROVIDERS["meta-muse"].login = async ctrl => loginMetaMuse(ctrl, { + ...deps, fetchImpl: (async () => new Response("{}", { status: 200 })) as typeof fetch, + }); + const req = request(); + const res = await handleManagementAPI(req, new URL(req.url), cfg, {}, "gui-session"); + expect(res?.status).toBe(200); + for (let i = 0; i < 100 && !getLoginStatus("meta-muse").hint?.url; i++) await Bun.sleep(1); + expect(getLoginStatus("meta-muse").hint?.instructions).toContain("denied"); + const codeReq = new ManagementRequest("http://localhost/api/oauth/login/code", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ provider: "meta-muse", input: key }), + }); + const codeRes = await handleManagementAPI(codeReq, new URL(codeReq.url), cfg, {}, "gui-session"); + expect(codeRes?.status).toBe(200); + for (let i = 0; i < 100 && !getLoginStatus("meta-muse").done; i++) await Bun.sleep(1); + const status = getLoginStatus("meta-muse"); + expect(status.done).toBe(true); + expect(status.loggedIn).toBe(true); + expect(JSON.stringify(status)).not.toContain(key); + }); + + test("validation transport errors never reflect provider text", async () => { + const key = `LLM|${"1".repeat(16)}|${"c".repeat(27)}`; + const error = await loginMetaMuse({}, { ...deps, readKeychain: async () => JSON.stringify({ api_key: key }), + fetchImpl: (async () => { throw new Error("private-network-details"); }) as unknown as typeof fetch, + }).catch(e => e); + expect(error.kind).toBe("validation-network"); + expect(publicOAuthAuthenticationErrorMessage(error)).not.toContain("private-network-details"); + }); + + test("cancellation after an injected read prevents validation and persistence", async () => { + const ac = new AbortController(); + let fetched = false; + await expect(loginMetaMuse({ signal: ac.signal }, { ...deps, + readKeychain: async () => { ac.abort(); return "{}"; }, + fetchImpl: (async () => { fetched = true; return new Response(); }) as unknown as typeof fetch, + })).rejects.toThrow(/cancelled/); + expect(fetched).toBe(false); + }); +}); diff --git a/tests/providers/meta-muse-keychain.test.ts b/tests/providers/meta-muse-keychain.test.ts new file mode 100644 index 00000000000..7112b3fc64f --- /dev/null +++ b/tests/providers/meta-muse-keychain.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from "bun:test"; +import { readMuseKeychain, MUSE_KEYCHAIN_TIMEOUT_MS, type MuseKeychainProcess } from "../../src/oauth/meta-muse-keychain"; +import { MuseLoginError } from "../../src/oauth/meta-muse-errors"; + +function child(code: number, output = ""): MuseKeychainProcess { + return { stdout: new Blob([output]).stream(), exited: Promise.resolve(code), kill() {} }; +} + +describe("Muse Keychain approval lifecycle (no real Keychain)", () => { + test("allows time for a human approval and uses the measured service/account", async () => { + expect(MUSE_KEYCHAIN_TIMEOUT_MS).toBe(120_000); + let args: string[] = []; + const result = await readMuseKeychain(undefined, { + spawn: argv => { args = argv; return child(0, " synthetic-credential\n"); }, + }); + expect(args).toEqual(["/usr/bin/security", "find-generic-password", "-s", "ai.meta.dev.credentials", "-a", "meta", "-w"]); + expect(result).toBe("synthetic-credential"); + }); + + test("approval after the old five-second cutoff still succeeds", async () => { + const result = await readMuseKeychain(undefined, { + spawn: () => ({ ...child(0, "approved"), exited: Bun.sleep(5_100).then(() => 0) }), + }); + expect(result).toBe("approved"); + }, 10_000); + + test.each([[44, "keychain-missing"], [51, "keychain-denied"], [128, "keychain-denied"], [36, "keychain-locked"], [1, "keychain-unreadable"]] as const)("exit %i has safe reason %s", async (code, kind) => { + const error = await readMuseKeychain(undefined, { spawn: () => child(code, "private-output") }).catch(e => e); + expect(error).toBeInstanceOf(MuseLoginError); + expect(error.kind).toBe(kind); + expect(error.message).not.toContain("private-output"); + }); + + test("deadline terminates a pending prompt and reports timeout", async () => { + let killed = false; + const error = await readMuseKeychain(undefined, { + timeoutMs: 5, + spawn: () => ({ stdout: new ReadableStream({ start() {} }), exited: new Promise(() => {}), kill() { killed = true; } }), + }).catch(e => e); + expect(error.kind).toBe("keychain-timeout"); + expect(killed).toBe(true); + }); + + test("cancellation kills the child without leaking its reason", async () => { + const ac = new AbortController(); + let killed = false; + const pending = readMuseKeychain(ac.signal, { + spawn: () => ({ stdout: new ReadableStream(), exited: new Promise(() => {}), kill() { killed = true; } }), + }); + ac.abort(new Error("private-cancellation-reason")); + const error = await pending.catch(e => e); + expect(error.kind).toBe("cancelled"); + expect(error.message).not.toContain("private-cancellation-reason"); + expect(killed).toBe(true); + }); + + test("an already cancelled login never spawns security", async () => { + const ac = new AbortController(); ac.abort(); + let spawned = false; + await expect(readMuseKeychain(ac.signal, { spawn: () => { spawned = true; return child(0); } })).rejects.toThrow(/cancelled/); + expect(spawned).toBe(false); + }); + + test("spawn errors and empty entries have fixed diagnostics", async () => { + await expect(readMuseKeychain(undefined, { spawn: () => { throw new Error("private-path"); } })).rejects.toThrow("Could not read the Muse Code credential"); + await expect(readMuseKeychain(undefined, { spawn: () => child(0) })).rejects.toThrow("Could not read the Muse Code credential"); + }); +}); diff --git a/tests/providers/meta-muse-login-order.test.ts b/tests/providers/meta-muse-login-order.test.ts index 49c9567315c..2c3cc2517e6 100644 --- a/tests/providers/meta-muse-login-order.test.ts +++ b/tests/providers/meta-muse-login-order.test.ts @@ -67,7 +67,7 @@ describe("muse login selection order", () => { // second login to work around a local fault. They stay refusals. test("a Keychain timeout still refuses and starts no grant", async () => { const h = harness({ readKeychain: async () => null }); - await expect(loginMetaMuse({}, h.deps)).rejects.toThrow(/within 5s/); + await expect(loginMetaMuse({}, h.deps)).rejects.toThrow(/Could not read/); expect(h.calls.device).toBe(0); }); diff --git a/tests/providers/meta-muse-oauth.test.ts b/tests/providers/meta-muse-oauth.test.ts index a68a2a52306..f63f73f9161 100644 --- a/tests/providers/meta-muse-oauth.test.ts +++ b/tests/providers/meta-muse-oauth.test.ts @@ -182,7 +182,7 @@ describe("meta-muse credential import", () => { await expect(loginMetaMuse({}, deps({ // Mimics the real reader's contract: it resolves null once its deadline fires. readKeychain: async () => null, - }))).rejects.toThrow(/within 5s/); + }))).rejects.toThrow(/Could not read/); expect(Date.now() - started).toBeLessThan(5_000); });