Skip to content
Merged
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
5 changes: 4 additions & 1 deletion docs-site/src/content/docs/guides/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
19 changes: 19 additions & 0 deletions docs-site/src/content/docs/reference/platform-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src/oauth/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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"
Expand Down
4 changes: 4 additions & 0 deletions src/oauth/login-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 41 additions & 0 deletions src/oauth/meta-muse-errors.ts
Original file line number Diff line number Diff line change
@@ -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;
}
52 changes: 52 additions & 0 deletions src/oauth/meta-muse-keychain.ts
Original file line number Diff line number Diff line change
@@ -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<Uint8Array>;
exited: Promise<number>;
kill(): void;
}
interface KeychainDeps {
spawn?: (argv: string[]) => MuseKeychainProcess;
timeoutMs?: number;
}

export async function readMuseKeychain(signal?: AbortSignal, deps: KeychainDeps = {}): Promise<string> {
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<never>((_, 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 */ } }
}
}
Loading
Loading