From ebf0b41b61c8fb9d961488531a851ae709b115ad Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:42:00 +0900 Subject: [PATCH 1/6] fix(meta-muse): tell the truth about why import is unavailable loginMetaMuse refused every non-darwin host with one message blaming the macOS Keychain. On Windows that reason is false: Meta ships no native Windows Muse Code CLI at all, so there is no credential store to look in, and a user who believed the message would go hunting for the wrong thing. Windows now names WSL2, which is Meta's own documented route. Linux names the real blocker, that its credential storage has not been measured, so an unverified shape is refused rather than guessed. Both point at the supported META_MODEL_API_KEY path. No reader is added. The pointer interface declares only mechanism, storage and user_email, so a Linux branch would have to invent the fields it reads. devlog 050 records the four facts a real Linux pointer would have to supply before that code can exist. The consent warning still fires before every refusal, including the new ones, since it is the CLI's only warning surface. --- src/oauth/meta-muse.ts | 14 ++++++++++++-- src/providers/registry.ts | 2 +- tests/meta-muse-oauth.test.ts | 25 +++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/src/oauth/meta-muse.ts b/src/oauth/meta-muse.ts index 6839be42b3..6005d21391 100644 --- a/src/oauth/meta-muse.ts +++ b/src/oauth/meta-muse.ts @@ -133,10 +133,20 @@ export async function loginMetaMuse( ctrl.onProgress?.(CONSENT_WARNING); const platform = deps.platform ?? process.platform; + // Two refusals rather than one, because the reasons are different and the old + // shared message was wrong on Windows: it blamed the Keychain when the actual + // reason is that Meta ships no Windows build of the CLI at all. + if (platform === "win32") { + throw new Error( + "Meta does not ship a native Windows Muse Code CLI, so there is no Windows credential to import. " + + "Install the CLI inside WSL2 and import there, or use the meta-model provider with your own key (META_MODEL_API_KEY).", + ); + } if (platform !== "darwin") { throw new Error( - "Meta Muse Code login is macOS-only: the CLI stores its credential in the macOS Keychain, " - + "and no other platform's storage has been verified. Use the meta-model provider with your own key instead.", + "Meta Muse Code import is verified only on macOS. The Muse CLI runs on Linux, but the credential storage " + + "it writes there has not been measured, and importing an unverified credential shape is refused. " + + "Use the meta-model provider with your own key (META_MODEL_API_KEY).", ); } diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 2063c9d2c0..8a5d8a3457 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -1540,7 +1540,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ modelInputModalities: Object.fromEntries(META_MUSE_MODELS.map(id => [id, ["text", "image"] as ["text", "image"]])), modelReasoningEfforts: Object.fromEntries(META_MUSE_MODELS.map(id => [id, META_MUSE_REASONING_EFFORTS])), modelReasoningEffortMap: Object.fromEntries(META_MUSE_MODELS.map(id => [id, META_MUSE_REASONING_EFFORT_MAP])), - note: "Reuses the API key the Muse Code CLI stores after `muse login` (macOS only; requires the CLI installed and signed in). Meta scopes that credential to the Muse Code CLI, so this is an UNSUPPORTED use: Meta does not authorize subscription coverage outside its own CLI, how these calls settle is not observable from the API, and you should treat every call as billable against your account. The imported key is copied into OpenCodex's auth store. OpenCodex reads Meta's subscription windows from streaming responses and shows the last observed value with its age; there is no endpoint to query them on demand, so refreshing one requires another streaming turn, and translated (non-passthrough) turns report none. Rate limits apply per team, not per key. For a supported path use the meta-model provider with your own key (export it as META_MODEL_API_KEY).", + note: "Reuses the API key the Muse Code CLI stores after `muse login` (macOS only; requires the CLI installed and signed in). Meta ships no native Windows CLI, so there is nothing to import there; run the CLI under WSL2 instead. The Linux credential storage has not been measured, so importing on Linux is refused rather than guessed. Meta scopes that credential to the Muse Code CLI, so this is an UNSUPPORTED use: Meta does not authorize subscription coverage outside its own CLI, how these calls settle is not observable from the API, and you should treat every call as billable against your account. The imported key is copied into OpenCodex's auth store. OpenCodex reads Meta's subscription windows from streaming responses and shows the last observed value with its age; there is no endpoint to query them on demand, so refreshing one requires another streaming turn, and translated (non-passthrough) turns report none. Rate limits apply per team, not per key. For a supported path use the meta-model provider with your own key (export it as META_MODEL_API_KEY).", }, { id: "umans", diff --git a/tests/meta-muse-oauth.test.ts b/tests/meta-muse-oauth.test.ts index 7ef5a08490..1a74f3458f 100644 --- a/tests/meta-muse-oauth.test.ts +++ b/tests/meta-muse-oauth.test.ts @@ -177,6 +177,31 @@ describe("meta-muse credential import", () => { expect(received).toBe(ac.signal); }); + // The old refusal blamed the macOS Keychain on EVERY platform. On Windows that + // is simply false — Meta ships no Windows CLI — and a user who believed it + // would go looking for a credential store instead of WSL2. + test("the Windows refusal names WSL2 and does not blame the Keychain", async () => { + await expect(loginMetaMuse({}, deps({ platform: "win32" }))).rejects.toThrow(/WSL2/); + await expect(loginMetaMuse({}, deps({ platform: "win32" }))).rejects.toThrow(/META_MODEL_API_KEY/); + await expect(loginMetaMuse({}, deps({ platform: "win32" }))).rejects.not.toThrow(/Keychain/); + }); + + test("the Linux refusal names the unmeasured storage, not the Keychain", async () => { + await expect(loginMetaMuse({}, deps({ platform: "linux" }))).rejects.toThrow(/not been measured/); + await expect(loginMetaMuse({}, deps({ platform: "linux" }))).rejects.toThrow(/META_MODEL_API_KEY/); + await expect(loginMetaMuse({}, deps({ platform: "linux" }))).rejects.not.toThrow(/Keychain/); + }); + + test("the consent warning still precedes an unsupported-platform refusal", async () => { + for (const platform of ["win32", "linux"] as const) { + const seen: string[] = []; + await expect( + loginMetaMuse({ onProgress: m => seen.push(m) }, deps({ platform })), + ).rejects.toThrow(); + expect(seen[0]).toContain("UNSUPPORTED"); + } + }); + for (const [label, over] of [ ["a non-darwin platform", { platform: "linux" }], ["no credential file", { readPointer: async () => null }], From e169857b52ac76f156389f9c3009215cdab76671 Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:47:24 +0900 Subject: [PATCH 2/6] fix(meta-muse): stop pointing Windows users at a WSL2 import that also refuses Implementation review caught a contradiction in the message this branch just added: it told Windows users to install the CLI under WSL2 and import there, but WSL2 reports platform linux, which lands on the Linux refusal two lines below. The advice walked the user into a dead end. WSL2 is still named, because it is where the CLI can actually run. What changed is the promise: import stays unavailable on that path until the Linux credential storage is measured. The registry note and the plan doc say the same thing now. A regression test pins it, since this is a wording trap that would be easy to reintroduce. --- .../010_wp1_muse_platform_refusals.md | 10 ++++++++-- src/oauth/meta-muse.ts | 3 ++- src/providers/registry.ts | 2 +- tests/meta-muse-oauth.test.ts | 8 ++++++++ 4 files changed, 19 insertions(+), 4 deletions(-) diff --git a/devlog/_plan/260904_cross_platform_parity/010_wp1_muse_platform_refusals.md b/devlog/_plan/260904_cross_platform_parity/010_wp1_muse_platform_refusals.md index b1f23fadb7..fdcf284c0d 100644 --- a/devlog/_plan/260904_cross_platform_parity/010_wp1_muse_platform_refusals.md +++ b/devlog/_plan/260904_cross_platform_parity/010_wp1_muse_platform_refusals.md @@ -41,11 +41,18 @@ keep refusing Linux until a real pointer is measured. if (platform === "win32") { throw new Error( "Meta does not ship a native Windows Muse Code CLI, so there is no Windows credential to import. " - + "Install the CLI inside WSL2 and import there, or use the meta-model provider with your own key (META_MODEL_API_KEY).", + + "The CLI runs under WSL2, but importing from there is not available either until its Linux credential storage is measured. " + + "Use the meta-model provider with your own key (META_MODEL_API_KEY).", ); } ``` +The WSL2 sentence must NOT read as "install it there and import". Implementation +review round 1 caught exactly that: WSL2 reports `linux`, so that advice walks the +user straight into the Linux refusal below. Name WSL2 because it is where the CLI +can run, then say plainly that import stays unavailable until the Linux storage +is measured. + ### 2. Linux gets a true refusal, not a guess ```ts @@ -100,4 +107,3 @@ Ship a Linux credential reader. `050` records the measurement that would unblock it: a real `~/.config/muse/auth.json` from a Linux install, with its exact `storage` value and, if the secret is file-backed, the exact field naming the file. That is a measurement task, not an implementation guess. - diff --git a/src/oauth/meta-muse.ts b/src/oauth/meta-muse.ts index 6005d21391..8a14fee9e8 100644 --- a/src/oauth/meta-muse.ts +++ b/src/oauth/meta-muse.ts @@ -139,7 +139,8 @@ export async function loginMetaMuse( if (platform === "win32") { throw new Error( "Meta does not ship a native Windows Muse Code CLI, so there is no Windows credential to import. " - + "Install the CLI inside WSL2 and import there, or use the meta-model provider with your own key (META_MODEL_API_KEY).", + + "The CLI runs under WSL2, but importing from there is not available either until its Linux credential storage is measured. " + + "Use the meta-model provider with your own key (META_MODEL_API_KEY).", ); } if (platform !== "darwin") { diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 8a5d8a3457..5a9e907584 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -1540,7 +1540,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ modelInputModalities: Object.fromEntries(META_MUSE_MODELS.map(id => [id, ["text", "image"] as ["text", "image"]])), modelReasoningEfforts: Object.fromEntries(META_MUSE_MODELS.map(id => [id, META_MUSE_REASONING_EFFORTS])), modelReasoningEffortMap: Object.fromEntries(META_MUSE_MODELS.map(id => [id, META_MUSE_REASONING_EFFORT_MAP])), - note: "Reuses the API key the Muse Code CLI stores after `muse login` (macOS only; requires the CLI installed and signed in). Meta ships no native Windows CLI, so there is nothing to import there; run the CLI under WSL2 instead. The Linux credential storage has not been measured, so importing on Linux is refused rather than guessed. Meta scopes that credential to the Muse Code CLI, so this is an UNSUPPORTED use: Meta does not authorize subscription coverage outside its own CLI, how these calls settle is not observable from the API, and you should treat every call as billable against your account. The imported key is copied into OpenCodex's auth store. OpenCodex reads Meta's subscription windows from streaming responses and shows the last observed value with its age; there is no endpoint to query them on demand, so refreshing one requires another streaming turn, and translated (non-passthrough) turns report none. Rate limits apply per team, not per key. For a supported path use the meta-model provider with your own key (export it as META_MODEL_API_KEY).", + note: "Reuses the API key the Muse Code CLI stores after `muse login` (macOS only; requires the CLI installed and signed in). Meta ships no native Windows CLI, so there is nothing to import there. The CLI runs on Linux and under WSL2, but that credential storage has not been measured, so importing on either is refused rather than guessed. Meta scopes that credential to the Muse Code CLI, so this is an UNSUPPORTED use: Meta does not authorize subscription coverage outside its own CLI, how these calls settle is not observable from the API, and you should treat every call as billable against your account. The imported key is copied into OpenCodex's auth store. OpenCodex reads Meta's subscription windows from streaming responses and shows the last observed value with its age; there is no endpoint to query them on demand, so refreshing one requires another streaming turn, and translated (non-passthrough) turns report none. Rate limits apply per team, not per key. For a supported path use the meta-model provider with your own key (export it as META_MODEL_API_KEY).", }, { id: "umans", diff --git a/tests/meta-muse-oauth.test.ts b/tests/meta-muse-oauth.test.ts index 1a74f3458f..a9200ce74c 100644 --- a/tests/meta-muse-oauth.test.ts +++ b/tests/meta-muse-oauth.test.ts @@ -186,6 +186,14 @@ describe("meta-muse credential import", () => { await expect(loginMetaMuse({}, deps({ platform: "win32" }))).rejects.not.toThrow(/Keychain/); }); + // WSL2 reports `linux`, so telling a Windows user to "import there" would walk + // them straight into the Linux refusal. Naming WSL2 is fine; promising an import + // from it is not. + test("the Windows refusal does not promise that importing under WSL2 works", async () => { + await expect(loginMetaMuse({}, deps({ platform: "win32" }))).rejects.not.toThrow(/import there/); + await expect(loginMetaMuse({}, deps({ platform: "win32" }))).rejects.toThrow(/not available/); + }); + test("the Linux refusal names the unmeasured storage, not the Keychain", async () => { await expect(loginMetaMuse({}, deps({ platform: "linux" }))).rejects.toThrow(/not been measured/); await expect(loginMetaMuse({}, deps({ platform: "linux" }))).rejects.toThrow(/META_MODEL_API_KEY/); From dfe23d468ac3fc15a26be5fe5ac6905eca31a196 Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:53:47 +0900 Subject: [PATCH 3/6] feat(meta-muse): accept a pasted Muse Code key on Windows and Linux Refusing these platforms was reporting a limitation of our importer as a limitation of the platform. Meta ships no Windows CLI and its Linux credential storage has never been measured, so there is nothing to read from disk -- but the same API key is visible in Meta's own developer console, so the user is not actually out of options. Off darwin the login now resolves with instructions and a paste field, the same shape kiro.ts uses when no local token exists. A host with no paste surface still refuses, and the message names where to get the key. The pasted key is not a weaker credential. Import and paste now share one validator: the same LLM| grammar check and the same live call against the Model API. A key that skipped either would differ from an imported one only by 401ing mid-session. It carries source manual and no email, since there is no pointer to read one from. The consent warning still fires first on every path. --- src/oauth/meta-muse.ts | 108 +++++++++++++++++++++++++++------- src/providers/registry.ts | 2 +- tests/meta-muse-oauth.test.ts | 70 ++++++++++++++++------ 3 files changed, 140 insertions(+), 40 deletions(-) diff --git a/src/oauth/meta-muse.ts b/src/oauth/meta-muse.ts index 8a14fee9e8..97ef83bf6e 100644 --- a/src/oauth/meta-muse.ts +++ b/src/oauth/meta-muse.ts @@ -114,6 +114,44 @@ async function defaultReadKeychain(signal?: AbortSignal): Promise const INSTALL_HINT = "Install it from https://dev.meta.ai/install.sh, run `muse login`, then retry."; +/** + * Where a user without the CLI gets a key by hand. + * + * The Muse Code API key is visible in Meta's own developer console, so a host with no + * CLI is not out of options ??it is out of an IMPORT path. That distinction is the whole + * reason this branch exists: refusing a platform because our importer cannot read its + * store, while the vendor hands the same key to the user in a browser, is a limitation + * of the importer being reported as a limitation of the platform. + */ +const MANUAL_KEY_URL = "https://dev.meta.ai"; + +/** + * Accept a hand-entered Muse Code API key. + * + * Every guarantee the import path makes still holds here, because they are enforced + * BELOW this function rather than inside it: the same `LLM|` grammar check, the same + * live validation against the Model API, and the same consent warning, which has + * already fired before any of this runs. What is missing is only the pointer, so the + * credential carries no email and `source` is `manual` rather than `local-cli`. + */ +async function manualKeyCredential( + ctrl: OAuthController, + platformLabel: string, +): Promise { + if (!ctrl.onManualCodeInput) return null; + // Resolve the login flow first so the GUI renders its paste field; otherwise + // onManualCodeInput blocks and the dashboard never sees a response (kiro.ts:405). + ctrl.onAuth?.({ + url: MANUAL_KEY_URL, + instructions: + `Meta ships no Muse Code CLI for ${platformLabel}, so there is no credential to import. ` + + `Sign in at ${MANUAL_KEY_URL}, copy your Muse Code API key, and paste it below.`, + }); + ctrl.onProgress?.(`Paste a Muse Code API key from ${MANUAL_KEY_URL} (it starts with "LLM|").`); + const pasted = (await ctrl.onManualCodeInput()).trim(); + return pasted.length > 0 ? pasted : null; +} + function normalizedEmail(value: unknown): string | undefined { if (typeof value !== "string") return undefined; const trimmed = value.trim().toLowerCase(); @@ -133,22 +171,22 @@ export async function loginMetaMuse( ctrl.onProgress?.(CONSENT_WARNING); const platform = deps.platform ?? process.platform; - // Two refusals rather than one, because the reasons are different and the old - // shared message was wrong on Windows: it blamed the Keychain when the actual - // reason is that Meta ships no Windows build of the CLI at all. - if (platform === "win32") { - throw new Error( - "Meta does not ship a native Windows Muse Code CLI, so there is no Windows credential to import. " - + "The CLI runs under WSL2, but importing from there is not available either until its Linux credential storage is measured. " - + "Use the meta-model provider with your own key (META_MODEL_API_KEY).", - ); - } + // Off darwin there is no store this importer can read: Meta ships no native Windows + // CLI, and the Linux credential shape has never been measured. That is a limitation + // of the IMPORT, not of the platform ??the same key is visible in Meta's console ?? + // so these hosts get a paste field instead of a dead end. The pasted key then goes + // through the identical grammar check and live validation as an imported one. if (platform !== "darwin") { - throw new Error( - "Meta Muse Code import is verified only on macOS. The Muse CLI runs on Linux, but the credential storage " - + "it writes there has not been measured, and importing an unverified credential shape is refused. " - + "Use the meta-model provider with your own key (META_MODEL_API_KEY).", - ); + const label = platform === "win32" ? "Windows" : "this platform"; + const pasted = await manualKeyCredential(ctrl, label); + if (pasted === null) { + throw new Error( + `Meta ships no Muse Code CLI for ${label}, so there is no credential to import. ` + + `Sign in at ${MANUAL_KEY_URL}, copy your Muse Code API key, and paste it when prompted, ` + + "or use the meta-model provider with your own key (META_MODEL_API_KEY).", + ); + } + return await validatedMetaMuseCredential(pasted, ctrl, deps, undefined, "manual"); } const pointerRaw = await (deps.readPointer ?? defaultReadPointer)(); @@ -189,14 +227,40 @@ export async function loginMetaMuse( } // access_token is present but 401s against the Model API (003 §B) — never fall back to it. - const apiKey = sanitizeApiKeyValue(secret.api_key); + return await validatedMetaMuseCredential( + secret.api_key, + ctrl, + deps, + normalizedEmail(meta.user_email), + "local-cli", + ); +} + +/** + * The single gate every credential passes, imported or pasted. + * + * Both paths share it deliberately. A pasted key that skipped the grammar check or the + * live validation would be a weaker credential wearing the same provider id, and the + * difference would surface only as a 401 in the middle of a session. + */ +async function validatedMetaMuseCredential( + candidate: unknown, + ctrl: OAuthController, + deps: MuseImportDeps, + email: string | undefined, + source: "local-cli" | "manual", +): Promise { + const origin = source === "manual" ? "pasted" : "stored"; + const retry = source === "manual" + ? `Copy it again from ${MANUAL_KEY_URL}.` + : "Run `muse login` again."; + const apiKey = sanitizeApiKeyValue(candidate); if (!apiKey) { - throw new Error("The Muse Code Keychain entry carries no usable API key. Run `muse login` again."); + throw new Error(`The ${origin} Muse Code credential carries no usable API key. ${retry}`); } if (!/^LLM\|\d+\|[A-Za-z0-9_-]{10,}$/.test(apiKey)) { - throw new Error("The Muse Code credential is not in the expected Meta API key format. Run `muse login` again."); + throw new Error(`The ${origin} Muse Code credential is not in the expected Meta API key format. ${retry}`); } - ctrl.onProgress?.("Validating the imported Meta credential…"); const fetchImpl = deps.fetchImpl ?? fetch; // ctrl.signal is OPTIONAL and the CLI controller supplies none: AbortSignal.any([undefined]) @@ -216,7 +280,7 @@ export async function loginMetaMuse( } if (!response.ok) { throw new Error( - `The Muse Code credential was rejected by the Meta Model API (HTTP ${response.status}). Run \`muse login\` again.`, + `The Muse Code credential was rejected by the Meta Model API (HTTP ${response.status}). ${retry}`, ); } @@ -227,8 +291,8 @@ export async function loginMetaMuse( expires: Number.MAX_SAFE_INTEGER, // `email`, not `accountId`: the account list masks email for display, and store.ts // already falls back to it for slot identity, so multi-account still works. - ...(normalizedEmail(meta.user_email) ? { email: normalizedEmail(meta.user_email) } : {}), - source: "local-cli", + ...(email ? { email } : {}), + source, }; } diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 5a9e907584..f38321019f 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -1540,7 +1540,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ modelInputModalities: Object.fromEntries(META_MUSE_MODELS.map(id => [id, ["text", "image"] as ["text", "image"]])), modelReasoningEfforts: Object.fromEntries(META_MUSE_MODELS.map(id => [id, META_MUSE_REASONING_EFFORTS])), modelReasoningEffortMap: Object.fromEntries(META_MUSE_MODELS.map(id => [id, META_MUSE_REASONING_EFFORT_MAP])), - note: "Reuses the API key the Muse Code CLI stores after `muse login` (macOS only; requires the CLI installed and signed in). Meta ships no native Windows CLI, so there is nothing to import there. The CLI runs on Linux and under WSL2, but that credential storage has not been measured, so importing on either is refused rather than guessed. Meta scopes that credential to the Muse Code CLI, so this is an UNSUPPORTED use: Meta does not authorize subscription coverage outside its own CLI, how these calls settle is not observable from the API, and you should treat every call as billable against your account. The imported key is copied into OpenCodex's auth store. OpenCodex reads Meta's subscription windows from streaming responses and shows the last observed value with its age; there is no endpoint to query them on demand, so refreshing one requires another streaming turn, and translated (non-passthrough) turns report none. Rate limits apply per team, not per key. For a supported path use the meta-model provider with your own key (export it as META_MODEL_API_KEY).", + note: "Reuses the API key the Muse Code CLI stores after `muse login` (macOS only; requires the CLI installed and signed in). Meta ships no native Windows CLI and the Linux credential storage has not been measured, so on those platforms OpenCodex asks you to paste the Muse Code API key from https://dev.meta.ai instead of importing one; a pasted key faces the same format check and live validation as an imported one. Meta scopes that credential to the Muse Code CLI, so this is an UNSUPPORTED use: Meta does not authorize subscription coverage outside its own CLI, how these calls settle is not observable from the API, and you should treat every call as billable against your account. The imported key is copied into OpenCodex's auth store. OpenCodex reads Meta's subscription windows from streaming responses and shows the last observed value with its age; there is no endpoint to query them on demand, so refreshing one requires another streaming turn, and translated (non-passthrough) turns report none. Rate limits apply per team, not per key. For a supported path use the meta-model provider with your own key (export it as META_MODEL_API_KEY).", }, { id: "umans", diff --git a/tests/meta-muse-oauth.test.ts b/tests/meta-muse-oauth.test.ts index a9200ce74c..9bf8d8fe1b 100644 --- a/tests/meta-muse-oauth.test.ts +++ b/tests/meta-muse-oauth.test.ts @@ -180,27 +180,62 @@ describe("meta-muse credential import", () => { // The old refusal blamed the macOS Keychain on EVERY platform. On Windows that // is simply false — Meta ships no Windows CLI — and a user who believed it // would go looking for a credential store instead of WSL2. - test("the Windows refusal names WSL2 and does not blame the Keychain", async () => { - await expect(loginMetaMuse({}, deps({ platform: "win32" }))).rejects.toThrow(/WSL2/); - await expect(loginMetaMuse({}, deps({ platform: "win32" }))).rejects.toThrow(/META_MODEL_API_KEY/); - await expect(loginMetaMuse({}, deps({ platform: "win32" }))).rejects.not.toThrow(/Keychain/); + // Off darwin there is no store to import from, but Meta shows the same key in its + // own console — so these hosts get a paste field, not a dead end. + test("Windows offers a paste field pointing at Meta's console", async () => { + const seen: string[] = []; + let prompted = false; + const creds = await loginMetaMuse( + { + onAuth: info => { seen.push(info.instructions ?? ""); seen.push(info.url); }, + onManualCodeInput: async () => { prompted = true; return CANARY; }, + }, + deps({ platform: "win32" }), + ); + expect(prompted).toBe(true); + expect(creds.access).toBe(CANARY); + expect(creds.source).toBe("manual"); + expect(seen.join(" ")).toContain("dev.meta.ai"); + }); + + test("Linux offers the same paste field", async () => { + const creds = await loginMetaMuse( + { onManualCodeInput: async () => CANARY }, + deps({ platform: "linux" }), + ); + expect(creds.access).toBe(CANARY); + expect(creds.refresh).toBe(CANARY); + expect(creds.source).toBe("manual"); + }); + + // The paste path must not be a weaker credential wearing the same provider id. + test("a pasted key still faces the grammar check and the live validation", async () => { + await expect(loginMetaMuse( + { onManualCodeInput: async () => "not-a-meta-key" }, + deps({ platform: "win32" }), + )).rejects.toThrow(/expected Meta API key format/); + + const denied = (async () => new Response("nope", { status: 401 })) as unknown as typeof fetch; + await expect(loginMetaMuse( + { onManualCodeInput: async () => CANARY }, + deps({ platform: "win32", fetchImpl: denied }), + )).rejects.toThrow(/401/); }); - // WSL2 reports `linux`, so telling a Windows user to "import there" would walk - // them straight into the Linux refusal. Naming WSL2 is fine; promising an import - // from it is not. - test("the Windows refusal does not promise that importing under WSL2 works", async () => { - await expect(loginMetaMuse({}, deps({ platform: "win32" }))).rejects.not.toThrow(/import there/); - await expect(loginMetaMuse({}, deps({ platform: "win32" }))).rejects.toThrow(/not available/); + test("a host with no paste surface still refuses with an actionable message", async () => { + await expect(loginMetaMuse({}, deps({ platform: "win32" }))).rejects.toThrow(/dev\.meta\.ai/); + await expect(loginMetaMuse({}, deps({ platform: "win32" }))).rejects.toThrow(/META_MODEL_API_KEY/); + await expect(loginMetaMuse({}, deps({ platform: "linux" }))).rejects.toThrow(/dev\.meta\.ai/); }); - test("the Linux refusal names the unmeasured storage, not the Keychain", async () => { - await expect(loginMetaMuse({}, deps({ platform: "linux" }))).rejects.toThrow(/not been measured/); - await expect(loginMetaMuse({}, deps({ platform: "linux" }))).rejects.toThrow(/META_MODEL_API_KEY/); - await expect(loginMetaMuse({}, deps({ platform: "linux" }))).rejects.not.toThrow(/Keychain/); + test("an empty paste refuses instead of storing a blank credential", async () => { + await expect(loginMetaMuse( + { onManualCodeInput: async () => " " }, + deps({ platform: "win32" }), + )).rejects.toThrow(/no credential to import/); }); - test("the consent warning still precedes an unsupported-platform refusal", async () => { + test("the consent warning still precedes every unsupported-platform path", async () => { for (const platform of ["win32", "linux"] as const) { const seen: string[] = []; await expect( @@ -209,9 +244,10 @@ describe("meta-muse credential import", () => { expect(seen[0]).toContain("UNSUPPORTED"); } }); - for (const [label, over] of [ - ["a non-darwin platform", { platform: "linux" }], + // Non-darwin without a paste surface: still a refusal, and the table asserts it + // stays actionable rather than silently succeeding. + ["a non-darwin platform with no paste surface", { platform: "linux" }], ["no credential file", { readPointer: async () => null }], ["a malformed credential file", { readPointer: async () => "{not json" }], ["no signed-in Meta account", { readPointer: async () => JSON.stringify({ providers: {} }) }], From 4581e3e97635e89de9acc7f68cb579121c311148 Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:02:20 +0900 Subject: [PATCH 4/6] fix(meta-muse): correct the paste path's messaging, refresh label, and docs Implementation review found five real problems in the paste path. The instructions told Linux users Meta ships no CLI for their platform. It does; only its credential storage is unmeasured. The two platforms are unavailable for different reasons and now say which. refreshMetaMuseToken hardcoded source local-cli, and merged() in index.ts only preserves a source that is not local-cli, so a pasted key would be relabeled as imported on its first refresh. It now takes the stored credential and keeps manual. Extracting the shared validator had quietly reworded two macOS errors. A refactor that rewrites a user-facing string is a behavior change in disguise, so the Keychain wording is restored verbatim. The no-failure-path-echoes-the-credential test only ever ran the macOS import path, so its name overclaimed as soon as a second route existed. It now covers imported and pasted origins against both a 401 and a dead socket, and asserts the canary is absent from onAuth as well. The providers guide, the registry note and its decision record all still described the provider as import-only and macOS-only. --- .../000_problem_model.md | 5 +- .../002_muse_cli_storage_measurement.md | 9 +- .../010_wp1_muse_manual_key.md | 97 ++++++++++++++++ .../010_wp1_muse_platform_refusals.md | 109 ------------------ .../050_followups.md | 2 +- .../src/content/docs/guides/providers.md | 17 ++- src/oauth/meta-muse.ts | 50 +++++--- src/providers/registry.ts | 4 +- tests/meta-muse-oauth.test.ts | 62 ++++++++-- 9 files changed, 208 insertions(+), 147 deletions(-) create mode 100644 devlog/_plan/260904_cross_platform_parity/010_wp1_muse_manual_key.md delete mode 100644 devlog/_plan/260904_cross_platform_parity/010_wp1_muse_platform_refusals.md diff --git a/devlog/_plan/260904_cross_platform_parity/000_problem_model.md b/devlog/_plan/260904_cross_platform_parity/000_problem_model.md index 8b83e2e755..bf0793f843 100644 --- a/devlog/_plan/260904_cross_platform_parity/000_problem_model.md +++ b/devlog/_plan/260904_cross_platform_parity/000_problem_model.md @@ -28,8 +28,9 @@ claim is mostly FALSE and the exceptions are concentrated: So this unit is not a porting sweep. After four audit rounds it is three phases, each its own PR in a stacked chain: -- **wp1** - `meta-muse` refuses on Windows and Linux with accurate reasons - instead of a false macOS-Keychain one. +- **wp1** - `meta-muse` accepts a pasted Muse Code key on Windows and Linux. The + key is visible in Meta's own console, so refusing those platforms reported a + limitation of our importer as a limitation of the platform. - **wp2** - a platform-support reference page, so the capabilities that stay macOS-only have a written answer rather than a silent dead end. - **wp3** - the Windows identity decode fix, the one defect proven to exist in diff --git a/devlog/_plan/260904_cross_platform_parity/002_muse_cli_storage_measurement.md b/devlog/_plan/260904_cross_platform_parity/002_muse_cli_storage_measurement.md index 709dc72697..4d8312626c 100644 --- a/devlog/_plan/260904_cross_platform_parity/002_muse_cli_storage_measurement.md +++ b/devlog/_plan/260904_cross_platform_parity/002_muse_cli_storage_measurement.md @@ -68,11 +68,8 @@ the secret is file-backed, the field naming the file. ## What wp1 must therefore deliver -- Windows: replace the misleading macOS-Keychain refusal with an accurate one - that names WSL2 and the supported `META_MODEL_API_KEY` alternative. No WSL2 - pointer read: reachability was never measured, and a refusal that tells the - truth is a fix while a guess is not. -- Linux: a refusal naming the unmeasured storage rather than the Keychain. No - reader until a real pointer is measured. +- Windows and Linux: a manual paste field, because the same key is visible in + Meta's developer console. No reader on either platform until a real pointer is + measured, and no WSL2 bridge: reachability was never measured either. - Neither platform may weaken the ToS consent warning, which is the CLI's only warning surface. diff --git a/devlog/_plan/260904_cross_platform_parity/010_wp1_muse_manual_key.md b/devlog/_plan/260904_cross_platform_parity/010_wp1_muse_manual_key.md new file mode 100644 index 0000000000..9ff0ec6fe8 --- /dev/null +++ b/devlog/_plan/260904_cross_platform_parity/010_wp1_muse_manual_key.md @@ -0,0 +1,97 @@ +# 010 - wp1: meta-muse manual key entry off macOS + +One PR. Base `dev`. Branch `codex/260904-muse-platform-refusals`. +Evidence: `002`. Revised after audit round 1 and implementation review rounds 1-3. + +## Scope change, and why + +The first two drafts of this phase shipped REFUSALS: Windows and Linux would fail +with an accurate message instead of the old inaccurate one blaming the macOS +Keychain. Audit round 1 had already cut a Linux credential READER, because the +pointer interface declares no path or inline-key field and writing against +invented schema is what `meta-muse.ts` refuses to do everywhere else. + +Then the repository owner pointed out the thing both drafts missed: **the Muse +Code API key is visible in Meta's own developer console.** A user on Windows is +not out of options, they are out of an IMPORT path. Refusing the whole platform +because our importer cannot read its store, while the vendor hands the same key +to the user in a browser, reports a limitation of the importer as a limitation of +the platform. + +So this phase now ADDS a capability rather than only correcting prose. + +## The change + +### `src/oauth/meta-muse.ts` + +**Manual entry off darwin.** `loginMetaMuse` calls `manualKeyCredential`, which +fires `ctrl.onAuth` so the GUI renders its paste field, then awaits +`ctrl.onManualCodeInput`. This is the shape `kiro.ts:405` already uses when no +local token exists; resolving the flow first is load-bearing, because otherwise +the await blocks and the dashboard never receives a response. + +**Two reasons, not one.** Windows and Linux are unavailable for different +reasons, and the instructions say which: Meta ships no native Windows build, +while the Linux CLI exists and only its credential storage is unmeasured. +Implementation review round 3 caught the collapsed version telling a Linux user +something false about their own machine. + +**One validator for both origins.** `validatedMetaMuseCredential` does the +`LLM|` grammar check and the live `GET` against the Model API for imported and +pasted keys alike. A pasted key that skipped either would be a weaker credential +wearing the same provider id, and the difference would surface only as a 401 +mid-session. + +The macOS error strings are preserved VERBATIM. Extraction is a refactor, and a +refactor that quietly rewrites a user-facing error is a behavior change in +disguise (review round 3, blocker 3). + +**A host with no paste surface still refuses**, naming `dev.meta.ai` and +`META_MODEL_API_KEY`. An empty paste refuses rather than storing a blank +credential. + +**`refreshMetaMuseToken` preserves the origin.** `merged()` +(`src/oauth/index.ts:754`) keeps any source that is not `local-cli`, so returning +`local-cli` unconditionally would relabel a hand-pasted key as an imported one on +its first refresh. It now takes the existing credential and preserves `manual`. + +### `src/providers/registry.ts` and `docs-site` + +The note, the decision record, and the providers guide all described the provider +as import-only and macOS-only. All three now describe macOS import plus +Windows/Linux paste, and the consent warning says "the key you import or paste". + +## What is deliberately NOT here + +A Linux credential reader. `002` records that no Linux pointer has ever been +observed, and `050` records the four facts one would have to supply. Manual entry +makes that reader a convenience rather than a blocker, which is a better place +for it to sit. + +A WSL2 bridge. Reaching into `\\wsl$\\...` needs distro enumeration and +a reachability probe, neither measured. + +## Tests - `tests/meta-muse-oauth.test.ts` + +1. Windows offers a paste field naming `dev.meta.ai`; the credential returns + `source: "manual"` with `access === refresh`. +2. Linux offers the same field. +3. A pasted key still faces the grammar check, and a 401 still fails the login. +4. A host with no paste surface refuses with an actionable message. +5. An empty paste refuses rather than storing a blank credential. +6. The consent warning precedes every unsupported-platform path. +7. Refresh preserves `manual` and still reports `local-cli` for an imported key. +8. **No failure path echoes the credential**, across four cases: imported and + pasted, each with a rejected (401) and an unreachable (socket) upstream, + asserting absence from the error, the stack, `onProgress`, AND `onAuth`. The + old single-case version only ever exercised the macOS path, so its name + overclaimed once a second route existed (review round 3, blocker 4). + +## Acceptance + +- `bun test tests/meta-muse-oauth.test.ts` green. +- `bun x tsc --noEmit` clean. +- macOS import path byte-identical in behavior, including error wording. +- No credential value reaches a log, an error, or a callback payload. +- CI green. + diff --git a/devlog/_plan/260904_cross_platform_parity/010_wp1_muse_platform_refusals.md b/devlog/_plan/260904_cross_platform_parity/010_wp1_muse_platform_refusals.md deleted file mode 100644 index fdcf284c0d..0000000000 --- a/devlog/_plan/260904_cross_platform_parity/010_wp1_muse_platform_refusals.md +++ /dev/null @@ -1,109 +0,0 @@ -# 010 - wp1: meta-muse honest platform refusals - -One PR. Base `dev`. Branch `codex/260904-muse-platform-refusals`. -Evidence: `002`. Revised after audit round 1 (FAIL, blocker 6). - -## What the audit changed - -The first draft accepted `storage: "file"` and "the path the pointer names". Both -were INVENTED. `MusePointer` (`src/oauth/meta-muse.ts:58-60`) declares only -`mechanism`, `storage` and `user_email` - there is no path field and no inline-key -field, and `002` itself records that no Linux pointer has ever been observed. -Writing a reader for fields nobody has seen is precisely the unverified-credential -path the module refuses everywhere else. - -The XDG change was also wrong as drafted: making `XDG_CONFIG_HOME` authoritative -on ALL platforms would redirect the MEASURED macOS path whenever that variable -happens to be set, with no evidence the macOS CLI honors it. - -So this phase ships what is actually provable: refusals that tell the truth. - -## The change in one sentence - -Replace the single `platform !== "darwin"` throw, which blames the macOS Keychain -on every platform, with per-platform refusals that state the real reason - and -keep refusing Linux until a real pointer is measured. - -## What must not change - -- The consent warning fires before any credential read (`meta-muse.ts:128-143`, - audit-confirmed: it precedes platform selection). -- Import-only. Nothing spawns `muse login`. -- The `LLM|` grammar check, the `access_token` prohibition, the refusal of any - unmeasured shape. -- `refreshMetaMuseToken` does not re-read storage. - -## MODIFY `src/oauth/meta-muse.ts` - -### 1. Windows gets a true refusal - -```ts -if (platform === "win32") { - throw new Error( - "Meta does not ship a native Windows Muse Code CLI, so there is no Windows credential to import. " - + "The CLI runs under WSL2, but importing from there is not available either until its Linux credential storage is measured. " - + "Use the meta-model provider with your own key (META_MODEL_API_KEY).", - ); -} -``` - -The WSL2 sentence must NOT read as "install it there and import". Implementation -review round 1 caught exactly that: WSL2 reports `linux`, so that advice walks the -user straight into the Linux refusal below. Name WSL2 because it is where the CLI -can run, then say plainly that import stays unavailable until the Linux storage -is measured. - -### 2. Linux gets a true refusal, not a guess - -```ts -if (platform !== "darwin") { - throw new Error( - "Meta Muse Code import is verified only on macOS. The Muse CLI runs on Linux, but the credential " - + "storage it writes there has not been measured, and importing an unverified credential shape is refused. " - + "Use the meta-model provider with your own key (META_MODEL_API_KEY).", - ); -} -``` - -This is a real fix even though Linux still refuses. Today's message tells a Linux -user their Keychain is the problem, which is false and sends them nowhere. The new -message states what is actually true and names the path that works. - -### 3. XDG lookup is Linux-only and inert for now - -Deferred with the Linux reader. When `002` is updated with a measured pointer, -the resolver lands with it and is gated to non-darwin platforms so the measured -macOS path cannot move. - -## MODIFY `src/providers/registry.ts` - -The `meta-muse` `note` says "macOS only". Make it precise: requires the Muse Code -CLI signed in on macOS; not available on Windows (no native CLI); Linux import is -not yet verified. No other field changes. - -## Tests in `tests/meta-muse-oauth.test.ts` - -The existing table at `:181` already asserts `{ platform: "linux" }` rejects, so -that case stays green. Added: - -1. win32 refusal message names WSL2 and `META_MODEL_API_KEY`, and does NOT claim - the macOS Keychain is the reason. -2. linux refusal message names the unmeasured storage and `META_MODEL_API_KEY`, - and does NOT claim the macOS Keychain is the reason. -3. The consent warning is emitted before the throw on both refusal paths. - -Focused run: `bun test tests/meta-muse-oauth.test.ts`. - -## Acceptance - -- `bun x tsc --noEmit` clean. -- The focused file passes; no existing case changes behavior. -- No code reads a pointer field that has not been observed. -- CI green. - -## What this phase deliberately does not do - -Ship a Linux credential reader. `050` records the measurement that would unblock -it: a real `~/.config/muse/auth.json` from a Linux install, with its exact -`storage` value and, if the secret is file-backed, the exact field naming the -file. That is a measurement task, not an implementation guess. diff --git a/devlog/_plan/260904_cross_platform_parity/050_followups.md b/devlog/_plan/260904_cross_platform_parity/050_followups.md index 74f29c872e..f1bdc2e820 100644 --- a/devlog/_plan/260904_cross_platform_parity/050_followups.md +++ b/devlog/_plan/260904_cross_platform_parity/050_followups.md @@ -70,7 +70,7 @@ already scoped it. ## WSL2 credential bridge for meta-muse -`010` refuses on Windows with an accurate message instead of reading a WSL2 +`010` offers manual key entry on Windows instead of reading a WSL2 pointer at `\\\\wsl$\\\\home\\\\.config\\muse\\auth.json`. Doing that properly needs distro enumeration, Linux-user mapping, and a reachability probe, none of which were measured. A guess would ship an unverified credential path, diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 7409cae523..1b87c03ff2 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -458,16 +458,21 @@ material off it. Muse Spark is also reachable through resellers, with a narrower `command-code` carries both tiers, while `opencode-go` serves only `muse-spark-1.3-contributor`. -**Meta Muse Code (`meta-muse`).** If you already use the Muse Code CLI, this imports the -API key it stored after `muse login` instead of asking you to provision a second one. -macOS only — the CLI keeps that key in the macOS Keychain, and no other platform's -storage has been verified. OpenCodex never launches the CLI: if no credential is present -it tells you to run `muse login` yourself. +**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. **Read this before enabling it.** Meta scopes that credential to the Muse Code CLI, so using it here is an *unsupported* path. Meta does not authorize subscription coverage outside its own client, how these calls settle is not observable from the API, and you -should treat every call as billable against your account. The imported key is copied into +should treat every call as billable against your account. The key, imported or pasted, is copied into OpenCodex's auth store (`~/.opencodex/auth.json`, mode 0600) like every other OAuth credential. The dashboard shows a Terms-of-Service warning before the first login and before any reauthentication — the same treatment Anthropic and Google Antigravity get. diff --git a/src/oauth/meta-muse.ts b/src/oauth/meta-muse.ts index 97ef83bf6e..cc2f920ed5 100644 --- a/src/oauth/meta-muse.ts +++ b/src/oauth/meta-muse.ts @@ -45,7 +45,7 @@ const CONSENT_WARNING = [ "Meta scopes the Muse Code credential to the Muse Code CLI.", "Using it here is UNSUPPORTED: Meta does not authorize subscription coverage outside its own CLI,", "how these calls settle is not observable from the API, and you should treat every call as billable.", - "The imported key is copied into OpenCodex's auth store (~/.opencodex/auth.json, 0600).", + "The key you import or paste is copied into OpenCodex's auth store (~/.opencodex/auth.json, 0600).", "Supported alternative: the meta-model provider with your own key (META_MODEL_API_KEY).", ].join(" "); @@ -136,7 +136,7 @@ const MANUAL_KEY_URL = "https://dev.meta.ai"; */ async function manualKeyCredential( ctrl: OAuthController, - platformLabel: string, + reason: string, ): Promise { if (!ctrl.onManualCodeInput) return null; // Resolve the login flow first so the GUI renders its paste field; otherwise @@ -144,8 +144,7 @@ async function manualKeyCredential( ctrl.onAuth?.({ url: MANUAL_KEY_URL, instructions: - `Meta ships no Muse Code CLI for ${platformLabel}, so there is no credential to import. ` - + `Sign in at ${MANUAL_KEY_URL}, copy your Muse Code API key, and paste it below.`, + `${reason} Sign in at ${MANUAL_KEY_URL}, copy your Muse Code API key, and paste it below.`, }); ctrl.onProgress?.(`Paste a Muse Code API key from ${MANUAL_KEY_URL} (it starts with "LLM|").`); const pasted = (await ctrl.onManualCodeInput()).trim(); @@ -177,12 +176,18 @@ export async function loginMetaMuse( // so these hosts get a paste field instead of a dead end. The pasted key then goes // through the identical grammar check and live validation as an imported one. if (platform !== "darwin") { - const label = platform === "win32" ? "Windows" : "this platform"; - const pasted = await manualKeyCredential(ctrl, label); + // The two platforms are unavailable for DIFFERENT reasons, and saying so matters: + // Meta ships no Windows build at all, while the Linux CLI exists and only its + // credential storage is unmeasured. Collapsing them into "no CLI here" would tell + // a Linux user something false about their own machine. + const reason = platform === "win32" + ? "Meta ships no native Windows Muse Code CLI, so there is no credential to import." + : "The Muse Code CLI runs here, but where it stores its credential has not been measured, " + + "so importing one is refused rather than guessed."; + const pasted = await manualKeyCredential(ctrl, reason); if (pasted === null) { throw new Error( - `Meta ships no Muse Code CLI for ${label}, so there is no credential to import. ` - + `Sign in at ${MANUAL_KEY_URL}, copy your Muse Code API key, and paste it when prompted, ` + `${reason} Sign in at ${MANUAL_KEY_URL}, copy your Muse Code API key, and paste it when prompted, ` + "or use the meta-model provider with your own key (META_MODEL_API_KEY).", ); } @@ -250,16 +255,21 @@ async function validatedMetaMuseCredential( email: string | undefined, source: "local-cli" | "manual", ): Promise { - const origin = source === "manual" ? "pasted" : "stored"; const retry = source === "manual" ? `Copy it again from ${MANUAL_KEY_URL}.` : "Run `muse login` again."; const apiKey = sanitizeApiKeyValue(candidate); if (!apiKey) { - throw new Error(`The ${origin} Muse Code credential carries no usable API key. ${retry}`); + // 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(`The ${origin} Muse Code credential is not in the expected Meta API key format. ${retry}`); + 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."); } ctrl.onProgress?.("Validating the imported Meta credential…"); const fetchImpl = deps.fetchImpl ?? fetch; @@ -303,8 +313,22 @@ async function validatedMetaMuseCredential( * the slot being refreshed, so if the user ran `muse login` with a DIFFERENT account in * between, a re-import would silently overwrite one stored identity with another. Only an * explicit login may import. + * + * It also does not assert a source. `merged()` in index.ts keeps the previous source for + * anything that is not `local-cli`, so returning `local-cli` here would relabel a + * hand-pasted key as an imported one on its first refresh and misreport where the + * credential came from. */ -export async function refreshMetaMuseToken(apiKey: string): Promise { +export async function refreshMetaMuseToken( + apiKey: string, + _signal?: AbortSignal, + credential?: OAuthCredentials, +): Promise { if (!apiKey) throw new Error("Meta Muse Code API key missing; run `ocx login meta-muse`"); - return { access: apiKey, refresh: apiKey, expires: Number.MAX_SAFE_INTEGER, source: "local-cli" }; + return { + access: apiKey, + refresh: apiKey, + expires: Number.MAX_SAFE_INTEGER, + source: credential?.source === "manual" ? "manual" : "local-cli", + }; } diff --git a/src/providers/registry.ts b/src/providers/registry.ts index f38321019f..fd00ba8277 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -1519,7 +1519,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ - 목적과 의도: Let an operator who already signed the Muse Code CLI in reach Muse Spark with that credential, instead of provisioning a second key. - 기존 구현 및 제약 조건: The CLI stores a pointer at ~/.config/muse/auth.json and the secret in the macOS Keychain (ai.meta.dev.credentials/meta). Measured: the OAuth access_token 401s on /v1/models while the sibling api_key returns 200, so the usable artifact is a static key, not a refreshable token. - 검토한 주요 대안: spawn `muse login` and poll; reimplement Meta's device grant; treat it as a second key preset; ship nothing. - - 선택한 방식: an import-only, macOS-only OAuth provider that reads the existing credential, validates it once, and never spawns or reimplements anything. + - 선택한 방식: an OAuth provider that imports the existing credential on macOS and accepts a pasted key elsewhere, validates either once, and never spawns or reimplements anything. - 다른 대안 대신 이 방식을 선택한 이유: `muse login` has no non-interactive mode, so a spawned child could outlive cancellation, and polling for the pointer file is satisfied instantly by the one already on disk — reimporting the OLD account on a force-login. Reimplementing the grant would mean guessing a client id the vendor does not publish. - 장점, 단점 및 영향: no new credential to provision, and the id is distinct from meta-model so neither pool contaminates the other. Meta scopes this credential to its own CLI, so the provider carries a HIGH_RISK ToS warning, a CLI-side warning before any read, and a note that says plainly what is unsupported. */ @@ -1540,7 +1540,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ modelInputModalities: Object.fromEntries(META_MUSE_MODELS.map(id => [id, ["text", "image"] as ["text", "image"]])), modelReasoningEfforts: Object.fromEntries(META_MUSE_MODELS.map(id => [id, META_MUSE_REASONING_EFFORTS])), modelReasoningEffortMap: Object.fromEntries(META_MUSE_MODELS.map(id => [id, META_MUSE_REASONING_EFFORT_MAP])), - note: "Reuses the API key the Muse Code CLI stores after `muse login` (macOS only; requires the CLI installed and signed in). Meta ships no native Windows CLI and the Linux credential storage has not been measured, so on those platforms OpenCodex asks you to paste the Muse Code API key from https://dev.meta.ai instead of importing one; a pasted key faces the same format check and live validation as an imported one. Meta scopes that credential to the Muse Code CLI, so this is an UNSUPPORTED use: Meta does not authorize subscription coverage outside its own CLI, how these calls settle is not observable from the API, and you should treat every call as billable against your account. The imported key is copied into OpenCodex's auth store. OpenCodex reads Meta's subscription windows from streaming responses and shows the last observed value with its age; there is no endpoint to query them on demand, so refreshing one requires another streaming turn, and translated (non-passthrough) turns report none. Rate limits apply per team, not per key. For a supported path use the meta-model provider with your own key (export it as META_MODEL_API_KEY).", + note: "Reuses the API key the Muse Code CLI stores after `muse login` (macOS only; requires the CLI installed and signed in). Meta ships no native Windows CLI and the Linux credential storage has not been measured, so on those platforms OpenCodex asks you to paste the Muse Code API key from https://dev.meta.ai instead of importing one; a pasted key faces the same format check and live validation as an imported one. Meta scopes that credential to the Muse Code CLI, so this is an UNSUPPORTED use: Meta does not authorize subscription coverage outside its own CLI, how these calls settle is not observable from the API, and you should treat every call as billable against your account. The key, imported or pasted, is copied into OpenCodex's auth store. OpenCodex reads Meta's subscription windows from streaming responses and shows the last observed value with its age; there is no endpoint to query them on demand, so refreshing one requires another streaming turn, and translated (non-passthrough) turns report none. Rate limits apply per team, not per key. For a supported path use the meta-model provider with your own key (export it as META_MODEL_API_KEY).", }, { id: "umans", diff --git a/tests/meta-muse-oauth.test.ts b/tests/meta-muse-oauth.test.ts index 9bf8d8fe1b..41838faf4b 100644 --- a/tests/meta-muse-oauth.test.ts +++ b/tests/meta-muse-oauth.test.ts @@ -274,15 +274,39 @@ describe("meta-muse credential import", () => { */ test("no failure path echoes the credential", async () => { const denied = (async () => new Response("nope", { status: 401 })) as unknown as typeof fetch; - const progress: string[] = []; - let message = ""; - try { - await loginMetaMuse({ onProgress: m => progress.push(m) }, deps({ fetchImpl: denied })); - } catch (error) { - message = String((error as Error).message) + String((error as Error).stack ?? ""); + const torn = (async () => { throw new Error("socket hang up"); }) as unknown as typeof fetch; + + // Both origins and both failure shapes. The import path alone used to stand in for + // "no failure path", which stopped being true once a pasted key could reach the + // same validator by a different route. + const cases = [ + { label: "imported/rejected", platform: "darwin", fetchImpl: denied }, + { label: "imported/unreachable", platform: "darwin", fetchImpl: torn }, + { label: "pasted/rejected", platform: "win32", fetchImpl: denied }, + { label: "pasted/unreachable", platform: "linux", fetchImpl: torn }, + ] as const; + + for (const c of cases) { + const progress: string[] = []; + const auth: string[] = []; + let message = ""; + try { + await loginMetaMuse( + { + onProgress: m => progress.push(m), + onAuth: info => auth.push(`${info.url} ${info.instructions ?? ""}`), + onManualCodeInput: async () => CANARY, + }, + deps({ platform: c.platform, fetchImpl: c.fetchImpl }), + ); + throw new Error(`${c.label} should have failed`); + } catch (error) { + message = String((error as Error).message) + String((error as Error).stack ?? ""); + } + expect(message).not.toContain(CANARY); + for (const line of progress) expect(line).not.toContain(CANARY); + for (const line of auth) expect(line).not.toContain(CANARY); } - expect(message).not.toContain(CANARY); - for (const line of progress) expect(line).not.toContain(CANARY); }); }); @@ -298,4 +322,26 @@ describe("meta-muse refresh", () => { test("an empty key is refused rather than replayed", async () => { await expect(refreshMetaMuseToken("")).rejects.toThrow(/ocx login meta-muse/); }); + + // merged() in index.ts keeps any source that is not "local-cli", so asserting + // "local-cli" here would relabel a hand-pasted key as an imported one and + // misreport where the credential came from. + test("refresh preserves a manually pasted origin", async () => { + const pasted = await refreshMetaMuseToken(CANARY, undefined, { + access: CANARY, + refresh: CANARY, + expires: Number.MAX_SAFE_INTEGER, + source: "manual", + }); + expect(pasted.source).toBe("manual"); + + const imported = await refreshMetaMuseToken(CANARY, undefined, { + access: CANARY, + refresh: CANARY, + expires: Number.MAX_SAFE_INTEGER, + source: "local-cli", + }); + expect(imported.source).toBe("local-cli"); + expect((await refreshMetaMuseToken(CANARY)).source).toBe("local-cli"); + }); }); From 77be8aef86e12e93a77773e9e6ecbac5deb0bce0 Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:08:15 +0900 Subject: [PATCH 5/6] fix(meta-muse): make the credential-leak guard fail when a case succeeds The non-disclosure test threw its own 'should have failed' sentinel inside the try block and caught it in the same catch. A case that unexpectedly SUCCEEDED therefore produced an error whose message has no canary in it, and the test passed. It was asserting the sentinel, not the product. The failure is now tracked in its own flag and asserted before the non-disclosure checks. Driven red to prove it: pointing the pasted/rejected case at a 200 response fails the test. Two wording fixes alongside it. The branch with no paste surface told the user to paste when prompted, which is precisely what that branch cannot do; it now names where a prompt exists. And validation reported a pasted key as imported. --- .../010_wp1_muse_manual_key.md | 1 - src/oauth/meta-muse.ts | 7 +++++-- tests/meta-muse-oauth.test.ts | 6 +++++- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/devlog/_plan/260904_cross_platform_parity/010_wp1_muse_manual_key.md b/devlog/_plan/260904_cross_platform_parity/010_wp1_muse_manual_key.md index 9ff0ec6fe8..526d038240 100644 --- a/devlog/_plan/260904_cross_platform_parity/010_wp1_muse_manual_key.md +++ b/devlog/_plan/260904_cross_platform_parity/010_wp1_muse_manual_key.md @@ -94,4 +94,3 @@ a reachability probe, neither measured. - macOS import path byte-identical in behavior, including error wording. - No credential value reaches a log, an error, or a callback payload. - CI green. - diff --git a/src/oauth/meta-muse.ts b/src/oauth/meta-muse.ts index cc2f920ed5..1000ac4090 100644 --- a/src/oauth/meta-muse.ts +++ b/src/oauth/meta-muse.ts @@ -187,7 +187,8 @@ export async function loginMetaMuse( const pasted = await manualKeyCredential(ctrl, reason); if (pasted === null) { throw new Error( - `${reason} Sign in at ${MANUAL_KEY_URL}, copy your Muse Code API key, and paste it when prompted, ` + `${reason} This client cannot prompt for a key, so run \`ocx login meta-muse\` from the CLI ` + + `or the dashboard and paste yours from ${MANUAL_KEY_URL}, ` + "or use the meta-model provider with your own key (META_MODEL_API_KEY).", ); } @@ -271,7 +272,9 @@ async function validatedMetaMuseCredential( ? `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."); } - ctrl.onProgress?.("Validating the imported Meta credential…"); + ctrl.onProgress?.(source === "manual" + ? "Validating the pasted Meta credential..." + : "Validating the imported Meta credential..."); const fetchImpl = deps.fetchImpl ?? fetch; // ctrl.signal is OPTIONAL and the CLI controller supplies none: AbortSignal.any([undefined]) // throws a TypeError, which would fail every CLI login right after the warning printed. diff --git a/tests/meta-muse-oauth.test.ts b/tests/meta-muse-oauth.test.ts index 41838faf4b..7ea46c7d90 100644 --- a/tests/meta-muse-oauth.test.ts +++ b/tests/meta-muse-oauth.test.ts @@ -289,6 +289,9 @@ describe("meta-muse credential import", () => { for (const c of cases) { const progress: string[] = []; const auth: string[] = []; + // Tracked separately: catching our own sentinel would let a case that + // unexpectedly SUCCEEDED satisfy the non-disclosure assertions vacuously. + let failed = false; let message = ""; try { await loginMetaMuse( @@ -299,10 +302,11 @@ describe("meta-muse credential import", () => { }, deps({ platform: c.platform, fetchImpl: c.fetchImpl }), ); - throw new Error(`${c.label} should have failed`); } catch (error) { + failed = true; message = String((error as Error).message) + String((error as Error).stack ?? ""); } + expect(failed, `${c.label} should have failed`).toBe(true); expect(message).not.toContain(CANARY); for (const line of progress) expect(line).not.toContain(CANARY); for (const line of auth) expect(line).not.toContain(CANARY); From 37a4c283d7a043453f9ee87f20b4e42f9f1aa221 Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:12:45 +0900 Subject: [PATCH 6/6] test(oauth): pin that a Muse Code key survives the manual-paste gate The dashboard paste field is the surface a Windows or Linux user reaches for meta-muse, and everything it accepts passes through parseCallbackInput and the shared submitManualLoginCode gate first. A Muse key has no code= and no #, so it depends on the raw branch to arrive intact. That is currently true and nothing states it. A future tightening of the gate -- requiring a state parameter, or splitting on # unconditionally -- would truncate the key into an invalid credential and the failure would surface as a 401 from Meta rather than as a parsing bug. --- tests/oauth-manual-code.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/oauth-manual-code.test.ts b/tests/oauth-manual-code.test.ts index be041a43a6..8053a5b99c 100644 --- a/tests/oauth-manual-code.test.ts +++ b/tests/oauth-manual-code.test.ts @@ -55,6 +55,14 @@ describe("parseCallbackInput kinds", () => { expect(parseCallbackInput("?code=abc&state=xyz")).toEqual({ kind: "query", code: "abc", state: "xyz" }); }); + // A Meta Muse Code key is pasted into this same field on Windows and Linux, where + // there is no CLI credential to import. It carries no "code=" and no "#", so it must + // survive as a raw value: the shared gate rejects anything with no code, and a key + // split on "#" would be truncated into an invalid credential. + test("a Muse Code API key survives as a raw value", () => { + const key = "LLM|1234567890123456|abcdefghijklmnopqrstuvwxy"; + expect(parseCallbackInput(key)).toEqual({ kind: "raw", code: key, state: undefined }); + }); test("raw authorization code -> kind raw", () => { expect(parseCallbackInput(" raw-auth-code ")).toEqual({ kind: "raw", code: "raw-auth-code", state: undefined }); });